diff --git a/.github/scripts/precreate-repo-radius-state-package.sh b/.github/scripts/precreate-repo-radius-state-package.sh new file mode 100644 index 0000000000..9ce1d2f5d5 --- /dev/null +++ b/.github/scripts/precreate-repo-radius-state-package.sh @@ -0,0 +1,253 @@ +#!/bin/bash + +# Creates or verifies the private GHCR package used by the Repo Radius state +# rehydration workflow. + +set -euo pipefail + +readonly SCRIPT_NAME="$(basename "$0")" +readonly BOOTSTRAP_TAG="bootstrap" +readonly BOOTSTRAP_ARTIFACT_TYPE="application/vnd.radius.statearchive.bootstrap.v1" + +PACKAGE="" +SOURCE_REPOSITORY="" +WORK_DIR="" + +usage() { + cat < \\ + --source-repository + +Creates a harmless bootstrap version when the package is absent, then verifies +that the package is private or internal and linked to the source repository. + +Prerequisites: + - gh authenticated as a user allowed to create the package + - the active gh token includes write:packages + - oras and jq are installed +EOF +} + +fail() { + echo "[precreate-state-package] ERROR: $*" >&2 + exit 1 +} + +log() { + echo "[precreate-state-package] $*" +} + +cleanup() { + if [[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]]; then + rm -rf "${WORK_DIR}" + fi +} + +trap cleanup EXIT + +urlencode() { + jq -nr --arg value "$1" '$value | @uri' +} + +parse_args() { + while (($# > 0)); do + case "$1" in + --package) + [[ $# -ge 2 ]] || fail "--package requires a value" + PACKAGE="$2" + shift 2 + ;; + --source-repository) + [[ $# -ge 2 ]] \ + || fail "--source-repository requires a value" + SOURCE_REPOSITORY="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac + done + + [[ -n "${PACKAGE}" ]] || fail "--package is required" + [[ -n "${SOURCE_REPOSITORY}" ]] \ + || fail "--source-repository is required" +} + +require_tools() { + local tool + for tool in gh oras jq; do + command -v "${tool}" >/dev/null 2>&1 \ + || fail "${tool} is required" + done +} + +parse_package() { + if [[ ! "${PACKAGE}" =~ ^ghcr\.io/([^/]+)/(.+)$ ]]; then + fail "package must match ghcr.io//" + fi + PACKAGE_OWNER="${BASH_REMATCH[1]}" + PACKAGE_NAME="${BASH_REMATCH[2]}" + + if [[ ! "${PACKAGE_OWNER}" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]]; then + fail "invalid GHCR owner: ${PACKAGE_OWNER}" + fi + if [[ ! "${PACKAGE_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]]; then + fail "invalid GHCR package name: ${PACKAGE_NAME}" + fi + + local source="${SOURCE_REPOSITORY%.git}" + source="${source%/}" + if [[ ! "${source}" =~ ^https://github\.com/([^/]+)/([^/]+)$ ]]; then + fail "source repository must match https://github.com//" + fi + SOURCE_REPOSITORY="${source}" + SOURCE_REPOSITORY_FULL_NAME="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" +} + +configure_package_api() { + local owner_type owner_path package_path + owner_type="$(gh api "/users/${PACKAGE_OWNER}" --jq '.type')" + case "${owner_type}" in + Organization) owner_path="orgs" ;; + User) owner_path="users" ;; + *) fail "unsupported owner type ${owner_type} for ${PACKAGE_OWNER}" ;; + esac + + package_path="$(urlencode "${PACKAGE_NAME}")" + PACKAGE_API="/${owner_path}/$(urlencode "${PACKAGE_OWNER}")/packages/container/${package_path}" +} + +package_exists() { + local output + if output="$(gh api "${PACKAGE_API}" 2>&1)"; then + printf '%s' "${output}" >"${WORK_DIR}/package.json" + return 0 + fi + if grep -Eqi '(^|[^0-9])404([^0-9]|$)|package not found|not found' \ + <<<"${output}"; then + return 1 + fi + echo "${output}" >&2 + fail "could not query package metadata" +} + +login_to_ghcr() { + local username token + username="$(gh api /user --jq '.login')" + token="$(gh auth token)" \ + || fail "could not read the active gh token" + printf '%s' "${token}" \ + | oras login ghcr.io --username "${username}" --password-stdin +} + +push_bootstrap() { + mkdir -p "${WORK_DIR}/artifact" + printf '%s\n' \ + "Harmless bootstrap for the Repo Radius state package." \ + >"${WORK_DIR}/artifact/bootstrap.txt" + + ( + cd "${WORK_DIR}/artifact" + oras push "${PACKAGE}:${BOOTSTRAP_TAG}" \ + --artifact-type "${BOOTSTRAP_ARTIFACT_TYPE}" \ + --annotation \ + "org.opencontainers.image.source=${SOURCE_REPOSITORY}" \ + "bootstrap.txt:text/plain" + ) +} + +wait_for_package() { + local _ + for _ in {1..20}; do + if package_exists; then + return 0 + fi + sleep 2 + done + fail "package metadata did not become available after bootstrap" +} + +verify_package() { + local visibility linked_repository + visibility="$(jq -r '.visibility // empty' \ + "${WORK_DIR}/package.json")" + case "${visibility}" in + private | internal) ;; + public) + fail "package ${PACKAGE} is public; change its name or visibility before storing Radius state" + ;; + *) fail "unsupported package visibility: ${visibility:-missing}" ;; + esac + + linked_repository="$(jq -r '.repository.full_name // empty' \ + "${WORK_DIR}/package.json")" + if [[ ! "${linked_repository,,}" == \ + "${SOURCE_REPOSITORY_FULL_NAME,,}" ]]; then + fail "package is linked to ${linked_repository:-no repository}; expected ${SOURCE_REPOSITORY_FULL_NAME}" + fi +} + +ensure_bootstrap() { + if oras manifest fetch --descriptor \ + "${PACKAGE}:${BOOTSTRAP_TAG}" >"${WORK_DIR}/bootstrap.json" 2>/dev/null; then + return 0 + fi + + log "bootstrap tag is missing; adding harmless bootstrap artifact" + login_to_ghcr + push_bootstrap + oras manifest fetch --descriptor \ + "${PACKAGE}:${BOOTSTRAP_TAG}" >"${WORK_DIR}/bootstrap.json" +} + +print_result() { + local digest package_url + digest="$(jq -r '.digest' "${WORK_DIR}/bootstrap.json")" + package_url="$(jq -r '.html_url' "${WORK_DIR}/package.json")" + + cat <"${WORK_DIR}/bootstrap.json" + fi + + print_result +} + +main "$@" diff --git a/.github/scripts/test-repo-radius-state-e2e.sh b/.github/scripts/test-repo-radius-state-e2e.sh new file mode 100644 index 0000000000..0b865a9fa8 --- /dev/null +++ b/.github/scripts/test-repo-radius-state-e2e.sh @@ -0,0 +1,799 @@ +#!/bin/bash + +# Proves Repo Radius state can round-trip through GHCR while the workload +# remains on a separate Kubernetes cluster. + +set -euo pipefail + +readonly APP_NAME="repo-radius-state-e2e" +readonly RESOURCE_NAME="repo-radius-state-container" +readonly ENVIRONMENT_NAME="repo-radius-state-e2e" +readonly WORKLOAD_NAMESPACE="repo-radius-state-e2e" +readonly WORKSPACE_NAME="repo-radius-state-e2e" +readonly SELECTOR="radapp.io/application=${APP_NAME},radapp.io/resource=${RESOURCE_NAME}" +readonly REPOSITORY_ROOT="${GITHUB_WORKSPACE:-$(git rev-parse --show-toplevel)}" +readonly DIAGNOSTICS_DIR="${REPOSITORY_ROOT}/dist/repo-radius-state-e2e" +readonly SOURCE_APP_FILE="${REPOSITORY_ROOT}/test/functional-portable/statestore/noncloud/testdata/repo-radius-state-app.bicep" +readonly RUN_SUFFIX="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" +readonly NETWORK_NAME="repo-radius-state-${RUN_SUFFIX}" +readonly REGISTRY_CONTAINER="repo-radius-registry-${RUN_SUFFIX}" +readonly REGISTRY_ALIAS="repo-radius-registry" +readonly CLUSTER_REGISTRY="${REGISTRY_ALIAS}:5000" +readonly WORKLOAD_CLUSTER="radius-workload-${RUN_SUFFIX}" +readonly CONTROL_PLANE_A="radius-cp-a-${RUN_SUFFIX}" +readonly CONTROL_PLANE_B="radius-cp-b-${RUN_SUFFIX}" +readonly WORK_DIR="${RUNNER_TEMP:-/tmp}/repo-radius-state-${RUN_SUFFIX}" +readonly HOST_WORKLOAD_KUBECONFIG="${WORK_DIR}/workload-host.kubeconfig" +readonly INTERNAL_WORKLOAD_KUBECONFIG="${WORK_DIR}/workload-internal.kubeconfig" +readonly REGISTRY_CONFIG="${WORK_DIR}/registries.yaml" +readonly APP_FILE="${WORK_DIR}/repo-radius-state-app.bicep" +readonly STATE_OWNED_MARKER="${WORK_DIR}/state-owned" +readonly SAVED_DIGEST_FILE="${WORK_DIR}/saved-state-digest" +readonly PHASE_DIR="${WORK_DIR}/phases" +readonly BOOTSTRAP_TAG="bootstrap" + +: "${LOCAL_DOCKER_REGISTRY:?LOCAL_DOCKER_REGISTRY must be set}" +: "${DOCKER_TAG_VERSION:?DOCKER_TAG_VERSION must be set}" +: "${GH_TOKEN:?GH_TOKEN must be set}" +: "${RADIUS_STATE_ARCHIVE:?RADIUS_STATE_ARCHIVE must be set}" +: "${RADIUS_STATE_BACKEND:?RADIUS_STATE_BACKEND must be set}" +: "${RADIUS_STATE_REGISTRY:?RADIUS_STATE_REGISTRY must be set}" + +export RADIUS_PREVIEW=true +export RADIUS_STATE_REGISTRY="${RADIUS_STATE_REGISTRY,,}" +readonly STATE_REFERENCE="${RADIUS_STATE_REGISTRY}:${RADIUS_STATE_ARCHIVE}" + +PACKAGE_API="" +PACKAGE_NAME="" + +usage() { + cat < + +Phases: + validate-state-package + prepare-workload + install-initial-control-plane + deploy-initial + persist-state + replace-control-plane + restore-state + update-workload + diagnostics + cleanup-state-version + cleanup + all +EOF +} + +mark_phase() { + mkdir -p "${PHASE_DIR}" + touch "${PHASE_DIR}/$1" +} + +begin_phase() { + mkdir -p "${WORK_DIR}" "${PHASE_DIR}" + printf '%s\n' "$1" >"${WORK_DIR}/current-phase" + append_summary "" + append_summary "### $2" +} + +require_phase() { + local phase="$1" + if [[ ! -f "${PHASE_DIR}/${phase}" ]]; then + echo "Required phase '${phase}' has not completed." >&2 + return 1 + fi +} + +append_summary() { + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + printf '%s\n' "$*" >>"${GITHUB_STEP_SUMMARY}" + fi +} + +urlencode() { + jq -nr --arg value "$1" '$value | @uri' +} + +configure_package_api() { + local registry_path owner owner_type owner_scope + registry_path="${RADIUS_STATE_REGISTRY#ghcr.io/}" + owner="${registry_path%%/*}" + PACKAGE_NAME="${registry_path#*/}" + if [[ "${registry_path}" == "${RADIUS_STATE_REGISTRY}" || + -z "${owner}" || -z "${PACKAGE_NAME}" ]]; then + echo "RADIUS_STATE_REGISTRY must match ghcr.io//." >&2 + return 1 + fi + if [[ ! "${PACKAGE_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]]; then + echo "Invalid GHCR package name: ${PACKAGE_NAME}" >&2 + return 1 + fi + + owner_type="$(gh api "/users/${owner}" --jq '.type')" + case "${owner_type}" in + Organization) owner_scope="orgs" ;; + User) owner_scope="users" ;; + *) + echo "Unsupported GitHub owner type: ${owner_type}" >&2 + return 1 + ;; + esac + PACKAGE_API="/${owner_scope}/$(urlencode "${owner}")/packages/container/$(urlencode "${PACKAGE_NAME}")" +} + +fetch_package_metadata() { + configure_package_api + mkdir -p "${WORK_DIR}" + gh api "${PACKAGE_API}" >"${WORK_DIR}/package.json" +} + +verify_package_metadata() { + local visibility linked_repository + visibility="$(jq -r '.visibility // empty' "${WORK_DIR}/package.json")" + case "${visibility}" in + private | internal) ;; + *) + echo "State package must be private or internal; found ${visibility:-missing}." >&2 + return 1 + ;; + esac + + linked_repository="$(jq -r '.repository.full_name // empty' \ + "${WORK_DIR}/package.json")" + if [[ -n "${GITHUB_REPOSITORY:-}" && + "${linked_repository,,}" != "${GITHUB_REPOSITORY,,}" ]]; then + echo "State package is linked to ${linked_repository:-no repository}; expected ${GITHUB_REPOSITORY}." >&2 + return 1 + fi +} + +collect_diagnostics() { + mkdir -p "${DIAGNOSTICS_DIR}" + + if [[ -d "${PHASE_DIR}" ]]; then + find "${PHASE_DIR}" -maxdepth 1 -type f -printf '%f\n' \ + | sort >"${DIAGNOSTICS_DIR}/completed-phases.txt" + fi + if [[ -f "${SAVED_DIGEST_FILE}" ]]; then + cp "${SAVED_DIGEST_FILE}" \ + "${DIAGNOSTICS_DIR}/saved-state-digest.txt" + fi + if fetch_package_metadata; then + jq '{ + name, + visibility, + repository: .repository.full_name, + version_count, + html_url + }' "${WORK_DIR}/package.json" \ + >"${DIAGNOSTICS_DIR}/package-metadata.json" + fi + + rad app list --output json \ + >"${DIAGNOSTICS_DIR}/rad-app-list.json" 2>&1 || true + oras manifest fetch --descriptor "${STATE_REFERENCE}" \ + >"${DIAGNOSTICS_DIR}/state-descriptor.json" 2>&1 || true + + local cluster + for cluster in "${CONTROL_PLANE_A}" "${CONTROL_PLANE_B}"; do + if ! k3d cluster list --no-headers \ + | awk '{print $1}' \ + | grep -Fxq "${cluster}"; then + continue + fi + + kubectl --context "k3d-${cluster}" get pods -A -o wide \ + >"${DIAGNOSTICS_DIR}/${cluster}-pods.txt" 2>&1 || true + kubectl --context "k3d-${cluster}" get events -A \ + --sort-by=.lastTimestamp \ + >"${DIAGNOSTICS_DIR}/${cluster}-events.txt" 2>&1 || true + + local component + for component in applications-rp dynamic-rp bicep-de controller ucp; do + kubectl --context "k3d-${cluster}" logs \ + -n radius-system \ + -l "app.kubernetes.io/name=${component}" \ + --all-containers --tail=300 \ + >"${DIAGNOSTICS_DIR}/${cluster}-${component}.log" \ + 2>&1 || true + done + done + + if [[ -f "${HOST_WORKLOAD_KUBECONFIG}" ]]; then + kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" get all -A -o wide \ + >"${DIAGNOSTICS_DIR}/workload-resources.txt" 2>&1 || true + kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" get deployment \ + -n "${WORKLOAD_NAMESPACE}" -l "${SELECTOR}" -o yaml \ + >"${DIAGNOSTICS_DIR}/workload-deployment.yaml" 2>&1 || true + fi +} + +delete_state_manifest() { + local status + + if manifest_exists; then + status=0 + else + status=$? + fi + if ((status == 1)); then + return 0 + fi + if ((status != 0)); then + return "${status}" + fi + + configure_package_api + local versions + versions="$(gh api --paginate \ + "${PACKAGE_API}/versions?per_page=100")" + + local -a version_ids + mapfile -t version_ids < <( + jq --slurp --raw-output --arg tag "${RADIUS_STATE_ARCHIVE}" \ + '[.[][] | + select(any(.metadata.container.tags[]?; . == $tag)) | + .id] | .[]' <<<"${versions}" + ) + if ((${#version_ids[@]} != 1)); then + echo "Expected one GHCR package version for ${STATE_REFERENCE}," \ + "found ${#version_ids[@]}." >&2 + return 1 + fi + + local version_count + version_count="$(gh api "${PACKAGE_API}" --jq '.version_count')" + if ((version_count == 1)); then + echo "Refusing to delete the final version of precreated package ${RADIUS_STATE_REGISTRY}." >&2 + return 1 + fi + gh api --method DELETE \ + "${PACKAGE_API}/versions/${version_ids[0]}" + + local _ + for _ in {1..30}; do + if manifest_exists; then + sleep 1 + continue + else + status=$? + fi + if ((status == 1)); then + break + fi + return "${status}" + done + + if manifest_exists; then + echo "State manifest still exists after cleanup: ${STATE_REFERENCE}" >&2 + return 1 + fi + fetch_package_metadata + verify_package_metadata + oras manifest fetch --descriptor \ + "${RADIUS_STATE_REGISTRY}:${BOOTSTRAP_TAG}" >/dev/null +} + +cleanup() { + local result=$? + local cleanup_result=0 + set +e + + if ((result != 0)); then + collect_diagnostics + fi + + if [[ -f "${STATE_OWNED_MARKER}" ]]; then + delete_state_manifest || cleanup_result=1 + fi + k3d cluster delete "${CONTROL_PLANE_A}" >/dev/null 2>&1 + k3d cluster delete "${CONTROL_PLANE_B}" >/dev/null 2>&1 + k3d cluster delete "${WORKLOAD_CLUSTER}" >/dev/null 2>&1 + docker rm --force "${REGISTRY_CONTAINER}" >/dev/null 2>&1 + docker network rm "${NETWORK_NAME}" >/dev/null 2>&1 + rm -rf "${WORK_DIR}" + + if ((cleanup_result == 0)); then + append_summary "- State version cleanup: succeeded" + else + append_summary "- State version cleanup: failed" + fi + if ((result == 0 && cleanup_result != 0)); then + result="${cleanup_result}" + fi + exit "${result}" +} + +cluster_exists() { + local cluster="$1" + k3d cluster list --no-headers \ + | awk '{print $1}' \ + | grep -Fxq "${cluster}" +} + +wait_for_kubernetes_api() { + local -a kubectl_args=("$@") + local deadline=$((SECONDS + 60)) + + while ((SECONDS < deadline)); do + if kubectl "${kubectl_args[@]}" --request-timeout=5s \ + get --raw=/readyz \ + >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + echo "Kubernetes API did not become ready." >&2 + return 1 +} + +write_registry_config() { + cat >"${REGISTRY_CONFIG}" </dev/null + docker run --detach --rm \ + --name "${REGISTRY_CONTAINER}" \ + --network "${NETWORK_NAME}" \ + --network-alias "${REGISTRY_ALIAS}" \ + --publish 127.0.0.1:5000:5000 \ + registry:2 >/dev/null + + local _ + for _ in {1..30}; do + if curl -fsS http://127.0.0.1:5000/v2/ >/dev/null; then + return 0 + fi + sleep 1 + done + echo "Local OCI registry did not become ready." >&2 + return 1 +} + +publish_branch_artifacts() { + local image + for image in ucpd applications-rp dynamic-rp controller bicep; do + docker push \ + "${LOCAL_DOCKER_REGISTRY}/${image}:${DOCKER_TAG_VERSION}" + done + + cp "${SOURCE_APP_FILE}" "${APP_FILE}" + cat >"${WORK_DIR}/bicepconfig.json" </dev/null +} + +create_workload_cluster() { + k3d cluster create "${WORKLOAD_CLUSTER}" \ + --network "${NETWORK_NAME}" \ + --registry-config "${REGISTRY_CONFIG}" \ + --k3s-arg "--disable=traefik@server:*" \ + --wait + + k3d kubeconfig get "${WORKLOAD_CLUSTER}" \ + >"${HOST_WORKLOAD_KUBECONFIG}" + cp "${HOST_WORKLOAD_KUBECONFIG}" \ + "${INTERNAL_WORKLOAD_KUBECONFIG}" + wait_for_kubernetes_api --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" + + local cluster_key + local workload_ip + cluster_key="$(kubectl \ + --kubeconfig "${INTERNAL_WORKLOAD_KUBECONFIG}" \ + config view --minify \ + -o jsonpath='{.contexts[0].context.cluster}')" + workload_ip="$(docker inspect \ + --format \ + "{{(index .NetworkSettings.Networks \"${NETWORK_NAME}\").IPAddress}}" \ + "k3d-${WORKLOAD_CLUSTER}-server-0")" + + if [[ -z "${workload_ip}" ]]; then + echo "Could not determine the workload cluster IP." >&2 + return 1 + fi + + kubectl --kubeconfig "${INTERNAL_WORKLOAD_KUBECONFIG}" \ + config set "clusters.${cluster_key}.server" \ + "https://${workload_ip}:6443" >/dev/null + kubectl --kubeconfig "${INTERNAL_WORKLOAD_KUBECONFIG}" \ + config unset \ + "clusters.${cluster_key}.certificate-authority-data" >/dev/null + kubectl --kubeconfig "${INTERNAL_WORKLOAD_KUBECONFIG}" \ + config set \ + "clusters.${cluster_key}.insecure-skip-tls-verify" \ + "true" >/dev/null + + kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" \ + create namespace "${WORKLOAD_NAMESPACE}" +} + +configure_workspace() { + local cluster="$1" + + kubectl config use-context "k3d-${cluster}" >/dev/null + rad workspace create kubernetes "${WORKSPACE_NAME}" \ + --context "k3d-${cluster}" \ + --force + rad workspace switch "${WORKSPACE_NAME}" + rad group create default + rad group switch default +} + +install_control_plane() { + local cluster="$1" + local install_needs_recovery=false + + k3d cluster create "${cluster}" \ + --network "${NETWORK_NAME}" \ + --registry-config "${REGISTRY_CONFIG}" \ + --k3s-arg "--disable=traefik@server:*" \ + --wait + wait_for_kubernetes_api --context "k3d-${cluster}" + kubectl config use-context "k3d-${cluster}" >/dev/null + + kubectl create namespace radius-system + kubectl create secret generic target-kubeconfig \ + --namespace radius-system \ + --from-file=kubeconfig="${INTERNAL_WORKLOAD_KUBECONFIG}" + + if rad install kubernetes \ + --chart "${REPOSITORY_ROOT}/deploy/Chart" \ + --set database.enabled=true \ + --set global.targetCluster.enabled=true \ + --set rp.publicEndpointOverride=localhost \ + --set \ + "rp.image=${CLUSTER_REGISTRY}/applications-rp,rp.tag=${DOCKER_TAG_VERSION}" \ + --set \ + "dynamicrp.image=${CLUSTER_REGISTRY}/dynamic-rp,dynamicrp.tag=${DOCKER_TAG_VERSION}" \ + --set \ + "controller.image=${CLUSTER_REGISTRY}/controller,controller.tag=${DOCKER_TAG_VERSION}" \ + --set \ + "ucp.image=${CLUSTER_REGISTRY}/ucpd,ucp.tag=${DOCKER_TAG_VERSION}" \ + --set \ + "bicep.image=${CLUSTER_REGISTRY}/bicep,bicep.tag=${DOCKER_TAG_VERSION}"; then + : + else + install_needs_recovery=true + if ! kubectl get deployment/ucp statefulset/database \ + --namespace radius-system >/dev/null; then + echo "Radius Helm installation did not complete." >&2 + return 1 + fi + echo "Radius was installed, but its initial API readiness check failed." + fi + + # Deployment Engine and dashboard are built in separate repositories. Their + # chart defaults intentionally remain on the compatible edge channel. + kubectl wait --for=condition=Ready pod/database-0 \ + --namespace radius-system \ + --timeout=300s + if [[ "${install_needs_recovery}" == "true" ]]; then + kubectl rollout restart deployment/ucp \ + --namespace radius-system + kubectl rollout status deployment/ucp \ + --namespace radius-system \ + --timeout=300s + fi + kubectl wait --for=condition=Available deployment --all \ + --namespace radius-system \ + --timeout=300s + configure_workspace "${cluster}" +} + +assert_application_listed() { + local output_file="$1" + + rad app list --output json | tee "${output_file}" + jq --exit-status --arg app "${APP_NAME}" \ + 'type == "array" and + length == 1 and + ((.[0].name // .[0].Name // "") == $app)' \ + "${output_file}" >/dev/null +} + +assert_workload_phase() { + local phase="$1" + local output_file="$2" + local pod_output="${output_file%.json}-pods.json" + + kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" \ + rollout status deployment \ + --namespace "${WORKLOAD_NAMESPACE}" \ + --selector "${SELECTOR}" \ + --timeout=300s + kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" \ + get deployment \ + --namespace "${WORKLOAD_NAMESPACE}" \ + --selector "${SELECTOR}" \ + --output json \ + | tee "${output_file}" + kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" \ + wait --for=condition=Ready pod \ + --namespace "${WORKLOAD_NAMESPACE}" \ + --selector "${SELECTOR}" \ + --timeout=300s + kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" \ + get pod \ + --namespace "${WORKLOAD_NAMESPACE}" \ + --selector "${SELECTOR}" \ + --output json \ + | tee "${pod_output}" + jq --exit-status --arg phase "${phase}" \ + '(.items | length) == 1 and + ([.items[].spec.template.spec.containers[].args[]] + | any(contains($phase)))' \ + "${output_file}" >/dev/null + jq --exit-status --arg phase "${phase}" \ + '(.items | length) >= 1 and + ([.items[].spec.containers[].args[]] + | any(contains($phase)))' \ + "${pod_output}" >/dev/null + + local pod + pod="$(jq --raw-output '.items[0].metadata.name' "${pod_output}")" + local _ + for _ in {1..30}; do + if kubectl --kubeconfig "${HOST_WORKLOAD_KUBECONFIG}" \ + logs --namespace "${WORKLOAD_NAMESPACE}" "${pod}" \ + | grep -Fq "${phase}"; then + return 0 + fi + sleep 1 + done + echo "The running workload did not log phase ${phase}." >&2 + return 1 +} + +assert_absent_from_control_plane() { + local cluster="$1" + local resources + + resources="$(kubectl --context "k3d-${cluster}" get all \ + --all-namespaces \ + --selector "${SELECTOR}" \ + --output name)" + if [[ -n "${resources}" ]]; then + echo "Workload resources unexpectedly exist on ${cluster}:" >&2 + echo "${resources}" >&2 + return 1 + fi +} + +deploy_phase() { + local phase="$1" + local environment_id + + environment_id="$(rad env show "${ENVIRONMENT_NAME}" \ + --output json \ + | jq --slurp --raw-output \ + 'map(select(type == "object")) | first | (.id // .Id // empty)')" + if [[ -z "${environment_id}" || "${environment_id}" == "null" ]]; then + echo "Could not resolve the Radius environment ID." >&2 + return 1 + fi + + rad deploy "${APP_FILE}" \ + --environment "${ENVIRONMENT_NAME}" \ + --parameters "environment=${environment_id}" \ + --parameters "deploymentPhase=${phase}" +} + +manifest_exists() { + local output + + if output="$(oras manifest fetch --descriptor \ + "${STATE_REFERENCE}" 2>&1)"; then + return 0 + fi + if grep -Eqi '(^|[^0-9])(404|not found)([^0-9]|$)' <<<"${output}"; then + return 1 + fi + + echo "${output}" >&2 + return 2 +} + +assert_state_absent() { + local status + + if manifest_exists; then + echo "Run-specific state already exists: ${STATE_REFERENCE}" >&2 + return 1 + else + status=$? + fi + if ((status != 1)); then + return "${status}" + fi +} + +state_digest() { + oras manifest fetch --descriptor "${STATE_REFERENCE}" \ + | jq --exit-status --raw-output '.digest' +} + +phase_validate_state_package() { + begin_phase "validate-state-package" "Validate private state package" + mkdir -p "${WORK_DIR}" "${DIAGNOSTICS_DIR}" + fetch_package_metadata + verify_package_metadata + oras manifest fetch --descriptor \ + "${RADIUS_STATE_REGISTRY}:${BOOTSTRAP_TAG}" \ + >"${WORK_DIR}/bootstrap-descriptor.json" + assert_state_absent + mark_phase "validate-state-package" + append_summary "- Package: \`${RADIUS_STATE_REGISTRY}\`" + append_summary "- Visibility: $(jq -r '.visibility' \ + "${WORK_DIR}/package.json")" +} + +phase_prepare_workload() { + begin_phase "prepare-workload" "Prepare target workload cluster" + require_phase "validate-state-package" + write_registry_config + start_registry + publish_branch_artifacts + create_workload_cluster + mark_phase "prepare-workload" +} + +phase_install_initial_control_plane() { + begin_phase \ + "install-initial-control-plane" \ + "Install first Radius control plane" + require_phase "prepare-workload" + install_control_plane "${CONTROL_PLANE_A}" + rad startup + mark_phase "install-initial-control-plane" +} + +phase_deploy_initial() { + begin_phase "deploy-initial" "Deploy and verify initial application" + require_phase "install-initial-control-plane" + rad env create "${ENVIRONMENT_NAME}" \ + --kubernetes-namespace "${WORKLOAD_NAMESPACE}" + deploy_phase "before-restore" + assert_application_listed \ + "${DIAGNOSTICS_DIR}/apps-before-restore.json" + assert_workload_phase "before-restore" \ + "${DIAGNOSTICS_DIR}/workload-before-restore.json" + assert_absent_from_control_plane "${CONTROL_PLANE_A}" + mark_phase "deploy-initial" +} + +phase_persist_state() { + begin_phase "persist-state" "Persist Radius state to GHCR" + require_phase "deploy-initial" + # The preflight proved this run-unique tag was absent. Claim it before + # shutdown so cleanup removes any tag left by a partially failed upload. + touch "${STATE_OWNED_MARKER}" + rad shutdown + local saved_digest + saved_digest="$(state_digest)" + printf '%s\n' "${saved_digest}" >"${SAVED_DIGEST_FILE}" + mark_phase "persist-state" + echo "Saved Radius state as ${saved_digest}." + append_summary "- Saved state digest: \`${saved_digest}\`" +} + +phase_replace_control_plane() { + begin_phase "replace-control-plane" "Replace Radius control plane" + require_phase "persist-state" + k3d cluster delete "${CONTROL_PLANE_A}" + if cluster_exists "${CONTROL_PLANE_A}"; then + echo "The first control plane was not deleted." >&2 + return 1 + fi + + install_control_plane "${CONTROL_PLANE_B}" + mark_phase "replace-control-plane" +} + +phase_restore_state() { + begin_phase "restore-state" "Restore and verify Radius state" + require_phase "replace-control-plane" + [[ -s "${SAVED_DIGEST_FILE}" ]] \ + || { + echo "Saved state digest is missing." >&2 + return 1 + } + rad startup + assert_application_listed \ + "${DIAGNOSTICS_DIR}/apps-after-restore.json" + + local restored_digest saved_digest + saved_digest="$(<"${SAVED_DIGEST_FILE}")" + restored_digest="$(state_digest)" + if [[ "${restored_digest}" != "${saved_digest}" ]]; then + echo "State digest changed during restore." >&2 + return 1 + fi + mark_phase "restore-state" + append_summary "- Restored state digest: \`${restored_digest}\`" +} + +phase_update_workload() { + begin_phase "update-workload" "Update and verify existing workload" + require_phase "restore-state" + deploy_phase "after-restore" + assert_workload_phase "after-restore" \ + "${DIAGNOSTICS_DIR}/workload-after-restore.json" + assert_absent_from_control_plane "${CONTROL_PLANE_B}" + mark_phase "update-workload" + append_summary "- Result: state rehydration and workload update succeeded" + echo "Repo Radius GHCR state rehydration succeeded." +} + +phase_diagnostics() { + append_summary "" + append_summary "### Failure diagnostics" + if [[ -f "${WORK_DIR}/current-phase" ]]; then + append_summary "- Failed phase: \`$(<"${WORK_DIR}/current-phase")\`" + fi + append_summary "- Artifact: \`repo-radius-state-e2e-diagnostics\`" + collect_diagnostics +} + +phase_cleanup_state_version() { + delete_state_manifest + append_summary "- Remote state version cleanup: succeeded" +} + +run_all() { + trap cleanup EXIT + phase_validate_state_package + phase_prepare_workload + phase_install_initial_control_plane + phase_deploy_initial + phase_persist_state + phase_replace_control_plane + phase_restore_state + phase_update_workload + trap - EXIT + cleanup +} + +main() { + local phase="${1:-all}" + case "${phase}" in + validate-state-package) phase_validate_state_package ;; + prepare-workload) phase_prepare_workload ;; + install-initial-control-plane) + phase_install_initial_control_plane + ;; + deploy-initial) phase_deploy_initial ;; + persist-state) phase_persist_state ;; + replace-control-plane) phase_replace_control_plane ;; + restore-state) phase_restore_state ;; + update-workload) phase_update_workload ;; + diagnostics) phase_diagnostics ;; + cleanup-state-version) phase_cleanup_state_version ;; + cleanup) cleanup ;; + all) run_all ;; + -h | --help) usage ;; + *) + echo "Unknown phase: ${phase}" >&2 + usage >&2 + return 2 + ;; + esac +} + +main "$@" diff --git a/.github/workflows/repo-radius-state-e2e.yaml b/.github/workflows/repo-radius-state-e2e.yaml new file mode 100644 index 0000000000..224a274205 --- /dev/null +++ b/.github/workflows/repo-radius-state-e2e.yaml @@ -0,0 +1,194 @@ +# ------------------------------------------------------------ +# Copyright 2023 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +--- +name: repo-radius-state-e2e + +on: + workflow_dispatch: + inputs: + state_package: + description: Existing private GHCR package used for Radius state + required: false + default: radius-repo-state-e2e + schedule: + # Run daily at 04:17 UTC, outside the non-cloud functional-test schedule. + - cron: 17 4 * * * + +permissions: + contents: read + packages: write # Push and delete this repository's GHCR state package. + +concurrency: + group: repo-radius-state-e2e-${{ inputs.state_package || 'radius-repo-state-e2e' }} + cancel-in-progress: false + +env: + ACTION_LINK: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + LOCAL_DOCKER_REGISTRY: localhost:5000 + DOCKER_TAG_VERSION: repo-radius-${{ github.run_id }}-${{ github.run_attempt }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RADIUS_STATE_BACKEND: oci + RADIUS_STATE_ARCHIVE: repo-radius-state-e2e-${{ github.run_id }}-${{ github.run_attempt }} + RADIUS_STATE_REGISTRY: ghcr.io/${{ github.repository_owner }}/${{ inputs.state_package || 'radius-repo-state-e2e' }} + +jobs: + state-rehydration: + name: Persist and rehydrate Radius state + runs-on: ubuntu-24.04 + timeout-minutes: 60 + if: github.event_name == 'workflow_dispatch' || github.repository == vars.RADIUS_REPOSITORY + steps: + - name: Checkout dispatched ref + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + cache: true + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install k3d + run: make install-k3d + + - name: Install ORAS + uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1 + + - name: Install Bicep + run: | + make install-bicep BICEP_INSTALL_DIR="${HOME}/.rad/bin" + echo "${HOME}/.rad/bin" >> "${GITHUB_PATH}" + + - name: Validate private state package + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh validate-state-package + + - name: Build Repo Radius test artifacts + env: + DOCKER_REGISTRY: ${{ env.LOCAL_DOCKER_REGISTRY }} + run: | + make copy-manifests \ + build-rad \ + docker-build-ucpd \ + docker-build-applications-rp \ + docker-build-dynamic-rp \ + docker-build-controller \ + docker-build-bicep + + - name: Install branch CLI + run: | + install -D -m 0755 \ + ./dist/linux_amd64/release/rad \ + "${HOME}/.local/bin/rad" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Prepare target workload cluster + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh prepare-workload + + - name: Install first Radius control plane + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh install-initial-control-plane + + - name: Deploy and verify initial application + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh deploy-initial + + - name: Persist Radius state to GHCR + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh persist-state + + - name: Replace Radius control plane + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh replace-control-plane + + - name: Restore and verify Radius state + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh restore-state + + - name: Update and verify existing workload + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh update-workload + + - name: Collect failure diagnostics + if: failure() + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh diagnostics + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: repo-radius-state-e2e-diagnostics + path: dist/repo-radius-state-e2e/ + if-no-files-found: ignore + retention-days: 3 + + - name: Clean up state version and clusters + if: always() + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh cleanup + + cleanup-state-version: + name: Ensure state version cleanup + needs: state-rehydration + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + packages: write + steps: + - name: Checkout workflow revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install ORAS + uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1 + + - name: Ensure run-specific state version is deleted + run: bash ./.github/scripts/test-repo-radius-state-e2e.sh cleanup-state-version + + report-failure: + name: Report test failure + needs: [state-rehydration, cleanup-state-version] + if: failure() && github.event_name == 'schedule' && github.repository == vars.RADIUS_REPOSITORY + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Create failure issue for failing scheduled run + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + github.rest.issues.create({ + ...context.repo, + title: `Scheduled Repo Radius GHCR state test failed - Run ID: ${context.runId}`, + labels: ['test-failure'], + body: `## Bug information\n\nThe scheduled Repo Radius GHCR state lifecycle failed. Review the failed lifecycle step and download the \`repo-radius-state-e2e-diagnostics\` artifact from [the workflow run](${process.env.ACTION_LINK}).` + }) diff --git a/.radius/app.bicep b/.radius/app.bicep new file mode 100644 index 0000000000..4acd0cc68b --- /dev/null +++ b/.radius/app.bicep @@ -0,0 +1,32 @@ +extension radius + +@description('The Radius environment resource ID for the application.') +param environment string + +@description('A marker written by the container to prove that an update was deployed.') +param deploymentPhase string = 'before-restore' + +resource repoRadiusStateApp 'Radius.Core/applications@2025-08-01-preview' = { + name: 'repo-radius-state-e2e' + location: 'global' + properties: { + environment: environment + } +} + +resource repoRadiusStateContainer 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'repo-radius-state-container' + location: 'global' + properties: { + application: repoRadiusStateApp.id + environment: environment + codeReference: 'test/functional-portable/statestore/noncloud/testdata/repo-radius-state-app.bicep' + containers: { + main: { + image: 'ghcr.io/radius-project/mirror/debian:latest' + command: ['/bin/sh'] + args: ['-c', 'while true; do echo ${deploymentPhase}; sleep 10; done'] + } + } + } +} diff --git a/.radius/bicepconfig.json b/.radius/bicepconfig.json new file mode 100644 index 0000000000..ba32f165c9 --- /dev/null +++ b/.radius/bicepconfig.json @@ -0,0 +1,8 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + }, + "extensions": { + "radius": "br:biceptypes.azurecr.io/radius:latest" + } +} diff --git a/docs/architecture/state-archive.md b/docs/architecture/state-archive.md index 00421edd7d..8c91e94825 100644 --- a/docs/architecture/state-archive.md +++ b/docs/architecture/state-archive.md @@ -214,6 +214,10 @@ they have separate lifecycles and access requirements. - **Local testing** can use `RADIUS_ARCHIVE_PLAIN_HTTP=true` with a local OCI registry. +## End-to-End Test + +The OCI state archive has a dedicated end-to-end test that exercises the full save/restore lifecycle against a real GHCR package. It deploys an application through one ephemeral Radius control plane to a separate persistent target cluster, saves state to a private GHCR package, replaces the control plane, restores the saved state, and confirms the replacement control plane still manages the existing workload. This validates the round-trip durability contract and the GHCR visibility guard end to end. It runs on a schedule rather than in the per-PR matrix because it needs `packages: write` and a precreated private package. See [Repo Radius GHCR state end-to-end test](../contributing/contributing-code/contributing-code-tests/repo-radius-state-e2e.md) for how to provision, run, and troubleshoot it. + ## How Consumers Stay Decoupled Every consumer stores a `statearchive.Archive` (the interface) and accepts any diff --git a/docs/contributing/contributing-code/contributing-code-tests/README.md b/docs/contributing/contributing-code/contributing-code-tests/README.md index 2285c84d0b..dc75c02ce4 100644 --- a/docs/contributing/contributing-code/contributing-code-tests/README.md +++ b/docs/contributing/contributing-code/contributing-code-tests/README.md @@ -81,6 +81,7 @@ When you are iterating on control-plane images (for example the applications res - We write unit tests in a straightforward style and use [testify](https://github.com/stretchr/testify) for assertions. - We favor [subtests](https://go.dev/blog/subtests) and [table-driven tests](https://dave.cheney.net/2019/05/07/prefer-table-driven-tests) and apply them where appropriate. - For functional tests specifically, see [writing functional tests](./writing-functional-tests.md), the [naming conventions](./tests-naming-conventions.md), [test logging](./tests-logging.md), and [using standard images in tests](./tests-images-pushtoghcr.md). +- For the scheduled OCI state archive end-to-end test, see [Repo Radius GHCR state end-to-end test](./repo-radius-state-e2e.md). ## Verification diff --git a/docs/contributing/contributing-code/contributing-code-tests/repo-radius-state-e2e.md b/docs/contributing/contributing-code/contributing-code-tests/repo-radius-state-e2e.md new file mode 100644 index 0000000000..f2fbf12f76 --- /dev/null +++ b/docs/contributing/contributing-code/contributing-code-tests/repo-radius-state-e2e.md @@ -0,0 +1,45 @@ +# Repo Radius GHCR state end-to-end test + +## Purpose + +This page describes the standalone [`repo-radius-state-e2e.yaml`](../../../../.github/workflows/repo-radius-state-e2e.yaml) workflow, a daily end-to-end test of the OCI state archive, and how to provision, run, and troubleshoot it. It is for maintainers who own the scheduled run and for contributors changing the OCI state archive or its workflow. For the OCI state archive itself, see the [durable state archive architecture](../../../architecture/state-archive.md). + +## How the test works + +[`repo-radius-state-e2e.yaml`](../../../../.github/workflows/repo-radius-state-e2e.yaml) deploys an application through one ephemeral Radius control plane to a separate persistent k3d target cluster, saves Radius state to a private GHCR package, replaces the control plane, restores the saved state, and updates the existing target-cluster workload. + +The scheduled run uses `ghcr.io/radius-project/radius-repo-state-e2e`. This package must be precreated as private and linked to `radius-project/radius`; a workflow in the public repository would otherwise create a public package, which Radius refuses to use for state. + +## Provision the state package + +Provision or verify the package with a GitHub CLI credential that has `write:packages`: + +```bash +$ make install-oras +$ export PATH="$HOME/.local/bin:$PATH" +$ ./.github/scripts/precreate-repo-radius-state-package.sh \ + --package ghcr.io/radius-project/radius-repo-state-e2e \ + --source-repository https://github.com/radius-project/radius +``` + +The script is idempotent. It creates or verifies a harmless `bootstrap` version, private/internal visibility, and repository linkage. It never changes a public package to private. + +## Run the test + +The workflow runs daily at 04:17 UTC from the default branch. For manual testing, dispatch another ref and optionally select a different precreated package under that repository owner: + +```bash +$ gh workflow run repo-radius-state-e2e.yaml \ + --ref \ + -f state_package= +``` + +## Diagnostics and failure handling + +Each lifecycle phase is a separate workflow step. On failure, inspect the failed step first, then download the `repo-radius-state-e2e-diagnostics` artifact. A separate post-lifecycle job retries run-specific state cleanup even if the main job times out. Scheduled failures in the upstream repository also create an issue labeled `test-failure`; manual and fork runs do not. Successful runs record the saved/restored digest in the job summary; the private package and bootstrap version remain. + +## Related docs + +- [Durable state archive](../../../architecture/state-archive.md) — the OCI state archive this test exercises. +- [Running functional tests](./running-functional-tests.md) — the broader end-to-end test tier. +- [Contributing to GitHub Actions workflows](../contributing-code-github-workflows/README.md) — how to change the automation under `.github/workflows/`. diff --git a/test/functional-portable/statestore/noncloud/testdata/repo-radius-state-app.bicep b/test/functional-portable/statestore/noncloud/testdata/repo-radius-state-app.bicep new file mode 100644 index 0000000000..3187aa641a --- /dev/null +++ b/test/functional-portable/statestore/noncloud/testdata/repo-radius-state-app.bicep @@ -0,0 +1,31 @@ +extension radius + +@description('The Radius environment resource ID for the application.') +param environment string + +@description('A marker written by the container to prove that an update was deployed.') +param deploymentPhase string + +resource repoRadiusStateApp 'Radius.Core/applications@2025-08-01-preview' = { + name: 'repo-radius-state-e2e' + location: 'global' + properties: { + environment: environment + } +} + +resource repoRadiusStateContainer 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'repo-radius-state-container' + location: 'global' + properties: { + application: repoRadiusStateApp.id + environment: environment + containers: { + main: { + image: 'ghcr.io/radius-project/mirror/debian:latest' + command: ['/bin/sh'] + args: ['-c', 'while true; do echo ${deploymentPhase}; sleep 10; done'] + } + } + } +}