From 30bebb12f39d108c6c4810951c857a0ef3c21dfd Mon Sep 17 00:00:00 2001 From: sk593 Date: Fri, 10 Jul 2026 11:06:13 -0700 Subject: [PATCH 01/18] Add custom recipe pack support to run-rad-commands workflows Deploy any *recipe-pack*.bicep files in the app's .radius/ folder, then list all recipe packs and update the environment to reference all of them via rad env update --recipe-packs. Provider-agnostic logic lives in a new shared apply-custom-recipe-packs composite action wired into both the Azure and AWS workflows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .github/extension/README.md | 11 +-- .../apply-custom-recipe-packs/action.yml | 73 +++++++++++++++++++ .github/extension/run-rad-commands-aws.yml | 6 ++ .github/extension/run-rad-commands-azure.yml | 6 ++ .../2026-06-repo-radius-deploy-workflow.md | 2 + 5 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 .github/extension/actions/apply-custom-recipe-packs/action.yml diff --git a/.github/extension/README.md b/.github/extension/README.md index c2fe2678023..503767583b2 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -63,7 +63,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 [resource-types-contrib](https://github.com/radius-project/resource-types-contrib). - **`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. -- **`actions/*`** — composite actions holding the provider-agnostic phases both provider workflows share: [`setup-control-plane`](actions/setup-control-plane/action.yml), [`restore-state`](actions/restore-state/action.yml), [`run-rad-commands`](actions/run-rad-commands/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), [`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), 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`. @@ -82,10 +82,11 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 9. **Restore persisted state (`rad startup`).** Restores the control-plane databases and the Terraform recipe-state Secrets saved by the previous run, so `rad deploy` plans against prior state rather than an empty backend. A no-op on the first run. 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. Azure downloads the `azure-avm` pack (Azure Verified Modules) from [resource-types-contrib](https://github.com/radius-project/resource-types-contrib); AWS generates an inline `aws-terraform` pack. `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. **Provision registry credentials on the control plane.** Creates the `ghcr-registry-creds` secret from `github.actor` and the built-in `GITHUB_TOKEN` so the containerImages recipe's in-pod BuildKit can push the application image. -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. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`) and any application parameters from the `RADIUS_DEPLOY_PARAMS` secret. -14. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. -15. **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). +12. **Apply custom recipe packs.** When the app's `.radius/` folder contains one or more `*recipe-pack*.bicep` files (alongside `.radius/app.bicep`) for the repo's own custom resource types, the shared `apply-custom-recipe-packs` action `rad deploy`s each pack, runs `rad recipe-pack list` to enumerate every pack the control plane now knows, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom packs. When no such files exist this step is a no-op and the default pack stays in place. +13. **Provision registry credentials on the control plane.** Creates the `ghcr-registry-creds` secret from `github.actor` and the built-in `GITHUB_TOKEN` so the containerImages recipe's in-pod BuildKit can push the application image. +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. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`) and any application parameters from the `RADIUS_DEPLOY_PARAMS` secret. +15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. 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). ### Triggers and permissions diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml new file mode 100644 index 00000000000..5cf2cb9a825 --- /dev/null +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -0,0 +1,73 @@ +# Provider-agnostic custom recipe-pack apply shared by run-rad-commands-aws.yml +# and run-rad-commands-azure.yml. Runs after the provider-specific "Create Radius +# environment and recipe pack" step. When the repo defines custom resource types +# via one or more `*recipe-pack*.bicep` files in the app's `.radius/` folder +# (alongside `.radius/app.bicep`), this deploys each of them, lists every recipe +# pack now known to the control plane, and rewrites the environment's recipe pack +# list to reference all of them -- so the environment carries both the default +# provider pack and the user's custom packs. When no such files exist the action +# is a no-op, leaving the default pack in place. +name: Radius - Apply custom recipe packs +description: Deploy the repo's custom recipe pack bicep files (if any) and attach every recipe pack to the environment. + +inputs: + environment: + description: Radius environment name to update with the full recipe pack list. + required: true + app-file: + description: Application bicep file. Its directory (e.g. .radius/) is searched for *recipe-pack*.bicep files. + required: true + +runs: + using: composite + steps: + - name: Deploy custom recipe packs and attach to environment + shell: bash + env: + ENVIRONMENT: ${{ inputs.environment }} + APP_FILE: ${{ inputs.app-file }} + run: | + set -euo pipefail + + # Custom recipe packs live next to the app file (e.g. .radius/) so that + # `rad deploy` resolves the repo's own .radius/bicepconfig.json (which + # declares the `radius` extension) -- bicep resolves the config nearest the + # .bicep file. Any file matching *recipe-pack*.bicep in that directory is + # treated as a custom recipe pack. No matches => nothing to do, keep the + # default pack. + APP_DIR=$(dirname "$APP_FILE") + + shopt -s nullglob + RECIPE_PACK_FILES=("$APP_DIR"/*recipe-pack*.bicep) + shopt -u nullglob + + if [ ${#RECIPE_PACK_FILES[@]} -eq 0 ]; then + echo "No custom recipe pack (*recipe-pack*.bicep) found in $APP_DIR; keeping the default recipe pack." + exit 0 + fi + + for RECIPE_PACK_BICEP in "${RECIPE_PACK_FILES[@]}"; do + echo "Custom recipe pack file: $RECIPE_PACK_BICEP" + cat "$RECIPE_PACK_BICEP" + echo "" + echo "Deploying custom recipe pack from $RECIPE_PACK_BICEP..." + rad deploy "$RECIPE_PACK_BICEP" + done + + # List every recipe pack the control plane now knows about (the default + # provider pack created earlier plus the custom packs just deployed) and + # collect their full resource IDs. IDs are used rather than names so the + # env update resolves each pack unambiguously regardless of its scope. + echo "Listing recipe packs..." + PACK_IDS=$(rad recipe-pack list -o json | jq -r '(if type == "array" then . else [.] end) | map(.id) | join(",")') + + if [ -z "$PACK_IDS" ]; then + echo "No recipe packs found to attach to environment '$ENVIRONMENT'." >&2 + exit 1 + fi + + # Replace the environment's recipe pack list with every pack so the custom + # types resolve alongside the default provider recipes. + echo "Attaching recipe packs to environment '$ENVIRONMENT': $PACK_IDS" + rad env update "$ENVIRONMENT" --recipe-packs "$PACK_IDS" + echo "✅ Environment '$ENVIRONMENT' updated with all recipe packs." diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index abbbdff512a..76479804fa3 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -336,6 +336,12 @@ jobs: rad deploy "$ENV_BICEP" echo "✅ Environment '$ENVIRONMENT' created with recipe pack." + - name: Apply custom recipe packs + uses: radius-project/radius/.github/extension/actions/apply-custom-recipe-packs@{{RADIUS_REF}} + with: + environment: ${{ inputs.environment }} + app-file: ${{ env.APP_FILE }} + - name: Run rad commands uses: radius-project/radius/.github/extension/actions/run-rad-commands@{{RADIUS_REF}} with: diff --git a/.github/extension/run-rad-commands-azure.yml b/.github/extension/run-rad-commands-azure.yml index 8a7d11c7d09..59abb47d4b2 100644 --- a/.github/extension/run-rad-commands-azure.yml +++ b/.github/extension/run-rad-commands-azure.yml @@ -255,6 +255,12 @@ jobs: --parameters containerImagesRegistrySecretName="$REGISTRY_SECRET_NAME" echo "✅ Environment '$ENVIRONMENT' created with recipe pack." + - name: Apply custom recipe packs + uses: radius-project/radius/.github/extension/actions/apply-custom-recipe-packs@{{RADIUS_REF}} + with: + environment: ${{ inputs.environment }} + app-file: ${{ env.APP_FILE }} + - name: Run rad commands uses: radius-project/radius/.github/extension/actions/run-rad-commands@{{RADIUS_REF}} with: diff --git a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md index b857ae8018f..fcb60420184 100644 --- a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md +++ b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md @@ -158,6 +158,8 @@ The workflow provisions a `Radius.Core/recipePacks` resource and a `Radius.Core/ The `radius-env.bicep` that carries the pack is written to the app file's directory (e.g. `.radius/`) and deployed from there. bicep resolves `bicepconfig.json` nearest the `.bicep` file, so deploying from that directory picks up the repo's own `.radius/bicepconfig.json` — which declares the `radius` extension — rather than a (non-existent) config at the workspace root. The `Radius.Compute/containerImages` type ships with the published `radius` Bicep extension, so the workflow no longer registers resource types or wires a local Bicep extension at deploy time. +**Custom recipe packs.** A repo that defines its own custom resource types supplies their recipes via one or more `*recipe-pack*.bicep` files in the app's `.radius/` folder (next to `.radius/app.bicep`). After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action `rad deploy`s each file, enumerates every pack with `rad recipe-pack list`, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom packs (`--recipe-packs` replaces the list, so passing every pack id preserves the default). The files are deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. When no such files exist the step is a no-op and the default pack stays in place. + ### Control plane startup (Investment 5) Because the control plane is created and torn down on every operation, startup time is on the critical path for every user-facing action and is the primary determinant of perceived responsiveness. Two backend decisions follow from that, both owned by this technical design rather than the feature spec: From 70071f6a13ff3c9d6d7a8d1b410735a4b1e230a1 Mon Sep 17 00:00:00 2001 From: sk593 Date: Fri, 10 Jul 2026 11:52:38 -0700 Subject: [PATCH 02/18] Register custom types and use fixed recipe pack filename Register custom resource types from .radius/custom-types.yaml via rad resource-type create --from-file before deploying the recipe pack, and use the fixed .radius/custom-recipe-pack.bicep filename instead of a glob. Each file is optional and independent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .github/extension/README.md | 2 +- .../apply-custom-recipe-packs/action.yml | 72 +++++++++++-------- .../2026-06-repo-radius-deploy-workflow.md | 2 +- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index 503767583b2..75f1cf40a46 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -82,7 +82,7 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 9. **Restore persisted state (`rad startup`).** Restores the control-plane databases and the Terraform recipe-state Secrets saved by the previous run, so `rad deploy` plans against prior state rather than an empty backend. A no-op on the first run. 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. Azure downloads the `azure-avm` pack (Azure Verified Modules) from [resource-types-contrib](https://github.com/radius-project/resource-types-contrib); AWS generates an inline `aws-terraform` pack. `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. **Apply custom recipe packs.** When the app's `.radius/` folder contains one or more `*recipe-pack*.bicep` files (alongside `.radius/app.bicep`) for the repo's own custom resource types, the shared `apply-custom-recipe-packs` action `rad deploy`s each pack, runs `rad recipe-pack list` to enumerate every pack the control plane now knows, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom packs. When no such files exist this step is a no-op and the default pack stays in place. +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 `rad deploy`s that pack, runs `rad recipe-pack list` to enumerate every pack the control plane now knows, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom pack (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. 13. **Provision registry credentials on the control plane.** Creates the `ghcr-registry-creds` secret from `github.actor` and the built-in `GITHUB_TOKEN` so the containerImages recipe's in-pod BuildKit can push the application image. 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. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`) and any application parameters from the `RADIUS_DEPLOY_PARAMS` secret. 15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml index 5cf2cb9a825..ed32e40c17b 100644 --- a/.github/extension/actions/apply-custom-recipe-packs/action.yml +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -1,27 +1,30 @@ -# Provider-agnostic custom recipe-pack apply shared by run-rad-commands-aws.yml -# and run-rad-commands-azure.yml. Runs after the provider-specific "Create Radius -# environment and recipe pack" step. When the repo defines custom resource types -# via one or more `*recipe-pack*.bicep` files in the app's `.radius/` folder -# (alongside `.radius/app.bicep`), this deploys each of them, lists every recipe -# pack now known to the control plane, and rewrites the environment's recipe pack -# list to reference all of them -- so the environment carries both the default -# provider pack and the user's custom packs. When no such files exist the action -# is a no-op, leaving the default pack in place. +# Provider-agnostic custom resource-type / recipe-pack apply shared by +# run-rad-commands-aws.yml and run-rad-commands-azure.yml. Runs after the +# provider-specific "Create Radius environment and recipe pack" step. When the +# app's `.radius/` folder (alongside `.radius/app.bicep`) carries custom resource +# types, this: +# 1. registers those types from `.radius/custom-types.yaml` via +# `rad resource-type create --from-file` (skipped when the file is absent), and +# 2. deploys `.radius/custom-recipe-pack.bicep`, then lists every recipe pack now +# known to the control plane and rewrites the environment's recipe pack list to +# reference all of them -- so the environment carries both the default provider +# pack and the user's custom pack (skipped when the file is absent). +# When neither file exists the action is a no-op, leaving the default pack in place. name: Radius - Apply custom recipe packs -description: Deploy the repo's custom recipe pack bicep files (if any) and attach every recipe pack to the environment. +description: Register the repo's custom resource types and recipe pack (if present) and attach every recipe pack to the environment. inputs: environment: description: Radius environment name to update with the full recipe pack list. required: true app-file: - description: Application bicep file. Its directory (e.g. .radius/) is searched for *recipe-pack*.bicep files. + description: Application bicep file. Its directory (e.g. .radius/) is searched for custom-types.yaml and custom-recipe-pack.bicep. required: true runs: using: composite steps: - - name: Deploy custom recipe packs and attach to environment + - name: Register custom types and apply custom recipe pack shell: bash env: ENVIRONMENT: ${{ inputs.environment }} @@ -29,33 +32,40 @@ runs: run: | set -euo pipefail - # Custom recipe packs live next to the app file (e.g. .radius/) so that - # `rad deploy` resolves the repo's own .radius/bicepconfig.json (which - # declares the `radius` extension) -- bicep resolves the config nearest the - # .bicep file. Any file matching *recipe-pack*.bicep in that directory is - # treated as a custom recipe pack. No matches => nothing to do, keep the - # default pack. + # Custom type / recipe-pack files live next to the app file (e.g. .radius/). + # Deploying from that directory lets `rad deploy` resolve the repo's own + # .radius/bicepconfig.json (which declares the `radius` extension) -- bicep + # resolves the config nearest the .bicep file. APP_DIR=$(dirname "$APP_FILE") + CUSTOM_TYPES_YAML="$APP_DIR/custom-types.yaml" + RECIPE_PACK_BICEP="$APP_DIR/custom-recipe-pack.bicep" - shopt -s nullglob - RECIPE_PACK_FILES=("$APP_DIR"/*recipe-pack*.bicep) - shopt -u nullglob + # 1. Register custom resource types with Radius. --from-file registers every + # type defined in the manifest. Must run before deploying the recipe pack, + # which references these types. Absent file => nothing to register. + if [ -f "$CUSTOM_TYPES_YAML" ]; then + echo "Registering custom resource types from $CUSTOM_TYPES_YAML..." + rad resource-type create --from-file "$CUSTOM_TYPES_YAML" + echo "✅ Custom resource types registered." + else + echo "No custom resource types at $CUSTOM_TYPES_YAML; skipping type registration." + fi - if [ ${#RECIPE_PACK_FILES[@]} -eq 0 ]; then - echo "No custom recipe pack (*recipe-pack*.bicep) found in $APP_DIR; keeping the default recipe pack." + # 2. Deploy the custom recipe pack and attach it to the environment. Absent + # file => nothing to deploy, keep the default pack. + if [ ! -f "$RECIPE_PACK_BICEP" ]; then + echo "No custom recipe pack at $RECIPE_PACK_BICEP; keeping the default recipe pack." exit 0 fi - for RECIPE_PACK_BICEP in "${RECIPE_PACK_FILES[@]}"; do - echo "Custom recipe pack file: $RECIPE_PACK_BICEP" - cat "$RECIPE_PACK_BICEP" - echo "" - echo "Deploying custom recipe pack from $RECIPE_PACK_BICEP..." - rad deploy "$RECIPE_PACK_BICEP" - done + echo "Custom recipe pack file:" + cat "$RECIPE_PACK_BICEP" + echo "" + echo "Deploying custom recipe pack from $RECIPE_PACK_BICEP..." + rad deploy "$RECIPE_PACK_BICEP" # List every recipe pack the control plane now knows about (the default - # provider pack created earlier plus the custom packs just deployed) and + # provider pack created earlier plus the custom pack just deployed) and # collect their full resource IDs. IDs are used rather than names so the # env update resolves each pack unambiguously regardless of its scope. echo "Listing recipe packs..." diff --git a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md index fcb60420184..d8862050212 100644 --- a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md +++ b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md @@ -158,7 +158,7 @@ The workflow provisions a `Radius.Core/recipePacks` resource and a `Radius.Core/ The `radius-env.bicep` that carries the pack is written to the app file's directory (e.g. `.radius/`) and deployed from there. bicep resolves `bicepconfig.json` nearest the `.bicep` file, so deploying from that directory picks up the repo's own `.radius/bicepconfig.json` — which declares the `radius` extension — rather than a (non-existent) config at the workspace root. The `Radius.Compute/containerImages` type ships with the published `radius` Bicep extension, so the workflow no longer registers resource types or wires a local Bicep extension at deploy time. -**Custom recipe packs.** A repo that defines its own custom resource types supplies their recipes via one or more `*recipe-pack*.bicep` files in the app's `.radius/` folder (next to `.radius/app.bicep`). After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action `rad deploy`s each file, enumerates every pack with `rad recipe-pack list`, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom packs (`--recipe-packs` replaces the list, so passing every pack id preserves the default). The files are deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. When no such files exist the step is a no-op and the default pack stays in place. +**Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then `rad deploy`s `custom-recipe-pack.bicep`, enumerates every pack with `rad recipe-pack list`, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom pack (`--recipe-packs` replaces the list, so passing every pack id preserves the default). Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. ### Control plane startup (Investment 5) From afc92dd47f67cd13b87508f69542dd7ed685ef79 Mon Sep 17 00:00:00 2001 From: sk593 Date: Mon, 13 Jul 2026 14:37:33 -0700 Subject: [PATCH 03/18] Add delete application/environment workflows and fix env update --preview Add Repo Radius workflows to delete a Radius application or environment on the same ephemeral k3d control plane the deploy flow uses. Two thin dispatchers (delete-application.yml, delete-environment.yml) detect the provider and call reusable provider workflows (delete-azure.yml, delete-aws.yml), which restore persisted state, delete via the shared delete-resource composite action, and persist the updated state again. delete-resource runs `rad app delete`/`rad env delete --yes --preview` and writes a rad-delete-result artifact. `--preview` is required so these commands use the Radius.Core surface instead of the legacy path; apply the same fix to the `rad env update --recipe-packs` call. Document the delete workflows in the extension README, the deploy design note, and a new radius-delete skill. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .github/extension/README.md | 13 +- .../apply-custom-recipe-packs/action.yml | 6 +- .../actions/delete-resource/action.yml | 95 ++++++++++ .github/extension/delete-application.yml | 70 +++++++ .github/extension/delete-aws.yml | 178 ++++++++++++++++++ .github/extension/delete-azure.yml | 149 +++++++++++++++ .github/extension/delete-environment.yml | 73 +++++++ .../extension/skills/radius-delete/SKILL.md | 69 +++++++ .../2026-06-repo-radius-deploy-workflow.md | 9 + 9 files changed, 659 insertions(+), 3 deletions(-) create mode 100644 .github/extension/actions/delete-resource/action.yml create mode 100644 .github/extension/delete-application.yml create mode 100644 .github/extension/delete-aws.yml create mode 100644 .github/extension/delete-azure.yml create mode 100644 .github/extension/delete-environment.yml create mode 100644 .github/extension/skills/radius-delete/SKILL.md diff --git a/.github/extension/README.md b/.github/extension/README.md index 75f1cf40a46..beb375fe0f9 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -63,10 +63,21 @@ 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 [resource-types-contrib](https://github.com/radius-project/resource-types-contrib). - **`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. -- **`actions/*`** — composite actions holding the provider-agnostic phases both provider workflows share: [`setup-control-plane`](actions/setup-control-plane/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), 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), [`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), [`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`. +## `delete-application.yml` / `delete-environment.yml` (delete dispatchers and provider workflows) + +Radius deletes a deployed application or an environment with the same ephemeral-control-plane model as the deploy flow. Because deleting recipe-backed resources runs the recipes' delete path (e.g. `terraform destroy`) against the target cluster and cloud, the delete workflows restore the persisted Radius state first, run the delete, then persist the updated state again — so subsequent runs plan against the post-delete state. + +- **`delete-application.yml`** — dispatcher to delete one application. `workflow_dispatch` inputs: `environment` (GitHub Environment name) and `application` (application name). A `detect` job binds the environment, reads the provider variable, and calls the matching provider delete workflow with `resource_type: application`. +- **`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. Unlike the deploy provider workflows they do **not** create the environment, recipe pack, or 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`. + + ### What it does The dispatcher routes to the matching provider workflow, which runs on `ubuntu-latest`. It stands up an ephemeral [k3d](https://k3d.io) cluster to host the Radius control plane on the runner, points that control plane at the user's existing EKS/AKS cluster, and deploys the application there. The control-plane setup, state restore, and run/teardown phases below run from the shared composite actions; the OIDC login, cluster connection, token projection, credential registration, and recipe-pack creation are the provider-specific steps. When a provider's identifying variable is empty, its steps are skipped and resources deploy to the ephemeral control-plane cluster instead of an external target. diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml index ed32e40c17b..f2f688d489d 100644 --- a/.github/extension/actions/apply-custom-recipe-packs/action.yml +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -77,7 +77,9 @@ runs: fi # Replace the environment's recipe pack list with every pack so the custom - # types resolve alongside the default provider recipes. + # types resolve alongside the default provider recipes. --preview selects the + # Radius.Core implementation; without it `rad env update` dispatches to the + # legacy command, which does not understand --recipe-packs. echo "Attaching recipe packs to environment '$ENVIRONMENT': $PACK_IDS" - rad env update "$ENVIRONMENT" --recipe-packs "$PACK_IDS" + rad env update "$ENVIRONMENT" --recipe-packs "$PACK_IDS" --preview echo "✅ Environment '$ENVIRONMENT' updated with all recipe packs." diff --git a/.github/extension/actions/delete-resource/action.yml b/.github/extension/actions/delete-resource/action.yml new file mode 100644 index 00000000000..e5608edb84f --- /dev/null +++ b/.github/extension/actions/delete-resource/action.yml @@ -0,0 +1,95 @@ +# Provider-agnostic resource delete shared by delete-azure.yml and delete-aws.yml. +# Runs after restore-state (rad startup) has brought back the control-plane state, +# so the environment, its recipe packs, the application, and the Terraform recipe +# state all exist and recipe deletes can run. Deletes a single application or +# environment with the Radius.Core preview surface, then writes a rad-delete-result +# artifact. State is persisted again by the separate `teardown` action (rad shutdown), +# which runs unconditionally after this step. +name: Radius - Delete resource +description: Delete a Radius application or environment (Radius.Core preview) and write a result artifact. + +inputs: + resource-type: + description: What to delete -- "application" or "environment". + required: true + name: + description: Name of the application or environment to delete. + required: true + +runs: + using: composite + steps: + - name: Delete Radius resource + shell: bash + env: + RESOURCE_TYPE: ${{ inputs.resource-type }} + RESOURCE_NAME: ${{ inputs.name }} + run: | + # pipefail so a failed `rad` whose output is piped through `tee` is detected + # via PIPESTATUS rather than masked by tee's exit code. + set -uo pipefail + mkdir -p /tmp/radius-output + RESULT_FILE=/tmp/radius-output/rad-delete-result.json + + # Write the combined result on exit so the rad-delete-result artifact is + # complete even when the delete fails and the step exits early. + OUTCOME="succeeded" + EXIT=0 + OUTPUT="" + write_result() { + jq -n \ + --arg outcome "$OUTCOME" \ + --argjson exitCode "$EXIT" \ + --arg resourceType "$RESOURCE_TYPE" \ + --arg name "$RESOURCE_NAME" \ + --arg output "$OUTPUT" \ + '{schemaVersion:"1.0", outcome:$outcome, exitCode:$exitCode, resourceType:$resourceType, name:$name, output:$output}' \ + > "$RESULT_FILE" + } + trap write_result EXIT + + if [ -z "${RESOURCE_NAME//[[:space:]]/}" ]; then + echo "No resource name supplied to delete." >&2 + OUTCOME="invalid_input" + EXIT=2 + exit 2 + fi + + # Map the resource type to its rad command. Only application and environment + # are supported; anything else fails fast without touching the control plane. + case "$RESOURCE_TYPE" in + application) VERB=(app delete) ;; + environment) VERB=(env delete) ;; + *) + echo "Unsupported resource type: '$RESOURCE_TYPE' (expected 'application' or 'environment')." >&2 + OUTCOME="invalid_input" + EXIT=2 + exit 2 + ;; + esac + + # --yes bypasses the interactive confirmation prompt; --preview selects the + # Radius.Core implementation the deploy workflow provisions (without it the + # base command dispatches to the legacy, non-Radius.Core surface). + outfile=$(mktemp) + echo "::group::rad ${VERB[*]} $RESOURCE_NAME --yes --preview" + rad "${VERB[@]}" "$RESOURCE_NAME" --yes --preview 2>&1 | tee "$outfile" + EXIT=${PIPESTATUS[0]} + echo "::endgroup::" + OUTPUT=$(cat "$outfile") + rm -f "$outfile" + + if [ "$EXIT" -ne 0 ]; then + OUTCOME="failed" + echo "Failed to delete $RESOURCE_TYPE '$RESOURCE_NAME'." >&2 + exit "$EXIT" + fi + echo "✅ Deleted $RESOURCE_TYPE '$RESOURCE_NAME'." + + - name: Upload delete result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rad-delete-result + path: /tmp/radius-output/ + retention-days: 1 diff --git a/.github/extension/delete-application.yml b/.github/extension/delete-application.yml new file mode 100644 index 00000000000..bccfdd4d20f --- /dev/null +++ b/.github/extension/delete-application.yml @@ -0,0 +1,70 @@ +# This workflow is auto-generated by Radius. It deletes a Radius application: it +# detects whether the selected GitHub Environment is wired for Azure or AWS and +# calls the matching reusable delete workflow (delete-azure.yml / delete-aws.yml) +# with resource_type=application. Deleting runs on an ephemeral k3d control plane +# that restores the persisted Radius state, runs `rad app delete` (including any +# recipe deletes against the target cluster), and persists the updated state again. +name: Radius - Delete Application + +on: + workflow_dispatch: + inputs: + environment: + description: 'GitHub Environment name' + required: true + default: '{{ENV}}' + application: + description: 'Name of the application to delete' + required: true + +permissions: + id-token: write + contents: write + packages: read + +jobs: + # Bind to the GitHub Environment so environment-scoped variables are visible, then + # pick the provider from whichever identifying variable is set. A job that calls a + # reusable workflow (`uses:`) can't bind an environment itself, so this routing + # decision has to happen in a regular job first. + detect: + name: Detect provider + runs-on: ubuntu-latest + environment: ${{ inputs.environment || '{{ENV}}' }} + outputs: + provider: ${{ steps.detect.outputs.provider }} + steps: + - name: Determine provider from environment variables + id: detect + run: | + if [ -n "${{ vars.AZURE_CLIENT_ID }}" ]; then + echo "provider=azure" >> "$GITHUB_OUTPUT" + elif [ -n "${{ vars.AWS_ROLE_ARN }}" ]; then + echo "provider=aws" >> "$GITHUB_OUTPUT" + else + echo "No AZURE_CLIENT_ID or AWS_ROLE_ARN set on this environment." >&2 + echo "provider=none" >> "$GITHUB_OUTPUT" + exit 1 + fi + + azure: + name: Azure + needs: detect + if: ${{ needs.detect.outputs.provider == 'azure' }} + uses: ./.github/workflows/delete-azure.yml + with: + environment: ${{ inputs.environment || '{{ENV}}' }} + resource_type: application + name: ${{ inputs.application }} + secrets: inherit + + aws: + name: AWS + needs: detect + if: ${{ needs.detect.outputs.provider == 'aws' }} + uses: ./.github/workflows/delete-aws.yml + with: + environment: ${{ inputs.environment || '{{ENV}}' }} + resource_type: application + name: ${{ inputs.application }} + secrets: inherit diff --git a/.github/extension/delete-aws.yml b/.github/extension/delete-aws.yml new file mode 100644 index 00000000000..28341719209 --- /dev/null +++ b/.github/extension/delete-aws.yml @@ -0,0 +1,178 @@ +# This workflow is auto-generated by Radius to delete a Radius application or +# environment from an AWS environment. It is a reusable (workflow_call) workflow +# invoked by the delete-application.yml / delete-environment.yml dispatchers; it is +# not dispatched directly. It creates an ephemeral k3d cluster for the Radius control +# plane, connects to the user's EKS cluster, restores persisted state, deletes the +# requested resource (running any recipe deletes against the target cluster), then +# persists the updated state again and tears the control plane down. The provider- +# agnostic phases are shared composite actions in radius-project/radius; only the +# AWS-specific steps live here. +name: Radius - Delete (AWS) + +on: + workflow_call: + inputs: + environment: + description: 'GitHub Environment name' + type: string + required: true + resource_type: + description: 'What to delete: application or environment' + type: string + required: true + name: + description: 'Name of the application or environment to delete' + type: string + required: true + +permissions: + id-token: write + contents: write + packages: read + +env: + ENVIRONMENT: ${{ inputs.environment }} + +jobs: + delete: + name: Delete with Radius + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Configure AWS Credentials (OIDC) + if: ${{ vars.AWS_ROLE_ARN != '' }} + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Get target cluster kubeconfig + run: | + mkdir -p "$HOME/.kube" + echo "RADIUS_TARGET_KUBECONFIG=$HOME/.kube/target-cluster" >> "$GITHUB_ENV" + + - name: Connect to EKS cluster + if: ${{ vars.AWS_EKS_CLUSTER_NAME != '' }} + run: | + CLUSTER="${{ vars.AWS_EKS_CLUSTER_NAME }}" + REGION="${{ vars.AWS_REGION }}" + ROLE_ARN="${{ vars.AWS_ROLE_ARN }}" + TARGET="$RADIUS_TARGET_KUBECONFIG" + + # Ensure the IAM role has access to the EKS cluster. + echo "Ensuring EKS access entry for $ROLE_ARN..." + aws eks create-access-entry \ + --cluster-name "$CLUSTER" \ + --principal-arn "$ROLE_ARN" \ + --type STANDARD \ + --region "$REGION" 2>/dev/null || echo "Access entry already exists" + aws eks associate-access-policy \ + --cluster-name "$CLUSTER" \ + --principal-arn "$ROLE_ARN" \ + --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \ + --access-scope type=cluster \ + --region "$REGION" 2>/dev/null || echo "Access policy already associated" + + # Build a static kubeconfig with a bearer token instead of exec-based auth. + ENDPOINT=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.endpoint' --output text) + CA_DATA=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.certificateAuthority.data' --output text) + TOKEN=$(aws eks get-token --cluster-name "$CLUSTER" --region "$REGION" --output json | jq -r '.status.token') + printf 'apiVersion: v1\nclusters:\n- cluster:\n certificate-authority-data: %s\n server: %s\n name: eks\ncontexts:\n- context:\n cluster: eks\n user: eks-user\n name: eks\ncurrent-context: eks\nkind: Config\nusers:\n- name: eks-user\n user:\n token: %s\n' "$CA_DATA" "$ENDPOINT" "$TOKEN" > "$TARGET" + echo "EKS kubeconfig saved with static token" + kubectl --kubeconfig "$TARGET" cluster-info || echo "WARNING: Could not connect to EKS cluster" + + - name: Set up control plane + uses: radius-project/radius/.github/extension/actions/setup-control-plane@{{RADIUS_REF}} + + - name: Project cloud OIDC tokens into Radius pods + run: | + # Mint GitHub OIDC tokens and mount them where Radius expects them, so the + # recipe deletes triggered by the delete can authenticate to AWS exactly as + # the deploy workflow does. GitHub Actions is already a trusted OIDC issuer + # for AWS via the IAM role trust policy. + if [ -n "${{ vars.AWS_ROLE_ARN }}" ]; then + echo "Projecting AWS OIDC token..." + AWS_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value') + kubectl create secret generic aws-oidc-token -n radius-system \ + --from-literal=token="$AWS_TOKEN" --dry-run=client -o yaml | kubectl apply -f - + + # AWS IRSA: the UCP AWS proxy (ucp) and the Terraform AWS provider + # (dynamic-rp) both read the token from the hard-coded path + # /var/run/secrets/eks.amazonaws.com/serviceaccount/token. + AWS_PATCH='[ + {"op":"add","path":"/spec/template/spec/volumes/-","value":{"name":"aws-oidc-token","secret":{"secretName":"aws-oidc-token"}}}, + {"op":"add","path":"/spec/template/spec/containers/0/volumeMounts/-","value":{"name":"aws-oidc-token","mountPath":"/var/run/secrets/eks.amazonaws.com/serviceaccount","readOnly":true}} + ]' + for deploy in applications-rp dynamic-rp ucp; do + kubectl patch deployment $deploy -n radius-system --type=json -p="$AWS_PATCH" 2>/dev/null || true + done + fi + + for deploy in applications-rp dynamic-rp bicep-de ucp; do + kubectl rollout status deployment/$deploy -n radius-system --timeout=300s || true + done + echo "✅ Cloud OIDC tokens projected into Radius pods." + + - name: Refresh external deployment target credentials + run: | + TARGET_KUBECONFIG="$RADIUS_TARGET_KUBECONFIG" + + if [ ! -f "$TARGET_KUBECONFIG" ]; then + echo "No target kubeconfig found, resources are on the k3d control plane" + exit 0 + fi + + # Refresh the EKS token right before delete (EKS tokens are short-lived and + # the one minted earlier may have expired during install). + if [ -n "${{ vars.AWS_ROLE_ARN }}" ] && [ -n "${{ vars.AWS_EKS_CLUSTER_NAME }}" ]; then + echo "Generating fresh EKS token..." + CLUSTER="${{ vars.AWS_EKS_CLUSTER_NAME }}" + REGION="${{ vars.AWS_REGION }}" + ENDPOINT=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.endpoint' --output text) + CA_DATA=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.certificateAuthority.data' --output text) + TOKEN=$(aws eks get-token --cluster-name "$CLUSTER" --region "$REGION" --output json | jq -r '.status.token') + printf 'apiVersion: v1\nclusters:\n- cluster:\n certificate-authority-data: %s\n server: %s\n name: eks\ncontexts:\n- context:\n cluster: eks\n user: eks-user\n name: eks\ncurrent-context: eks\nkind: Config\nusers:\n- name: eks-user\n user:\n token: %s\n' "$CA_DATA" "$ENDPOINT" "$TOKEN" > "$TARGET_KUBECONFIG" + fi + + # Update the secret the chart mounted at install with the refreshed + # kubeconfig, then restart the recipe-executing pods so they re-read it. + kubectl create secret generic target-kubeconfig --namespace radius-system \ + --from-file=kubeconfig="$TARGET_KUBECONFIG" --dry-run=client -o yaml | kubectl apply -f - + for deploy in applications-rp dynamic-rp bicep-de; do + kubectl rollout restart deployment/$deploy -n radius-system + done + + echo "Waiting for rollouts..." + kubectl rollout status deployment/applications-rp -n radius-system --timeout=300s + kubectl rollout status deployment/dynamic-rp -n radius-system --timeout=300s + kubectl rollout status deployment/bicep-de -n radius-system --timeout=300s + echo "External deployment target configured." + + - name: Restore Radius state + uses: radius-project/radius/.github/extension/actions/restore-state@{{RADIUS_REF}} + with: + namespace: ${{ vars.KUBERNETES_NAMESPACE || 'default' }} + + - name: Register cloud credentials with Radius + run: | + # Register the AWS identity so the recipe deletes can authenticate, + # reading the projected OIDC token at runtime. + if [ -n "${{ vars.AWS_ROLE_ARN }}" ]; then + echo "Registering AWS IRSA credential..." + rad credential register aws irsa --iam-role "${{ vars.AWS_ROLE_ARN }}" + fi + echo "✅ Cloud credentials registered with Radius." + + - name: Delete Radius resource + uses: radius-project/radius/.github/extension/actions/delete-resource@{{RADIUS_REF}} + with: + resource-type: ${{ inputs.resource_type }} + name: ${{ inputs.name }} + + - 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 new file mode 100644 index 00000000000..79733ceba7e --- /dev/null +++ b/.github/extension/delete-azure.yml @@ -0,0 +1,149 @@ +# This workflow is auto-generated by Radius to delete a Radius application or +# environment from an Azure environment. It is a reusable (workflow_call) workflow +# invoked by the delete-application.yml / delete-environment.yml dispatchers; it is +# not dispatched directly. It creates an ephemeral k3d cluster for the Radius control +# plane, connects to the user's AKS cluster, restores persisted state, deletes the +# requested resource (running any recipe deletes against the target cluster), then +# persists the updated state again and tears the control plane down. The provider- +# agnostic phases are shared composite actions in radius-project/radius; only the +# Azure-specific steps live here. +name: Radius - Delete (Azure) + +on: + workflow_call: + inputs: + environment: + description: 'GitHub Environment name' + type: string + required: true + resource_type: + description: 'What to delete: application or environment' + type: string + required: true + name: + description: 'Name of the application or environment to delete' + type: string + required: true + +permissions: + id-token: write + contents: write + packages: read + +env: + ENVIRONMENT: ${{ inputs.environment }} + +jobs: + delete: + name: Delete with Radius + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Azure Login (OIDC) + if: ${{ vars.AZURE_CLIENT_ID != '' }} + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Get target cluster kubeconfig + run: | + mkdir -p "$HOME/.kube" + echo "RADIUS_TARGET_KUBECONFIG=$HOME/.kube/target-cluster" >> "$GITHUB_ENV" + + - name: Connect to AKS cluster + if: ${{ vars.AZURE_AKS_CLUSTER_NAME != '' }} + run: | + az aks get-credentials \ + --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --name "${{ vars.AZURE_AKS_CLUSTER_NAME }}" \ + --subscription "${{ vars.AZURE_SUBSCRIPTION_ID }}" \ + --file "$RADIUS_TARGET_KUBECONFIG" + + - name: Set up control plane + uses: radius-project/radius/.github/extension/actions/setup-control-plane@{{RADIUS_REF}} + + - name: Project cloud OIDC tokens into Radius pods + run: | + # Mint GitHub OIDC tokens and mount them where Radius expects them, so the + # recipe deletes triggered by the delete can authenticate to Azure exactly + # as the deploy workflow does. GitHub Actions is already a trusted OIDC + # issuer for Azure via the AAD federated credential. + if [ -n "${{ vars.AZURE_CLIENT_ID }}" ]; then + echo "Projecting Azure OIDC token..." + AZ_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq -r '.value') + # The secret data key becomes the mounted file name, so it must be + # 'azure-identity-token' -- the file Radius and the Terraform azurerm + # provider read from /var/run/secrets/azure/tokens/. + kubectl create secret generic azure-oidc-token -n radius-system \ + --from-literal=azure-identity-token="$AZ_TOKEN" --dry-run=client -o yaml | kubectl apply -f - + + AZ_PATCH='[ + {"op":"add","path":"/spec/template/spec/volumes/-","value":{"name":"azure-oidc-token","secret":{"secretName":"azure-oidc-token"}}}, + {"op":"add","path":"/spec/template/spec/containers/0/volumeMounts/-","value":{"name":"azure-oidc-token","mountPath":"/var/run/secrets/azure/tokens","readOnly":true}}, + {"op":"add","path":"/spec/template/spec/containers/0/env/-","value":{"name":"AZURE_FEDERATED_TOKEN_FILE","value":"/var/run/secrets/azure/tokens/azure-identity-token"}} + ]' + for deploy in applications-rp dynamic-rp bicep-de; do + kubectl patch deployment $deploy -n radius-system --type=json -p="$AZ_PATCH" 2>/dev/null || true + done + fi + + for deploy in applications-rp dynamic-rp bicep-de ucp; do + kubectl rollout status deployment/$deploy -n radius-system --timeout=300s || true + done + echo "✅ Cloud OIDC tokens projected into Radius pods." + + - name: Refresh external deployment target credentials + run: | + TARGET_KUBECONFIG="$RADIUS_TARGET_KUBECONFIG" + + if [ ! -f "$TARGET_KUBECONFIG" ]; then + echo "No target kubeconfig found, resources are on the k3d control plane" + exit 0 + fi + + # Update the secret the chart mounted at install with the refreshed + # kubeconfig, then restart the recipe-executing pods so they re-read it. + kubectl create secret generic target-kubeconfig --namespace radius-system \ + --from-file=kubeconfig="$TARGET_KUBECONFIG" --dry-run=client -o yaml | kubectl apply -f - + for deploy in applications-rp dynamic-rp bicep-de; do + kubectl rollout restart deployment/$deploy -n radius-system + done + + echo "Waiting for rollouts..." + kubectl rollout status deployment/applications-rp -n radius-system --timeout=300s + kubectl rollout status deployment/dynamic-rp -n radius-system --timeout=300s + kubectl rollout status deployment/bicep-de -n radius-system --timeout=300s + echo "External deployment target configured." + + - name: Restore Radius state + uses: radius-project/radius/.github/extension/actions/restore-state@{{RADIUS_REF}} + with: + namespace: ${{ vars.KUBERNETES_NAMESPACE || 'default' }} + + - name: Register cloud credentials with Radius + run: | + # Register the Azure identity so the recipe deletes can authenticate, + # reading the projected OIDC token at runtime. + if [ -n "${{ vars.AZURE_CLIENT_ID }}" ]; then + echo "Registering Azure workload identity credential..." + rad credential register azure wi \ + --client-id "${{ vars.AZURE_CLIENT_ID }}" \ + --tenant-id "${{ vars.AZURE_TENANT_ID }}" + fi + echo "✅ Cloud credentials registered with Radius." + + - name: Delete Radius resource + uses: radius-project/radius/.github/extension/actions/delete-resource@{{RADIUS_REF}} + with: + resource-type: ${{ inputs.resource_type }} + name: ${{ inputs.name }} + + - name: Teardown + if: always() + uses: radius-project/radius/.github/extension/actions/teardown@{{RADIUS_REF}} diff --git a/.github/extension/delete-environment.yml b/.github/extension/delete-environment.yml new file mode 100644 index 00000000000..0f475147f94 --- /dev/null +++ b/.github/extension/delete-environment.yml @@ -0,0 +1,73 @@ +# This workflow is auto-generated by Radius. It deletes a Radius environment: it +# detects whether the selected GitHub Environment is wired for Azure or AWS and +# calls the matching reusable delete workflow (delete-azure.yml / delete-aws.yml) +# with resource_type=environment. Deleting runs on an ephemeral k3d control plane +# that restores the persisted Radius state, runs `rad env delete`, and persists the +# updated state again. The Radius environment name defaults to the GitHub Environment +# name (the deploy workflow names the Radius environment after it), but can be +# overridden with the environment_name input. +name: Radius - Delete Environment + +on: + workflow_dispatch: + inputs: + environment: + description: 'GitHub Environment name' + required: true + default: '{{ENV}}' + environment_name: + description: 'Radius environment name to delete (defaults to the GitHub Environment name)' + required: false + default: '' + +permissions: + id-token: write + contents: write + packages: read + +jobs: + # Bind to the GitHub Environment so environment-scoped variables are visible, then + # pick the provider from whichever identifying variable is set. A job that calls a + # reusable workflow (`uses:`) can't bind an environment itself, so this routing + # decision has to happen in a regular job first. + detect: + name: Detect provider + runs-on: ubuntu-latest + environment: ${{ inputs.environment || '{{ENV}}' }} + outputs: + provider: ${{ steps.detect.outputs.provider }} + steps: + - name: Determine provider from environment variables + id: detect + run: | + if [ -n "${{ vars.AZURE_CLIENT_ID }}" ]; then + echo "provider=azure" >> "$GITHUB_OUTPUT" + elif [ -n "${{ vars.AWS_ROLE_ARN }}" ]; then + echo "provider=aws" >> "$GITHUB_OUTPUT" + else + echo "No AZURE_CLIENT_ID or AWS_ROLE_ARN set on this environment." >&2 + echo "provider=none" >> "$GITHUB_OUTPUT" + exit 1 + fi + + azure: + name: Azure + needs: detect + if: ${{ needs.detect.outputs.provider == 'azure' }} + uses: ./.github/workflows/delete-azure.yml + with: + environment: ${{ inputs.environment || '{{ENV}}' }} + resource_type: environment + name: ${{ inputs.environment_name || inputs.environment || '{{ENV}}' }} + secrets: inherit + + aws: + name: AWS + needs: detect + if: ${{ needs.detect.outputs.provider == 'aws' }} + uses: ./.github/workflows/delete-aws.yml + with: + environment: ${{ inputs.environment || '{{ENV}}' }} + resource_type: environment + name: ${{ inputs.environment_name || inputs.environment || '{{ENV}}' }} + secrets: inherit diff --git a/.github/extension/skills/radius-delete/SKILL.md b/.github/extension/skills/radius-delete/SKILL.md new file mode 100644 index 00000000000..6c820685d03 --- /dev/null +++ b/.github/extension/skills/radius-delete/SKILL.md @@ -0,0 +1,69 @@ +--- +name: radius-delete +description: Delete a Radius application or environment via the auto-generated GitHub Actions workflow. Use when the user asks to delete, remove, or tear down a deployed Radius application or a Radius environment. +--- + +# Radius — Delete Application or Environment + +Trigger the `Radius - Delete Application` or `Radius - Delete Environment` workflow. Each spins up an ephemeral k3d Radius control plane, connects to the target AKS/EKS cluster, restores persisted state, runs `rad app delete` / `rad env delete`, and persists the updated state again before tearing the control plane down. Deleting an application also deletes that application's resources (running their recipes' delete path against the target cluster and cloud). + +## When to use this skill + +- "Delete my app" +- "Remove application X from env Y" +- "Tear down the test environment" +- "Delete the environment" + +## Prerequisites + +Before invoking this skill, all of these must exist: +1. A GitHub Environment configured with cloud credentials → use the `radius-environment` skill if missing. +2. Previously persisted Radius state for that environment (i.e. the app/environment was deployed at least once). Delete restores that state to know what to delete. +3. Authenticated access to dispatch the workflow (e.g. a logged-in `gh` CLI, or a token with `actions: write` on the repo). The token only triggers the run; it is never passed into the workflow. + +## How to invoke + +Delete an application (`application` is the Radius application name): + +``` +POST /repos/{owner}/{repo}/actions/workflows/delete-application.yml/dispatches +{ "ref": "main", "inputs": { "environment": "", "application": "" } } +``` + +```bash +gh workflow run delete-application.yml -f environment= -f application= +``` + +Delete an environment (`environment_name` defaults to the GitHub Environment name, which the deploy flow uses as the Radius environment name): + +``` +POST /repos/{owner}/{repo}/actions/workflows/delete-environment.yml/dispatches +{ "ref": "main", "inputs": { "environment": "", "environment_name": "" } } +``` + +```bash +gh workflow run delete-environment.yml -f environment= [-f environment_name=] +``` + +Then follow the run (`gh run watch` or the run URL) until it succeeds, fails, or times out. Each delete workflow is a dispatcher: it detects the environment's provider (from `AZURE_CLIENT_ID` / `AWS_ROLE_ARN`) and calls the matching reusable workflow (`delete-azure.yml` / `delete-aws.yml`), so the actual delete work runs as a called workflow underneath it. + +## What the workflow does + +1. The dispatcher detects the environment's provider and calls the matching provider delete workflow, which authenticates to that cloud via OIDC. +2. Fetches a kubeconfig for the target cluster, installs `k3d` + the `rad` CLI + Terraform, and installs Radius on the ephemeral control plane wired to the target cluster (same setup as deploy). +3. Projects GitHub OIDC tokens into the pods and registers the cloud identity with `rad credential register`, so recipe deletes can reach the target cluster and cloud. +4. Runs `rad startup` to restore the control-plane databases and Terraform recipe-state Secrets persisted by the previous run — this is what tells the delete which environment, recipe packs, resources, and Terraform state exist. Unlike deploy, it does **not** recreate the environment, recipe pack, or registry credentials. +5. Runs `rad app delete --yes --preview` or `rad env delete --yes --preview` (`--preview` selects the Radius.Core surface) via the `delete-resource` action, which writes a `rad-delete-result` artifact (JSON: `outcome`, `exitCode`, `resourceType`, `name`, `output`). +6. `rad shutdown` (`if: always()`) persists the post-delete control-plane databases and Terraform recipe-state Secrets back to the `radius-state` git orphan branch, so the next operation plans against the updated state. On failure, logs are uploaded as the `radius-logs` artifact; the k3d cluster is always deleted. + +## After a successful delete + +- Tell the user the delete succeeded and include the workflow run URL. +- Note that deleting an application removed the app's resources; deleting an environment removed the environment and its recipe-pack associations. + +## Related files + +- `.github/extension/delete-application.yml` and `.github/extension/delete-environment.yml` (this repo) — the delete dispatcher templates; copies are committed into the user repo at `.github/workflows/` and are the files that get dispatched. +- `.github/extension/delete-azure.yml` and `.github/extension/delete-aws.yml` — the provider-specific reusable (`workflow_call`) workflows the dispatchers call; committed alongside the dispatchers. +- `.github/extension/actions/*` — the shared composite actions (`setup-control-plane`, `restore-state`, `delete-resource`, `teardown`) the provider workflows reference from `radius-project/radius`; not copied into the user repo. +- `.github/extension/README.md` — the workflow contract: trigger/inputs, required `vars`, secrets, and prerequisites. diff --git a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md index d8862050212..efb01c47922 100644 --- a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md +++ b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md @@ -160,6 +160,15 @@ The `radius-env.bicep` that carries the pack is written to the app file's direct **Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then `rad deploy`s `custom-recipe-pack.bicep`, enumerates every pack with `rad recipe-pack list`, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom pack (`--recipe-packs` replaces the list, so passing every pack id preserves the default). Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. +### Delete workflows + +Deleting an application or an environment reuses the deploy composition, minus the create stages. `delete-application.yml` and `delete-environment.yml` are thin dispatchers that mirror `run-rad-commands.yml`: a `detect` job binds the GitHub Environment, picks the provider from `AZURE_CLIENT_ID` / `AWS_ROLE_ARN`, and calls a reusable provider workflow (`delete-azure.yml` / `delete-aws.yml`) with `resource_type` (`application` or `environment`) and the target `name`. One provider workflow pair, parameterized by `resource_type`, keeps provider setup defined once per provider rather than once per resource type. + +The provider delete workflows run the same provider setup as deploy — OIDC login, cluster connection, cloud OIDC token projection, `rad credential register` — because `rad app delete` runs the resources' recipe delete path (e.g. `terraform destroy`) against the target cluster and cloud. They skip the deploy-only stages (create environment, deploy recipe pack, register registry credentials): the environment and its recipes come back from restored state, and deleting builds no images. The order is therefore restore state → delete → persist state. Restoring first (`rad startup`) is what makes the delete see the environment, recipe packs, resources, and Terraform state; persisting after (`rad shutdown`, run with `if: always()` in `teardown`) is what satisfies the requirement that the post-delete state is stored again, so the next operation plans against it. + +The shared `delete-resource` composite action runs `rad app delete --yes --preview` or `rad env delete --yes --preview`. `--preview` is required: without it these commands fall through to the legacy implementation instead of the Radius.Core surface the deploy flow provisions. It writes a `rad-delete-result` artifact (JSON with `outcome`, `exitCode`, `resourceType`, `name`, `output`) so a frontend can report the result the same way it reads the deploy result. Deleting an application also deletes that application's resources; deleting an environment removes the environment and its recipe-pack associations. + + ### Control plane startup (Investment 5) Because the control plane is created and torn down on every operation, startup time is on the critical path for every user-facing action and is the primary determinant of perceived responsiveness. Two backend decisions follow from that, both owned by this technical design rather than the feature spec: From b8f1dfe2a87ef77b276f1ce274aa1dd345555766 Mon Sep 17 00:00:00 2001 From: sk593 Date: Mon, 20 Jul 2026 14:59:15 -0700 Subject: [PATCH 04/18] Wire OCI/GHCR state archive into delete workflows The delete provider workflows restore and persist Radius control-plane state via rad startup / rad shutdown, but they lacked the OCI-backed state-archive configuration the deploy workflows now use. As a result rad startup would fail with 'OCI archive repository is not configured; set RADIUS_STATE_REGISTRY or RADIUS_GRAPH_REGISTRY', or fall back to a different backend than deploy wrote, so the delete could not find the state it needed to plan recipe deletes. Mirror the deploy provider workflows in delete-aws.yml and delete-azure.yml: - set the job-level RADIUS_STATE_BACKEND / RADIUS_STATE_REGISTRY / RADIUS_STATE_ARCHIVE env vars from environment-scoped vars.* so the shared restore-state and teardown actions can open the archive, - add a GHCR docker login before restore-state so the private radius-state package pull/push authenticates instead of 401ing, and - raise packages: read to packages: write since rad shutdown now pushes the updated state artifact to GHCR. Update the README to note the delete workflows log in to GHCR and set the RADIUS_STATE_* variables for the OCI-backed state archive. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extension/README.md | 2 +- .github/extension/delete-aws.yml | 28 +++++++++++++++++++++++++++- .github/extension/delete-azure.yml | 28 +++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index beb375fe0f9..64cdff7aecb 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -73,7 +73,7 @@ Radius deletes a deployed application or an environment with the same ephemeral- - **`delete-application.yml`** — dispatcher to delete one application. `workflow_dispatch` inputs: `environment` (GitHub Environment name) and `application` (application name). A `detect` job binds the environment, reads the provider variable, and calls the matching provider delete workflow with `resource_type: application`. - **`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. Unlike the deploy provider workflows they do **not** create the environment, recipe pack, or registry credentials — the environment and its recipes are restored from state, and deleting builds no images. +- **`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`. diff --git a/.github/extension/delete-aws.yml b/.github/extension/delete-aws.yml index 28341719209..f561e05fd9c 100644 --- a/.github/extension/delete-aws.yml +++ b/.github/extension/delete-aws.yml @@ -28,7 +28,7 @@ on: permissions: id-token: write contents: write - packages: read + packages: write env: ENVIRONMENT: ${{ inputs.environment }} @@ -38,6 +38,17 @@ jobs: name: Delete with Radius runs-on: ubuntu-latest environment: ${{ inputs.environment }} + # OCI-backed Radius control-plane state, provisioned per-environment by the + # deploy tooling (canvas extension) as environment variables. Set at job + # level (not workflow level) so environment-scoped vars.* resolve, and so the + # shared restore-state (rad startup) and teardown (rad shutdown) actions — + # plus the delete commands — inherit them and can open the state archive. + # Without these, rad startup fails with "OCI archive repository is not + # configured; set RADIUS_STATE_REGISTRY or RADIUS_GRAPH_REGISTRY". + env: + RADIUS_STATE_BACKEND: ${{ vars.RADIUS_STATE_BACKEND }} + RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} + RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -49,6 +60,21 @@ jobs: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} + - name: Log in to GHCR for the state archive + # The OCI-backed Radius state archive authenticates to GHCR using the + # runner's Docker credential store (~/.docker/config.json). rad startup + # (restore-state) and rad shutdown (teardown) open that archive, so the + # runner must be logged in BEFORE those steps run -- otherwise the pull + # is anonymous and GHCR rejects the private radius-state package with a + # 401. docker login persists for the whole job, covering teardown too. + # GITHUB_TOKEN has packages: write (set at job level) for repo-linked + # packages; a PAT can be supplied instead for other-owner packages. + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Get target cluster kubeconfig run: | mkdir -p "$HOME/.kube" diff --git a/.github/extension/delete-azure.yml b/.github/extension/delete-azure.yml index 79733ceba7e..66c607f28c0 100644 --- a/.github/extension/delete-azure.yml +++ b/.github/extension/delete-azure.yml @@ -28,7 +28,7 @@ on: permissions: id-token: write contents: write - packages: read + packages: write env: ENVIRONMENT: ${{ inputs.environment }} @@ -38,6 +38,17 @@ jobs: name: Delete with Radius runs-on: ubuntu-latest environment: ${{ inputs.environment }} + # OCI-backed Radius control-plane state, provisioned per-environment by the + # deploy tooling (canvas extension) as environment variables. Set at job + # level (not workflow level) so environment-scoped vars.* resolve, and so the + # shared restore-state (rad startup) and teardown (rad shutdown) actions — + # plus the delete commands — inherit them and can open the state archive. + # Without these, rad startup fails with "OCI archive repository is not + # configured; set RADIUS_STATE_REGISTRY or RADIUS_GRAPH_REGISTRY". + env: + RADIUS_STATE_BACKEND: ${{ vars.RADIUS_STATE_BACKEND }} + RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} + RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -50,6 +61,21 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Log in to GHCR for the state archive + # The OCI-backed Radius state archive authenticates to GHCR using the + # runner's Docker credential store (~/.docker/config.json). rad startup + # (restore-state) and rad shutdown (teardown) open that archive, so the + # runner must be logged in BEFORE those steps run -- otherwise the pull + # is anonymous and GHCR rejects the private radius-state package with a + # 401. docker login persists for the whole job, covering teardown too. + # GITHUB_TOKEN has packages: write (set at job level) for repo-linked + # packages; a PAT can be supplied instead for other-owner packages. + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Get target cluster kubeconfig run: | mkdir -p "$HOME/.kube" From 117885e6692dc6739776050ff528a87d74a9fd64 Mon Sep 17 00:00:00 2001 From: sk593 Date: Mon, 20 Jul 2026 15:24:49 -0700 Subject: [PATCH 05/18] Grant packages: write on delete dispatchers The delete-aws.yml / delete-azure.yml reusable workflows now request packages: write to push the OCI-backed state archive to GHCR. A called workflow cannot request more permissions than its caller grants, so the delete-application.yml and delete-environment.yml dispatchers failed at validation with 'is requesting packages: write, but is only allowed packages: read'. Raise both dispatchers to packages: write to match. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extension/delete-application.yml | 2 +- .github/extension/delete-environment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/extension/delete-application.yml b/.github/extension/delete-application.yml index bccfdd4d20f..86b0b06976d 100644 --- a/.github/extension/delete-application.yml +++ b/.github/extension/delete-application.yml @@ -20,7 +20,7 @@ on: permissions: id-token: write contents: write - packages: read + packages: write jobs: # Bind to the GitHub Environment so environment-scoped variables are visible, then diff --git a/.github/extension/delete-environment.yml b/.github/extension/delete-environment.yml index 0f475147f94..81a8160a62e 100644 --- a/.github/extension/delete-environment.yml +++ b/.github/extension/delete-environment.yml @@ -23,7 +23,7 @@ on: permissions: id-token: write contents: write - packages: read + packages: write jobs: # Bind to the GitHub Environment so environment-scoped variables are visible, then From b6eb91a78bf74790a5103f39dcec70a2ef0c0385 Mon Sep 17 00:00:00 2001 From: Shruthi Kumar Date: Tue, 21 Jul 2026 14:04:42 -0700 Subject: [PATCH 06/18] update docs with preview flag Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shruthi Kumar --- .../environments/2026-06-repo-radius-deploy-workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md index efb01c47922..e0785bbb98a 100644 --- a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md +++ b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md @@ -158,7 +158,7 @@ The workflow provisions a `Radius.Core/recipePacks` resource and a `Radius.Core/ The `radius-env.bicep` that carries the pack is written to the app file's directory (e.g. `.radius/`) and deployed from there. bicep resolves `bicepconfig.json` nearest the `.bicep` file, so deploying from that directory picks up the repo's own `.radius/bicepconfig.json` — which declares the `radius` extension — rather than a (non-existent) config at the workspace root. The `Radius.Compute/containerImages` type ships with the published `radius` Bicep extension, so the workflow no longer registers resource types or wires a local Bicep extension at deploy time. -**Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then `rad deploy`s `custom-recipe-pack.bicep`, enumerates every pack with `rad recipe-pack list`, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom pack (`--recipe-packs` replaces the list, so passing every pack id preserves the default). Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. +**Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then `rad deploy`s `custom-recipe-pack.bicep`, enumerates every pack with `rad recipe-pack list`, and runs `rad env update --recipe-packs --preview` so the environment references both the default provider pack and the custom pack (`--recipe-packs` replaces the list, so passing every pack id preserves the default). Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. ### Delete workflows From e17dfe5bb081b3716167faed16846272325c5408 Mon Sep 17 00:00:00 2001 From: Shruthi Kumar Date: Tue, 21 Jul 2026 14:04:54 -0700 Subject: [PATCH 07/18] update docs with preview flag Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shruthi Kumar --- .github/extension/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index 64cdff7aecb..da9cade044a 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -93,7 +93,7 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 9. **Restore persisted state (`rad startup`).** Restores the control-plane databases and the Terraform recipe-state Secrets saved by the previous run, so `rad deploy` plans against prior state rather than an empty backend. A no-op on the first run. 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. Azure downloads the `azure-avm` pack (Azure Verified Modules) from [resource-types-contrib](https://github.com/radius-project/resource-types-contrib); AWS generates an inline `aws-terraform` pack. `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 `rad deploy`s that pack, runs `rad recipe-pack list` to enumerate every pack the control plane now knows, and runs `rad env update --recipe-packs ` so the environment references both the default provider pack and the custom pack (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. +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 `rad deploy`s that pack, runs `rad recipe-pack list` to enumerate every pack the control plane now knows, and runs `rad env update --recipe-packs --preview` so the environment references both the default provider pack and the custom pack (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. 13. **Provision registry credentials on the control plane.** Creates the `ghcr-registry-creds` secret from `github.actor` and the built-in `GITHUB_TOKEN` so the containerImages recipe's in-pod BuildKit can push the application image. 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. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`) and any application parameters from the `RADIUS_DEPLOY_PARAMS` secret. 15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. From e72379ef42a7934c2e45da274adbee2aff76ec54 Mon Sep 17 00:00:00 2001 From: sk593 Date: Wed, 22 Jul 2026 11:17:37 -0700 Subject: [PATCH 08/18] Address PR review comments on custom recipe pack and delete actions - apply-custom-recipe-packs: stop cat-ing custom-recipe-pack.bicep to the workflow log (repo files may hold sensitive values and it's noise; the contents aren't needed to deploy it). - apply-custom-recipe-packs: make the recipe-pack id jq robust by dropping entries whose .id is missing, null, or non-string, so a malformed pack can't abort jq or inject an empty element into --recipe-packs. - delete-resource: keep the ::group:: label constant instead of embedding the untrusted RESOURCE_NAME, which could corrupt log rendering or inject workflow commands if it contained a newline or ::-style text. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extension/actions/apply-custom-recipe-packs/action.yml | 7 +++---- .github/extension/actions/delete-resource/action.yml | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml index f2f688d489d..493ae026742 100644 --- a/.github/extension/actions/apply-custom-recipe-packs/action.yml +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -58,9 +58,6 @@ runs: exit 0 fi - echo "Custom recipe pack file:" - cat "$RECIPE_PACK_BICEP" - echo "" echo "Deploying custom recipe pack from $RECIPE_PACK_BICEP..." rad deploy "$RECIPE_PACK_BICEP" @@ -68,8 +65,10 @@ runs: # provider pack created earlier plus the custom pack just deployed) and # collect their full resource IDs. IDs are used rather than names so the # env update resolves each pack unambiguously regardless of its scope. + # select() drops any entry whose .id is missing, null, or non-string so a + # malformed pack cannot abort jq or inject an empty element into the list. echo "Listing recipe packs..." - PACK_IDS=$(rad recipe-pack list -o json | jq -r '(if type == "array" then . else [.] end) | map(.id) | join(",")') + PACK_IDS=$(rad recipe-pack list -o json | jq -r '(if type == "array" then . else [.] end) | map(.id) | map(select(type == "string" and . != "")) | join(",")') if [ -z "$PACK_IDS" ]; then echo "No recipe packs found to attach to environment '$ENVIRONMENT'." >&2 diff --git a/.github/extension/actions/delete-resource/action.yml b/.github/extension/actions/delete-resource/action.yml index e5608edb84f..62cd27eaa3f 100644 --- a/.github/extension/actions/delete-resource/action.yml +++ b/.github/extension/actions/delete-resource/action.yml @@ -71,8 +71,11 @@ runs: # --yes bypasses the interactive confirmation prompt; --preview selects the # Radius.Core implementation the deploy workflow provisions (without it the # base command dispatches to the legacy, non-Radius.Core surface). + # Keep the group label constant: RESOURCE_NAME is untrusted and a newline + # or workflow-command-like text in it could corrupt log rendering or inject + # workflow commands. The actual `rad` invocation below still carries the name. outfile=$(mktemp) - echo "::group::rad ${VERB[*]} $RESOURCE_NAME --yes --preview" + echo "::group::rad ${VERB[*]} --yes --preview" rad "${VERB[@]}" "$RESOURCE_NAME" --yes --preview 2>&1 | tee "$outfile" EXIT=${PIPESTATUS[0]} echo "::endgroup::" From ebec18e6062df218c4cbf05affae946289f37c59 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 12:01:46 -0700 Subject: [PATCH 09/18] Address PR review: scope recipe-pack attach and harden delete script - apply-custom-recipe-packs: stop attaching every pack from 'rad recipe-pack list' (which spans all scopes and can include unrelated packs restored from prior state, risking recipe-pack conflict validation or unexpected recipe resolution). Instead diff the pack list before/after the deploy to find the newly-created pack(s), preserve the environment's existing recipePacks (via rad env show), and set --recipe-packs to the union so only the intended packs are attached. - delete-resource: enable 'set -euo pipefail' so setup and result-writing failures (mkdir, mktemp, jq, redirection) fail fast instead of silently producing a missing/partial rad-delete-result artifact. -e is relaxed only around the 'rad | tee' pipeline so the delete's own exit code is still captured via PIPESTATUS and reported. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apply-custom-recipe-packs/action.yml | 44 +++++++++++++------ .../actions/delete-resource/action.yml | 13 +++++- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml index 493ae026742..2a684fef61d 100644 --- a/.github/extension/actions/apply-custom-recipe-packs/action.yml +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -58,27 +58,45 @@ runs: exit 0 fi + # Capture the set of recipe-pack IDs before deploying so we can identify + # exactly which pack(s) this step creates. `rad recipe-pack list` returns + # every pack across scopes (including ones restored from prior state), so we + # must not blindly attach all of them -- that could pull unrelated packs into + # the environment and trip server-side recipe-pack conflict validation. + # ids_json emits a compact JSON array of the non-empty string IDs. + ids_json() { + rad recipe-pack list -o json \ + | jq -c '(if type == "array" then . else [.] end) | map(.id) | map(select(type == "string" and . != ""))' + } + + echo "Recording existing recipe packs..." + PACKS_BEFORE=$(ids_json) + echo "Deploying custom recipe pack from $RECIPE_PACK_BICEP..." rad deploy "$RECIPE_PACK_BICEP" - # List every recipe pack the control plane now knows about (the default - # provider pack created earlier plus the custom pack just deployed) and - # collect their full resource IDs. IDs are used rather than names so the - # env update resolves each pack unambiguously regardless of its scope. - # select() drops any entry whose .id is missing, null, or non-string so a - # malformed pack cannot abort jq or inject an empty element into the list. - echo "Listing recipe packs..." - PACK_IDS=$(rad recipe-pack list -o json | jq -r '(if type == "array" then . else [.] end) | map(.id) | map(select(type == "string" and . != "")) | join(",")') + PACKS_AFTER=$(ids_json) + + # New pack(s) = those present after the deploy but not before. + NEW_PACKS=$(jq -nc --argjson before "$PACKS_BEFORE" --argjson after "$PACKS_AFTER" '$after - $before') + + # Preserve the environment's current recipe packs (e.g. the default provider + # pack attached when the environment was created) and add only the new pack(s). + # `rad env update --recipe-packs` REPLACES the list, so we compute the full + # desired set here rather than passing every pack the control plane knows. + EXISTING_PACKS=$(rad env show "$ENVIRONMENT" -o json | jq -c '(.properties.recipePacks // []) | map(select(type == "string" and . != ""))') + + PACK_IDS=$(jq -nr --argjson existing "$EXISTING_PACKS" --argjson new "$NEW_PACKS" '($existing + $new) | unique | join(",")') if [ -z "$PACK_IDS" ]; then echo "No recipe packs found to attach to environment '$ENVIRONMENT'." >&2 exit 1 fi - # Replace the environment's recipe pack list with every pack so the custom - # types resolve alongside the default provider recipes. --preview selects the - # Radius.Core implementation; without it `rad env update` dispatches to the - # legacy command, which does not understand --recipe-packs. + # Replace the environment's recipe pack list with the preserved + new set so + # the custom types resolve alongside the default provider recipes. --preview + # selects the Radius.Core implementation; without it `rad env update` + # dispatches to the legacy command, which does not understand --recipe-packs. echo "Attaching recipe packs to environment '$ENVIRONMENT': $PACK_IDS" rad env update "$ENVIRONMENT" --recipe-packs "$PACK_IDS" --preview - echo "✅ Environment '$ENVIRONMENT' updated with all recipe packs." + echo "✅ Environment '$ENVIRONMENT' updated with recipe packs." diff --git a/.github/extension/actions/delete-resource/action.yml b/.github/extension/actions/delete-resource/action.yml index 62cd27eaa3f..53d55b22e60 100644 --- a/.github/extension/actions/delete-resource/action.yml +++ b/.github/extension/actions/delete-resource/action.yml @@ -26,8 +26,12 @@ runs: RESOURCE_NAME: ${{ inputs.name }} run: | # pipefail so a failed `rad` whose output is piped through `tee` is detected - # via PIPESTATUS rather than masked by tee's exit code. - set -uo pipefail + # via PIPESTATUS rather than masked by tee's exit code. -e/-u make setup and + # result-writing failures (mkdir, mktemp, jq, redirection) fail fast instead + # of silently producing a missing or partial rad-delete-result artifact; -e + # is relaxed only around the `rad | tee` pipeline below so the delete's own + # exit code is captured and reported rather than aborting the step. + set -euo pipefail mkdir -p /tmp/radius-output RESULT_FILE=/tmp/radius-output/rad-delete-result.json @@ -76,8 +80,13 @@ runs: # workflow commands. The actual `rad` invocation below still carries the name. outfile=$(mktemp) echo "::group::rad ${VERB[*]} --yes --preview" + # Relax -e only for the pipeline so a non-zero `rad` exit is captured via + # PIPESTATUS and reported below, rather than aborting the step before we + # can write the result artifact. -e is restored immediately after. + set +e rad "${VERB[@]}" "$RESOURCE_NAME" --yes --preview 2>&1 | tee "$outfile" EXIT=${PIPESTATUS[0]} + set -e echo "::endgroup::" OUTPUT=$(cat "$outfile") rm -f "$outfile" From ed7b385a22b43dd027c35d4528885c7de2bb8a76 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 13:08:12 -0700 Subject: [PATCH 10/18] Address PR review: sanitize delete logs and correct state-archive docs - delete-resource: sanitize RESOURCE_TYPE/RESOURCE_NAME (strip control chars) before echoing them to logs, so a name containing a newline can't start a new log line or inject GitHub Actions workflow commands. Raw values are still passed to rad and JSON-encoded via jq --arg. - Docs (README, radius-delete SKILL, deploy-workflow design note): describe state persistence via the state archive accurately -- OCI-backed by default (RADIUS_STATE_* + GHCR login), git orphan branch only when RADIUS_STATE_BACKEND=git -- instead of implying the git branch is the only backend. - Docs (README step 12, design note custom-types section): update the recipe-pack attach description to match the action, which unions the environment's existing recipePacks with only the newly-created pack(s) rather than attaching every pack from 'rad recipe-pack list'. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extension/README.md | 8 ++++---- .../extension/actions/delete-resource/action.yml | 14 +++++++++++--- .github/extension/skills/radius-delete/SKILL.md | 2 +- .../2026-06-repo-radius-deploy-workflow.md | 12 ++++++------ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index da9cade044a..0a51d820160 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -93,10 +93,10 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 9. **Restore persisted state (`rad startup`).** Restores the control-plane databases and the Terraform recipe-state Secrets saved by the previous run, so `rad deploy` plans against prior state rather than an empty backend. A no-op on the first run. 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. Azure downloads the `azure-avm` pack (Azure Verified Modules) from [resource-types-contrib](https://github.com/radius-project/resource-types-contrib); AWS generates an inline `aws-terraform` pack. `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 `rad deploy`s that pack, runs `rad recipe-pack list` to enumerate every pack the control plane now knows, and runs `rad env update --recipe-packs --preview` so the environment references both the default provider pack and the custom pack (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. +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 snapshots the recipe-pack IDs before and after `rad deploy`ing that pack to identify the newly-created pack(s), reads the environment's existing `recipePacks` with `rad env show`, and runs `rad env update --recipe-packs --preview` so the environment keeps the default provider pack and gains the custom pack — without pulling in unrelated packs the control plane may know about (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. 13. **Provision registry credentials on the control plane.** Creates the `ghcr-registry-creds` secret from `github.actor` and the built-in `GITHUB_TOKEN` so the containerImages recipe's in-pod BuildKit can push the application image. 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. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`) and any application parameters from the `RADIUS_DEPLOY_PARAMS` secret. -15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. +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). ### Triggers and permissions @@ -115,7 +115,7 @@ Triggers and permissions live on the **dispatcher** (`run-rad-commands.yml`); th | `rad_commands` | No | A single `rad` command string, or a JSON array of command strings run in order (the `rad` prefix omitted, e.g. `["deploy .radius/app.bicep --environment dev", "app graph my-app -o json"]`). Each command is validated against the allowed-command set. Falls back to the `RADIUS_RAD_COMMANDS` variable. When empty, the workflow runs its default `rad deploy` of the app bicep. | - **Outputs:** a combined `rad-commands-result` artifact — a JSON document with a top-level `outcome`/`exitCode` and a `commands` array (one entry per command, in input order, with each command's exit code and output). -- **Permissions:** `id-token: write` (required for OIDC), `contents: write` (so `rad shutdown` can push the `radius-state` branch), and `packages: write` (to push the application image built by the containerImages recipe). +- **Permissions:** `id-token: write` (required for OIDC), `contents: write` (so `rad shutdown` can push the `radius-state` branch when the git state backend is selected), and `packages: write` (to push the OCI-backed state archive to GHCR and the application image built by the containerImages recipe). ### Required environment variables @@ -138,7 +138,7 @@ This workflow also reads GitHub Actions **secrets** for image push and applicati ### State persistence (`rad startup` / `rad shutdown`) -`rad startup` and `rad shutdown` are kind-agnostic CLI commands that restore and back up all durable Radius state (control-plane PostgreSQL + Terraform recipe-state Secrets) to a `radius-state` git orphan branch. They do not manage cluster lifecycle — the workflow owns creating and destroying the ephemeral control plane around them. `rad startup` runs after the install (so `rad deploy` plans against prior state) and `rad shutdown` runs after the commands with `if: always()` (so state survives a failed deploy). +`rad startup` and `rad shutdown` are kind-agnostic CLI commands that restore and back up all durable Radius state (control-plane PostgreSQL + Terraform recipe-state Secrets). These workflows use the OCI-backed state archive by default — the `RADIUS_STATE_*` variables select an OCI repository and the workflow logs in to GHCR before `rad startup`/`rad shutdown` — and fall back to the `radius-state` git orphan branch only when `RADIUS_STATE_BACKEND=git`. They do not manage cluster lifecycle — the workflow owns creating and destroying the ephemeral control plane around them. `rad startup` runs after the install (so `rad deploy` plans against prior state) and `rad shutdown` runs after the commands with `if: always()` (so state survives a failed deploy). ### Prerequisites diff --git a/.github/extension/actions/delete-resource/action.yml b/.github/extension/actions/delete-resource/action.yml index 53d55b22e60..5e11f25d5d2 100644 --- a/.github/extension/actions/delete-resource/action.yml +++ b/.github/extension/actions/delete-resource/action.yml @@ -52,6 +52,14 @@ runs: } trap write_result EXIT + # Sanitize untrusted inputs for log output only: strip control characters + # (newlines/CR in particular) so a crafted resource type/name cannot start a + # new log line or inject GitHub Actions workflow commands (e.g. a name + # containing `\n::warning::`). The raw values are still passed verbatim to + # `rad` and to write_result (jq --arg JSON-encodes them safely). + SAFE_TYPE=$(printf '%s' "$RESOURCE_TYPE" | tr -d '[:cntrl:]') + SAFE_NAME=$(printf '%s' "$RESOURCE_NAME" | tr -d '[:cntrl:]') + if [ -z "${RESOURCE_NAME//[[:space:]]/}" ]; then echo "No resource name supplied to delete." >&2 OUTCOME="invalid_input" @@ -65,7 +73,7 @@ runs: application) VERB=(app delete) ;; environment) VERB=(env delete) ;; *) - echo "Unsupported resource type: '$RESOURCE_TYPE' (expected 'application' or 'environment')." >&2 + echo "Unsupported resource type: '$SAFE_TYPE' (expected 'application' or 'environment')." >&2 OUTCOME="invalid_input" EXIT=2 exit 2 @@ -93,10 +101,10 @@ runs: if [ "$EXIT" -ne 0 ]; then OUTCOME="failed" - echo "Failed to delete $RESOURCE_TYPE '$RESOURCE_NAME'." >&2 + echo "Failed to delete $SAFE_TYPE '$SAFE_NAME'." >&2 exit "$EXIT" fi - echo "✅ Deleted $RESOURCE_TYPE '$RESOURCE_NAME'." + echo "✅ Deleted $SAFE_TYPE '$SAFE_NAME'." - name: Upload delete result if: always() diff --git a/.github/extension/skills/radius-delete/SKILL.md b/.github/extension/skills/radius-delete/SKILL.md index 6c820685d03..bace104ecae 100644 --- a/.github/extension/skills/radius-delete/SKILL.md +++ b/.github/extension/skills/radius-delete/SKILL.md @@ -54,7 +54,7 @@ Then follow the run (`gh run watch` or the run URL) until it succeeds, fails, or 3. Projects GitHub OIDC tokens into the pods and registers the cloud identity with `rad credential register`, so recipe deletes can reach the target cluster and cloud. 4. Runs `rad startup` to restore the control-plane databases and Terraform recipe-state Secrets persisted by the previous run — this is what tells the delete which environment, recipe packs, resources, and Terraform state exist. Unlike deploy, it does **not** recreate the environment, recipe pack, or registry credentials. 5. Runs `rad app delete --yes --preview` or `rad env delete --yes --preview` (`--preview` selects the Radius.Core surface) via the `delete-resource` action, which writes a `rad-delete-result` artifact (JSON: `outcome`, `exitCode`, `resourceType`, `name`, `output`). -6. `rad shutdown` (`if: always()`) persists the post-delete control-plane databases and Terraform recipe-state Secrets back to the `radius-state` git orphan branch, so the next operation plans against the updated state. On failure, logs are uploaded as the `radius-logs` artifact; the k3d cluster is always deleted. +6. `rad shutdown` (`if: always()`) persists the post-delete control-plane databases and Terraform recipe-state Secrets back 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` — so the next operation plans against the updated state. On failure, logs are uploaded as the `radius-logs` artifact; the k3d cluster is always deleted. ## After a successful delete diff --git a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md index e0785bbb98a..dd97b14827e 100644 --- a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md +++ b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md @@ -23,7 +23,7 @@ The workflow was originally a single file that branched on which provider variab Explicitly **out of scope**: - **Cloud-side OIDC / permission provisioning** — creating the AWS IAM role + trust policy or the Entra app registration + federated credential. The workflow *consumes* an environment that is already federated; standing that up is tracked separately. -- **The state-storage mechanism** (`rad startup` / `rad shutdown`, the `radius-state` git orphan branch) — owned by the [state-storage design](../2026-06-repo-radius-state-storage.md). +- **The state-storage mechanism** (`rad startup` / `rad shutdown`, the OCI-backed state archive or the `radius-state` git orphan branch) — owned by the [state-storage design](../2026-06-repo-radius-state-storage.md). - **The multi-cluster seam internals** (`global.targetCluster`, the cluster access resolver) — owned by the [multi-cluster design](2026-06-multi-cluster.md). This document only describes how the workflow *drives* that seam. - **Mid-run cloud-token refresh** beyond the single pre-deploy EKS refresh — a long Azure run may outlive the one-time token exchange; refreshing it mid-run is a deferred fast follow. @@ -91,7 +91,7 @@ All **third-party actions** used anywhere in the generated workflows and shared ### Permissions -`id-token: write` (OIDC), `contents: write` (so `rad shutdown` can push the `radius-state` branch), and `packages: write` (so container-image recipes can push to GHCR). Declared on the dispatcher and, because reusable workflows run with the caller's grants, inherited by the provider workflows. +`id-token: write` (OIDC), `contents: write` (so `rad shutdown` can push the `radius-state` branch when the git state backend is selected), and `packages: write` (so container-image recipes and the OCI-backed state archive can push to GHCR). Declared on the dispatcher and, because reusable workflows run with the caller's grants, inherited by the provider workflows. ## Workflow stages @@ -109,7 +109,7 @@ flowchart TD J --> K[Create environment + recipe pack] K --> L[Provision registry creds on control plane] L --> M[Run rad_commands or default deploy
upload rad-commands-result artifact] - M --> N[rad shutdown
back up + push radius-state] + M --> N[rad shutdown
back up + push state archive] N --> O[Delete k3d cluster] ``` @@ -150,7 +150,7 @@ The action contract hides which model is in use: the dispatch inputs and the res ### State persistence — `rad startup` / `rad shutdown` -`rad startup` and `rad shutdown` are kind-agnostic CLI commands that back up and restore all durable Radius state (control-plane PostgreSQL + Terraform recipe-state Secrets) to a `radius-state` git orphan branch pushed to the repo's `origin`. They do not manage cluster lifecycle — the workflow owns creating and destroying the ephemeral control plane around them. The mechanism is the plan of record; see the [state-storage design](../2026-06-repo-radius-state-storage.md). +`rad startup` and `rad shutdown` are kind-agnostic CLI commands that back up and restore all durable Radius state (control-plane PostgreSQL + Terraform recipe-state Secrets). These workflows use the OCI-backed state archive by default (the `RADIUS_STATE_*` variables select an OCI repository, pushed to GHCR after a docker login), falling back to a `radius-state` git orphan branch pushed to the repo's `origin` only when `RADIUS_STATE_BACKEND=git`. They do not manage cluster lifecycle — the workflow owns creating and destroying the ephemeral control plane around them. The mechanism is the plan of record; see the [state-storage design](../2026-06-repo-radius-state-storage.md). ### Recipe pack and environment @@ -158,13 +158,13 @@ The workflow provisions a `Radius.Core/recipePacks` resource and a `Radius.Core/ The `radius-env.bicep` that carries the pack is written to the app file's directory (e.g. `.radius/`) and deployed from there. bicep resolves `bicepconfig.json` nearest the `.bicep` file, so deploying from that directory picks up the repo's own `.radius/bicepconfig.json` — which declares the `radius` extension — rather than a (non-existent) config at the workspace root. The `Radius.Compute/containerImages` type ships with the published `radius` Bicep extension, so the workflow no longer registers resource types or wires a local Bicep extension at deploy time. -**Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then `rad deploy`s `custom-recipe-pack.bicep`, enumerates every pack with `rad recipe-pack list`, and runs `rad env update --recipe-packs --preview` so the environment references both the default provider pack and the custom pack (`--recipe-packs` replaces the list, so passing every pack id preserves the default). Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. +**Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then snapshots the recipe-pack IDs before and after `rad deploy`ing `custom-recipe-pack.bicep` to identify the newly-created pack(s), reads the environment's current `recipePacks` with `rad env show`, and runs `rad env update --recipe-packs --preview`. Because `--recipe-packs` replaces the list, the action computes the union of the environment's existing packs and only the newly-created pack(s) — it deliberately does **not** attach every pack from `rad recipe-pack list`, which spans all scopes and could pull unrelated packs (e.g. restored from prior state) into the environment and trip recipe-pack conflict validation. Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. ### Delete workflows Deleting an application or an environment reuses the deploy composition, minus the create stages. `delete-application.yml` and `delete-environment.yml` are thin dispatchers that mirror `run-rad-commands.yml`: a `detect` job binds the GitHub Environment, picks the provider from `AZURE_CLIENT_ID` / `AWS_ROLE_ARN`, and calls a reusable provider workflow (`delete-azure.yml` / `delete-aws.yml`) with `resource_type` (`application` or `environment`) and the target `name`. One provider workflow pair, parameterized by `resource_type`, keeps provider setup defined once per provider rather than once per resource type. -The provider delete workflows run the same provider setup as deploy — OIDC login, cluster connection, cloud OIDC token projection, `rad credential register` — because `rad app delete` runs the resources' recipe delete path (e.g. `terraform destroy`) against the target cluster and cloud. They skip the deploy-only stages (create environment, deploy recipe pack, register registry credentials): the environment and its recipes come back from restored state, and deleting builds no images. The order is therefore restore state → delete → persist state. Restoring first (`rad startup`) is what makes the delete see the environment, recipe packs, resources, and Terraform state; persisting after (`rad shutdown`, run with `if: always()` in `teardown`) is what satisfies the requirement that the post-delete state is stored again, so the next operation plans against it. +The provider delete workflows run the same provider setup as deploy — OIDC login, cluster connection, cloud OIDC token projection, `rad credential register` — because `rad app delete` runs the resources' recipe delete path (e.g. `terraform destroy`) against the target cluster and cloud. They skip the deploy-only stages (create environment, deploy recipe pack, register registry credentials): the environment and its recipes come back from restored state, and deleting builds no images. The order is therefore restore state → delete → persist state. Restoring first (`rad startup`) is what makes the delete see the environment, recipe packs, resources, and Terraform state; persisting after (`rad shutdown`, run with `if: always()` in `teardown`) is what satisfies the requirement that the post-delete state is stored again, so the next operation plans against it. Both use the state archive — OCI-backed by default (`RADIUS_STATE_*` + GHCR login), or the `radius-state` git orphan branch when `RADIUS_STATE_BACKEND=git`. The shared `delete-resource` composite action runs `rad app delete --yes --preview` or `rad env delete --yes --preview`. `--preview` is required: without it these commands fall through to the legacy implementation instead of the Radius.Core surface the deploy flow provisions. It writes a `rad-delete-result` artifact (JSON with `outcome`, `exitCode`, `resourceType`, `name`, `output`) so a frontend can report the result the same way it reads the deploy result. Deleting an application also deletes that application's resources; deleting an environment removes the environment and its recipe-pack associations. From 9711d81b3a8124cf1974e4d31d732a68970f8ec5 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 13:24:16 -0700 Subject: [PATCH 11/18] Address PR review: use rad env show --preview for recipePacks The apply-custom-recipe-packs action read the environment's existing recipePacks with 'rad env show', which routes to the legacy env surface that doesn't expose properties.recipePacks -- so the union could silently drop the default provider pack. Use 'rad env show --preview' to read the Radius.Core/environments resource. That command emits the resource JSON first followed by optional provider/recipe JSON documents, so switch the jq to slurp mode (-s) and read .[0].properties.recipePacks to avoid choking on the trailing documents. Update README step 12 and the design note to document 'rad env show --preview'. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extension/README.md | 2 +- .../extension/actions/apply-custom-recipe-packs/action.yml | 7 ++++++- .../environments/2026-06-repo-radius-deploy-workflow.md | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index 0a51d820160..8d2deb152c7 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -93,7 +93,7 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 9. **Restore persisted state (`rad startup`).** Restores the control-plane databases and the Terraform recipe-state Secrets saved by the previous run, so `rad deploy` plans against prior state rather than an empty backend. A no-op on the first run. 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. Azure downloads the `azure-avm` pack (Azure Verified Modules) from [resource-types-contrib](https://github.com/radius-project/resource-types-contrib); AWS generates an inline `aws-terraform` pack. `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 snapshots the recipe-pack IDs before and after `rad deploy`ing that pack to identify the newly-created pack(s), reads the environment's existing `recipePacks` with `rad env show`, and runs `rad env update --recipe-packs --preview` so the environment keeps the default provider pack and gains the custom pack — without pulling in unrelated packs the control plane may know about (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. +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 snapshots the recipe-pack IDs before and after `rad deploy`ing that pack to identify the newly-created pack(s), reads the environment's existing `recipePacks` with `rad env show --preview`, and runs `rad env update --recipe-packs --preview` so the environment keeps the default provider pack and gains the custom pack — without pulling in unrelated packs the control plane may know about (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. 13. **Provision registry credentials on the control plane.** Creates the `ghcr-registry-creds` secret from `github.actor` and the built-in `GITHUB_TOKEN` so the containerImages recipe's in-pod BuildKit can push the application image. 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. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`) and any application parameters from the `RADIUS_DEPLOY_PARAMS` secret. 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. diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml index 2a684fef61d..4722773e773 100644 --- a/.github/extension/actions/apply-custom-recipe-packs/action.yml +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -84,7 +84,12 @@ runs: # pack attached when the environment was created) and add only the new pack(s). # `rad env update --recipe-packs` REPLACES the list, so we compute the full # desired set here rather than passing every pack the control plane knows. - EXISTING_PACKS=$(rad env show "$ENVIRONMENT" -o json | jq -c '(.properties.recipePacks // []) | map(select(type == "string" and . != ""))') + # --preview reads the Radius.Core/environments resource (which carries + # properties.recipePacks); without it env show hits the legacy surface and + # can't see recipe packs. That command prints the resource first followed by + # optional provider/recipe JSON docs, so jq -s slurps the stream and takes the + # environment resource ([0]) rather than choking on the trailing documents. + EXISTING_PACKS=$(rad env show "$ENVIRONMENT" --preview -o json | jq -sc '((.[0].properties.recipePacks) // []) | map(select(type == "string" and . != ""))') PACK_IDS=$(jq -nr --argjson existing "$EXISTING_PACKS" --argjson new "$NEW_PACKS" '($existing + $new) | unique | join(",")') diff --git a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md index dd97b14827e..f6aa82e49c0 100644 --- a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md +++ b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md @@ -158,7 +158,7 @@ The workflow provisions a `Radius.Core/recipePacks` resource and a `Radius.Core/ The `radius-env.bicep` that carries the pack is written to the app file's directory (e.g. `.radius/`) and deployed from there. bicep resolves `bicepconfig.json` nearest the `.bicep` file, so deploying from that directory picks up the repo's own `.radius/bicepconfig.json` — which declares the `radius` extension — rather than a (non-existent) config at the workspace root. The `Radius.Compute/containerImages` type ships with the published `radius` Bicep extension, so the workflow no longer registers resource types or wires a local Bicep extension at deploy time. -**Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then snapshots the recipe-pack IDs before and after `rad deploy`ing `custom-recipe-pack.bicep` to identify the newly-created pack(s), reads the environment's current `recipePacks` with `rad env show`, and runs `rad env update --recipe-packs --preview`. Because `--recipe-packs` replaces the list, the action computes the union of the environment's existing packs and only the newly-created pack(s) — it deliberately does **not** attach every pack from `rad recipe-pack list`, which spans all scopes and could pull unrelated packs (e.g. restored from prior state) into the environment and trip recipe-pack conflict validation. Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. +**Custom resource types and recipe packs.** A repo that defines its own custom resource types places two files in the app's `.radius/` folder (next to `.radius/app.bicep`): a `custom-types.yaml` resource-type manifest and a `custom-recipe-pack.bicep` recipe pack. After the default provider pack and environment are created, the shared `apply-custom-recipe-packs` composite action registers the types with `rad resource-type create --from-file custom-types.yaml` (so the recipe pack's referenced types exist), then snapshots the recipe-pack IDs before and after `rad deploy`ing `custom-recipe-pack.bicep` to identify the newly-created pack(s), reads the environment's current `recipePacks` with `rad env show --preview`, and runs `rad env update --recipe-packs --preview`. Because `--recipe-packs` replaces the list, the action computes the union of the environment's existing packs and only the newly-created pack(s) — it deliberately does **not** attach every pack from `rad recipe-pack list`, which spans all scopes and could pull unrelated packs (e.g. restored from prior state) into the environment and trip recipe-pack conflict validation. Each file is optional and independent: an absent `custom-types.yaml` skips registration, an absent `custom-recipe-pack.bicep` keeps the default pack. The recipe pack is deployed from `.radius/` for the same `bicepconfig.json` resolution reason as `radius-env.bicep`. ### Delete workflows From 993708c8547d931256b01678426d95539c8a879c Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 13:38:04 -0700 Subject: [PATCH 12/18] Address PR review: surface EKS access errors and fix stale action comment - delete-aws.yml / run-rad-commands-aws.yml: the EKS create-access-entry and associate-access-policy calls used '2>/dev/null || echo already exists', masking real auth/permission/throttling errors behind an 'already exists' message. Capture stderr instead and only treat a ResourceInUseException as already-present; surface any other error and fail the step so the root cause is visible. - apply-custom-recipe-packs: update the stale header comment and description, which still said the action attaches every recipe pack, to match the current behavior (preserve the environment's existing packs and add only the newly-created pack). Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apply-custom-recipe-packs/action.yml | 11 +++---- .github/extension/delete-aws.yml | 28 ++++++++++++++---- .github/extension/run-rad-commands-aws.yml | 29 +++++++++++++++---- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml index 4722773e773..800e4e50c71 100644 --- a/.github/extension/actions/apply-custom-recipe-packs/action.yml +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -5,13 +5,14 @@ # types, this: # 1. registers those types from `.radius/custom-types.yaml` via # `rad resource-type create --from-file` (skipped when the file is absent), and -# 2. deploys `.radius/custom-recipe-pack.bicep`, then lists every recipe pack now -# known to the control plane and rewrites the environment's recipe pack list to -# reference all of them -- so the environment carries both the default provider -# pack and the user's custom pack (skipped when the file is absent). +# 2. deploys `.radius/custom-recipe-pack.bicep`, then attaches only the pack(s) +# newly created by that deploy to the environment -- preserving the +# environment's existing recipe packs (e.g. the default provider pack) and +# adding the custom pack, without pulling in unrelated packs the control +# plane may know about (skipped when the file is absent). # When neither file exists the action is a no-op, leaving the default pack in place. name: Radius - Apply custom recipe packs -description: Register the repo's custom resource types and recipe pack (if present) and attach every recipe pack to the environment. +description: Register the repo's custom resource types and recipe pack (if present) and attach the new pack to the environment alongside its existing packs. inputs: environment: diff --git a/.github/extension/delete-aws.yml b/.github/extension/delete-aws.yml index f561e05fd9c..70871295a51 100644 --- a/.github/extension/delete-aws.yml +++ b/.github/extension/delete-aws.yml @@ -88,19 +88,37 @@ jobs: ROLE_ARN="${{ vars.AWS_ROLE_ARN }}" TARGET="$RADIUS_TARGET_KUBECONFIG" - # Ensure the IAM role has access to the EKS cluster. + # Ensure the IAM role has access to the EKS cluster. These calls are + # idempotent by intent: a ResourceInUseException means the entry/policy is + # already present, which is fine to ignore. Any other failure (auth, + # permissions, throttling) is surfaced with its stderr and fails the step + # rather than being masked behind an "already exists" message. echo "Ensuring EKS access entry for $ROLE_ARN..." - aws eks create-access-entry \ + if ! err=$(aws eks create-access-entry \ --cluster-name "$CLUSTER" \ --principal-arn "$ROLE_ARN" \ --type STANDARD \ - --region "$REGION" 2>/dev/null || echo "Access entry already exists" - aws eks associate-access-policy \ + --region "$REGION" 2>&1); then + if printf '%s' "$err" | grep -q 'ResourceInUseException'; then + echo "Access entry already exists" + else + printf '%s\n' "$err" >&2 + exit 1 + fi + fi + if ! err=$(aws eks associate-access-policy \ --cluster-name "$CLUSTER" \ --principal-arn "$ROLE_ARN" \ --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \ --access-scope type=cluster \ - --region "$REGION" 2>/dev/null || echo "Access policy already associated" + --region "$REGION" 2>&1); then + if printf '%s' "$err" | grep -q 'ResourceInUseException'; then + echo "Access policy already associated" + else + printf '%s\n' "$err" >&2 + exit 1 + fi + fi # Build a static kubeconfig with a bearer token instead of exec-based auth. ENDPOINT=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.endpoint' --output text) diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index 76479804fa3..8bf4a8d3dd8 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -94,20 +94,37 @@ jobs: ROLE_ARN="${{ vars.AWS_ROLE_ARN }}" TARGET="$RADIUS_TARGET_KUBECONFIG" - # Ensure the IAM role has access to the EKS cluster. - # Creates an access entry if one doesn't already exist. + # Ensure the IAM role has access to the EKS cluster. These calls are + # idempotent by intent: a ResourceInUseException means the entry/policy is + # already present, which is fine to ignore. Any other failure (auth, + # permissions, throttling) is surfaced with its stderr and fails the step + # rather than being masked behind an "already exists" message. echo "Ensuring EKS access entry for $ROLE_ARN..." - aws eks create-access-entry \ + if ! err=$(aws eks create-access-entry \ --cluster-name "$CLUSTER" \ --principal-arn "$ROLE_ARN" \ --type STANDARD \ - --region "$REGION" 2>/dev/null || echo "Access entry already exists" - aws eks associate-access-policy \ + --region "$REGION" 2>&1); then + if printf '%s' "$err" | grep -q 'ResourceInUseException'; then + echo "Access entry already exists" + else + printf '%s\n' "$err" >&2 + exit 1 + fi + fi + if ! err=$(aws eks associate-access-policy \ --cluster-name "$CLUSTER" \ --principal-arn "$ROLE_ARN" \ --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \ --access-scope type=cluster \ - --region "$REGION" 2>/dev/null || echo "Access policy already associated" + --region "$REGION" 2>&1); then + if printf '%s' "$err" | grep -q 'ResourceInUseException'; then + echo "Access policy already associated" + else + printf '%s\n' "$err" >&2 + exit 1 + fi + fi # Build a static kubeconfig with a bearer token instead of exec-based auth. # The exec-based config requires aws CLI inside the container, which Radius From 61b2640bc836f8f63c4eb655724e2699d37a0e33 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 13:44:57 -0700 Subject: [PATCH 13/18] Address PR review: embed delete output via jq --rawfile write_result passed the full captured rad output through jq --arg, which can exceed OS argv limits for large output and fail the rad-delete-result artifact write even on a successful delete. Capture the output to a temp file and use jq --rawfile so the artifact write is independent of command-line length limits and preserves newlines verbatim. The file is created up front and left in place so the EXIT trap can always read it, including on early exits. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extension/actions/delete-resource/action.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/extension/actions/delete-resource/action.yml b/.github/extension/actions/delete-resource/action.yml index 5e11f25d5d2..3d9c08bf4b0 100644 --- a/.github/extension/actions/delete-resource/action.yml +++ b/.github/extension/actions/delete-resource/action.yml @@ -37,16 +37,21 @@ runs: # Write the combined result on exit so the rad-delete-result artifact is # complete even when the delete fails and the step exits early. + # Capture rad's combined output to a file (not a shell variable) so the + # result artifact can embed it via `jq --rawfile` regardless of size -- + # passing large output through `jq --arg` risks exceeding OS argv limits and + # failing the artifact write even on a successful delete. Created up front and + # left empty so write_result can always read it, including on an early exit. OUTCOME="succeeded" EXIT=0 - OUTPUT="" + OUTFILE=$(mktemp) write_result() { jq -n \ --arg outcome "$OUTCOME" \ --argjson exitCode "$EXIT" \ --arg resourceType "$RESOURCE_TYPE" \ --arg name "$RESOURCE_NAME" \ - --arg output "$OUTPUT" \ + --rawfile output "$OUTFILE" \ '{schemaVersion:"1.0", outcome:$outcome, exitCode:$exitCode, resourceType:$resourceType, name:$name, output:$output}' \ > "$RESULT_FILE" } @@ -86,18 +91,15 @@ runs: # Keep the group label constant: RESOURCE_NAME is untrusted and a newline # or workflow-command-like text in it could corrupt log rendering or inject # workflow commands. The actual `rad` invocation below still carries the name. - outfile=$(mktemp) echo "::group::rad ${VERB[*]} --yes --preview" # Relax -e only for the pipeline so a non-zero `rad` exit is captured via # PIPESTATUS and reported below, rather than aborting the step before we # can write the result artifact. -e is restored immediately after. set +e - rad "${VERB[@]}" "$RESOURCE_NAME" --yes --preview 2>&1 | tee "$outfile" + rad "${VERB[@]}" "$RESOURCE_NAME" --yes --preview 2>&1 | tee "$OUTFILE" EXIT=${PIPESTATUS[0]} set -e echo "::endgroup::" - OUTPUT=$(cat "$outfile") - rm -f "$outfile" if [ "$EXIT" -ne 0 ]; then OUTCOME="failed" From a232cccd896d32cc96ffcd409cb8111856e974ab Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 13:50:46 -0700 Subject: [PATCH 14/18] Address PR review: add apply-custom-recipe-packs to workflow diagram The workflow-stages Mermaid diagram skipped the apply-custom-recipe-packs phase that runs between creating the environment/recipe pack and provisioning registry creds. Add the (optional) node so the diagram matches the documented and implemented step order. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../environments/2026-06-repo-radius-deploy-workflow.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md index f6aa82e49c0..582c978bba3 100644 --- a/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md +++ b/eng/design-notes/environments/2026-06-repo-radius-deploy-workflow.md @@ -107,7 +107,8 @@ flowchart TD H --> I[rad startup
restore PostgreSQL + Terraform state] I --> J[rad credential register
aws irsa / azure wi] J --> K[Create environment + recipe pack] - K --> L[Provision registry creds on control plane] + K --> KA[Apply custom recipe packs
register custom types + attach new pack
optional] + KA --> L[Provision registry creds on control plane] L --> M[Run rad_commands or default deploy
upload rad-commands-result artifact] M --> N[rad shutdown
back up + push state archive] N --> O[Delete k3d cluster] From d7347fd94fe6462d63a1f63f6dad91f3b94918be Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 14:45:14 -0700 Subject: [PATCH 15/18] Move radius-delete skill to ai-extensions repo The radius-delete skill (like radius-deploy before it) belongs with the other Radius skills in radius-project/ai-extensions under plugins/radius/skills/, not in this repo. The delete workflow templates and composite actions remain canonical here in .github/extension/; only the agent-facing skill moves. Added in radius-project/ai-extensions#147. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extension/skills/radius-delete/SKILL.md | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 .github/extension/skills/radius-delete/SKILL.md diff --git a/.github/extension/skills/radius-delete/SKILL.md b/.github/extension/skills/radius-delete/SKILL.md deleted file mode 100644 index bace104ecae..00000000000 --- a/.github/extension/skills/radius-delete/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: radius-delete -description: Delete a Radius application or environment via the auto-generated GitHub Actions workflow. Use when the user asks to delete, remove, or tear down a deployed Radius application or a Radius environment. ---- - -# Radius — Delete Application or Environment - -Trigger the `Radius - Delete Application` or `Radius - Delete Environment` workflow. Each spins up an ephemeral k3d Radius control plane, connects to the target AKS/EKS cluster, restores persisted state, runs `rad app delete` / `rad env delete`, and persists the updated state again before tearing the control plane down. Deleting an application also deletes that application's resources (running their recipes' delete path against the target cluster and cloud). - -## When to use this skill - -- "Delete my app" -- "Remove application X from env Y" -- "Tear down the test environment" -- "Delete the environment" - -## Prerequisites - -Before invoking this skill, all of these must exist: -1. A GitHub Environment configured with cloud credentials → use the `radius-environment` skill if missing. -2. Previously persisted Radius state for that environment (i.e. the app/environment was deployed at least once). Delete restores that state to know what to delete. -3. Authenticated access to dispatch the workflow (e.g. a logged-in `gh` CLI, or a token with `actions: write` on the repo). The token only triggers the run; it is never passed into the workflow. - -## How to invoke - -Delete an application (`application` is the Radius application name): - -``` -POST /repos/{owner}/{repo}/actions/workflows/delete-application.yml/dispatches -{ "ref": "main", "inputs": { "environment": "", "application": "" } } -``` - -```bash -gh workflow run delete-application.yml -f environment= -f application= -``` - -Delete an environment (`environment_name` defaults to the GitHub Environment name, which the deploy flow uses as the Radius environment name): - -``` -POST /repos/{owner}/{repo}/actions/workflows/delete-environment.yml/dispatches -{ "ref": "main", "inputs": { "environment": "", "environment_name": "" } } -``` - -```bash -gh workflow run delete-environment.yml -f environment= [-f environment_name=] -``` - -Then follow the run (`gh run watch` or the run URL) until it succeeds, fails, or times out. Each delete workflow is a dispatcher: it detects the environment's provider (from `AZURE_CLIENT_ID` / `AWS_ROLE_ARN`) and calls the matching reusable workflow (`delete-azure.yml` / `delete-aws.yml`), so the actual delete work runs as a called workflow underneath it. - -## What the workflow does - -1. The dispatcher detects the environment's provider and calls the matching provider delete workflow, which authenticates to that cloud via OIDC. -2. Fetches a kubeconfig for the target cluster, installs `k3d` + the `rad` CLI + Terraform, and installs Radius on the ephemeral control plane wired to the target cluster (same setup as deploy). -3. Projects GitHub OIDC tokens into the pods and registers the cloud identity with `rad credential register`, so recipe deletes can reach the target cluster and cloud. -4. Runs `rad startup` to restore the control-plane databases and Terraform recipe-state Secrets persisted by the previous run — this is what tells the delete which environment, recipe packs, resources, and Terraform state exist. Unlike deploy, it does **not** recreate the environment, recipe pack, or registry credentials. -5. Runs `rad app delete --yes --preview` or `rad env delete --yes --preview` (`--preview` selects the Radius.Core surface) via the `delete-resource` action, which writes a `rad-delete-result` artifact (JSON: `outcome`, `exitCode`, `resourceType`, `name`, `output`). -6. `rad shutdown` (`if: always()`) persists the post-delete control-plane databases and Terraform recipe-state Secrets back 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` — so the next operation plans against the updated state. On failure, logs are uploaded as the `radius-logs` artifact; the k3d cluster is always deleted. - -## After a successful delete - -- Tell the user the delete succeeded and include the workflow run URL. -- Note that deleting an application removed the app's resources; deleting an environment removed the environment and its recipe-pack associations. - -## Related files - -- `.github/extension/delete-application.yml` and `.github/extension/delete-environment.yml` (this repo) — the delete dispatcher templates; copies are committed into the user repo at `.github/workflows/` and are the files that get dispatched. -- `.github/extension/delete-azure.yml` and `.github/extension/delete-aws.yml` — the provider-specific reusable (`workflow_call`) workflows the dispatchers call; committed alongside the dispatchers. -- `.github/extension/actions/*` — the shared composite actions (`setup-control-plane`, `restore-state`, `delete-resource`, `teardown`) the provider workflows reference from `radius-project/radius`; not copied into the user repo. -- `.github/extension/README.md` — the workflow contract: trigger/inputs, required `vars`, secrets, and prerequisites. From 8b6bd4b9d91e8e7c34a8a7e69dc230c22cd3def8 Mon Sep 17 00:00:00 2001 From: sk593 Date: Fri, 24 Jul 2026 09:40:03 -0700 Subject: [PATCH 16/18] Address PR review: pass --environment to custom recipe pack deploy custom-recipe-pack.bicep only creates a Radius.Core/recipePacks resource and no environment, so 'rad deploy' requires an environment to be set -- otherwise it fails when no default environment is configured. Pass --environment "$ENVIRONMENT" (created by the preceding step) explicitly. Signed-off-by: sk593 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extension/actions/apply-custom-recipe-packs/action.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/extension/actions/apply-custom-recipe-packs/action.yml b/.github/extension/actions/apply-custom-recipe-packs/action.yml index 800e4e50c71..9015649ef0b 100644 --- a/.github/extension/actions/apply-custom-recipe-packs/action.yml +++ b/.github/extension/actions/apply-custom-recipe-packs/action.yml @@ -73,8 +73,12 @@ runs: echo "Recording existing recipe packs..." PACKS_BEFORE=$(ids_json) + # Pass --environment explicitly: the recipe pack bicep only creates a + # Radius.Core/recipePacks resource (no environment), so rad deploy would + # otherwise require a default environment to be set. The environment was + # created by the preceding step. echo "Deploying custom recipe pack from $RECIPE_PACK_BICEP..." - rad deploy "$RECIPE_PACK_BICEP" + rad deploy "$RECIPE_PACK_BICEP" --environment "$ENVIRONMENT" PACKS_AFTER=$(ids_json) From fdf712fd52c7499dd58b2de6558a0898f6c3fa29 Mon Sep 17 00:00:00 2001 From: Shruthi Kumar Date: Fri, 24 Jul 2026 15:20:00 -0700 Subject: [PATCH 17/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .github/extension/delete-aws.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/extension/delete-aws.yml b/.github/extension/delete-aws.yml index 70871295a51..009f0395084 100644 --- a/.github/extension/delete-aws.yml +++ b/.github/extension/delete-aws.yml @@ -67,8 +67,8 @@ jobs: # runner must be logged in BEFORE those steps run -- otherwise the pull # is anonymous and GHCR rejects the private radius-state package with a # 401. docker login persists for the whole job, covering teardown too. - # GITHUB_TOKEN has packages: write (set at job level) for repo-linked - # packages; a PAT can be supplied instead for other-owner packages. + # GITHUB_TOKEN has packages: write (set at job level) for repo-linked packages. + # If the state archive lives under a different owner/org, replace the password with a PAT stored as a secret that can read/write that GHCR package. uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io From f444a25054a27cc504da97e557310434e3bfa114 Mon Sep 17 00:00:00 2001 From: Shruthi Kumar Date: Fri, 24 Jul 2026 15:20:09 -0700 Subject: [PATCH 18/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .github/extension/delete-azure.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/extension/delete-azure.yml b/.github/extension/delete-azure.yml index 66c607f28c0..9cd34b327ec 100644 --- a/.github/extension/delete-azure.yml +++ b/.github/extension/delete-azure.yml @@ -68,8 +68,8 @@ jobs: # runner must be logged in BEFORE those steps run -- otherwise the pull # is anonymous and GHCR rejects the private radius-state package with a # 401. docker login persists for the whole job, covering teardown too. - # GITHUB_TOKEN has packages: write (set at job level) for repo-linked - # packages; a PAT can be supplied instead for other-owner packages. + # GITHUB_TOKEN has packages: write (set at job level) for repo-linked packages. + # If the state archive lives under a different owner/org, replace the password with a PAT stored as a secret that can read/write that GHCR package. uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io