diff --git a/.github/extension/README.md b/.github/extension/README.md index 18edd8e00f..fdbc2a6de4 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -75,7 +75,7 @@ To keep the two provider paths from duplicating the ~80% of steps they share, it - **`run-rad-commands.yml`** — the unified **dispatcher** and the only file that is dispatched. It owns the dispatch contract (`workflow_dispatch` inputs and the `Radius - Verify Credentials` auto-trigger). A `detect` job binds the GitHub Environment, reads which provider variable is set (`AZURE_CLIENT_ID` / `AWS_ROLE_ARN`), and calls the matching provider workflow via `workflow_call` with `secrets: inherit`. - **`run-rad-commands-azure.yml`** — a reusable (`workflow_call`) workflow with only the Azure-specific steps: Azure OIDC login, AKS connection (`az aks get-credentials`), workload-identity credential registration, and the `azure-avm` recipe pack (Azure Verified Modules) downloaded from the immutable `resource-types-contrib` commit recorded in `deploy/manifest/defaults.yaml`. Its Kubernetes recipe sources are rewritten from upstream's floating aliases to the corresponding validated namespace commits before deployment. - **`run-rad-commands-aws.yml`** — a reusable (`workflow_call`) workflow with only the AWS-specific steps: AWS OIDC login, EKS connection (access entry + static token kubeconfig), IRSA credential registration, and the `aws-terraform` recipe pack. Every Terraform source uses its resource-type namespace's immutable catalog commit; the container image build recipe defaults to the Compute namespace commit. -- **`actions/*`** — composite actions holding the provider-agnostic phases both provider workflows share: [`setup-control-plane`](actions/setup-control-plane/action.yml), [`load-contrib-catalog`](actions/load-contrib-catalog/action.yml), [`restore-state`](actions/restore-state/action.yml), [`apply-custom-recipe-packs`](actions/apply-custom-recipe-packs/action.yml), [`run-rad-commands`](actions/run-rad-commands/action.yml), [`publish-deploy-status`](actions/publish-deploy-status/action.yml), [`delete-resource`](actions/delete-resource/action.yml), and [`teardown`](actions/teardown/action.yml). The provider workflows reference them from `radius-project/radius` at a pinned ref (the `{{RADIUS_REF}}` placeholder the generator fills in), so the shared logic has a single reviewed home and is not copied into user repos. Third-party actions in these workflows are pinned to full commit SHAs (with a `# vX` comment); only the first-party Radius composite actions are referenced by ref. +- **`actions/*`** — composite actions holding the provider-agnostic phases both provider workflows share: [`setup-control-plane`](actions/setup-control-plane/action.yml), [`load-contrib-catalog`](actions/load-contrib-catalog/action.yml), [`restore-state`](actions/restore-state/action.yml), [`apply-custom-recipe-packs`](actions/apply-custom-recipe-packs/action.yml), [`manage-routes-gateway`](actions/manage-routes-gateway/action.yml), [`run-rad-commands`](actions/run-rad-commands/action.yml), [`publish-deploy-status`](actions/publish-deploy-status/action.yml), [`delete-resource`](actions/delete-resource/action.yml), and [`teardown`](actions/teardown/action.yml). The provider workflows reference them from `radius-project/radius` at a pinned ref (the `{{RADIUS_REF}}` placeholder the generator fills in), so the shared logic has a single reviewed home and is not copied into user repos. Third-party actions in these workflows are pinned to full commit SHAs (with a `# vX` comment); only the first-party Radius composite actions are referenced by ref. The deploy flow generates the dispatcher and both provider workflows, commits them to the target repo under `.github/workflows/`, and dispatches `run-rad-commands.yml`. @@ -87,7 +87,7 @@ Radius deletes a deployed application or an environment with the same ephemeral- - **`delete-environment.yml`** — dispatcher to delete one environment. `workflow_dispatch` inputs: `environment` (GitHub Environment name) and optional `environment_name` (the Radius environment name, defaulting to the GitHub Environment name, since the deploy flow names the Radius environment after it). Calls the provider delete workflow with `resource_type: environment`. - **`delete-azure.yml`** / **`delete-aws.yml`** — reusable (`workflow_call`) workflows with the provider-specific steps (OIDC login, cluster connection, cloud OIDC token projection, and credential registration) shared with the deploy provider workflows. They reuse the `setup-control-plane`, `restore-state`, [`delete-resource`](actions/delete-resource/action.yml), and `teardown` composite actions. Like the deploy provider workflows they log in to GHCR and set the `RADIUS_STATE_*` variables so `rad startup`/`rad shutdown` can open the OCI-backed state archive. Unlike the deploy provider workflows they do **not** create the environment, recipe pack, or the in-pod image-push registry credentials — the environment and its recipes are restored from state, and deleting builds no images. -The `delete-resource` composite action runs `rad app delete --yes --preview` or `rad env delete --yes --preview` (`--preview` selects the Radius.Core surface the deploy flow provisions) and writes a `rad-delete-result` artifact — a JSON document with `outcome`, `exitCode`, `resourceType`, `name`, and the command `output`. +The `delete-resource` composite action runs `rad app delete --yes --preview` or `rad env delete --yes --preview` (`--preview` selects the Radius.Core surface the deploy flow provisions) and writes a `rad-delete-result` artifact — a JSON document with `outcome`, `exitCode`, `resourceType`, `name`, and the command `output`. After a successful application delete, the provider workflow runs `manage-routes-gateway` cleanup. It preserves the shared Gateway while any remaining Radius application has a `Radius.Compute/routes` resource, fails closed when application/resource discovery fails, and removes only lifecycle-owned infrastructure when no Radius or non-Radius Gateway API objects, Kubernetes Ingresses, or Contour HTTPProxy/TLSCertificateDelegation objects still use it. BYO Gateways and pre-existing CRDs are never deleted. ### What it does @@ -105,12 +105,13 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-2 10. **Register cloud credentials.** Registers the cloud identity with `rad credential register azure wi` / `aws irsa` so Radius holds the identity selector and reads the projected token at runtime. 11. **Create the Radius environment and recipe pack.** `rad deploy`s a `radius-env.bicep` that defines a `Radius.Core/recipePacks` resource and the `Radius.Core/environments` resource that references it. The shared `load-contrib-catalog` action exposes `deploy/manifest/defaults.yaml` from the same pinned Radius ref as the workflow actions. Azure resolves the `azure-avm` pack from that catalog and replaces its floating Kubernetes recipe aliases with catalog-derived sources; AWS generates an inline `aws-terraform` pack whose Git and OCI sources are resolved from the same catalog. No contrib refs are duplicated in generated workflow files. `radius-env.bicep` is written to the app file's directory (e.g. `.radius/`) and deployed from there, so `rad deploy` resolves the repo's own `bicepconfig.json` (which declares the `radius` extension) — bicep resolves the config nearest the `.bicep` file. The `Radius.Compute/containerImages` type ships with the Radius extension, so no separate resource-type registration is needed. 12. **Register custom types and apply custom recipe pack.** When the app's `.radius/` folder carries a `custom-types.yaml` file, the shared `apply-custom-recipe-packs` action registers those resource types with `rad resource-type create --from-file` (skipped when absent). When it carries a `custom-recipe-pack.bicep` file, the action compiles the file to identify the literal names of non-existing `Radius.Core/recipePacks` resources authored by the template, deploys it, resolves those exact packs in the current workspace scope, reads the environment's existing `recipePacks` with `rad env show --preview`, and runs `rad env update --recipe-packs --preview`. Existing recipe-pack resources in the template are references and are not attached by this action. This works for both first deploys and idempotent redeploys after state restore, keeps the default and other already-attached packs, and never pulls in unrelated packs from the control plane's global list (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. -13. **Run the requested rad commands.** Validates each command in `rad_commands` against the allowed-command set, then runs them in order (stopping on the first failure) and writes a combined `rad-commands-result` artifact. Before deploying the app, the shared action compiles its Bicep file once and reads the declared ARM parameters. It passes each extension-generated parameter only when the template declares it: `image` (the workflow input, defaulting to `github.sha`), `registryUsername` (`github.actor`), and `registryPassword` (the built-in `GITHUB_TOKEN`). Caller-configured application parameters from the `RADIUS_DEPLOY_PARAMS` secret remain strict and are passed unchanged. The registry parameters feed the app's `Radius.Security/secrets` resource (`radius-ghcr-registry-creds`), when present, so the containerImages recipe's in-pod BuildKit can push the application image. Secret values are passed via an argv array and never written into the recorded command string. +13. **Ensure the routes Gateway lifecycle.** After the effective recipe-pack list is final and before application deployment, `manage-routes-gateway` compiles the app and acts only when it declares a non-existing `Radius.Compute/routes` resource whose first effective recipe source matches `ghcr.io/radius-project/kube-recipes/routes:`. Apps without routes and custom route recipes are no-ops. An explicit `RADIUS_ROUTES_GATEWAY_NAME` selects validation-only BYO behavior: the action requires the namespace, Gateway API CRDs, GatewayClass/controller, and Gateway to be ready and never replaces or adopts them. Otherwise it idempotently ensures Gateway API `v1.2.1`, Contour chart `0.1.0`, GatewayClass `contour`, and Gateway `radius` in `radius-system` on the target cluster. These versions match the Radius CLI's pinned Contour chart and its bundled Gateway API version; Gateway CRDs are managed separately so only CRDs created by this action can be removed. Managed Envoy exposure defaults to `ClusterIP`; exact `public` opts the whole shared Gateway into `LoadBalancer` and emits a warning listing every currently affected Radius application. +14. **Run the requested rad commands.** Validates each command in `rad_commands` against the allowed-command set, then runs them in order (stopping on the first failure) and writes a combined `rad-commands-result` artifact. Before deploying the app, the shared action compiles its Bicep file once and reads the declared ARM parameters. It passes each extension-generated parameter only when the template declares it: `image` (the workflow input, defaulting to `github.sha`), `registryUsername` (`github.actor`), and `registryPassword` (the built-in `GITHUB_TOKEN`). Caller-configured application parameters from the `RADIUS_DEPLOY_PARAMS` secret remain strict and are passed unchanged. The registry parameters feed the app's `Radius.Security/secrets` resource (`radius-ghcr-registry-creds`), when present, so the containerImages recipe's in-pod BuildKit can push the application image. Secret values are passed via an argv array and never written into the recorded command string. Only an application deploy of the configured app file starts live progress polling. While that command runs, the action polls `rad resource list --preview --application --output json` every five seconds and publishes a snapshot only when the canonical resource state changes. Live artifacts are named `radius-deploy-status---live--slot-<0..7>`, retained for one day, and rotated through eight slots. Each payload has an increasing `sequence`, the active `runId`, and run-level state `in_progress`; unknown resource states also normalize to `in_progress`. The action uses a checked-in bundle of the official `@actions/artifact` client because a composite `uses:` step cannot run concurrently with `rad deploy`. Immediately before the composite deploy action, a JavaScript action captures the action-scoped artifact runtime in a private runner-temp file; the deploy process loads it for the background publisher and removes it during EXIT cleanup. Live reporting is best-effort. Poll, JSON, deletion, and upload failures warn without changing the deploy result. The poller is stopped and awaited immediately after the application deploy, before any later command runs, and the EXIT cleanup also stops it after an unexpected interruption. Unrelated commands and deploys of other Bicep files do not start the poller. -14. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.json` — per-resource status the canvas paints the graph from, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt` — a flat `key=value` summary of the run, not read by the canvas) as a **workflow artifact** named `radius-deploy-status--` (lowercased, with characters outside `[a-z0-9._-]` collapsed to `-`), retained for 30 days. It publishes on failed deploys too, since that is when the Deployed graph is most useful. +15. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.json` — per-resource status the canvas paints the graph from, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt` — a flat `key=value` summary of the run, not read by the canvas) as a **workflow artifact** named `radius-deploy-status--` (lowercased, with characters outside `[a-z0-9._-]` collapsed to `-`), retained for 30 days. It publishes on failed deploys too, since that is when the Deployed graph is most useful. Publishing is best-effort and never changes the deployment result. When the application name cannot be resolved, or `rad app graph` fails, the action warns, publishes nothing, and exits 0. Resource status comes from `rad resource list --preview`, which resolves application ownership through `Radius.Core/applications`; the non-preview command targets the legacy `Applications.Core` identity and is not used by this flow. When preview resource listing fails, the action emits one sanitized warning and still publishes: `deploy-progress.json` carries the run-level state with an empty `resources` array, so the Deployed tab shows the run rather than nothing. @@ -121,13 +122,15 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-2 The fixed-name terminal payload uses one sequence greater than the last successful live upload, or sequence 1 when no live upload succeeded. Consumers therefore select the numerically greatest valid sequence without relying on artifact list order or slot number, and the terminal artifact wins naturally after deployment ends. Workflow artifacts are the transport because the REST API can read them **while the run is still in progress** (`GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`), which is what lets the canvas show deployment state as it happens; `GET /repos/{owner}/{repo}/actions/artifacts?name=` finds the newest one later without knowing the run. They also require no extra registry, no `packages: write` permission, and no name derivation duplicated between this action and the canvas reader. -15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the state archive — the OCI-backed archive by default (pushed to GHCR, selected by the `RADIUS_STATE_*` variables), or the `radius-state` git orphan branch when `RADIUS_STATE_BACKEND=git`. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. -16. **Tear down.** Runs `rad app list`, and always deletes the ephemeral `radius-cp` cluster. On failure, Radius and application logs are collected and uploaded as the `radius-logs` artifact (three-day retention). +16. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the state archive — the OCI-backed archive by default (pushed to GHCR, selected by the `RADIUS_STATE_*` variables), or the `radius-state` git orphan branch when `RADIUS_STATE_BACKEND=git`. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. +17. **Tear down.** Runs `rad app list`, and always deletes the ephemeral `radius-cp` cluster. On failure, Radius and application logs are collected and uploaded as the `radius-logs` artifact (three-day retention). ### Triggers and permissions Triggers and permissions live on the **dispatcher** (`run-rad-commands.yml`); the provider workflows are `workflow_call`-only and inherit permissions and secrets from it. +The Azure and AWS deploy/delete provider workflows share a repository-and-environment concurrency group with `cancel-in-progress: false`. This serializes the complete ensure-through-deploy and delete-through-cleanup lifecycle for one Radius environment while allowing different environments to proceed independently. + - **Triggers:** - `workflow_dispatch` with an `environment` input (the GitHub Environment name) plus optional `image` and `rad_commands` inputs. The `detect` job binds that environment via `environment: ${{ inputs.environment }}` to read the provider variables. - `workflow_run` after the `Radius - Verify Credentials` workflow completes. The `detect` job runs only when the upstream verify run concluded `success`, so a successful credential check auto-triggers a deploy. @@ -148,7 +151,7 @@ Triggers and permissions live on the **dispatcher** (`run-rad-commands.yml`); th The workflow reads cloud and cluster configuration from GitHub Actions **variables** (`vars`). Configure the relevant provider's set on the target GitHub Environment: -- Common: `KUBERNETES_NAMESPACE` (default `default`), `RADIUS_BUILD_REGISTRY` (default `ghcr.io//`), `RADIUS_RAD_COMMANDS` (optional fallback for `rad_commands`), `RADIUS_GRAPH_REGISTRY` (optional OCI repository for the `rad` CLI's modeled graph archive) +- Common: `KUBERNETES_NAMESPACE` (default `default`), `RADIUS_BUILD_REGISTRY` (default `ghcr.io//`), `RADIUS_RAD_COMMANDS` (optional fallback for `rad_commands`), `RADIUS_GRAPH_REGISTRY` (optional OCI repository for the `rad` CLI's modeled graph archive), `RADIUS_ROUTES_GATEWAY_NAME` and `RADIUS_ROUTES_GATEWAY_NAMESPACE` (optional validation-only BYO Gateway; set both), and `RADIUS_ROUTES_EXPOSURE` (optional managed Gateway exposure: unset/`private` for `ClusterIP`, or `public` for `LoadBalancer`; invalid with BYO) - Azure (`run-rad-commands-azure.yml`): `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP`, `AZURE_AKS_CLUSTER_NAME` - AWS (`run-rad-commands-aws.yml`): `AWS_ROLE_ARN`, `AWS_REGION`, `AWS_ACCOUNT_ID`, `AWS_EKS_CLUSTER_NAME`, `RADIUS_VPC_ID`, `RADIUS_SUBNET_IDS` diff --git a/.github/extension/actions/manage-routes-gateway/action.yml b/.github/extension/actions/manage-routes-gateway/action.yml new file mode 100644 index 0000000000..b81b8e7b21 --- /dev/null +++ b/.github/extension/actions/manage-routes-gateway/action.yml @@ -0,0 +1,60 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-action.json +--- +name: Radius - Manage routes Gateway +description: >- + Ensure or clean up the target-cluster Gateway API infrastructure used by the + Radius default routes recipe. + +inputs: + mode: + description: Lifecycle operation, either ensure or cleanup. + required: true + target-kubeconfig: + description: Kubeconfig for the workload cluster. + required: false + default: "" + environment-name: + description: Radius environment whose effective routes recipe is inspected. + required: true + application-name: + description: Application being deployed or deleted, used in diagnostics. + required: false + default: "" + app-file: + description: Application Bicep file inspected by ensure mode. + required: false + default: "" + gateway-name: + description: Effective Gateway name passed to the routes recipe. + required: false + default: "" + gateway-namespace: + description: Effective Gateway namespace passed to the routes recipe. + required: false + default: "" + gateway-explicit: + description: Whether RADIUS_ROUTES_GATEWAY_NAME was explicitly configured. + required: false + default: "false" + exposure: + description: Managed Gateway exposure; empty/private or public. + required: false + default: "" + +runs: + using: composite + steps: + - name: Manage routes Gateway infrastructure + shell: bash + env: + MODE: ${{ inputs.mode }} + TARGET_KUBECONFIG: ${{ inputs.target-kubeconfig }} + ENVIRONMENT_NAME: ${{ inputs.environment-name }} + APPLICATION_NAME: ${{ inputs.application-name }} + APP_FILE: ${{ inputs.app-file }} + GATEWAY_NAME: ${{ inputs.gateway-name }} + GATEWAY_NAMESPACE: ${{ inputs.gateway-namespace }} + GATEWAY_EXPLICIT: ${{ inputs.gateway-explicit }} + EXPOSURE: ${{ inputs.exposure }} + run: | + bash "${GITHUB_ACTION_PATH}/routes-gateway.sh" diff --git a/.github/extension/actions/manage-routes-gateway/routes-gateway.sh b/.github/extension/actions/manage-routes-gateway/routes-gateway.sh new file mode 100644 index 0000000000..6a0e326a02 --- /dev/null +++ b/.github/extension/actions/manage-routes-gateway/routes-gateway.sh @@ -0,0 +1,1151 @@ +#!/bin/bash + +# Manages the target-cluster Gateway API lifecycle for Radius.Compute/routes. + +set -euo pipefail + +readonly CONTOUR_CHART_REPO="https://projectcontour.github.io/helm-charts" +readonly CONTOUR_CHART_VERSION="0.1.0" +readonly CONTOUR_RELEASE="contour" +readonly GATEWAY_API_VERSION="v1.2.1" +readonly GATEWAY_API_URL="https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_VERSION}/experimental-install.yaml" +readonly MANAGED_BY="radius-repo" +readonly MANAGED_LABEL="app.kubernetes.io/managed-by" +readonly OWNERSHIP_ANNOTATION="radius-project.io/routes-gateway-lifecycle" +readonly OWNERSHIP_VALUE="v1" +readonly DEFAULT_GATEWAY_NAME="radius" +readonly DEFAULT_GATEWAY_NAMESPACE="radius-system" +readonly DEFAULT_GATEWAY_CLASS="contour" +readonly CONTOUR_CONTROLLER="projectcontour.io/gateway-controller" +readonly DEFAULT_ROUTES_SOURCE_PREFIX="ghcr.io/radius-project/kube-recipes/routes:" +readonly WAIT_TIMEOUT="${RADIUS_ROUTES_WAIT_TIMEOUT:-5m}" +readonly RETRY_DELAY="${RADIUS_ROUTES_RETRY_DELAY:-5}" +readonly RETRY_ATTEMPTS="${RADIUS_ROUTES_RETRY_ATTEMPTS:-12}" + +readonly -a REQUIRED_CRDS=( + "gatewayclasses.gateway.networking.k8s.io" + "gateways.gateway.networking.k8s.io" + "httproutes.gateway.networking.k8s.io" + "referencegrants.gateway.networking.k8s.io" + "grpcroutes.gateway.networking.k8s.io" + "tcproutes.gateway.networking.k8s.io" + "tlsroutes.gateway.networking.k8s.io" + "udproutes.gateway.networking.k8s.io" +) +readonly -a GATEWAY_API_OBJECTS=( + "gatewayclasses.gateway.networking.k8s.io" + "gateways.gateway.networking.k8s.io" + "httproutes.gateway.networking.k8s.io" + "grpcroutes.gateway.networking.k8s.io" + "tcproutes.gateway.networking.k8s.io" + "tlsroutes.gateway.networking.k8s.io" + "udproutes.gateway.networking.k8s.io" + "referencegrants.gateway.networking.k8s.io" +) + +MODE="${MODE:-}" +TARGET_KUBECONFIG="${TARGET_KUBECONFIG:-}" +ENVIRONMENT_NAME="${ENVIRONMENT_NAME:-}" +APPLICATION_NAME="${APPLICATION_NAME:-}" +APP_FILE="${APP_FILE:-}" +GATEWAY_NAME="${GATEWAY_NAME-}" +GATEWAY_NAMESPACE="${GATEWAY_NAMESPACE-}" +GATEWAY_EXPLICIT="${GATEWAY_EXPLICIT:-false}" +EXPOSURE="${EXPOSURE:-}" + +declare -a KUBE_ARGS=() +declare -a HELM_KUBE_ARGS=() + +TEMP_DIR="" + +cleanup_temp() { + if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then + rm -rf "${TEMP_DIR}" + fi +} +trap cleanup_temp EXIT + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +kube() { + kubectl "${KUBE_ARGS[@]}" "$@" +} + +helm_target() { + helm "${HELM_KUBE_ARGS[@]}" "$@" +} + +require_detection_tools() { + local command + for command in jq rad; do + command -v "${command}" >/dev/null 2>&1 || + fail "${command} is required to manage the routes Gateway" + done +} + +require_gateway_tools() { + local command + for command in helm kubectl; do + command -v "${command}" >/dev/null 2>&1 || + fail "${command} is required to manage the routes Gateway" + done + if [[ "${MODE}" == "ensure" ]]; then + command -v curl >/dev/null 2>&1 || + fail "curl is required to manage the routes Gateway" + fi +} + +validate_basic_inputs() { + case "${MODE}" in + ensure | cleanup) ;; + *) fail "mode must be 'ensure' or 'cleanup', got '${MODE}'" ;; + esac + [[ -n "${ENVIRONMENT_NAME}" ]] || + fail "environment-name is required" +} + +validate_gateway_inputs() { + case "${GATEWAY_EXPLICIT}" in + true | false) ;; + *) + fail "gateway-explicit must be 'true' or 'false', got '${GATEWAY_EXPLICIT}'" + ;; + esac + + case "${EXPOSURE}" in + "" | private | public) ;; + *) + fail "RADIUS_ROUTES_EXPOSURE must be 'private' or 'public', got '${EXPOSURE}'" + ;; + esac + + if [[ "${GATEWAY_EXPLICIT}" == "true" ]]; then + [[ -n "${GATEWAY_NAME}" ]] || + fail "RADIUS_ROUTES_GATEWAY_NAME cannot be empty when explicitly configured" + [[ -n "${GATEWAY_NAMESPACE}" ]] || + fail "RADIUS_ROUTES_GATEWAY_NAMESPACE is required with RADIUS_ROUTES_GATEWAY_NAME" + if [[ -n "${EXPOSURE}" ]]; then + fail "RADIUS_ROUTES_EXPOSURE cannot be set with an explicit BYO Gateway" + fi + else + if [[ -n "${GATEWAY_NAMESPACE}" ]]; then + fail "RADIUS_ROUTES_GATEWAY_NAMESPACE cannot be set without RADIUS_ROUTES_GATEWAY_NAME" + fi + GATEWAY_NAME="${DEFAULT_GATEWAY_NAME}" + GATEWAY_NAMESPACE="${DEFAULT_GATEWAY_NAMESPACE}" + fi + + if [[ -n "${TARGET_KUBECONFIG}" ]]; then + if [[ ! -f "${TARGET_KUBECONFIG}" || + ! -r "${TARGET_KUBECONFIG}" ]]; then + fail "target kubeconfig is not a readable regular file at ${TARGET_KUBECONFIG}" + fi + KUBE_ARGS=(--kubeconfig "${TARGET_KUBECONFIG}") + HELM_KUBE_ARGS=(--kubeconfig "${TARGET_KUBECONFIG}") + fi +} + +compile_app() { + local bicep_bin="${BICEP_BIN:-${BICEP:-${HOME}/.rad/bin/bicep}}" + local output="$1" + + [[ -n "${APP_FILE}" ]] || fail "app-file is required in ensure mode" + [[ -f "${APP_FILE}" ]] || fail "application file not found at ${APP_FILE}" + if [[ -d "${bicep_bin}" ]]; then + bicep_bin="${bicep_bin}/bicep" + fi + [[ -x "${bicep_bin}" ]] || + fail "Bicep compiler not found at ${bicep_bin}" + "${bicep_bin}" build "${APP_FILE}" --outfile "${output}" || + fail "failed to compile ${APP_FILE} before routes Gateway detection" +} + +app_declares_routes() { + local compiled="$1" + jq -e ' + def resource_values: + if type == "object" or type == "array" then + .[] + else + error("compiled template resources must be an object or array") + end; + def is_nested_deployment: + ((.type // "") | ascii_downcase) == + "microsoft.resources/deployments"; + def validate_resources: + if type != "object" and type != "array" then + error("compiled template resources must be an object or array") + else + [ + resource_values | + if type != "object" then + error("compiled template resource entries must be objects") + elif ( + is_nested_deployment and + (.properties | type) == "object" and + (.properties | has("template")) + ) then + if (.properties.template | type) != "object" then + error("nested deployment template must be an object") + else + .properties.template.resources | validate_resources + end + else + true + end + ] | + true + end; + def declares_routes: + any( + resource_values; + if ( + (((.type // "") | ascii_downcase) | + . == "radius.compute/routes" or + startswith("radius.compute/routes@")) and + ((.existing // false) != true) + ) then + true + elif ( + is_nested_deployment and + (.properties | type) == "object" and + (.properties | has("template")) + ) then + if (.properties.template | type) != "object" then + error("nested deployment template must be an object") + else + .properties.template.resources | declares_routes + end + else + false + end + ); + .resources as $resources | + if ($resources | validate_resources) then + $resources | declares_routes + else + false + end + ' "${compiled}" >/dev/null +} + +environment_recipe_pack_ids_json() { + local environment_json + environment_json="$( + rad env show "${ENVIRONMENT_NAME}" --preview -o json + )" || fail "failed to inspect Radius environment ${ENVIRONMENT_NAME}" + jq -sc ' + if length == 0 then + error("environment lookup returned no JSON") + elif (.[0].properties.recipePacks | type) != "array" then + error("environment recipePacks must be an array") + elif any(.[0].properties.recipePacks[]; + type != "string" or length == 0) then + error("environment recipePacks contains an invalid resource ID") + else + .[0].properties.recipePacks + end + ' <<<"${environment_json}" || + fail "environment '${ENVIRONMENT_NAME}' returned invalid recipe-pack data" +} + +effective_routes_source() { + local pack_ids + local pack_id + local pack_json + local route_source + + pack_ids="$(environment_recipe_pack_ids_json)" + while IFS= read -r pack_id; do + [[ -n "${pack_id}" ]] || continue + pack_json="$(rad recipe-pack show "${pack_id}" -o json)" || + fail "failed to inspect recipe pack ${pack_id}" + route_source="$( + jq -sce ' + if length != 1 then + error("recipe pack lookup must return one JSON document") + elif (.[0].properties.recipes | type) != "object" then + error("recipe pack recipes must be an object") + else + [ + .[0].properties.recipes | to_entries[] | + select(.key | ascii_downcase == "radius.compute/routes") + ] | + if length == 0 then null + elif length != 1 or + (.[0].value.source | type) != "string" or + (.[0].value.source | length) == 0 then + error("routes recipe must have one non-empty string source") + else + .[0].value.source + end + end + ' <<<"${pack_json}" + )" || fail "recipe pack ${pack_id} returned invalid recipe data" + if [[ "${route_source}" != "null" ]]; then + jq -r . <<<"${route_source}" + return 0 + fi + done < <(jq -r '.[]' <<<"${pack_ids}") + + return 1 +} + +uses_default_routes_recipe() { + local source + source="$(effective_routes_source)" || + fail "environment '${ENVIRONMENT_NAME}' has no effective Radius.Compute/routes recipe" + if [[ "${source}" == "${DEFAULT_ROUTES_SOURCE_PREFIX}"* && + "${source#"${DEFAULT_ROUTES_SOURCE_PREFIX}"}" != "" ]]; then + echo "Effective routes recipe is the Radius default: ${source}" + return 0 + fi + echo "Custom routes recipe detected (${source}); Gateway lifecycle is a no-op." + return 1 +} + +gateway_crd_state() { + local crd="$1" + local result + + if result="$( + kube get "customresourcedefinition/${crd}" -o name 2>&1 + )"; then + case "${result}" in + "customresourcedefinition.apiextensions.k8s.io/${crd}" | \ + "customresourcedefinition/${crd}") + printf 'present\n' + ;; + *) + fail "Gateway API CRD ${crd} discovery returned unexpected output" + ;; + esac + return 0 + fi + if grep -Eqi 'not found|notfound' <<<"${result}"; then + printf 'missing\n' + return 0 + fi + printf '%s\n' "${result}" >&2 + fail "failed to inspect Gateway API CRD ${crd}" +} + +gateway_crds_state() { + local crd + local state + local incomplete=false + + for crd in "${REQUIRED_CRDS[@]}"; do + if ! state="$(gateway_crd_state "${crd}")"; then + return 1 + fi + if [[ "${state}" == "missing" ]]; then + incomplete=true + fi + done + if [[ "${incomplete}" == "true" ]]; then + printf 'incomplete\n' + else + printf 'complete\n' + fi +} + +wait_for_crds() { + local crd + for crd in "${REQUIRED_CRDS[@]}"; do + kube wait --for=condition=Established \ + "customresourcedefinition/${crd}" \ + --timeout="${WAIT_TIMEOUT}" || + fail "Gateway API CRD ${crd} did not become Established" + done +} + +install_missing_crds() { + local crd + local object + local state + local manifest="${TEMP_DIR}/gateway-api.yaml" + + curl -fsSL "${GATEWAY_API_URL}" -o "${manifest}" || + fail "failed to download Gateway API ${GATEWAY_API_VERSION} CRDs" + + for crd in "${REQUIRED_CRDS[@]}"; do + state="$(gateway_crd_state "${crd}")" || + fail "failed to determine Gateway API CRD ${crd} state" + if [[ "${state}" == "present" ]]; then + continue + fi + + object="$( + kube create --dry-run=client -f "${manifest}" -o json | + jq -sc --arg name "${crd}" \ + --arg label "${MANAGED_LABEL}" \ + --arg managed_by "${MANAGED_BY}" \ + --arg annotation "${OWNERSHIP_ANNOTATION}" \ + --arg value "${OWNERSHIP_VALUE}" ' + [ + .[] | + if .kind == "List" then .items[] else . end | + select(.kind == "CustomResourceDefinition" and + .metadata.name == $name) | + .metadata.labels[$label] = $managed_by | + .metadata.annotations[$annotation] = $value + ][0] // error("CRD not found in pinned Gateway API manifest") + ' + )" || fail "failed to render Gateway API CRD ${crd}" + + if ! printf '%s\n' "${object}" | kube create -f -; then + state="$(gateway_crd_state "${crd}")" || + fail "failed to determine Gateway API CRD ${crd} state" + [[ "${state}" == "present" ]] || + fail "failed to create Gateway API CRD ${crd}" + fi + done + wait_for_crds +} + +resource_owned() { + local resource="$1" + local namespace="${2:-}" + local name="$3" + local value + declare -a namespace_args=() + if [[ -n "${namespace}" ]]; then + namespace_args=(-n "${namespace}") + fi + value="$( + kube get "${resource}" "${name}" "${namespace_args[@]}" \ + -o "jsonpath={.metadata.annotations.${OWNERSHIP_ANNOTATION//./\\.}}" \ + 2>/dev/null || true + )" + [[ "${value}" == "${OWNERSHIP_VALUE}" ]] +} + +create_namespace_if_missing() { + if kube get namespace "${DEFAULT_GATEWAY_NAMESPACE}" >/dev/null 2>&1; then + return 0 + fi + if ! cat </dev/null 2>&1 || + fail "failed to create namespace ${DEFAULT_GATEWAY_NAMESPACE}" + fi +} + +contour_release_state() { + local stderr_file="${TEMP_DIR}/contour-status.stderr" + local status + local values + + if status="$( + helm_target status "${CONTOUR_RELEASE}" \ + -n "${DEFAULT_GATEWAY_NAMESPACE}" -o json 2>"${stderr_file}" + )"; then + if [[ -s "${stderr_file}" ]]; then + cat "${stderr_file}" >&2 + fi + jq -e --arg name "${CONTOUR_RELEASE}" \ + --arg namespace "${DEFAULT_GATEWAY_NAMESPACE}" ' + type == "object" and + .name == $name and + .namespace == $namespace + ' <<<"${status}" >/dev/null || + fail "Contour Helm release status returned invalid JSON" + elif grep -Eqi \ + 'release: not found|release: "[^"]+" not found' \ + "${stderr_file}"; then + printf 'absent\n' + return 0 + else + cat "${stderr_file}" >&2 + if [[ -n "${status}" ]]; then + printf '%s\n' "${status}" >&2 + fi + fail "failed to inspect Contour Helm release" + fi + + values="$( + helm_target get values "${CONTOUR_RELEASE}" \ + -n "${DEFAULT_GATEWAY_NAMESPACE}" -o json + )" || fail "failed to inspect Contour Helm release ownership" + jq -e 'type == "object"' <<<"${values}" >/dev/null || + fail "Contour Helm release values returned invalid JSON" + if jq -e --arg key "${OWNERSHIP_ANNOTATION}" \ + --arg value "${OWNERSHIP_VALUE}" \ + '.commonAnnotations[$key] == $value' <<<"${values}" >/dev/null; then + printf 'owned\n' + else + printf 'unowned\n' + fi +} + +contour_components_state() { + local deployments + local services + deployments="$( + kube get deployment -n "${DEFAULT_GATEWAY_NAMESPACE}" \ + -l app.kubernetes.io/component=contour -o json | + jq '.items | length' + )" || fail "failed to discover existing Contour deployments" + services="$( + kube get service -n "${DEFAULT_GATEWAY_NAMESPACE}" \ + -l app.kubernetes.io/component=envoy -o json | + jq '.items | length' + )" || fail "failed to discover existing Envoy Services" + printf '%s:%s\n' "${deployments}" "${services}" +} + +desired_service_type() { + if [[ "${EXPOSURE}" == "public" ]]; then + printf 'LoadBalancer\n' + else + printf 'ClusterIP\n' + fi +} + +upgrade_managed_contour() { + local service_type + local attempt + service_type="$(desired_service_type)" + for ((attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++)); do + if helm_target upgrade --install "${CONTOUR_RELEASE}" contour \ + --repo "${CONTOUR_CHART_REPO}" \ + --version "${CONTOUR_CHART_VERSION}" \ + --namespace "${DEFAULT_GATEWAY_NAMESPACE}" \ + --set gatewayAPI.manageCRDs=false \ + --set-string \ + "configInline.gateway.gatewayRef.name=${DEFAULT_GATEWAY_NAME}" \ + --set-string \ + "configInline.gateway.gatewayRef.namespace=${DEFAULT_GATEWAY_NAMESPACE}" \ + --set-string "envoy.service.type=${service_type}" \ + --set-string \ + "commonLabels.app\\.kubernetes\\.io/managed-by=${MANAGED_BY}" \ + --set-string \ + "commonAnnotations.radius-project\\.io/routes-gateway-lifecycle=${OWNERSHIP_VALUE}" \ + --wait --timeout "${WAIT_TIMEOUT}"; then + return 0 + fi + if ((attempt < RETRY_ATTEMPTS)); then + echo "Contour reconciliation is busy; retrying (${attempt}/${RETRY_ATTEMPTS})." + sleep "${RETRY_DELAY}" + fi + done + return 1 +} + +ensure_contour() { + local release_state + local state + release_state="$(contour_release_state)" || + fail "failed to determine Contour Helm release state" + case "${release_state}" in + owned) + upgrade_managed_contour + return 0 + ;; + absent | unowned) ;; + *) fail "unexpected Contour Helm release state: ${release_state}" ;; + esac + + state="$(contour_components_state)" + if [[ "${release_state}" == "unowned" ]]; then + [[ "${state}" == "1:1" ]] || + fail "pre-existing Contour release is incomplete and is not Radius-owned" + return 0 + fi + + case "${state}" in + 0:0) + if ! upgrade_managed_contour; then + release_state="$(contour_release_state)" || + fail "failed to determine Contour Helm release state" + if [[ "${release_state}" == "owned" ]]; then + upgrade_managed_contour + else + fail "failed to install pinned Contour ${CONTOUR_CHART_VERSION}" + fi + fi + ;; + 1:1) + echo "Reusing complete pre-existing Contour resources without adopting them." + ;; + *) + fail "pre-existing Contour resources are incomplete; refusing to adopt them" + ;; + esac +} + +gateway_class_valid() { + local class_name="$1" + local expected_controller="${2:-}" + local controller + controller="$( + kube get gatewayclass "${class_name}" \ + -o jsonpath='{.spec.controllerName}' 2>/dev/null + )" || return 1 + if [[ -n "${expected_controller}" && + "${controller}" != "${expected_controller}" ]]; then + return 1 + fi + kube wait --for=condition=Accepted "gatewayclass/${class_name}" \ + --timeout="${WAIT_TIMEOUT}" >/dev/null +} + +managed_gateway_spec_valid() { + local gateway_json="$1" + jq -e --arg expected_class "${DEFAULT_GATEWAY_CLASS}" ' + (.spec.listeners // []) as $listeners | + .spec.gatewayClassName == $expected_class and + ([ + $listeners[] | + select( + .name == "http" and + .protocol == "HTTP" and + .port == 80 and + .allowedRoutes.namespaces.from == "All" + ) + ] | length) == 1 and + ([ + $listeners[] | + select( + .name == "tls" and + .protocol == "TLS" and + .port == 443 and + .tls.mode == "Passthrough" and + .allowedRoutes.namespaces.from == "All" + ) + ] | length) == 1 + ' <<<"${gateway_json}" >/dev/null +} + +gateway_valid() { + local name="$1" + local namespace="$2" + local expected_class="${3:-}" + local gateway_json + local class_name + gateway_json="$( + kube get gateway "${name}" -n "${namespace}" -o json 2>/dev/null + )" || return 1 + class_name="$(jq -er '.spec.gatewayClassName' <<<"${gateway_json}")" || + return 1 + if [[ -n "${expected_class}" && + "${class_name}" != "${expected_class}" ]]; then + return 1 + fi + if [[ -n "${expected_class}" ]] && + ! managed_gateway_spec_valid "${gateway_json}"; then + return 1 + fi + gateway_class_valid "${class_name}" && + kube wait --for=condition=Programmed \ + "gateway/${name}" -n "${namespace}" \ + --timeout="${WAIT_TIMEOUT}" >/dev/null +} + +validate_managed_gateway_conflicts() { + local controller + local gateway_json + + if ! kube get \ + "customresourcedefinition/gateways.gateway.networking.k8s.io" \ + >/dev/null 2>&1; then + return 0 + fi + + if controller="$( + kube get gatewayclass "${DEFAULT_GATEWAY_CLASS}" \ + -o jsonpath='{.spec.controllerName}' 2>/dev/null + )"; then + [[ "${controller}" == "${CONTOUR_CONTROLLER}" ]] || + fail "GatewayClass contour conflicts with the Radius Contour controller" + fi + + if gateway_json="$( + kube get gateway "${DEFAULT_GATEWAY_NAME}" \ + -n "${DEFAULT_GATEWAY_NAMESPACE}" -o json 2>/dev/null + )"; then + managed_gateway_spec_valid "${gateway_json}" || + fail "Gateway radius conflicts with the required Radius listeners" + fi +} + +validate_byo() { + local crd_state + crd_state="$(gateway_crds_state)" || + fail "BYO Gateway validation failed: could not inspect Gateway API CRDs" + [[ "${crd_state}" == "complete" ]] || + fail "BYO Gateway validation failed: Gateway API CRDs are incomplete" + wait_for_crds + gateway_valid "${GATEWAY_NAME}" "${GATEWAY_NAMESPACE}" || + fail "BYO Gateway validation failed: Gateway, GatewayClass, or controller is not ready" + echo "BYO Gateway ${GATEWAY_NAMESPACE}/${GATEWAY_NAME} is ready; no resources changed." +} + +create_gateway_class_if_missing() { + if kube get gatewayclass "${DEFAULT_GATEWAY_CLASS}" >/dev/null 2>&1; then + gateway_class_valid \ + "${DEFAULT_GATEWAY_CLASS}" "${CONTOUR_CONTROLLER}" || + fail "GatewayClass contour conflicts with the Radius Contour controller" + return 0 + fi + if ! cat </dev/null 2>&1 || + fail "failed to create GatewayClass ${DEFAULT_GATEWAY_CLASS}" + fi + gateway_class_valid \ + "${DEFAULT_GATEWAY_CLASS}" "${CONTOUR_CONTROLLER}" || + fail "GatewayClass contour was not accepted by Contour" +} + +create_gateway_if_missing() { + if kube get gateway "${DEFAULT_GATEWAY_NAME}" \ + -n "${DEFAULT_GATEWAY_NAMESPACE}" >/dev/null 2>&1; then + gateway_valid "${DEFAULT_GATEWAY_NAME}" \ + "${DEFAULT_GATEWAY_NAMESPACE}" "${DEFAULT_GATEWAY_CLASS}" || + fail "Gateway radius conflicts with the Radius Contour Gateway" + return 0 + fi + if ! cat </dev/null 2>&1 || + fail "failed to create Gateway radius" + fi + gateway_valid "${DEFAULT_GATEWAY_NAME}" \ + "${DEFAULT_GATEWAY_NAMESPACE}" "${DEFAULT_GATEWAY_CLASS}" || + fail "Gateway radius did not become Programmed" +} + +envoy_service_json() { + kube get service -n "${DEFAULT_GATEWAY_NAMESPACE}" \ + -l app.kubernetes.io/component=envoy -o json | + jq -e ' + if (.items | length) == 1 then .items[0] + else error("expected exactly one Envoy Service") + end + ' +} + +validate_envoy_service() { + local expected_type + local service + local service_name + local actual_type + expected_type="$(desired_service_type)" + service="$(envoy_service_json)" || + fail "failed to find the Contour Envoy Service" + service_name="$(jq -r '.metadata.name' <<<"${service}")" + actual_type="$(jq -r '.spec.type' <<<"${service}")" + [[ "${actual_type}" == "${expected_type}" ]] || + fail "Envoy Service ${service_name} is ${actual_type}; expected ${expected_type}" + + kube get endpoints "${service_name}" \ + -n "${DEFAULT_GATEWAY_NAMESPACE}" -o json | + jq -e 'any(.subsets[]?.addresses[]?; .ip != null)' >/dev/null || + fail "Envoy Service ${service_name} has no ready endpoints" + + if [[ "${expected_type}" == "LoadBalancer" ]]; then + kube wait \ + --for=jsonpath='{.status.loadBalancer.ingress[0]}' \ + "service/${service_name}" \ + -n "${DEFAULT_GATEWAY_NAMESPACE}" \ + --timeout="${WAIT_TIMEOUT}" || + fail "public Envoy Service ${service_name} has no load balancer address" + fi +} + +normalize_items_json() { + local json="$1" + local context="$2" + jq -sce ' + if length != 1 then + error("expected one JSON document") + elif (.[0] | type) == "array" then .[0] + elif (.[0].items | type) == "array" then .[0].items + elif (.[0].value | type) == "array" then .[0].value + else error("expected an array, items array, or value array") + end + ' <<<"${json}" || + fail "${context} returned invalid JSON" +} + +radius_app_names_json() { + local apps_json + apps_json="$(rad app list --preview -o json)" || + fail "failed to list Radius applications" + apps_json="$(normalize_items_json "${apps_json}" "Radius application discovery")" + jq -ce ' + if any(.[]; + type != "object" or + ((.name // .properties.name) | type) != "string" or + ((.name // .properties.name) | length) == 0) then + error("application entries require non-empty names") + else + map(.name // .properties.name) + end + ' <<<"${apps_json}" || + fail "Radius application discovery returned invalid application entries" +} + +app_resources_json() { + local app="$1" + local resources_json + resources_json="$( + rad resource list --preview --application "${app}" -o json + )" || fail "failed to inspect resources for Radius application ${app}" + resources_json="$( + normalize_items_json \ + "${resources_json}" "resource discovery for application ${app}" + )" + jq -ce ' + if any(.[]; + type != "object" or + ((.type // .resourceType) | type) != "string" or + ((.type // .resourceType) | length) == 0) then + error("resource entries require non-empty types") + else + . + end + ' <<<"${resources_json}" || + fail "resource discovery for application ${app} returned invalid entries" +} + +resources_include_routes() { + local resources_json="$1" + jq -e ' + any(.[]; + ((.type // .resourceType) | ascii_downcase) as $type | + $type == "radius.compute/routes" or + ($type | startswith("radius.compute/routes@"))) + ' <<<"${resources_json}" >/dev/null +} + +radius_apps_using_routes() { + local apps + local resources_json + local app + apps="$(radius_app_names_json)" || + fail "failed to discover applications for the public exposure warning" + while IFS= read -r app; do + [[ -n "${app}" ]] || continue + resources_json="$(app_resources_json "${app}")" || + fail "failed to inspect ${app} for the public exposure warning" + if resources_include_routes "${resources_json}"; then + printf '%s\n' "${app}" + fi + done < <(jq -r '.[]' <<<"${apps}") +} + +warn_public_exposure() { + local apps + apps="$(radius_apps_using_routes)" || + fail "failed to identify applications affected by public exposure" + if [[ -z "${apps}" ]]; then + apps="(none currently deployed)" + else + apps="$(paste -sd ',' - <<<"${apps}" | sed 's/,/, /g')" + fi + echo "::warning::PUBLIC ROUTES EXPOSURE: the shared Gateway will expose every Radius application using routes. Affected applications: ${apps}" +} + +remaining_routes_exist() { + local apps + local resources_json + local app + apps="$(radius_app_names_json)" || + fail "failed to discover remaining Radius applications; preserving Gateway infrastructure" + while IFS= read -r app; do + [[ -n "${app}" ]] || continue + resources_json="$(app_resources_json "${app}")" || + fail "failed to inspect ${app}; preserving Gateway infrastructure" + if resources_include_routes "${resources_json}"; then + echo "Application ${app} still uses Radius.Compute/routes." + return 0 + fi + done < <(jq -r '.[]' <<<"${apps}") + return 1 +} + +non_radius_gateway_objects_exist() { + local resource + local objects + for resource in "${GATEWAY_API_OBJECTS[@]}"; do + objects="$(kube get "${resource}" -A -o json)" || + fail "failed to inspect cluster-wide ${resource}; preserving Gateway infrastructure" + objects="$( + normalize_items_json \ + "${objects}" "cluster-wide ${resource} discovery" + )" || fail "cluster-wide ${resource} discovery returned invalid JSON" + objects="$( + jq -ce ' + if any(.[]; + type != "object" or + (.metadata | type) != "object" or + ((.metadata.annotations // {}) | type) != "object") then + error("Gateway API entries require metadata and annotations") + else + . + end + ' <<<"${objects}" + )" || fail "cluster-wide ${resource} discovery returned invalid entries" + if jq -e --arg annotation "${OWNERSHIP_ANNOTATION}" \ + --arg value "${OWNERSHIP_VALUE}" ' + any(.[]; + (.metadata.annotations[$annotation] // "") != $value) + ' <<<"${objects}" >/dev/null; then + return 0 + fi + done + return 1 +} + +objects_exist() { + local resource="$1" + local context="$2" + local objects + objects="$(kube get "${resource}" -A -o json)" || + fail "failed to inspect ${context}; preserving Gateway infrastructure" + objects="$(normalize_items_json "${objects}" "${context}")" || + fail "${context} returned invalid JSON" + jq -e 'length > 0' <<<"${objects}" >/dev/null +} + +optional_contour_objects_exist() { + local resource="$1" + local crd="$2" + local context="$3" + local crd_result + + if crd_result="$( + kube get customresourcedefinition "${crd}" -o json 2>&1 + )"; then + objects_exist "${resource}" "${context}" + return $? + fi + if grep -Eqi 'not found|notfound' <<<"${crd_result}"; then + return 1 + fi + fail "failed to discover ${crd}; preserving Gateway infrastructure" +} + +shared_contour_consumers_exist() { + if objects_exist \ + "ingresses.networking.k8s.io" "cluster-wide Kubernetes Ingresses"; then + return 0 + fi + if optional_contour_objects_exist \ + "httpproxies.projectcontour.io" \ + "httpproxies.projectcontour.io" \ + "cluster-wide Contour HTTPProxies"; then + return 0 + fi + if optional_contour_objects_exist \ + "tlscertificatedelegations.projectcontour.io" \ + "tlscertificatedelegations.projectcontour.io" \ + "cluster-wide Contour TLSCertificateDelegations"; then + return 0 + fi + return 1 +} + +delete_owned_resource() { + local resource="$1" + local namespace="${2:-}" + local name="$3" + declare -a namespace_args=() + if [[ -n "${namespace}" ]]; then + namespace_args=(-n "${namespace}") + fi + if ! kube get "${resource}" "${name}" "${namespace_args[@]}" \ + >/dev/null 2>&1; then + return 0 + fi + if resource_owned "${resource}" "${namespace}" "${name}"; then + kube delete "${resource}" "${name}" "${namespace_args[@]}" \ + --ignore-not-found --wait=true + else + echo "Preserving pre-existing ${resource} ${namespace:+${namespace}/}${name}." + fi +} + +delete_owned_crds() { + local crd + for crd in "${REQUIRED_CRDS[@]}"; do + if resource_owned customresourcedefinition "" "${crd}"; then + kube delete customresourcedefinition "${crd}" \ + --ignore-not-found --wait=true + fi + done +} + +ensure_gateway() { + local compiled="${TEMP_DIR}/app.json" + local routes_status + compile_app "${compiled}" + set +e + app_declares_routes "${compiled}" + routes_status=$? + set -e + case "${routes_status}" in + 0) ;; + 1) + echo "Application declares no non-existing Radius.Compute/routes resources; no-op." + return 0 + ;; + *) + fail "compiled application template has invalid resources data" + ;; + esac + if ! uses_default_routes_recipe; then + return 0 + fi + validate_gateway_inputs + require_gateway_tools + if [[ "${GATEWAY_EXPLICIT}" == "true" ]]; then + validate_byo + return 0 + fi + + validate_managed_gateway_conflicts + + if [[ "${EXPOSURE}" == "public" ]]; then + warn_public_exposure + fi + + create_namespace_if_missing + install_missing_crds + ensure_contour + create_gateway_class_if_missing + create_gateway_if_missing + validate_envoy_service + echo "Routes Gateway is ready with $(desired_service_type) exposure." +} + +cleanup_gateway() { + local crd_state + local release_state + + if ! uses_default_routes_recipe; then + return 0 + fi + validate_gateway_inputs + require_gateway_tools + if [[ "${GATEWAY_EXPLICIT}" == "true" ]]; then + echo "BYO Gateway is never deleted by Radius; cleanup is a no-op." + return 0 + fi + if remaining_routes_exist; then + echo "Routes remain; retaining shared Gateway infrastructure." + return 0 + fi + release_state="$(contour_release_state)" || + fail "failed to determine Contour Helm release state" + if [[ "${release_state}" == "unowned" ]]; then + echo "Pre-existing Contour Helm release is not Radius-owned; retaining all Gateway infrastructure." + return 0 + fi + + crd_state="$(gateway_crds_state)" || + fail "failed to determine Gateway API CRD state" + if [[ "${crd_state}" == "incomplete" ]]; then + echo "Gateway API CRDs are absent or incomplete; nothing safe to remove." + return 0 + fi + + if shared_contour_consumers_exist; then + echo "Ingress or Contour objects use the controller; retaining all Gateway infrastructure." + return 0 + fi + + if non_radius_gateway_objects_exist; then + echo "Non-Radius Gateway API objects exist; retaining all Gateway infrastructure." + return 0 + fi + + delete_owned_resource gateway \ + "${DEFAULT_GATEWAY_NAMESPACE}" "${DEFAULT_GATEWAY_NAME}" + + if non_radius_gateway_objects_exist; then + echo "Gateway API objects appeared during cleanup; retaining shared controller and CRDs." + return 0 + fi + + delete_owned_resource gatewayclass "" "${DEFAULT_GATEWAY_CLASS}" + if [[ "${release_state}" == "owned" ]]; then + helm_target uninstall "${CONTOUR_RELEASE}" \ + -n "${DEFAULT_GATEWAY_NAMESPACE}" --wait \ + --timeout "${WAIT_TIMEOUT}" + fi + + if non_radius_gateway_objects_exist; then + echo "Gateway API objects appeared during cleanup; retaining CRDs." + return 0 + fi + delete_owned_crds + echo "Unused Radius-owned routes Gateway infrastructure removed." +} + +main() { + validate_basic_inputs + require_detection_tools + TEMP_DIR="$(mktemp -d)" + if [[ "${MODE}" == "ensure" ]]; then + ensure_gateway + else + cleanup_gateway + fi +} + +main "$@" diff --git a/.github/extension/actions/manage-routes-gateway/routes-gateway_test.sh b/.github/extension/actions/manage-routes-gateway/routes-gateway_test.sh new file mode 100644 index 0000000000..148e7123a0 --- /dev/null +++ b/.github/extension/actions/manage-routes-gateway/routes-gateway_test.sh @@ -0,0 +1,1081 @@ +#!/bin/bash + +# Hermetic tests for the routes Gateway lifecycle action. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SCRIPT="${SCRIPT_DIR}/routes-gateway.sh" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +readonly REPO_ROOT +readonly AZURE_WORKFLOW="${REPO_ROOT}/.github/extension/run-rad-commands-azure.yml" +readonly AWS_WORKFLOW="${REPO_ROOT}/.github/extension/run-rad-commands-aws.yml" + +TEST_ROOT="$(mktemp -d)" +readonly TEST_ROOT +trap 'rm -rf "${TEST_ROOT}"' EXIT +readonly BIN="${TEST_ROOT}/bin" +readonly STATE="${TEST_ROOT}/state" +readonly CALLS="${TEST_ROOT}/calls.log" +readonly OUTPUT="${TEST_ROOT}/output.log" +readonly APP_FILE="${TEST_ROOT}/app.bicep" +readonly COMPILED="${TEST_ROOT}/compiled.json" +readonly BICEP="${BIN}/bicep" + +ACTION_EXIT=0 + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_fakes() { + mkdir -p "${BIN}" "${STATE}" + + cat >"${BICEP}" <<'EOF' +#!/bin/bash +set -euo pipefail +cp "${COMPILED}" "$4" +EOF + + cat >"${BIN}/rad" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'rad %s\n' "$*" >>"${CALLS}" +case "${1:-} ${2:-}" in + "env show") + printf '%s\n' "${ENV_JSON}" + ;; + "recipe-pack show") + if [[ -n "${PACK_JSON_OVERRIDE}" ]]; then + printf '%s\n' "${PACK_JSON_OVERRIDE}" + else + jq -nc --arg source "${PACK_SOURCE}" \ + '{properties:{recipes:{"Radius.Compute/routes":{kind:"bicep",source:$source}}}}' + fi + ;; + "app list") + if [[ "${RAD_DISCOVERY_FAIL}" == "app" ]]; then + exit 1 + fi + printf '%s\n' "${APP_LIST_JSON}" + ;; + "resource list") + if [[ "${RAD_DISCOVERY_FAIL}" == "resource" ]]; then + exit 1 + fi + app="" + while (($#)); do + if [[ "$1" == "--application" ]]; then + app="$2" + break + fi + shift + done + if [[ -n "${RESOURCE_JSON_OVERRIDE}" ]]; then + printf '%s\n' "${RESOURCE_JSON_OVERRIDE}" + elif [[ ",${ROUTES_APPS}," == *",${app},"* ]]; then + printf '%s\n' \ + '[{"type":"Radius.Compute/routes@2025-08-01-preview"}]' + else + printf '%s\n' '[]' + fi + ;; + *) + echo "unexpected rad command: $*" >&2 + exit 91 + ;; +esac +EOF + + cat >"${BIN}/curl" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'curl %s\n' "$*" >>"${CALLS}" +out="" +while (($#)); do + if [[ "$1" == "-o" ]]; then + out="$2" + break + fi + shift +done +: >"${out}" +EOF + + cat >"${BIN}/helm" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'helm %s\n' "$*" >>"${CALLS}" +args=" $* " +case "${args}" in + *" status contour "*) + if [[ "${HELM_STATUS_WARNING}" == "true" ]]; then + echo 'WARNING: Kubernetes configuration file is group-readable' >&2 + fi + if [[ -f "${STATE}/helm-installed" ]]; then + printf '%s\n' '{"name":"contour","namespace":"radius-system"}' + exit 0 + fi + case "${HELM_STATE}" in + absent) + echo 'Error: release: not found' >&2 + exit 1 + ;; + failure) + echo 'synthetic Helm connectivity failure' >&2 + exit 1 + ;; + malformed) + printf '%s\n' '{"name":"wrong"}' + ;; + present) + printf '%s\n' \ + '{"name":"contour","namespace":"radius-system"}' + ;; + esac + ;; + *" get values contour "*) + if [[ "${HELM_VALUES_STATE}" == "failure" ]]; then + echo 'synthetic Helm values failure' >&2 + exit 1 + elif [[ "${HELM_VALUES_STATE}" == "malformed" ]]; then + printf '%s\n' 'not-json' + elif [[ "${HELM_OWNED}" == "true" || + -f "${STATE}/helm-installed" ]]; then + printf '%s\n' \ + '{"commonAnnotations":{"radius-project.io/routes-gateway-lifecycle":"v1"}}' + else + printf '%s\n' '{}' + fi + ;; + *" upgrade --install contour "*) + [[ "${HELM_FAIL}" != "true" ]] || exit 1 + touch "${STATE}/helm-installed" + if [[ "${args}" == *"envoy.service.type=LoadBalancer"* ]]; then + printf 'LoadBalancer' >"${STATE}/service-type" + else + printf 'ClusterIP' >"${STATE}/service-type" + fi + ;; + *" uninstall contour "*) + touch "${STATE}/helm-uninstalled" + ;; + *) + echo "unexpected helm command: $*" >&2 + exit 92 + ;; +esac +EOF + + cat >"${BIN}/kubectl" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'kubectl %s\n' "$*" >>"${CALLS}" + +if [[ "${1:-}" == "--kubeconfig" ]]; then + shift 2 +fi + +if [[ "${1:-}" == "create" && "${2:-}" == "--dry-run=client" ]]; then + printf '%s\n' "${CRD_LIST_JSON}" + exit 0 +fi + +if [[ "${1:-}" == "create" && "${2:-}" == "-f" ]]; then + body="$(cat)" + printf 'create-body %s\n' \ + "$(tr '\n' ' ' <<<"${body}")" >>"${CALLS}" + if jq -e . >/dev/null 2>&1 <<<"${body}"; then + name="$(jq -r '.metadata.name' <<<"${body}")" + touch "${STATE}/crd-${name}" + elif grep -q '^kind: Namespace' <<<"${body}"; then + touch "${STATE}/namespace" + elif grep -q '^kind: GatewayClass' <<<"${body}"; then + touch "${STATE}/gatewayclass" + elif grep -q '^kind: Gateway' <<<"${body}"; then + touch "${STATE}/gateway" + fi + exit 0 +fi + +if [[ "${1:-}" == "wait" ]]; then + if [[ -n "${WAIT_FAIL}" && "$*" == *"${WAIT_FAIL}"* ]]; then + exit 1 + fi + exit 0 +fi + +if [[ "${1:-}" == "delete" ]]; then + exit 0 +fi + +[[ "${1:-}" == "get" ]] || { + echo "unexpected kubectl command: $*" >&2 + exit 93 +} + +resource="${2:-}" +case "${resource}" in + customresourcedefinition/*) + name="${resource#*/}" + if [[ -f "${STATE}/crd-${name}" || + "${CRDS_STATE}" == "complete" ]]; then + printf 'customresourcedefinition.apiextensions.k8s.io/%s\n' \ + "${name}" + exit 0 + fi + case "${CRDS_STATE}" in + missing) + echo "Error from server (NotFound): customresourcedefinitions.apiextensions.k8s.io \"${name}\" not found" >&2 + exit 1 + ;; + failure) + echo 'synthetic CRD connectivity failure' >&2 + exit 1 + ;; + malformed) + printf '%s\n' 'unexpected-crd-output' + exit 0 + ;; + esac + if [[ "$*" == *"jsonpath="* ]]; then + [[ "${CRDS_OWNED}" == "true" ]] && printf 'v1' + fi + exit 0 + ;; + customresourcedefinition) + name="${3:-}" + if [[ "${name}" == "httpproxies.projectcontour.io" || + "${name}" == "tlscertificatedelegations.projectcontour.io" ]]; then + case "${CONTOUR_CRDS_STATE}" in + present) + printf '%s\n' '{"kind":"CustomResourceDefinition"}' + exit 0 + ;; + missing) + echo "Error from server (NotFound): customresourcedefinitions.apiextensions.k8s.io \"${name}\" not found" >&2 + exit 1 + ;; + failure) + echo "synthetic Contour CRD discovery failure" >&2 + exit 1 + ;; + esac + fi + if [[ "$*" == *"jsonpath="* ]]; then + [[ "${CRDS_OWNED}" == "true" ]] && printf 'v1' + fi + exit 0 + ;; + namespace) + [[ "${NAMESPACE_STATE}" == "present" || + -f "${STATE}/namespace" ]] + ;; + deployment) + count=0 + if [[ "${COMPONENTS_STATE}" == "complete" || + -f "${STATE}/helm-installed" ]]; then + count=1 + fi + jq -nc --argjson count "${count}" \ + '{items:[range(0;$count)|{metadata:{name:"contour"}}]}' + ;; + service) + count=0 + if [[ "${COMPONENTS_STATE}" == "complete" || + -f "${STATE}/helm-installed" ]]; then + count=1 + fi + type="${SERVICE_TYPE}" + [[ -f "${STATE}/service-type" ]] && + type="$(cat "${STATE}/service-type")" + ingress='[]' + [[ "${type}" == "LoadBalancer" ]] && + ingress='[{"hostname":"example.test"}]' + jq -nc --argjson count "${count}" --arg type "${type}" \ + --argjson ingress "${ingress}" \ + '{items:[range(0;$count)|{ + metadata:{name:"contour-envoy"}, + spec:{type:$type}, + status:{loadBalancer:{ingress:$ingress}} + }]}' + ;; + endpoints) + if [[ "${ENDPOINTS_READY}" == "true" ]]; then + printf '%s\n' '{"subsets":[{"addresses":[{"ip":"10.0.0.1"}]}]}' + else + printf '%s\n' '{"subsets":[]}' + fi + ;; + gatewayclass) + if [[ "${3:-}" == "contour" ]]; then + if [[ "${GATEWAYCLASS_STATE}" == "present" || + -f "${STATE}/gatewayclass" ]]; then + if [[ "$*" == *"routes-gateway-lifecycle"* ]]; then + [[ "${GATEWAY_RESOURCES_OWNED}" == "true" ]] && + printf 'v1' + elif [[ "$*" == *"jsonpath="* ]]; then + printf '%s' "${GATEWAYCLASS_CONTROLLER}" + fi + exit 0 + fi + exit 1 + fi + if [[ "$*" == *"jsonpath="* ]]; then + printf '%s' "${BYO_CLASS_CONTROLLER}" + exit 0 + fi + [[ "${BYO_STATE}" == "complete" ]] + ;; + gateway) + if [[ "${3:-}" == "radius" ]]; then + if [[ "${GATEWAY_STATE}" == "present" || + -f "${STATE}/gateway" ]]; then + if [[ "$*" == *"routes-gateway-lifecycle"* ]]; then + [[ "${GATEWAY_RESOURCES_OWNED}" == "true" ]] && + printf 'v1' + elif [[ "$*" == *"-o json"* ]]; then + if [[ "${GATEWAY_LISTENERS_VALID}" == "true" ]]; then + jq -nc '{ + spec:{ + gatewayClassName:"contour", + listeners:[ + { + name:"http",protocol:"HTTP",port:80, + allowedRoutes:{namespaces:{from:"All"}} + }, + { + name:"tls",protocol:"TLS",port:443, + tls:{mode:"Passthrough"}, + allowedRoutes:{namespaces:{from:"All"}} + } + ] + } + }' + else + printf '%s\n' \ + '{"spec":{"gatewayClassName":"contour","listeners":[]}}' + fi + fi + exit 0 + fi + exit 1 + fi + if [[ "$*" == *"-o json"* ]]; then + printf '%s\n' \ + '{"spec":{"gatewayClassName":"byo-class","listeners":[]}}' + exit 0 + fi + [[ "${BYO_STATE}" == "complete" ]] + ;; + gatewayclasses.gateway.networking.k8s.io | \ + gateways.gateway.networking.k8s.io | \ + httproutes.gateway.networking.k8s.io | \ + grpcroutes.gateway.networking.k8s.io | \ + tcproutes.gateway.networking.k8s.io | \ + tlsroutes.gateway.networking.k8s.io | \ + udproutes.gateway.networking.k8s.io | \ + referencegrants.gateway.networking.k8s.io) + if [[ -n "${CLUSTER_JSON_OVERRIDE}" ]]; then + printf '%s\n' "${CLUSTER_JSON_OVERRIDE}" + elif [[ "${SHARED_OBJECTS}" == "true" && + "${resource}" == "${SHARED_OBJECT_RESOURCE}" ]]; then + printf '%s\n' '{"items":[{"metadata":{"annotations":{}}}]}' + else + printf '%s\n' '{"items":[]}' + fi + ;; + ingresses.networking.k8s.io) + if [[ "${SHARED_INGRESS}" == "true" ]]; then + printf '%s\n' '{"items":[{"metadata":{"name":"shared-ingress"}}]}' + else + printf '%s\n' '{"items":[]}' + fi + ;; + httpproxies.projectcontour.io | \ + tlscertificatedelegations.projectcontour.io) + if [[ "${CONTOUR_SHARED_OBJECTS}" == "true" && + "${resource}" == "${CONTOUR_OBJECT_RESOURCE}" ]]; then + printf '%s\n' '{"items":[{"metadata":{"name":"shared-contour"}}]}' + else + printf '%s\n' '{"items":[]}' + fi + ;; + *) + echo "unexpected kubectl get: $*" >&2 + exit 94 + ;; +esac +EOF + + chmod +x "${BIN}"/* +} + +reset_case() { + rm -rf "${STATE}" + mkdir -p "${STATE}" + : >"${CALLS}" + : >"${OUTPUT}" + touch "${APP_FILE}" + jq -nc '{ + resources:{ + app:{ + type:"Radius.Compute/routes@2025-08-01-preview", + existing:false + } + } + }' >"${COMPILED}" + + ENV_JSON="$( + jq -nc \ + '{properties:{recipePacks:["/scope/providers/Radius.Core/recipePacks/default"]}}' + )" + PACK_SOURCE='ghcr.io/radius-project/kube-recipes/routes:test' + PACK_JSON_OVERRIDE='' + APP_LIST_JSON='[]' + ROUTES_APPS='' + RESOURCE_JSON_OVERRIDE='' + RAD_DISCOVERY_FAIL='' + CRDS_STATE='complete' + CRDS_OWNED='false' + NAMESPACE_STATE='present' + COMPONENTS_STATE='complete' + HELM_STATE='absent' + HELM_OWNED='false' + HELM_VALUES_STATE='valid' + HELM_STATUS_WARNING='false' + HELM_FAIL='false' + SERVICE_TYPE='ClusterIP' + ENDPOINTS_READY='true' + GATEWAYCLASS_STATE='present' + GATEWAYCLASS_CONTROLLER='projectcontour.io/gateway-controller' + GATEWAY_STATE='present' + GATEWAY_RESOURCES_OWNED='false' + GATEWAY_LISTENERS_VALID='true' + BYO_STATE='complete' + BYO_CLASS_CONTROLLER='example.io/controller' + SHARED_OBJECTS='false' + SHARED_OBJECT_RESOURCE='httproutes.gateway.networking.k8s.io' + CLUSTER_JSON_OVERRIDE='' + SHARED_INGRESS='false' + CONTOUR_CRDS_STATE='missing' + CONTOUR_SHARED_OBJECTS='false' + CONTOUR_OBJECT_RESOURCE='httpproxies.projectcontour.io' + WAIT_FAIL='' + TARGET_KUBECONFIG_OVERRIDE='' + CRD_LIST_JSON="$( + jq -nc '{ + apiVersion:"v1", + kind:"List", + items:[ + "gatewayclasses","gateways","httproutes","referencegrants", + "grpcroutes","tcproutes","tlsroutes","udproutes" + ] | map({ + apiVersion:"apiextensions.k8s.io/v1", + kind:"CustomResourceDefinition", + metadata:{name:(. + ".gateway.networking.k8s.io")} + }) + }' + )" + export ENV_JSON PACK_SOURCE PACK_JSON_OVERRIDE APP_LIST_JSON ROUTES_APPS + export RESOURCE_JSON_OVERRIDE + export RAD_DISCOVERY_FAIL CRDS_STATE CRDS_OWNED NAMESPACE_STATE + export COMPONENTS_STATE HELM_STATE HELM_OWNED HELM_VALUES_STATE + export HELM_STATUS_WARNING + export HELM_FAIL SERVICE_TYPE + export ENDPOINTS_READY GATEWAYCLASS_STATE GATEWAYCLASS_CONTROLLER + export GATEWAY_STATE GATEWAY_RESOURCES_OWNED GATEWAY_LISTENERS_VALID + export BYO_STATE + export BYO_CLASS_CONTROLLER SHARED_OBJECTS SHARED_OBJECT_RESOURCE + export CLUSTER_JSON_OVERRIDE + export SHARED_INGRESS CONTOUR_CRDS_STATE CONTOUR_SHARED_OBJECTS + export CONTOUR_OBJECT_RESOURCE + export WAIT_FAIL CRD_LIST_JSON TARGET_KUBECONFIG_OVERRIDE +} + +run_action() { + local mode="${1:-ensure}" + local exposure="${2:-}" + local explicit="${3:-false}" + local gateway_name="${4-}" + local gateway_namespace="${5-}" + set +e + ( + export PATH="${BIN}:${PATH}" CALLS STATE COMPILED + export MODE="${mode}" EXPOSURE="${exposure}" + export GATEWAY_EXPLICIT="${explicit}" + export GATEWAY_NAME="${gateway_name}" + export GATEWAY_NAMESPACE="${gateway_namespace}" + export ENVIRONMENT_NAME="test" APP_FILE + export BICEP_BIN="${BICEP}" RADIUS_ROUTES_WAIT_TIMEOUT="1s" + export RADIUS_ROUTES_RETRY_DELAY="0" + export TARGET_KUBECONFIG="${TARGET_KUBECONFIG_OVERRIDE}" + bash "${SCRIPT}" + ) >"${OUTPUT}" 2>&1 + ACTION_EXIT=$? + set -e +} + +assert_success() { + ((ACTION_EXIT == 0)) || + fail "$1: expected success, got ${ACTION_EXIT} +$(cat "${OUTPUT}")" +} + +assert_failure() { + ((ACTION_EXIT != 0)) || + fail "$1: expected failure +$(cat "${OUTPUT}")" +} + +assert_output() { + grep -qF -- "$1" "${OUTPUT}" || + fail "expected output '$1' +$(cat "${OUTPUT}")" +} + +assert_call() { + grep -qF -- "$1" "${CALLS}" || + fail "expected call '$1' +$(cat "${CALLS}")" +} + +assert_no_call() { + if grep -qF -- "$1" "${CALLS}"; then + fail "unexpected call '$1' +$(cat "${CALLS}")" + fi +} + +write_fakes + +# No routes and custom routes recipes are strict no-ops. +reset_case +jq -nc '{resources:{container:{type:"Radius.Compute/containers"}}}' \ + >"${COMPILED}" +run_action +assert_success "app without routes" +assert_output "declares no non-existing Radius.Compute/routes" +assert_no_call "kubectl" + +# Gateway-specific variables are irrelevant to no-route applications. +run_action ensure invalid true byo "" +assert_success "no-route ignores Gateway variables" +assert_output "declares no non-existing Radius.Compute/routes" +assert_no_call "rad env show" +assert_no_call "kubectl" + +reset_case +jq -nc '{ + resources:{ + custom:{ + type:"Example.Resources/widgets", + properties:{ + template:{ + resources:[ + { + type:"Radius.Compute/routes@2025-08-01-preview", + existing:false + }, + "arbitrary template content" + ] + } + } + } + } +}' >"${COMPILED}" +run_action +assert_success "non-deployment template property" +assert_output "declares no non-existing Radius.Compute/routes" +assert_no_call "rad env show" +assert_no_call "kubectl" + +reset_case +jq -nc '{ + resources:{ + module:{ + type:"Microsoft.Resources/deployments", + properties:{ + template:{ + resources:[ + { + type:"Radius.Compute/routes@2025-08-01-preview", + existing:false + } + ] + } + } + } + } +}' >"${COMPILED}" +run_action +assert_success "nested module routes" +assert_call "rad env show test --preview -o json" + +reset_case +jq -nc '{ + resources:[ + { + type:"Radius.Compute/routes@2025-08-01-preview", + existing:false + }, + { + type:"Microsoft.Resources/deployments", + properties:{template:{resources:["invalid"]}} + } + ] +}' >"${COMPILED}" +run_action +assert_failure "route before malformed nested resource" +assert_output "compiled application template has invalid resources data" +assert_no_call "rad env show" +assert_no_call "kubectl" + +reset_case +jq -nc '{ + resources:{ + module:{ + type:"Microsoft.Resources/deployments", + properties:{template:{resources:"invalid"}} + } + } +}' >"${COMPILED}" +run_action +assert_failure "malformed nested resources" +assert_output "compiled application template has invalid resources data" +assert_no_call "rad env show" +assert_no_call "kubectl" + +reset_case +jq -nc '{resources:"invalid"}' >"${COMPILED}" +run_action +assert_failure "malformed compiled resources" +assert_output "compiled application template has invalid resources data" +assert_no_call "rad env show" +assert_no_call "kubectl" + +reset_case +PACK_SOURCE='br:example.test/custom/routes:v1' +export PACK_SOURCE +run_action ensure invalid true byo "" +assert_success "custom routes recipe" +assert_output "Custom routes recipe detected" +assert_no_call "kubectl" + +reset_case +PACK_JSON_OVERRIDE="$(jq -nc '{properties:{recipes:[]}}')" +export PACK_JSON_OVERRIDE +run_action ensure +assert_failure "malformed recipe discovery" +assert_output "returned invalid recipe data" +assert_no_call "kubectl" + +# Invalid and conflicting inputs fail before any cluster mutation. +reset_case +run_action ensure internal +assert_failure "invalid exposure" +assert_output "must be 'private' or 'public'" +assert_no_call "kubectl" + +reset_case +run_action ensure public true byo byo-system +assert_failure "BYO exposure conflict" +assert_output "cannot be set with an explicit BYO Gateway" +assert_no_call "kubectl" + +reset_case +run_action ensure "" true byo "" +assert_failure "BYO missing namespace" +assert_output "RADIUS_ROUTES_GATEWAY_NAMESPACE is required" +assert_no_call "kubectl" + +# BYO is validation-only and reports incomplete dependencies. +reset_case +run_action ensure "" true byo byo-system +assert_success "complete BYO" +assert_output "BYO Gateway byo-system/byo is ready" +assert_no_call "kubectl create" +assert_no_call "helm " + +reset_case +CRDS_STATE='missing' +export CRDS_STATE +run_action ensure "" true byo byo-system +assert_failure "incomplete BYO CRDs" +assert_output "Gateway API CRDs are incomplete" +assert_no_call "kubectl create" + +# Private is the default and never requests a LoadBalancer. +reset_case +run_action ensure +assert_success "private default" +assert_output "ClusterIP exposure" +assert_call "rad recipe-pack show /scope/providers/Radius.Core/recipePacks/default -o json" +assert_no_call "envoy.service.type=LoadBalancer" + +# A configured target kubeconfig must exist and be readable. +reset_case +TARGET_KUBECONFIG_OVERRIDE="${TEST_ROOT}/missing-kubeconfig" +export TARGET_KUBECONFIG_OVERRIDE +run_action ensure +assert_failure "missing target kubeconfig" +assert_output "target kubeconfig is not a readable regular file" +assert_no_call "--kubeconfig ${TARGET_KUBECONFIG_OVERRIDE}" +assert_no_call "kubectl create" + +reset_case +TARGET_KUBECONFIG_OVERRIDE="${TEST_ROOT}/kubeconfig-directory" +mkdir -p "${TARGET_KUBECONFIG_OVERRIDE}" +export TARGET_KUBECONFIG_OVERRIDE +run_action ensure +assert_failure "directory target kubeconfig" +assert_output "target kubeconfig is not a readable regular file" +assert_no_call "--kubeconfig ${TARGET_KUBECONFIG_OVERRIDE}" +assert_no_call "kubectl create" + +# Public warns with every affected application and validates LoadBalancer. +reset_case +APP_LIST_JSON="$(jq -nc '[{name:"alpha"},{name:"beta"}]')" +ROUTES_APPS='alpha,beta' +SERVICE_TYPE='LoadBalancer' +export APP_LIST_JSON ROUTES_APPS SERVICE_TYPE +run_action ensure public +assert_success "public exposure" +assert_output "PUBLIC ROUTES EXPOSURE" +assert_output "alpha, beta" + +reset_case +RAD_DISCOVERY_FAIL='app' +export RAD_DISCOVERY_FAIL +run_action ensure public +assert_failure "public warning discovery failure" +assert_output "failed to identify applications affected by public exposure" +assert_no_call "kubectl create" +assert_no_call "helm upgrade" + +# A managed release is upgraded from public back to private. +reset_case +HELM_STATE='present' +HELM_OWNED='true' +SERVICE_TYPE='LoadBalancer' +export HELM_STATE HELM_OWNED SERVICE_TYPE +run_action ensure private +assert_success "public to private" +assert_call "envoy.service.type=ClusterIP" + +reset_case +HELM_STATE='present' +HELM_OWNED='true' +HELM_STATUS_WARNING='true' +export HELM_STATE HELM_OWNED HELM_STATUS_WARNING +run_action ensure private +assert_success "Helm status warning does not corrupt rerun" +assert_output "Kubernetes configuration file is group-readable" +assert_call "helm upgrade --install contour" + +# A first install creates only missing resources with ownership markers and pins. +reset_case +CRDS_STATE='missing' +NAMESPACE_STATE='missing' +COMPONENTS_STATE='missing' +GATEWAYCLASS_STATE='missing' +GATEWAY_STATE='missing' +TARGET_KUBECONFIG_OVERRIDE="${TEST_ROOT}/target-kubeconfig" +touch "${TARGET_KUBECONFIG_OVERRIDE}" +export CRDS_STATE NAMESPACE_STATE COMPONENTS_STATE +export GATEWAYCLASS_STATE GATEWAY_STATE TARGET_KUBECONFIG_OVERRIDE +run_action ensure +assert_success "first managed install" +assert_call "kubectl --kubeconfig ${TARGET_KUBECONFIG_OVERRIDE} create --dry-run=client" +assert_call "gateway-api/releases/download/v1.2.1/experimental-install.yaml" +assert_call "version 0.1.0" +assert_call "gatewayAPI.manageCRDs=false" +assert_call "radius-project.io/routes-gateway-lifecycle: v1" + +# Repeated ensures reuse complete unowned resources without adopting them. +reset_case +run_action ensure +assert_success "idempotent reuse" +assert_output "Reusing complete pre-existing Contour resources without adopting" +assert_no_call "helm upgrade" +assert_no_call "kubectl create" + +# Conflicts and readiness failures are surfaced. +reset_case +GATEWAYCLASS_CONTROLLER='other.example/controller' +export GATEWAYCLASS_CONTROLLER +run_action ensure +assert_failure "GatewayClass conflict" +assert_output "GatewayClass contour conflicts" + +reset_case +WAIT_FAIL='gateway/radius' +export WAIT_FAIL +run_action ensure +assert_failure "Gateway readiness" +assert_output "Gateway radius conflicts" + +reset_case +GATEWAY_LISTENERS_VALID='false' +HELM_STATE='present' +HELM_OWNED='true' +export GATEWAY_LISTENERS_VALID HELM_STATE HELM_OWNED +run_action ensure +assert_failure "Gateway listener conflict" +assert_output "Gateway radius conflicts" +assert_no_call "kubectl create" +assert_no_call "helm upgrade" + +reset_case +ENDPOINTS_READY='false' +export ENDPOINTS_READY +run_action ensure +assert_failure "Service readiness" +assert_output "has no ready endpoints" + +# Cleanup retains while routes remain and fails closed on discovery errors. +reset_case +PACK_SOURCE='br:example.test/custom/routes:v1' +export PACK_SOURCE +run_action cleanup +assert_success "custom recipe cleanup" +assert_output "Custom routes recipe detected" +assert_no_call "kubectl" + +reset_case +APP_LIST_JSON="$(jq -nc '[{name:"remaining"}]')" +ROUTES_APPS='remaining' +export APP_LIST_JSON ROUTES_APPS +run_action cleanup +assert_success "retain remaining routes" +assert_output "still uses Radius.Compute/routes" +assert_no_call "kubectl delete" + +reset_case +RAD_DISCOVERY_FAIL='app' +export RAD_DISCOVERY_FAIL +run_action cleanup +assert_failure "cleanup discovery failure" +assert_output "preserving Gateway infrastructure" +assert_no_call "kubectl delete" + +reset_case +APP_LIST_JSON="$(jq -nc '{}')" +export APP_LIST_JSON +run_action cleanup +assert_failure "malformed application discovery" +assert_output "invalid JSON" +assert_no_call "kubectl delete" + +reset_case +APP_LIST_JSON="$(jq -nc '[{name:"broken"}]')" +RESOURCE_JSON_OVERRIDE="$(jq -nc '{unexpected:true}')" +export APP_LIST_JSON RESOURCE_JSON_OVERRIDE +run_action cleanup +assert_failure "malformed resource discovery" +assert_output "invalid JSON" +assert_no_call "kubectl delete" + +# Helm release state and ownership are established before cleanup mutation. +reset_case +HELM_STATE='failure' +export HELM_STATE +run_action cleanup +assert_failure "Helm release discovery failure" +assert_output "failed to inspect Contour Helm release" +assert_no_call "kubectl delete" + +reset_case +HELM_STATE='malformed' +export HELM_STATE +run_action cleanup +assert_failure "malformed Helm release status" +assert_output "Contour Helm release status returned invalid JSON" +assert_no_call "kubectl delete" + +reset_case +HELM_STATE='present' +HELM_VALUES_STATE='failure' +export HELM_STATE HELM_VALUES_STATE +run_action cleanup +assert_failure "Helm release ownership discovery failure" +assert_output "failed to inspect Contour Helm release ownership" +assert_no_call "kubectl delete" + +reset_case +HELM_STATE='present' +export HELM_STATE +run_action cleanup +assert_success "preserve unowned Helm release" +assert_output "Contour Helm release is not Radius-owned" +assert_no_call "kubectl delete" + +# Confirmed missing CRDs no-op, while discovery uncertainty fails closed. +reset_case +CRDS_STATE='missing' +export CRDS_STATE +run_action cleanup +assert_success "missing CRDs cleanup" +assert_output "CRDs are absent or incomplete" +assert_no_call "kubectl delete" + +reset_case +CRDS_STATE='failure' +export CRDS_STATE +run_action cleanup +assert_failure "CRD discovery failure" +assert_output "failed to inspect Gateway API CRD" +assert_no_call "kubectl delete" + +reset_case +CRDS_STATE='malformed' +export CRDS_STATE +run_action cleanup +assert_failure "malformed CRD discovery" +assert_output "CRD gatewayclasses.gateway.networking.k8s.io discovery returned unexpected output" +assert_no_call "kubectl delete" + +# Shared Gateway API objects retain controller and CRDs after owned Gateway delete. +reset_case +SHARED_OBJECTS='true' +SHARED_OBJECT_RESOURCE='gatewayclasses.gateway.networking.k8s.io' +export SHARED_OBJECTS SHARED_OBJECT_RESOURCE +run_action cleanup +assert_success "retain shared infrastructure" +assert_output "retaining all Gateway infrastructure" +assert_no_call "kubectl delete gateway radius" +assert_no_call "helm uninstall" +assert_no_call "customresourcedefinition gatewayclasses" + +reset_case +SHARED_INGRESS='true' +export SHARED_INGRESS +run_action cleanup +assert_success "retain shared Ingress controller" +assert_output "Ingress or Contour objects use the controller" +assert_no_call "kubectl delete" +assert_no_call "helm uninstall" + +reset_case +CONTOUR_CRDS_STATE='present' +CONTOUR_SHARED_OBJECTS='true' +export CONTOUR_CRDS_STATE CONTOUR_SHARED_OBJECTS +run_action cleanup +assert_success "retain shared Contour controller" +assert_output "Ingress or Contour objects use the controller" +assert_no_call "kubectl delete" +assert_no_call "helm uninstall" + +reset_case +CONTOUR_CRDS_STATE='failure' +export CONTOUR_CRDS_STATE +run_action cleanup +assert_failure "Contour discovery failure" +assert_output "preserving Gateway infrastructure" +assert_no_call "kubectl delete" + +reset_case +CLUSTER_JSON_OVERRIDE="$(jq -nc '{items:"invalid"}')" +export CLUSTER_JSON_OVERRIDE +run_action cleanup +assert_failure "malformed cluster discovery" +assert_output "cluster-wide gatewayclasses.gateway.networking.k8s.io discovery returned invalid JSON" +assert_no_call "kubectl delete" + +# Fully unused owned infrastructure is removed; pre-existing CRDs are preserved. +reset_case +HELM_STATE='present' +HELM_OWNED='true' +CRDS_OWNED='true' +GATEWAY_RESOURCES_OWNED='true' +export HELM_STATE HELM_OWNED CRDS_OWNED GATEWAY_RESOURCES_OWNED +run_action cleanup +assert_success "remove owned infrastructure" +assert_call "kubectl delete gateway radius" +assert_call "kubectl delete gatewayclass contour" +assert_call "helm uninstall contour" +assert_call "kubectl delete customresourcedefinition gatewayclasses.gateway.networking.k8s.io" +inventory_line="$( + grep -n 'kubectl get gatewayclasses.gateway.networking.k8s.io -A -o json' \ + "${CALLS}" | head -1 | cut -d: -f1 +)" +delete_line="$( + grep -n 'kubectl delete gateway radius' "${CALLS}" | + head -1 | cut -d: -f1 +)" +((inventory_line < delete_line)) || + fail "foreign-object inventory must run before Gateway deletion" + +reset_case +HELM_STATE='present' +HELM_OWNED='true' +CRDS_OWNED='false' +export HELM_STATE HELM_OWNED CRDS_OWNED +run_action cleanup +assert_success "preserve pre-existing CRDs" +assert_no_call "kubectl delete customresourcedefinition" + +# BYO cleanup never mutates the cluster. +reset_case +run_action cleanup "" true byo byo-system +assert_success "BYO cleanup" +assert_output "BYO Gateway is never deleted" +assert_no_call "kubectl" +assert_no_call "helm " + +# Both provider workflows invoke ensure after recipe setup and before app deploy. +for workflow in "${AZURE_WORKFLOW}" "${AWS_WORKFLOW}"; do + apply_line="$(grep -n 'name: Apply custom recipe packs' "${workflow}" | + cut -d: -f1)" + ensure_line="$(grep -n 'name: Ensure routes Gateway infrastructure' \ + "${workflow}" | cut -d: -f1)" + deploy_line="$(grep -n 'name: Run rad commands' "${workflow}" | + cut -d: -f1)" + ((apply_line < ensure_line && ensure_line < deploy_line)) || + fail "${workflow}: Gateway ensure ordering is incorrect" + # shellcheck disable=SC2016 + grep -qF 'gateway-explicit: "${{ vars.RADIUS_ROUTES_GATEWAY_NAME != '\'''\'' }}"' \ + "${workflow}" || + fail "${workflow}: explicit Gateway boolean is not preserved" +done + +grep -qF "radius_contrib_kube_recipe_source Radius.Compute/routes routes" \ + "${AWS_WORKFLOW}" || + fail "AWS route recipe must use the Radius default OCI recipe" +grep -qF "gatewayName: '\${{ vars.RADIUS_ROUTES_GATEWAY_NAME || 'radius' }}'" \ + "${AWS_WORKFLOW}" || + fail "AWS route recipe is missing the managed Gateway default" + +for workflow in \ + "${REPO_ROOT}/.github/extension/delete-azure.yml" \ + "${REPO_ROOT}/.github/extension/delete-aws.yml"; do + delete_line="$(grep -n 'name: Delete Radius resource' "${workflow}" | + cut -d: -f1)" + cleanup_line="$( + grep -n 'name: Clean up unused routes Gateway infrastructure' \ + "${workflow}" | cut -d: -f1 + )" + teardown_line="$(grep -n 'name: Teardown' "${workflow}" | cut -d: -f1)" + ((delete_line < cleanup_line && cleanup_line < teardown_line)) || + fail "${workflow}: Gateway cleanup ordering is incorrect" + grep -qF "if: \${{ inputs.resource_type == 'application' }}" \ + "${workflow}" || + fail "${workflow}: cleanup must run only for application deletes" +done + +for workflow in \ + "${AZURE_WORKFLOW}" \ + "${AWS_WORKFLOW}" \ + "${REPO_ROOT}/.github/extension/delete-azure.yml" \ + "${REPO_ROOT}/.github/extension/delete-aws.yml"; do + # shellcheck disable=SC2016 + grep -qF 'group: radius-environment-${{ github.repository }}-${{ inputs.environment }}' \ + "${workflow}" || + fail "${workflow}: missing shared environment concurrency group" + grep -qF 'cancel-in-progress: false' "${workflow}" || + fail "${workflow}: environment concurrency must not cancel in-progress runs" +done + +echo "routes Gateway lifecycle tests passed" diff --git a/.github/extension/delete-aws.yml b/.github/extension/delete-aws.yml index 36071b88b2..c52782b659 100644 --- a/.github/extension/delete-aws.yml +++ b/.github/extension/delete-aws.yml @@ -32,6 +32,10 @@ permissions: contents: write packages: write +concurrency: + group: radius-environment-${{ github.repository }}-${{ inputs.environment }} + cancel-in-progress: false + env: ENVIRONMENT: ${{ inputs.environment }} @@ -51,6 +55,7 @@ jobs: RADIUS_STATE_BACKEND: ${{ vars.RADIUS_STATE_BACKEND }} RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} + RADIUS_TARGET_KUBECONFIG: ${{ vars.AWS_EKS_CLUSTER_NAME != '' && format('{0}/.radius-target-kubeconfig', github.workspace) || '' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 @@ -79,8 +84,11 @@ jobs: - name: Get target cluster kubeconfig run: | - mkdir -p "$HOME/.kube" - echo "RADIUS_TARGET_KUBECONFIG=$HOME/.kube/target-cluster" >> "$GITHUB_ENV" + if [ -z "$RADIUS_TARGET_KUBECONFIG" ]; then + echo "No external EKS cluster configured; using the control-plane cluster." + exit 0 + fi + mkdir -p "$(dirname "$RADIUS_TARGET_KUBECONFIG")" - name: Connect to EKS cluster if: ${{ vars.AWS_EKS_CLUSTER_NAME != '' }} @@ -219,6 +227,19 @@ jobs: resource-type: ${{ inputs.resource_type }} name: ${{ inputs.name }} + - name: Clean up unused routes Gateway infrastructure + if: ${{ inputs.resource_type == 'application' }} + uses: radius-project/radius/.github/extension/actions/manage-routes-gateway@{{RADIUS_REF}} + with: + mode: cleanup + target-kubeconfig: ${{ env.RADIUS_TARGET_KUBECONFIG }} + environment-name: ${{ inputs.environment }} + application-name: ${{ inputs.name }} + gateway-name: ${{ vars.RADIUS_ROUTES_GATEWAY_NAME }} + gateway-namespace: ${{ vars.RADIUS_ROUTES_GATEWAY_NAMESPACE }} + gateway-explicit: "${{ vars.RADIUS_ROUTES_GATEWAY_NAME != '' }}" + exposure: ${{ vars.RADIUS_ROUTES_EXPOSURE }} + - name: Teardown if: always() uses: radius-project/radius/.github/extension/actions/teardown@{{RADIUS_REF}} diff --git a/.github/extension/delete-azure.yml b/.github/extension/delete-azure.yml index b2b46e0bfb..1af5c84a8b 100644 --- a/.github/extension/delete-azure.yml +++ b/.github/extension/delete-azure.yml @@ -32,6 +32,10 @@ permissions: contents: write packages: write +concurrency: + group: radius-environment-${{ github.repository }}-${{ inputs.environment }} + cancel-in-progress: false + env: ENVIRONMENT: ${{ inputs.environment }} @@ -51,6 +55,7 @@ jobs: RADIUS_STATE_BACKEND: ${{ vars.RADIUS_STATE_BACKEND }} RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} + RADIUS_TARGET_KUBECONFIG: ${{ vars.AZURE_AKS_CLUSTER_NAME != '' && format('{0}/.radius-target-kubeconfig', github.workspace) || '' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 @@ -80,8 +85,11 @@ jobs: - name: Get target cluster kubeconfig run: | - mkdir -p "$HOME/.kube" - echo "RADIUS_TARGET_KUBECONFIG=$HOME/.kube/target-cluster" >> "$GITHUB_ENV" + if [ -z "$RADIUS_TARGET_KUBECONFIG" ]; then + echo "No external AKS cluster configured; using the control-plane cluster." + exit 0 + fi + mkdir -p "$(dirname "$RADIUS_TARGET_KUBECONFIG")" - name: Connect to AKS cluster if: ${{ vars.AZURE_AKS_CLUSTER_NAME != '' }} @@ -173,6 +181,19 @@ jobs: name: ${{ inputs.name }} azure-oidc-token-refresh: "${{ vars.AZURE_CLIENT_ID != '' }}" + - name: Clean up unused routes Gateway infrastructure + if: ${{ inputs.resource_type == 'application' }} + uses: radius-project/radius/.github/extension/actions/manage-routes-gateway@{{RADIUS_REF}} + with: + mode: cleanup + target-kubeconfig: ${{ env.RADIUS_TARGET_KUBECONFIG }} + environment-name: ${{ inputs.environment }} + application-name: ${{ inputs.name }} + gateway-name: ${{ vars.RADIUS_ROUTES_GATEWAY_NAME }} + gateway-namespace: ${{ vars.RADIUS_ROUTES_GATEWAY_NAMESPACE }} + gateway-explicit: "${{ vars.RADIUS_ROUTES_GATEWAY_NAME != '' }}" + exposure: ${{ vars.RADIUS_ROUTES_EXPOSURE }} + - name: Teardown if: always() uses: radius-project/radius/.github/extension/actions/teardown@{{RADIUS_REF}} diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index 3c966d3eba..4f6519a67f 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -34,6 +34,10 @@ permissions: contents: write packages: write +concurrency: + group: radius-environment-${{ github.repository }}-${{ inputs.environment }} + cancel-in-progress: false + env: ENVIRONMENT: ${{ inputs.environment }} APP_FILE: '{{APP_FILE}}' @@ -64,6 +68,7 @@ jobs: RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} RADIUS_GRAPH_REGISTRY: ${{ vars.RADIUS_GRAPH_REGISTRY }} + RADIUS_TARGET_KUBECONFIG: ${{ vars.AWS_EKS_CLUSTER_NAME != '' && format('{0}/.radius-target-kubeconfig', github.workspace) || '' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 @@ -92,8 +97,11 @@ jobs: - name: Get target cluster kubeconfig run: | - mkdir -p "$HOME/.kube" - echo "RADIUS_TARGET_KUBECONFIG=$HOME/.kube/target-cluster" >> "$GITHUB_ENV" + if [ -z "$RADIUS_TARGET_KUBECONFIG" ]; then + echo "No external EKS cluster configured; using the control-plane cluster." + exit 0 + fi + mkdir -p "$(dirname "$RADIUS_TARGET_KUBECONFIG")" - name: Connect to EKS cluster if: ${{ vars.AWS_EKS_CLUSTER_NAME != '' }} @@ -276,7 +284,7 @@ jobs: CONTAINERIMAGES_SOURCE="$(radius_contrib_kube_recipe_source Radius.Compute/containerImages containerimages)" CONTAINERS_SOURCE="$(radius_contrib_resource_git_source Radius.Compute/containers recipes/kubernetes/terraform)" PERSISTENTVOLUMES_SOURCE="$(radius_contrib_resource_git_source Radius.Compute/persistentVolumes recipes/kubernetes/terraform)" - ROUTES_SOURCE="$(radius_contrib_resource_git_source Radius.Compute/routes recipes/kubernetes/terraform)" + ROUTES_SOURCE="$(radius_contrib_kube_recipe_source Radius.Compute/routes routes)" POSTGRESQL_SOURCE="$(radius_contrib_resource_git_source Radius.Data/postgreSqlDatabases recipes/kubernetes/terraform)" MYSQL_SOURCE="$(radius_contrib_resource_git_source Radius.Data/mySqlDatabases recipes/aws/terraform)" SECRETS_SOURCE="$(radius_contrib_resource_git_source Radius.Security/secrets recipes/kubernetes/terraform)" @@ -341,8 +349,12 @@ jobs: source: '$PERSISTENTVOLUMES_SOURCE' } 'Radius.Compute/routes': { - kind: 'terraform' + kind: 'bicep' source: '$ROUTES_SOURCE' + parameters: { + gatewayName: '${{ vars.RADIUS_ROUTES_GATEWAY_NAME || 'radius' }}' + gatewayNamespace: '${{ vars.RADIUS_ROUTES_GATEWAY_NAMESPACE || 'radius-system' }}' + } } 'Radius.Data/postgreSqlDatabases': { kind: 'terraform' @@ -386,6 +398,18 @@ jobs: environment: ${{ inputs.environment }} app-file: ${{ env.APP_FILE }} + - name: Ensure routes Gateway infrastructure + uses: radius-project/radius/.github/extension/actions/manage-routes-gateway@{{RADIUS_REF}} + with: + mode: ensure + target-kubeconfig: ${{ env.RADIUS_TARGET_KUBECONFIG }} + environment-name: ${{ inputs.environment }} + app-file: ${{ env.APP_FILE }} + gateway-name: ${{ vars.RADIUS_ROUTES_GATEWAY_NAME }} + gateway-namespace: ${{ vars.RADIUS_ROUTES_GATEWAY_NAMESPACE }} + gateway-explicit: "${{ vars.RADIUS_ROUTES_GATEWAY_NAME != '' }}" + exposure: ${{ vars.RADIUS_ROUTES_EXPOSURE }} + - name: Prepare live deployment progress uses: radius-project/radius/.github/extension/actions/deploy-progress/artifact-uploader@{{RADIUS_REF}} diff --git a/.github/extension/run-rad-commands-azure.yml b/.github/extension/run-rad-commands-azure.yml index 6d095f0f62..c8936e22ec 100644 --- a/.github/extension/run-rad-commands-azure.yml +++ b/.github/extension/run-rad-commands-azure.yml @@ -34,6 +34,10 @@ permissions: contents: write packages: write +concurrency: + group: radius-environment-${{ github.repository }}-${{ inputs.environment }} + cancel-in-progress: false + env: ENVIRONMENT: ${{ inputs.environment }} APP_FILE: '{{APP_FILE}}' @@ -64,6 +68,7 @@ jobs: RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} RADIUS_GRAPH_REGISTRY: ${{ vars.RADIUS_GRAPH_REGISTRY }} + RADIUS_TARGET_KUBECONFIG: ${{ vars.AZURE_AKS_CLUSTER_NAME != '' && format('{0}/.radius-target-kubeconfig', github.workspace) || '' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 @@ -93,8 +98,11 @@ jobs: - name: Get target cluster kubeconfig run: | - mkdir -p "$HOME/.kube" - echo "RADIUS_TARGET_KUBECONFIG=$HOME/.kube/target-cluster" >> "$GITHUB_ENV" + if [ -z "$RADIUS_TARGET_KUBECONFIG" ]; then + echo "No external AKS cluster configured; using the control-plane cluster." + exit 0 + fi + mkdir -p "$(dirname "$RADIUS_TARGET_KUBECONFIG")" - name: Connect to AKS cluster if: ${{ vars.AZURE_AKS_CLUSTER_NAME != '' }} @@ -314,11 +322,11 @@ jobs: exit 1 fi - # routesGatewayName is a required pack parameter, but it only matters when - # a Radius.Compute/routes resource is actually deployed. Default to empty - # unless the repo configures an existing Kubernetes Gateway. - ROUTES_GATEWAY_NAME="${{ vars.RADIUS_ROUTES_GATEWAY_NAME }}" - ROUTES_GATEWAY_NAMESPACE="${{ vars.RADIUS_ROUTES_GATEWAY_NAMESPACE || 'default' }}" + # The default routes recipe always receives a concrete Gateway reference. + # A separately-passed boolean tells the lifecycle action whether this is a + # user-owned Gateway or the Radius-managed default. + ROUTES_GATEWAY_NAME="${{ vars.RADIUS_ROUTES_GATEWAY_NAME || 'radius' }}" + ROUTES_GATEWAY_NAMESPACE="${{ vars.RADIUS_ROUTES_GATEWAY_NAMESPACE || 'radius-system' }}" echo "Recipe pack file:" cat "$ENV_BICEP" @@ -341,6 +349,18 @@ jobs: environment: ${{ inputs.environment }} app-file: ${{ env.APP_FILE }} + - name: Ensure routes Gateway infrastructure + uses: radius-project/radius/.github/extension/actions/manage-routes-gateway@{{RADIUS_REF}} + with: + mode: ensure + target-kubeconfig: ${{ env.RADIUS_TARGET_KUBECONFIG }} + environment-name: ${{ inputs.environment }} + app-file: ${{ env.APP_FILE }} + gateway-name: ${{ vars.RADIUS_ROUTES_GATEWAY_NAME }} + gateway-namespace: ${{ vars.RADIUS_ROUTES_GATEWAY_NAMESPACE }} + gateway-explicit: "${{ vars.RADIUS_ROUTES_GATEWAY_NAME != '' }}" + exposure: ${{ vars.RADIUS_ROUTES_EXPOSURE }} + - name: Prepare live deployment progress uses: radius-project/radius/.github/extension/actions/deploy-progress/artifact-uploader@{{RADIUS_REF}} diff --git a/build/test.mk b/build/test.mk index eb6b678dfa..edaa66f16d 100644 --- a/build/test.mk +++ b/build/test.mk @@ -53,7 +53,7 @@ GOTEST_OPTS ?= GOTEST_TOOL ?= go tool gotestsum $(GOTESTSUM_OPTS) -- .PHONY: test -test: test-get-envtools test-helm test-manage-radius-installation test-apply-custom-recipe-packs test-run-rad-commands-action test-command-outcome test-azure-oidc-refresh test-build-platforms test-publish-deploy-status test-extension-action-shell-syntax test-deploy-progress test-verify-azure ## Runs unit tests, excluding kubernetes controller tests +test: test-get-envtools test-helm test-manage-radius-installation test-apply-custom-recipe-packs test-routes-gateway test-run-rad-commands-action test-command-outcome test-azure-oidc-refresh test-build-platforms test-publish-deploy-status test-extension-action-shell-syntax test-deploy-progress test-verify-azure ## Runs unit tests, excluding kubernetes controller tests KUBEBUILDER_ASSETS="$(shell $(ENV_SETUP) use -p path ${K8S_VERSION} --arch amd64)" CGO_ENABLED=1 $(GOTEST_TOOL) ./pkg/... ./test/validation/... $(GOTEST_OPTS) .PHONY: test-manage-radius-installation @@ -64,6 +64,10 @@ test-manage-radius-installation: ## Tests Radius installation lifecycle reconcil test-apply-custom-recipe-packs: ## Tests custom recipe pack reconciliation in the deploy action @bash ./.github/extension/actions/apply-custom-recipe-packs/apply-custom-recipe-packs_test.sh +.PHONY: test-routes-gateway +test-routes-gateway: ## Tests routes Gateway lifecycle reconciliation + @bash ./.github/extension/actions/manage-routes-gateway/routes-gateway_test.sh + .PHONY: test-run-rad-commands-action test-run-rad-commands-action: ## Tests application deploy parameter filtering in the run-rad-commands action @bash ./.github/extension/actions/run-rad-commands/deploy-parameters_test.sh