diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 537fc6349d0..033f3e0afef 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -5,10 +5,10 @@ "resolved": "ghcr.io/dapr/cli/dapr-cli@sha256:e935ef5a3e52f5cba9e87ccda401d380b8426c965068574cad0403e430c459dc", "integrity": "sha256:e935ef5a3e52f5cba9e87ccda401d380b8426c965068574cad0403e430c459dc" }, - "ghcr.io/devcontainers/features/docker-in-docker:3.0.0": { - "version": "3.0.0", - "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:256ffdbc156ffddd4c54b917176b5ddba379335706da7234e338bdd97bb4d771", - "integrity": "sha256:256ffdbc156ffddd4c54b917176b5ddba379335706da7234e338bdd97bb4d771" + "ghcr.io/devcontainers/features/docker-in-docker:3.0.1": { + "version": "3.0.1", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:ca2508495b01ba29eba93e8153772a2daa65eaa86471cc6863fe2a3d21933df9", + "integrity": "sha256:ca2508495b01ba29eba93e8153772a2daa65eaa86471cc6863fe2a3d21933df9" }, "ghcr.io/devcontainers/features/git:1": { "version": "1.3.5", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 49450496bfb..a8d87ab2b04 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,7 @@ "minikube": "none", "helm": "3.19.4" }, - "ghcr.io/devcontainers/features/docker-in-docker:3.0.0": { + "ghcr.io/devcontainers/features/docker-in-docker:3.0.1": { "version": "latest", "moby": true }, diff --git a/.github/actions/install-flux/action.yaml b/.github/actions/install-flux/action.yaml index fd472dd3832..127a1020a6d 100644 --- a/.github/actions/install-flux/action.yaml +++ b/.github/actions/install-flux/action.yaml @@ -11,7 +11,7 @@ runs: using: composite steps: - name: Install Flux CLI - uses: fluxcd/flux2/action@54e4ba378e155ada619caafdc599e5c4d759ce5c # v2.8.7 + uses: fluxcd/flux2/action@1fd61a06264d71cf445ed55c4f14d401d26a1c64 # v2.8.8 with: version: ${{ inputs.version }} diff --git a/.github/scripts/publish-recipes.sh b/.github/scripts/publish-recipes.sh index 4b3271c762a..d6874364379 100755 --- a/.github/scripts/publish-recipes.sh +++ b/.github/scripts/publish-recipes.sh @@ -65,5 +65,9 @@ for RECIPE in $(find "$DIRECTORY" -type f -name "*.bicep"); do echo "Publishing $RECIPE to $PUBLISH_REF" echo "- $PUBLISH_REF" >>$GITHUB_STEP_SUMMARY - rad bicep publish --file $RECIPE --target "br:$PUBLISH_REF" + PUBLISH_FLAGS=() + if [[ "${PLAIN_HTTP:-false}" == "true" ]]; then + PUBLISH_FLAGS+=(--plain-http) + fi + rad bicep publish --file "$RECIPE" --target "br:$PUBLISH_REF" "${PUBLISH_FLAGS[@]}" done diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index f97b9ca0448..1975e30874a 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -20,7 +20,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: repo-token: ${{ github.token }} stale-pr-message: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 45c689319f8..2e4257dde34 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -100,7 +100,7 @@ jobs: - name: Initialize CodeQL if: ${{ !startsWith(matrix.language, 'custom-') }} - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: config-file: .github/configs/.codeql.yml languages: ${{ matrix.language }} @@ -108,7 +108,7 @@ jobs: - name: Auto build if: matrix.build-mode == 'autobuild' - uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: working-directory: ${{ matrix.working-directory }} @@ -127,14 +127,14 @@ jobs: - name: Upload GoSec result if: ${{ always() && matrix.language == 'custom-gosec' }} - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: gosec-results.sarif wait-for-processing: true - name: Perform CodeQL Analysis if: ${{ !startsWith(matrix.language, 'custom-') }} - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 id: codeql-analyze with: category: /language:${{matrix.language}} @@ -143,7 +143,7 @@ jobs: - name: Upload CodeQL result if: ${{ always() && !startsWith(matrix.language, 'custom-') }} - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: ${{ format('{0}/{1}.sarif', steps.codeql-analyze.outputs.sarif-output, matrix.language) }} wait-for-processing: true diff --git a/.github/workflows/contrib-update-resource-types.yaml b/.github/workflows/contrib-update-resource-types.yaml new file mode 100644 index 00000000000..174662fba7c --- /dev/null +++ b/.github/workflows/contrib-update-resource-types.yaml @@ -0,0 +1,226 @@ +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +--- +name: contrib-update-resource-types + +# Triggered via `repository_dispatch` from radius-project/resource-types-contrib +# whenever its `main` branch updates. Opens (or refreshes) a PR that runs +# `make update-resource-types` so the manifest copies committed under +# deploy/manifest/built-in-providers/ stay in sync with the contrib repo. +# +# Merging the resulting PR triggers the existing `build-and-push-bicep-types` +# job in build.yaml, which dispatches to azure-octo/radius-publisher to +# republish `radius:latest` with the refreshed contrib types. +# +# See the design note: +# eng/design-notes/extensibility/2026-05-unified-bicep-extension-publishing.md + +on: + repository_dispatch: + types: + - resource-types-contrib-updated + # workflow_dispatch: # Enable during development for manual testing + # inputs: + # contrib_ref: + # description: "Optional resource-types-contrib commit SHA the update is based on." + # required: false + # default: "" + +permissions: {} + +concurrency: + # Only one in-flight refresh PR at a time. + group: contrib-update-resource-types + cancel-in-progress: false + +env: + PR_BRANCH: bot/update-resource-types + CONTRIB_REPO: radius-project/resource-types-contrib + YQ_VERSION: v4.44.3 + YQ_LINUX_AMD64_SHA256: a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7 + +jobs: + open-update-pr: + name: Open update-resource-types PR + if: github.repository == 'radius-project/radius' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read # actions/checkout + steps: + - name: Resolve contrib ref + # Extracts the contrib commit SHA from either the dispatch payload or + # workflow_dispatch input. Validates it as a hex SHA to prevent injection. + # Falls back to "main" if not provided. Note: this value is informational + # only (used in commit messages and PR body). The actual version fetched + # is determined by 'make update-resource-types' which runs 'go get ...@latest'. + id: contrib + env: + DISPATCH_REF: ${{ github.event.client_payload.contrib_ref }} + INPUT_REF: ${{ github.event.inputs.contrib_ref }} + run: | + set -euo pipefail + ref="${DISPATCH_REF:-${INPUT_REF:-}}" + if [ -z "${ref}" ]; then + ref="main" + elif ! [[ "${ref}" =~ ^[a-f0-9]{7,40}$ ]]; then + echo "ERROR: contrib_ref must be a hex commit SHA, got '${ref}'" + exit 1 + fi + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + echo "Contrib ref: ${ref}" + + - name: Generate App Token + # Uses a GitHub App token instead of the default GITHUB_TOKEN so that + # the bot can push the PR branch and the resulting PR triggers CI checks. + # The default GITHUB_TOKEN cannot trigger workflows on pushes it creates. + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RESOURCE_TYPES_BOT_CLIENT_ID }} + private-key: ${{ secrets.RESOURCE_TYPES_BOT_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + radius + permission-metadata: read + permission-contents: write + permission-pull-requests: write + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + persist-credentials: true + + - name: Set up Git identity + # Configure git author for the automated commit on the PR branch. + run: | + git config user.name "Radius CI Bot" + git config user.email "radiuscoreteam@service.microsoft.com" + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + cache: true + + - name: Install yq + # Required by make update-resource-types / sync-resource-types to parse + # deploy/manifest/defaults.yaml. + run: | + mkdir -p "${RUNNER_TEMP}/bin" + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "${RUNNER_TEMP}/bin/yq" + echo "${YQ_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/bin/yq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/yq" + echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" + + - name: Run make update-resource-types + # Bumps go.mod to the latest resource-types-contrib version and copies + # the manifest files listed in defaults.yaml into + # deploy/manifest/built-in-providers/{dev,self-hosted}/. + run: | + set -euo pipefail + if ! make -n update-resource-types >/dev/null 2>&1; then + echo "ERROR: 'make update-resource-types' target is not yet defined." >&2 + echo "This workflow depends on the target introduced by the default-registration design" >&2 + echo "(eng/design-notes/extensibility/2026-04-automated-default-resource-type-registration.md)." >&2 + exit 1 + fi + make update-resource-types + + - name: Detect changes + # Uses git status --porcelain (not git diff --quiet) because + # make update-resource-types may create new untracked files when new + # resource types are added to defaults.yaml. git diff only detects + # modifications to already-tracked files. + id: changes + run: | + set -euo pipefail + if [ -z "$(git status --porcelain)" ]; then + echo "changed=false" >> "${GITHUB_OUTPUT}" + echo "No changes from 'make update-resource-types'; nothing to do." + else + echo "changed=true" >> "${GITHUB_OUTPUT}" + fi + + - name: Create or update PR branch + # Force-pushes to the bot branch so that if a previous automated PR is + # still open, its content is replaced with the latest manifests rather + # than accumulating stale commits. + if: steps.changes.outputs.changed == 'true' + env: + CONTRIB_REF: ${{ steps.contrib.outputs.ref }} + run: | + set -euo pipefail + git checkout -B "${PR_BRANCH}" + git add -A + git commit -m "chore: refresh resource type manifests from ${CONTRIB_REPO} (${CONTRIB_REF})" + git push --force origin "${PR_BRANCH}" + + - name: Open or update pull request + # Creates a new PR if none exists for the bot branch, or updates the + # existing one's title and body. Uses github-script because we need + # conditional create-or-update logic that gh pr create doesn't support. + if: steps.changes.outputs.changed == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CONTRIB_REF: ${{ steps.contrib.outputs.ref }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const branch = process.env.PR_BRANCH; + const contribRef = process.env.CONTRIB_REF; + const title = "chore: refresh resource type manifests from resource-types-contrib"; + const body = [ + "Automated refresh of resource type manifests under `deploy/manifest/built-in-providers/`.", + "", + `Triggered by an update to [\`${process.env.CONTRIB_REPO}\`](https://github.com/${process.env.CONTRIB_REPO}) at ref \`${contribRef}\`.`, + "", + "Merging this PR will republish `br:biceptypes.azurecr.io/radius:latest` via the existing `build-and-push-bicep-types` job in `build.yaml`.", + ].join("\n"); + + const { owner, repo } = context.repo; + const { data: existing } = await github.rest.pulls.list({ + owner, + repo, + head: `${owner}:${branch}`, + state: "open", + }); + + if (existing.length === 0) { + const { data: pr } = await github.rest.pulls.create({ + owner, + repo, + title, + head: branch, + base: "main", + body, + }); + core.notice(`Opened PR #${pr.number}: ${pr.html_url}`); + } else { + const pr = existing[0]; + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + title, + body, + }); + core.notice(`Refreshed existing PR #${pr.number}: ${pr.html_url}`); + } diff --git a/.github/workflows/functional-test-cloud.yaml b/.github/workflows/functional-test-cloud.yaml index f7d3c7ba73f..45f6be2f624 100644 --- a/.github/workflows/functional-test-cloud.yaml +++ b/.github/workflows/functional-test-cloud.yaml @@ -53,6 +53,7 @@ env: GOTESTSUM_VER: 1.13.0 # yq version YQ_VERSION: v4.44.3 + YQ_LINUX_AMD64_SHA256: a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7 # Helm version HELM_VER: v3.19.4 # KinD cluster version @@ -452,7 +453,9 @@ jobs: # Required by make generate-bicep-types-contrib to parse defaults.yaml. run: | mkdir -p "${RUNNER_TEMP}/bin" - GOBIN="${RUNNER_TEMP}/bin" go install github.com/mikefarah/yq/v4@${{ env.YQ_VERSION }} + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "${RUNNER_TEMP}/bin/yq" + echo "${YQ_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/bin/yq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/yq" echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" - name: Generate Bicep extensibility types from OpenAPI specs diff --git a/.github/workflows/functional-test-noncloud.yaml b/.github/workflows/functional-test-noncloud.yaml index f77dcb3485c..a69fed177a6 100644 --- a/.github/workflows/functional-test-noncloud.yaml +++ b/.github/workflows/functional-test-noncloud.yaml @@ -49,6 +49,7 @@ env: GOTESTSUM_VER: 1.13.0 # yq version YQ_VERSION: v4.44.3 + YQ_LINUX_AMD64_SHA256: a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7 # Helm version HELM_VER: v3.19.4 # KinD cluster version @@ -243,7 +244,9 @@ jobs: # Required by make generate-bicep-types-contrib to parse defaults.yaml. run: | mkdir -p "${RUNNER_TEMP}/bin" - GOBIN="${RUNNER_TEMP}/bin" go install github.com/mikefarah/yq/v4@${{ env.YQ_VERSION }} + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "${RUNNER_TEMP}/bin/yq" + echo "${YQ_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/bin/yq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/yq" echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" - name: Generate Bicep extensibility types from OpenAPI specs diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d3df728d46f..8c12ec5b88c 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -32,6 +32,7 @@ concurrency: env: YQ_VERSION: v4.44.3 + YQ_LINUX_AMD64_SHA256: a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7 permissions: {} @@ -82,7 +83,9 @@ jobs: # Required by make generate-bicep-types-contrib to parse defaults.yaml. run: | mkdir -p "${RUNNER_TEMP}/bin" - GOBIN="${RUNNER_TEMP}/bin" go install github.com/mikefarah/yq/v4@${{ env.YQ_VERSION }} + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "${RUNNER_TEMP}/bin/yq" + echo "${YQ_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/bin/yq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/yq" echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" - name: Install helm diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index 9ae88ead25e..8bd5ba2af2a 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -26,6 +26,7 @@ permissions: {} env: YQ_VERSION: v4.44.3 + YQ_LINUX_AMD64_SHA256: a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7 jobs: changes: @@ -105,7 +106,9 @@ jobs: # Required by make generate-bicep-types-contrib to parse defaults.yaml. run: | mkdir -p "${RUNNER_TEMP}/bin" - GOBIN="${RUNNER_TEMP}/bin" go install github.com/mikefarah/yq/v4@${{ env.YQ_VERSION }} + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "${RUNNER_TEMP}/bin/yq" + echo "${YQ_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/bin/yq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/yq" echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" # Generate Bicep docs diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml index 80b2cc0af48..c1bf7bc8800 100644 --- a/.github/workflows/scorecard.yaml +++ b/.github/workflows/scorecard.yaml @@ -68,6 +68,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: results.sarif diff --git a/.github/workflows/triage-bot.yaml b/.github/workflows/triage-bot.yaml index 24b55c63d0d..74ddcda2709 100644 --- a/.github/workflows/triage-bot.yaml +++ b/.github/workflows/triage-bot.yaml @@ -44,7 +44,7 @@ jobs: private-key: ${{ secrets.RADIUS_TRIAGE_BOT_PRIVATE_KEY }} permission-issues: write - - uses: dessant/label-actions@9e5fd757ffe1e065abf55e9f74d899dbe012922a # v5.0.0 + - uses: dessant/label-actions@809e0f9bc11ce08744e7ccac7cd10621d00aba2f # v5.0.2 with: github-token: ${{ steps.get_installation_token.outputs.token }} config-path: .github/triage-bot/triage-bot-config.yaml diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 42a7aa437c2..2f370a514f5 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -79,7 +79,7 @@ jobs: make test-validate-cli - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: # This secret is unavailable for fork PRs; Codecov falls back to tokenless upload. token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/verify-resource-types.yaml b/.github/workflows/verify-resource-types.yaml index c9459599910..209ee8af89d 100644 --- a/.github/workflows/verify-resource-types.yaml +++ b/.github/workflows/verify-resource-types.yaml @@ -54,6 +54,10 @@ on: permissions: {} +env: + YQ_VERSION: v4.44.3 + YQ_LINUX_AMD64_SHA256: a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7 + concurrency: group: verify-resource-types-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true @@ -77,7 +81,9 @@ jobs: - name: Install yq run: | mkdir -p "${RUNNER_TEMP}/bin" - GOBIN="${RUNNER_TEMP}/bin" go install github.com/mikefarah/yq/v4@v4.44.3 + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "${RUNNER_TEMP}/bin/yq" + echo "${YQ_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/bin/yq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/yq" echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" # Re-run the copy step (without bumping the version) to regenerate the diff --git a/.gitignore b/.gitignore index 49e3c963e5a..39184a18c3a 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,11 @@ debug_files/* drad *.lock +# Local-only bicepconfig override written by `make debug-publish-bicep-types`. +# Lives next to the .bicep templates so bicep's nearest-config search picks it up +# over the tracked sibling at .../resources/bicepconfig.json. Removed by debug-stop. +test/functional-portable/dynamicrp/noncloud/resources/testdata/bicepconfig.json + # Demo app demo/* demo diff --git a/build/debug.mk b/build/debug.mk index a3f6f46670e..0de5adb1fbb 100644 --- a/build/debug.mk +++ b/build/debug.mk @@ -35,7 +35,67 @@ POSTGRES_ADMIN_CONNECTION ?= postgresql://postgres:radius_pass@localhost:5432/po POSTGRES_FALLBACK_CONNECTION ?= postgresql://$(shell whoami)@localhost:5432/postgres POSTGRES_CONTAINER_NAME ?= radius-postgres -.PHONY: debug-setup debug-start debug-stop debug-status debug-help debug-build-all debug-build-ucpd debug-build-applications-rp debug-build-controller debug-build-dynamic-rp debug-build-rad debug-deployment-engine-pull debug-deployment-engine-start debug-deployment-engine-deploy debug-deployment-engine-port-forward debug-deployment-engine-stop debug-deployment-engine-status debug-deployment-engine-logs debug-register-recipes debug-env-init debug-check-prereqs +# Local OCI registry for test Bicep recipes. applications-rp runs as a host +# process in debug mode, so it pulls recipes from localhost directly. +DEBUG_REGISTRY_NAME ?= radius-debug-registry +DEBUG_REGISTRY_PORT ?= 5000 +DEBUG_REGISTRY_HOST ?= localhost:$(DEBUG_REGISTRY_PORT) + +# In-cluster Git HTTP backend used by the kubernetes-noncloud Flux tests. +# Values mirror .github/workflows/functional-test-noncloud.yaml so that local +# runs of `make test-functional-kubernetes-noncloud` Just Work after +# `make debug-start`. +DEBUG_GIT_HTTP_NAMESPACE ?= git-http-backend +DEBUG_GIT_HTTP_USERNAME ?= testuser +DEBUG_GIT_HTTP_PASSWORD ?= not-a-secret-password +DEBUG_GIT_HTTP_EMAIL ?= testuser@radapp.io +DEBUG_GIT_HTTP_LOCAL_PORT ?= 30080 + +# Flux source-controller is required by the kubernetes-noncloud Flux tests. +# Version must match the locally installed `flux` CLI to avoid the compatibility +# check failing during `flux install`. +DEBUG_FLUX_NAMESPACE ?= flux-system +DEBUG_FLUX_VERSION ?= $(shell flux --version 2>/dev/null | awk '{print $$NF}') + +# Bicep extension types published to the local debug registry. +# Mirrors .github/workflows/functional-test-noncloud.yaml so dynamicrp-noncloud +# tests resolve `radius` / `testresources` extensions from localhost rather than +# the public ACR (which drifts from source `*.yaml`). +# +# Strategy: write a gitignored bicepconfig.json INSIDE the testdata/ dir. Bicep +# walks up from each .bicep template looking for the nearest bicepconfig.json, +# so this override wins over the tracked sibling one folder up — without +# mutating anything that's tracked. +DEBUG_BICEP_VERSION ?= latest +DEBUG_BICEP_TEST_RESOURCES_YAML ?= test/functional-portable/dynamicrp/noncloud/resources/testdata/testresourcetypes.yaml +DEBUG_BICEP_TEST_CONFIG_OVERRIDE ?= test/functional-portable/dynamicrp/noncloud/resources/testdata/bicepconfig.json +# Local file targets for the published bicep extensions. We deliberately avoid +# the local OCI registry here: the bicep CLI's `publish-extension` defaults to +# HTTPS even for `localhost:5000` and has no `--plain-http` flag, so a TLS +# handshake fails. bicepconfig.json's `extensions` block accepts absolute file +# paths to `.tgz` artifacts, which is faster and more deterministic anyway. +DEBUG_BICEP_EXT_DIR ?= $(DEBUG_DEV_ROOT)/bicep-extensions +DEBUG_BICEP_EXT_RADIUS ?= $(DEBUG_BICEP_EXT_DIR)/radius.tgz +DEBUG_BICEP_EXT_TESTRESOURCES ?= $(DEBUG_BICEP_EXT_DIR)/testresources.tgz + +# Contour ingress controller is required by the Gateway functional tests +# (corerp-noncloud) which create `projectcontour.io/v1` HTTPProxy resources. +# Production `rad install kubernetes` installs it; our local-OS-process stack +# bypasses that path, so install it directly via helm. +DEBUG_CONTOUR_NAMESPACE ?= radius-system +DEBUG_CONTOUR_RELEASE ?= contour +DEBUG_CONTOUR_CHART_VERSION ?= 0.1.0 +DEBUG_CONTOUR_HELM_REPO ?= https://projectcontour.github.io/helm-charts + +# In-cluster Terraform module server used by the corerp-noncloud +# TerraformRecipe_* tests. Tests resolve the server via TF_RECIPE_MODULE_SERVER_URL +# (set by test/testutil); CI uses the in-cluster DNS name. For the local stack +# (ARP runs as an OS process), we port-forward the service to localhost:8999. +DEBUG_TF_MODULE_NAMESPACE ?= radius-test-tf-module-server +DEBUG_TF_MODULE_DEPLOYMENT ?= tf-module-server +DEBUG_TF_MODULE_LOCAL_PORT ?= 8999 + +.PHONY: debug-setup debug-start debug-stop debug-status debug-help debug-build-all debug-build-ucpd debug-build-applications-rp debug-build-controller debug-build-dynamic-rp debug-build-rad debug-deployment-engine-pull debug-deployment-engine-start debug-deployment-engine-deploy debug-deployment-engine-port-forward debug-deployment-engine-stop debug-deployment-engine-status debug-deployment-engine-logs debug-register-recipes debug-env-init debug-check-prereqs debug-install-crds debug-start-registry debug-publish-recipes debug-stop-registry debug-install-git-http-backend debug-stop-git-http-backend debug-install-flux debug-install-contour debug-install-tf-module-server debug-stop-tf-module-server debug-publish-bicep-types debug-remove-bicep-types-override debug-help: ## Show debug automation help @echo "Debug Development Automation Commands:" @@ -111,6 +171,9 @@ debug-check-prereqs: ## Check if all required tools are installed for debugging if ! command -v terraform >/dev/null 2>&1; then \ MISSING_TOOLS="$$MISSING_TOOLS terraform"; \ fi; \ + if ! command -v yq >/dev/null 2>&1; then \ + MISSING_TOOLS="$$MISSING_TOOLS yq"; \ + fi; \ if [ -n "$$MISSING_TOOLS" ]; then \ echo "❌ Missing required tools:$$MISSING_TOOLS"; \ echo ""; \ @@ -120,6 +183,7 @@ debug-check-prereqs: ## Check if all required tools are installed for debugging echo " k3d: https://k3d.io/v5.6.0/#installation"; \ echo " kubectl: https://kubernetes.io/docs/tasks/tools/"; \ echo " terraform: https://learn.hashicorp.com/tutorials/terraform/install-cli"; \ + echo " yq: go install github.com/mikefarah/yq/v4@$(YQ_VERSION)"; \ exit 1; \ fi; \ echo "🔍 Checking PostgreSQL connectivity..."; \ @@ -209,14 +273,34 @@ debug-build-rad: ## Build rad CLI with debug symbols + create drad alias @echo "💡 Use './drad' for debug-configured CLI (preserves 'rad' for your installed version)" debug-start: debug-setup debug-build-all ## Start k3d cluster and all Radius components as OS processes - @echo "Creating k3d cluster..." - @if k3d cluster list | grep -q "radius-debug"; then \ - echo "k3d cluster 'radius-debug' already exists"; \ + @echo "Ensuring k3d cluster 'radius-debug' is running..." + @if k3d cluster list --no-headers 2>/dev/null | awk '{print $$1}' | grep -qx "radius-debug"; then \ + servers=$$(k3d cluster list --no-headers 2>/dev/null | awk '$$1=="radius-debug"{print $$2}'); \ + running=$$(echo "$$servers" | cut -d/ -f1); \ + if [ "$$running" = "0" ]; then \ + echo "k3d cluster 'radius-debug' exists but is stopped — starting it..."; \ + k3d cluster start radius-debug; \ + else \ + echo "k3d cluster 'radius-debug' already running ($$servers servers)"; \ + fi; \ else \ + echo "Creating k3d cluster 'radius-debug'..."; \ k3d cluster create radius-debug --api-port 0.0.0.0:6443 --wait --timeout 60s; \ fi @echo "Switching to k3d context..." + @# If the kubeconfig context is missing (e.g. cluster was created against a + @# different KUBECONFIG, or `kubectl config delete-context` was run), merge + @# the k3d kubeconfig back in before switching. `k3d kubeconfig merge` is + @# idempotent and writes/updates ~/.kube/config. + @if ! kubectl config get-contexts -o name 2>/dev/null | grep -qx "k3d-radius-debug"; then \ + echo " Context 'k3d-radius-debug' missing — merging k3d kubeconfig..."; \ + k3d kubeconfig merge radius-debug --kubeconfig-merge-default --kubeconfig-switch-context >/dev/null; \ + fi @kubectl config use-context k3d-radius-debug + @echo "Ensuring radius-encryption-key secret exists in k3d cluster..." + @chmod +x build/scripts/ensure-encryption-key.sh 2>/dev/null || true + @build/scripts/ensure-encryption-key.sh + @$(MAKE) debug-install-crds @echo "Starting Radius components as OS processes..." @build/scripts/start-radius.sh @echo "Waiting for components to be ready..." @@ -238,10 +322,256 @@ debug-start: debug-setup debug-build-all ## Start k3d cluster and all Radius com fi @echo "Initializing environment resources..." @$(MAKE) debug-env-init + @echo "Publishing test Bicep recipes to the local debug registry..." + @$(MAKE) debug-publish-recipes + @echo "Installing in-cluster Git HTTP backend (for kubernetes-noncloud Flux tests)..." + @$(MAKE) debug-install-git-http-backend + @echo "Installing Flux source-controller (for kubernetes-noncloud Flux tests)..." + @$(MAKE) debug-install-flux + @echo "Installing Contour ingress controller (for Gateway tests)..." + @$(MAKE) debug-install-contour + @echo "Installing Terraform module server (for TerraformRecipe_* tests)..." + @$(MAKE) debug-install-tf-module-server + @echo "Publishing test bicep extensions to the local debug registry..." + @$(MAKE) debug-publish-bicep-types @echo "🚀 All components started and environment initialized!" @echo "📊 Use 'make debug-status' to check component health" @echo "🚢 Use 'make debug-deployment-engine-status' to check deployment engine" +debug-install-crds: ## Apply Radius CRDs (radapp.io and ucp.dev) into the k3d-radius-debug cluster + @echo "Applying Radius CRDs to k3d-radius-debug..." + @kubectl --context k3d-radius-debug apply -f deploy/Chart/crds/radius -f deploy/Chart/crds/ucpd + @echo "✅ Radius CRDs applied" + +debug-start-registry: ## Start a local OCI registry on $(DEBUG_REGISTRY_HOST) for test recipes + @if docker ps --format '{{.Names}}' | grep -qx "$(DEBUG_REGISTRY_NAME)"; then \ + echo "✅ Local registry '$(DEBUG_REGISTRY_NAME)' already running on $(DEBUG_REGISTRY_HOST)"; \ + elif docker ps -a --format '{{.Names}}' | grep -qx "$(DEBUG_REGISTRY_NAME)"; then \ + echo "Starting existing registry container '$(DEBUG_REGISTRY_NAME)'..."; \ + docker start $(DEBUG_REGISTRY_NAME) >/dev/null; \ + echo "✅ Local registry started on $(DEBUG_REGISTRY_HOST)"; \ + else \ + echo "Creating local registry on $(DEBUG_REGISTRY_HOST)..."; \ + docker run -d --restart=unless-stopped --name $(DEBUG_REGISTRY_NAME) \ + -p $(DEBUG_REGISTRY_PORT):5000 registry:2 >/dev/null; \ + echo "✅ Local registry created on $(DEBUG_REGISTRY_HOST)"; \ + fi + +debug-publish-recipes: debug-start-registry ## Publish test Bicep recipes to the local debug registry + @$(MAKE) publish-test-bicep-recipes BICEP_RECIPE_REGISTRY=$(DEBUG_REGISTRY_HOST) BICEP_RECIPE_TAG_VERSION=latest BICEP_RECIPE_PLAIN_HTTP=true + @echo "✅ Recipes published to $(DEBUG_REGISTRY_HOST)" + @echo "💡 Functional tests will auto-detect this registry — no env vars needed." + +debug-stop-registry: ## Stop and remove the local debug OCI registry + @if docker ps -a --format '{{.Names}}' | grep -qx "$(DEBUG_REGISTRY_NAME)"; then \ + docker rm -f $(DEBUG_REGISTRY_NAME) >/dev/null; \ + echo "✅ Local registry '$(DEBUG_REGISTRY_NAME)' removed"; \ + else \ + echo "Local registry '$(DEBUG_REGISTRY_NAME)' not present"; \ + fi + +debug-install-git-http-backend: ## Deploy in-cluster Git HTTP backend and port-forward $(DEBUG_GIT_HTTP_LOCAL_PORT)->3000 (for Flux tests) + @echo "Deploying git-http-backend into namespace '$(DEBUG_GIT_HTTP_NAMESPACE)'..." + @KUBECONFIG_CTX=k3d-radius-debug; \ + kubectl config use-context $$KUBECONFIG_CTX >/dev/null + @.github/actions/install-git-http-backend/install-git-http-backend.sh \ + "$(DEBUG_GIT_HTTP_USERNAME)" "$(DEBUG_GIT_HTTP_PASSWORD)" \ + "$(DEBUG_GIT_HTTP_NAMESPACE)" + @mkdir -p $(DEBUG_DEV_ROOT)/logs + @if [ -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid ]; then \ + old=$$(cat $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid 2>/dev/null); \ + if [ -n "$$old" ] && kill -0 "$$old" 2>/dev/null; then kill "$$old" 2>/dev/null || true; fi; \ + rm -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid; \ + fi + @pkill -f "port-forward.*git-http-backend.*git-http" 2>/dev/null || true + @echo "Waiting for git-http-backend pod to be Ready..." + @kubectl --context k3d-radius-debug wait --for=condition=Available deployment/git-http-backend \ + -n $(DEBUG_GIT_HTTP_NAMESPACE) --timeout=120s >/dev/null + @echo "Starting port-forward localhost:$(DEBUG_GIT_HTTP_LOCAL_PORT) -> deploy/git-http-backend:3000..." + @# Port-forward to the deployment (auto-selects a Ready pod) rather than the + @# service: a service endpoint round-robin can land on a still-terminating pod + @# from the previous rollout and the forward dies with "network namespace ... + @# is closed". We also retry the whole forward+curl probe so a flaky pod + @# transition can't leave us with a stale, dead listener. + @attempt=0; max=6; ok=0; \ + while [ $$attempt -lt $$max ]; do \ + attempt=$$((attempt+1)); \ + pkill -f "port-forward.*git-http-backend.*git-http" 2>/dev/null || true; \ + sleep 1; \ + nohup kubectl --context k3d-radius-debug port-forward \ + -n $(DEBUG_GIT_HTTP_NAMESPACE) deploy/git-http-backend \ + $(DEBUG_GIT_HTTP_LOCAL_PORT):3000 \ + > $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.log 2>&1 & \ + pf_pid=$$!; \ + echo $$pf_pid > $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid; \ + probe=0; \ + while [ $$probe -lt 15 ]; do \ + probe=$$((probe+1)); \ + if kill -0 $$pf_pid 2>/dev/null && \ + curl -s -o /dev/null -m 2 "http://localhost:$(DEBUG_GIT_HTTP_LOCAL_PORT)"; then \ + ok=1; break; \ + fi; \ + sleep 2; \ + done; \ + if [ $$ok -eq 1 ]; then break; fi; \ + echo "git-http-backend port-forward attempt $$attempt/$$max failed, retrying..."; \ + done; \ + if [ $$ok -ne 1 ]; then \ + echo "❌ git-http-backend not reachable after $$max port-forward attempts"; \ + echo "--- port-forward log ---"; \ + tail -20 $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.log 2>/dev/null || true; \ + exit 1; \ + fi + @echo "✅ git-http-backend reachable at http://localhost:$(DEBUG_GIT_HTTP_LOCAL_PORT)" + @echo "💡 Functional tests auto-detect this — no env vars needed." + +debug-stop-git-http-backend: ## Tear down the in-cluster Git HTTP backend and port-forward + @if [ -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid ]; then \ + pid=$$(cat $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid 2>/dev/null); \ + if [ -n "$$pid" ] && kill -0 "$$pid" 2>/dev/null; then kill "$$pid" 2>/dev/null || true; fi; \ + rm -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid; \ + fi + @pkill -f "port-forward.*git-http-backend.*git-http" 2>/dev/null || true + @kubectl --context k3d-radius-debug delete namespace $(DEBUG_GIT_HTTP_NAMESPACE) --wait=false >/dev/null 2>&1 || true + @echo "✅ git-http-backend stopped" + +debug-install-flux: ## Install Flux source-controller into the k3d-radius-debug cluster (for Flux tests) + @if ! command -v flux >/dev/null 2>&1; then \ + echo "⚠️ flux CLI not found on PATH; skipping Flux source-controller install."; \ + echo " Install via: brew install fluxcd/tap/flux"; \ + echo " Then re-run: make debug-install-flux"; \ + exit 0; \ + fi + @if kubectl --context k3d-radius-debug get deployment -n $(DEBUG_FLUX_NAMESPACE) source-controller >/dev/null 2>&1; then \ + echo "✅ Flux source-controller already installed in namespace '$(DEBUG_FLUX_NAMESPACE)'"; \ + exit 0; \ + fi + @if [ -z "$(DEBUG_FLUX_VERSION)" ]; then \ + echo "❌ Could not determine flux CLI version"; \ + exit 1; \ + fi + @echo "Installing Flux source-controller v$(DEBUG_FLUX_VERSION) (matches local flux CLI)..." + @kubectl config use-context k3d-radius-debug >/dev/null + @for i in 1 2 3; do \ + flux install --namespace=$(DEBUG_FLUX_NAMESPACE) --version=v$(DEBUG_FLUX_VERSION) \ + --components=source-controller --network-policy=false && \ + kubectl wait --for=condition=available deployment \ + -l app.kubernetes.io/component=source-controller \ + -n $(DEBUG_FLUX_NAMESPACE) --timeout=120s && break; \ + echo "Attempt $$i failed, retrying in 10 seconds..."; \ + sleep 10; \ + done + @echo "✅ Flux source-controller ready in namespace '$(DEBUG_FLUX_NAMESPACE)'" + +debug-install-contour: ## Install the Contour ingress controller (required by Gateway tests) + @command -v helm >/dev/null 2>&1 || { echo "❌ helm CLI not found on PATH (install via: brew install helm)"; exit 1; } + @kubectl config use-context k3d-radius-debug >/dev/null + @if helm --kube-context k3d-radius-debug status $(DEBUG_CONTOUR_RELEASE) -n $(DEBUG_CONTOUR_NAMESPACE) -o json 2>/dev/null | grep -q '"status": *"deployed"'; then \ + echo "✅ Contour already installed (release '$(DEBUG_CONTOUR_RELEASE)' in namespace '$(DEBUG_CONTOUR_NAMESPACE)')"; \ + exit 0; \ + fi + @echo "Installing Contour $(DEBUG_CONTOUR_CHART_VERSION) into namespace '$(DEBUG_CONTOUR_NAMESPACE)'..." + @kubectl create namespace $(DEBUG_CONTOUR_NAMESPACE) --dry-run=client -o yaml | \ + kubectl --context k3d-radius-debug apply -f - >/dev/null + @helm --kube-context k3d-radius-debug repo add contour $(DEBUG_CONTOUR_HELM_REPO) >/dev/null 2>&1 || true + @helm --kube-context k3d-radius-debug repo update contour >/dev/null + @# Do NOT pass `--wait`: on k3d, contour-envoy is a LoadBalancer service whose + @# EXTERNAL-IP allocation by klipper-lb can take several minutes, causing the + @# helm release to be marked `failed` even though the controller + envoy pods + @# are Running. We wait explicitly on the things tests actually need below. + @helm --kube-context k3d-radius-debug upgrade --install $(DEBUG_CONTOUR_RELEASE) contour/contour \ + --namespace $(DEBUG_CONTOUR_NAMESPACE) --version $(DEBUG_CONTOUR_CHART_VERSION) --timeout 5m + @echo "Waiting for Contour controller + envoy to become Ready..." + @kubectl --context k3d-radius-debug wait --for=condition=Available \ + deployment/$(DEBUG_CONTOUR_RELEASE)-contour -n $(DEBUG_CONTOUR_NAMESPACE) --timeout=180s + @kubectl --context k3d-radius-debug rollout status \ + daemonset/$(DEBUG_CONTOUR_RELEASE)-envoy -n $(DEBUG_CONTOUR_NAMESPACE) --timeout=180s + @# Sanity check: the Gateway tests reference projectcontour.io/v1 HTTPProxy. + @kubectl --context k3d-radius-debug get crd httpproxies.projectcontour.io >/dev/null \ + || { echo "❌ HTTPProxy CRD missing after Contour install"; exit 1; } + @echo "✅ Contour ingress controller ready (HTTPProxy CRD present)" + +debug-install-tf-module-server: ## Deploy in-cluster Terraform module server and port-forward localhost:$(DEBUG_TF_MODULE_LOCAL_PORT) (for TerraformRecipe_* tests) + @echo "Publishing test Terraform recipes and deploying $(DEBUG_TF_MODULE_DEPLOYMENT) into namespace '$(DEBUG_TF_MODULE_NAMESPACE)'..." + @kubectl config use-context k3d-radius-debug >/dev/null + @$(MAKE) publish-test-terraform-recipes + @echo "Waiting for $(DEBUG_TF_MODULE_DEPLOYMENT) to become Available..." + @kubectl --context k3d-radius-debug wait --for=condition=Available \ + deployment/$(DEBUG_TF_MODULE_DEPLOYMENT) -n $(DEBUG_TF_MODULE_NAMESPACE) --timeout=120s + @# Kill any stale port-forward for this port so we can rebind cleanly. + @pkill -f "kubectl.*port-forward.*$(DEBUG_TF_MODULE_DEPLOYMENT).*$(DEBUG_TF_MODULE_LOCAL_PORT):" >/dev/null 2>&1 || true + @mkdir -p $(DEBUG_DEV_ROOT)/logs + @echo "Starting port-forward localhost:$(DEBUG_TF_MODULE_LOCAL_PORT) -> svc/$(DEBUG_TF_MODULE_DEPLOYMENT):80..." + @nohup kubectl --context k3d-radius-debug port-forward \ + svc/$(DEBUG_TF_MODULE_DEPLOYMENT) $(DEBUG_TF_MODULE_LOCAL_PORT):80 \ + -n $(DEBUG_TF_MODULE_NAMESPACE) \ + > $(DEBUG_DEV_ROOT)/logs/tf-module-server-pf.log 2>&1 & disown + @# Wait for the port-forward to actually accept connections before returning. + @for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \ + if curl -m 2 -fsS -o /dev/null "http://localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)/" 2>/dev/null \ + || curl -m 2 -sS -o /dev/null -w "%{http_code}" "http://localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)/" 2>/dev/null | grep -qE "^(2|3|4)"; then \ + echo "✅ tf-module-server reachable at http://localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "❌ tf-module-server port-forward not reachable on localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)"; \ + tail -20 $(DEBUG_DEV_ROOT)/logs/tf-module-server-pf.log 2>/dev/null || true; \ + exit 1 + +debug-stop-tf-module-server: ## Tear down the Terraform module server port-forward + @pkill -f "kubectl.*port-forward.*$(DEBUG_TF_MODULE_DEPLOYMENT).*$(DEBUG_TF_MODULE_LOCAL_PORT):" >/dev/null 2>&1 || true + @echo "✅ tf-module-server port-forward stopped" + +debug-publish-bicep-types: ## Build radius+testresources bicep extensions as local .tgz files and drop a closer-wins bicepconfig override next to the test templates + @command -v bicep >/dev/null 2>&1 || { echo "❌ bicep CLI not found on PATH (install via: az bicep install / brew install bicep)"; exit 1; } + @RAD_BIN="$(DEBUG_DEV_ROOT)/bin/rad"; \ + if [ ! -x "$$RAD_BIN" ]; then RAD_BIN="$$(command -v rad)" || true; fi; \ + if [ -z "$$RAD_BIN" ] || [ ! -x "$$RAD_BIN" ]; then \ + echo "❌ rad binary not found at $(DEBUG_DEV_ROOT)/bin/rad or on PATH (run 'make debug-build-rad' first)"; \ + exit 1; \ + fi; \ + mkdir -p $(DEBUG_BICEP_EXT_DIR); \ + echo "Generating bicep types (VERSION=$(DEBUG_BICEP_VERSION))..."; \ + $(MAKE) generate-bicep-types VERSION=$(DEBUG_BICEP_VERSION) || exit $$?; \ + echo "Publishing radius extension -> $(DEBUG_BICEP_EXT_RADIUS)..."; \ + bicep publish-extension ./hack/bicep-types-radius/generated/index.json \ + --target $(DEBUG_BICEP_EXT_RADIUS) --force || exit $$?; \ + echo "Publishing testresources extension -> $(DEBUG_BICEP_EXT_TESTRESOURCES)..."; \ + "$$RAD_BIN" bicep publish-extension \ + -f $(DEBUG_BICEP_TEST_RESOURCES_YAML) \ + --target $(DEBUG_BICEP_EXT_TESTRESOURCES) --force || exit $$? + @# Bicep resolves the nearest bicepconfig.json by walking up from each .bicep + @# file. Writing the override in testdata/ (gitignored) means it wins over the + @# tracked sibling in resources/ without mutating any tracked file. Absolute + @# file paths in `extensions` skip the OCI registry entirely (the bicep CLI's + @# publish-extension forces HTTPS even for localhost, which the local debug + @# registry does not support). + @radius_abs=$$(cd $$(dirname $(DEBUG_BICEP_EXT_RADIUS)) && pwd)/$$(basename $(DEBUG_BICEP_EXT_RADIUS)); \ + tr_abs=$$(cd $$(dirname $(DEBUG_BICEP_EXT_TESTRESOURCES)) && pwd)/$$(basename $(DEBUG_BICEP_EXT_TESTRESOURCES)); \ + { \ + echo '{'; \ + echo ' "experimentalFeaturesEnabled": {'; \ + echo ' "extensibility": true'; \ + echo ' },'; \ + echo ' "extensions": {'; \ + echo " \"radius\": \"$$radius_abs\","; \ + echo ' "aws": "br:biceptypes.azurecr.io/aws:latest",'; \ + echo " \"testresources\": \"$$tr_abs\""; \ + echo ' }'; \ + echo '}'; \ + } > $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE) + @echo "✅ Bicep extensions built to $(DEBUG_BICEP_EXT_DIR); override written to $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE)" + @echo "💡 The tracked resources/bicepconfig.json is untouched; 'make debug-stop' removes the override." + +debug-remove-bicep-types-override: ## Delete the gitignored bicepconfig override next to the dynamicrp test templates + @if [ -f $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE) ]; then \ + rm -f $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE); \ + echo "✅ Removed $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE)"; \ + else \ + echo "ℹ️ No bicepconfig override at $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE); nothing to remove"; \ + fi + debug-stop: ## Stop all running Radius components, destroy k3d cluster, and clean up @echo "Stopping Radius components..." @if [ -f build/scripts/stop-radius.sh ]; then \ @@ -253,6 +583,10 @@ debug-stop: ## Stop all running Radius components, destroy k3d cluster, and clea @echo "Cleaning up debug files and symlinks..." @rm -rf $(DEBUG_DEV_ROOT)/logs @rm -f ./drad + @$(MAKE) debug-remove-bicep-types-override + @$(MAKE) debug-stop-tf-module-server + @$(MAKE) debug-stop-git-http-backend + @$(MAKE) debug-stop-registry @echo "✅ Debug environment completely stopped and cleaned up" debug-status: ## Show status of all components @@ -281,17 +615,30 @@ debug-deployment-engine-pull: ## Pull latest deployment engine image from ghcr.i && echo "✅ Deployment Engine image pulled successfully" \ || echo "❌ Failed to pull Deployment Engine image" -debug-deployment-engine-start: ## Start deployment engine in k3d cluster - @echo "Installing ONLY deployment engine to k3d cluster..." - @if kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1 && \ - kubectl --context k3d-radius-debug get deployment deployment-engine -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -q "1" && \ - curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ - echo "✅ Deployment engine already running and healthy"; \ +debug-deployment-engine-start: ## Start deployment engine in k3d cluster (or reuse a local OS process on port 5017) + @echo "Checking for an existing Deployment Engine on localhost:5017..." + @listener_cmd=""; \ + if command -v lsof >/dev/null 2>&1; then \ + listener_cmd=$$(lsof -nP -iTCP:5017 -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $$1}'); \ + fi; \ + if [ -n "$$listener_cmd" ] && [ "$$listener_cmd" != "kubectl" ] && curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ + echo "✅ Detected local Deployment Engine process ($$listener_cmd) on port 5017 — reusing it"; \ + echo "💡 Skipping k3d deployment-engine install and port-forward"; \ + mkdir -p $(DEBUG_DEV_ROOT)/logs; \ + echo "external" > $(DEBUG_DEV_ROOT)/logs/de-external.marker; \ else \ - $(MAKE) debug-deployment-engine-deploy; \ - $(MAKE) debug-deployment-engine-port-forward; \ + rm -f $(DEBUG_DEV_ROOT)/logs/de-external.marker 2>/dev/null || true; \ + echo "Installing ONLY deployment engine to k3d cluster..."; \ + if kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1 && \ + kubectl --context k3d-radius-debug get deployment deployment-engine -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -q "1" && \ + curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ + echo "✅ Deployment engine already running and healthy in k3d"; \ + else \ + $(MAKE) debug-deployment-engine-deploy; \ + $(MAKE) debug-deployment-engine-port-forward; \ + fi; \ + echo "✅ Deployment engine installed and ready in k3d cluster"; \ fi - @echo "✅ Deployment engine installed and ready in k3d cluster" debug-deployment-engine-deploy: ## Deploy deployment engine to k3d cluster @echo "Applying deployment engine manifest to k3d cluster..." @@ -302,7 +649,12 @@ debug-deployment-engine-deploy: ## Deploy deployment engine to k3d cluster debug-deployment-engine-port-forward: ## Set up port forwarding for deployment engine @build/scripts/setup-deployment-engine-port-forward.sh -debug-deployment-engine-stop: ## Stop deployment engine in k3d cluster +debug-deployment-engine-stop: ## Stop deployment engine in k3d cluster (leaves external local DE process alone) + @if [ -f $(DEBUG_DEV_ROOT)/logs/de-external.marker ]; then \ + echo "ℹ️ External local Deployment Engine was in use — leaving it running"; \ + rm -f $(DEBUG_DEV_ROOT)/logs/de-external.marker; \ + exit 0; \ + fi @echo "Removing deployment engine from k3d cluster..." @if [ -f $(DEBUG_DEV_ROOT)/logs/de-port-forward.pid ]; then \ kill $$(cat $(DEBUG_DEV_ROOT)/logs/de-port-forward.pid) 2>/dev/null || true; \ @@ -315,7 +667,9 @@ debug-deployment-engine-stop: ## Stop deployment engine in k3d cluster debug-deployment-engine-status: ## Check deployment engine status @echo "🚀 Deployment Engine Status:" - @if kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1; then \ + @if [ -f $(DEBUG_DEV_ROOT)/logs/de-external.marker ] && curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ + echo "✅ Deployment Engine (external local process on :5017) - Running"; \ + elif kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1; then \ replicas=$$(kubectl --context k3d-radius-debug get deployment deployment-engine -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0"); \ if [ "$$replicas" = "1" ]; then \ echo "✅ Deployment Engine (k3d) - Running and ready"; \ diff --git a/build/recipes.mk b/build/recipes.mk index 2584cc8b1b8..7134890bcb6 100644 --- a/build/recipes.mk +++ b/build/recipes.mk @@ -27,7 +27,7 @@ publish-test-bicep-recipes: ## Publishes test recipes to @if [ -z "$(BICEP_RECIPE_REGISTRY)" ]; then echo "Error: BICEP_RECIPE_REGISTRY must be set to a valid OCI registry"; exit 1; fi @echo "$(ARROW) Publishing Bicep test recipes from ./test/testrecipes/test-bicep-recipes..." - ./.github/scripts/publish-recipes.sh \ + PLAIN_HTTP=$(BICEP_RECIPE_PLAIN_HTTP) ./.github/scripts/publish-recipes.sh \ ./test/testrecipes/test-bicep-recipes \ ${BICEP_RECIPE_REGISTRY}/test/testrecipes/test-bicep-recipes \ ${BICEP_RECIPE_TAG_VERSION} diff --git a/build/scripts/azure-local-testenv.sh b/build/scripts/azure-local-testenv.sh new file mode 100755 index 00000000000..8f8fd09f624 --- /dev/null +++ b/build/scripts/azure-local-testenv.sh @@ -0,0 +1,460 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------ +# Copyright 2024 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# ------------------------------------------------------------ +# +# Orchestrates an ephemeral Azure environment for local functional tests. +# +# Subcommands: +# setup Create an ephemeral resource group, deploy test fixtures, configure +# the current rad environment with the Azure provider scope, and write +# state to debug_files/logs/azure-local.env. +# run Source the state file and run the Test_Azure* subset of the +# corerp-cloud functional tests against the locally-running stack. +# teardown Delete the resource group (no-wait), clear the env-update on the +# current rad environment, and remove the state file. +# +# Auth model: this script assumes the caller is authenticated via `az login`. +# Radius components (RP/UCP) authenticate to Azure via the Azure CLI fallback in +# pkg/azure/armauth/auth.go. The Deployment Engine must also be running locally +# (not in a container) so it can use the same DefaultAzureCredential -> az CLI +# path. See debug_files/logs/de-external.marker for the external-DE indicator. +# +# Environment variables: +# AZURE_LOCATION Azure region (default: westus3). +# AZURE_SUBSCRIPTION_ID Subscription to use (default: az account show). +# AZURE_LOCAL_PREPROVISIONED_RG If set, reuse an existing RG and skip fixture +# deployment / teardown of that RG. The script +# will still write the state file so `run` works. +# RAD_ENV rad environment to configure (default: default). +# +# Note: AWS support is intentionally out of scope for this iteration. +# Note: Test_AzureMSSQL_* tests are not configured here; they auto-skip when the +# AZURE_MSSQL_* env vars are absent. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +STATE_DIR="${REPO_ROOT}/debug_files/logs" +STATE_FILE="${STATE_DIR}/azure-local.env" +BICEP_TEMPLATE="${REPO_ROOT}/test/createAzureTestResources.bicep" +TF_MODULE_SERVER_NS="radius-test-tf-module-server" +TF_MODULE_SERVER_PORT_FORWARD_PID_FILE="${STATE_DIR}/tf-module-server-pf.pid" +TF_MODULE_SERVER_PORT_FORWARD_LOG="${STATE_DIR}/tf-module-server-pf.log" + +AZURE_LOCATION="${AZURE_LOCATION:-westus3}" +RAD_ENV="${RAD_ENV:-default}" + +log() { printf '\033[0;34mℹ\033[0m %s\n' "$*"; } +ok() { printf '\033[0;32m✔\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m⚠\033[0m %s\n' "$*" >&2; } +err() { printf '\033[0;31m✖\033[0m %s\n' "$*" >&2; } + +require_cmd() { + for c in "$@"; do + command -v "$c" >/dev/null 2>&1 || { err "required command not found: $c"; exit 1; } + done +} + +require_az_login() { + if ! az account show >/dev/null 2>&1; then + err "not logged in to Azure. Run 'az login' first." + exit 1 + fi +} + +resolve_subscription() { + if [[ -n "${AZURE_SUBSCRIPTION_ID:-}" ]]; then + echo "${AZURE_SUBSCRIPTION_ID}" + return + fi + az account show --query id -o tsv +} + +ensure_tf_module_server() { + # Terraform-recipe tests fetch zipped modules from http://localhost:8999. + # In CI an in-cluster nginx serves them; locally we deploy the same nginx + # into the debug k3d cluster and port-forward it to localhost:8999. + require_cmd kubectl make + if curl -sf -o /dev/null -m 2 http://localhost:8999/azure-rg.zip; then + log "tf-module-server already reachable at http://localhost:8999" + return 0 + fi + if ! kubectl get ns "${TF_MODULE_SERVER_NS}" >/dev/null 2>&1 \ + || ! kubectl -n "${TF_MODULE_SERVER_NS}" get deploy tf-module-server >/dev/null 2>&1; then + log "Deploying tf-module-server into the debug cluster (publish-test-terraform-recipes)..." + (cd "${REPO_ROOT}" && make publish-test-terraform-recipes >/dev/null) \ + || { err "make publish-test-terraform-recipes failed"; exit 1; } + fi + log "Waiting for tf-module-server rollout..." + kubectl -n "${TF_MODULE_SERVER_NS}" rollout status deploy/tf-module-server --timeout=120s >/dev/null \ + || { err "tf-module-server rollout did not become ready"; exit 1; } + # Stop any stale port-forward before starting a new one. + if [[ -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" ]]; then + local old_pid + old_pid="$(cat "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" 2>/dev/null || true)" + if [[ -n "${old_pid}" ]] && kill -0 "${old_pid}" 2>/dev/null; then + kill "${old_pid}" 2>/dev/null || true + fi + rm -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" + fi + log "Starting kubectl port-forward svc/tf-module-server 8999:80 -n ${TF_MODULE_SERVER_NS}" + ( kubectl -n "${TF_MODULE_SERVER_NS}" port-forward svc/tf-module-server 8999:80 \ + >"${TF_MODULE_SERVER_PORT_FORWARD_LOG}" 2>&1 ) & + echo $! > "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" + # Wait briefly for the port-forward to come up. + local i + for i in $(seq 1 20); do + if curl -sf -o /dev/null -m 1 http://localhost:8999/azure-rg.zip; then + ok "tf-module-server reachable at http://localhost:8999 (pid $(cat "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}"))" + return 0 + fi + sleep 0.5 + done + err "tf-module-server port-forward did not become reachable; see ${TF_MODULE_SERVER_PORT_FORWARD_LOG}" + exit 1 +} + +stop_tf_module_server_port_forward() { + if [[ -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" ]]; then + local pid + pid="$(cat "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" 2>/dev/null || true)" + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + log "Stopping tf-module-server port-forward (pid ${pid})" + kill "${pid}" 2>/dev/null || true + fi + rm -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" + fi +} + +cmd_setup() { + require_cmd az jq rad + require_az_login + mkdir -p "${STATE_DIR}" + + if [[ -f "${STATE_FILE}" ]]; then + err "state file already exists at ${STATE_FILE}. Run 'teardown' first or remove it manually." + exit 1 + fi + + local sub + sub="$(resolve_subscription)" + local tenant + tenant="$(az account show --query tenantId -o tsv)" + log "Subscription: ${sub}" + + local rg + if [[ -n "${AZURE_LOCAL_PREPROVISIONED_RG:-}" ]]; then + rg="${AZURE_LOCAL_PREPROVISIONED_RG}" + log "Reusing pre-provisioned resource group: ${rg}" + if ! az group show --subscription "${sub}" --name "${rg}" >/dev/null 2>&1; then + err "pre-provisioned resource group ${rg} not found in subscription ${sub}" + exit 1 + fi + else + local user_slug epoch + user_slug="$(echo "${USER:-local}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g;s/^-//;s/-$//')" + epoch="$(date +%s)" + rg="radlocal-${user_slug}-${epoch}" + log "Creating resource group: ${rg} in ${AZURE_LOCATION}" + az group create \ + --subscription "${sub}" \ + --location "${AZURE_LOCATION}" \ + --name "${rg}" \ + --tags creationTime="${epoch}" creator="${USER:-unknown}" purpose=radius-local-test \ + -o none + while [[ "$(az group exists --subscription "${sub}" --name "${rg}")" != "true" ]]; do + sleep 2 + done + ok "Resource group created: ${rg}" + fi + + local cosmos_id="" + if [[ -z "${AZURE_LOCAL_PREPROVISIONED_RG:-}" ]]; then + # Cosmos DB account names are globally unique. Derive a stable-but-unique + # name from the RG (which already includes user + epoch) and trim to the + # 3-44 char limit. Cosmos requires lowercase alphanumerics + hyphens, and + # disallows leading or trailing hyphens. + local cosmos_name + cosmos_name="$(echo "radlocal-${rg#radlocal-}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9-' '-' \ + | cut -c1-44 \ + | sed -E 's/^-+//; s/-+$//')" + log "Deploying test fixtures (Cosmos Mongo account ${cosmos_name}) — this typically takes 3-5 minutes..." + local deploy_json + deploy_json="$(az deployment group create \ + --subscription "${sub}" \ + --resource-group "${rg}" \ + --template-file "${BICEP_TEMPLATE}" \ + --parameters cosmosAccountName="${cosmos_name}" \ + -o json)" + cosmos_id="$(echo "${deploy_json}" | jq -r '.properties.outputs.cosmosMongoAccountID.value')" + ok "Cosmos Mongo account deployed: ${cosmos_id}" + else + # Reuse: look up by tag/kind in the pre-provisioned RG. + cosmos_id="$(az resource list \ + --subscription "${sub}" \ + --resource-group "${rg}" \ + --resource-type Microsoft.DocumentDB/databaseAccounts \ + --query '[?kind==`MongoDB`] | [0].id' -o tsv || true)" + if [[ -z "${cosmos_id}" || "${cosmos_id}" == "null" ]]; then + warn "no Cosmos Mongo account found in ${rg}; Test_AzureConnections will fail." + else + ok "Found Cosmos Mongo account: ${cosmos_id}" + fi + fi + + log "Configuring rad environment '${RAD_ENV}' with Azure scope" + rad env update "${RAD_ENV}" \ + --azure-subscription-id "${sub}" \ + --azure-resource-group "${rg}" + + ensure_tf_module_server + + cat > "${STATE_FILE}" <-* RG owned by the current user. + local user_slug + user_slug="$(echo "${USER:-local}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g;s/^-//;s/-$//')" + local matches + matches="$(az group list --subscription "${sub}" --query "[?starts_with(name, 'radlocal-${user_slug}-')].name" -o tsv)" + local count + count="$(printf '%s\n' "${matches}" | grep -c . || true)" + if [[ "${count}" -eq 0 ]]; then + err "no state file at ${STATE_FILE} and no radlocal-* RG to recover from. Run 'setup' first." + exit 1 + fi + # Pick the newest RG (epoch suffix). RG names are radlocal--. + rg="$(printf '%s\n' ${matches} | sort -t- -k3 -n | tail -1)" + if [[ "${count}" -gt 1 ]]; then + warn "multiple radlocal-${user_slug}-* RGs found; using newest: ${rg}" + warn "clean up the rest with: $0 teardown --all-orphans" + fi + fi + if ! az group show --subscription "${sub}" --name "${rg}" >/dev/null 2>&1; then + err "resource group ${rg} not found in subscription ${sub}" + exit 1 + fi + cosmos_id="$(az resource list \ + --subscription "${sub}" \ + --resource-group "${rg}" \ + --resource-type Microsoft.DocumentDB/databaseAccounts \ + --query '[?kind==`MongoDB`] | [0].id' -o tsv 2>/dev/null || true)" + mkdir -p "${STATE_DIR}" + cat > "${STATE_FILE}" < ${STATE_FILE}" + ensure_tf_module_server +} + +cmd_run() { + if [[ ! -f "${STATE_FILE}" ]]; then + warn "no state file at ${STATE_FILE}; attempting to recover from an existing RG..." + recover_state + fi + # shellcheck disable=SC1090 + source "${STATE_FILE}" + ensure_tf_module_server + # Re-apply Azure scope on the rad env idempotently. `make debug-start` + # recreates the Postgres DB, which wipes the env's Azure provider config + # set during `setup`. Without this, bicep templates that use + # `resourceGroup().id` for `providers.azure.scope` (e.g. + # corerp-resources-terraform-azurerg.bicep) get an empty subscription id + # because the deployment-engine substitutes `resourceGroup().id` from the + # active env's Azure scope. + log "Ensuring rad env '${RAD_ENV}' has Azure scope (sub=${AZURE_SUBSCRIPTION_ID}, rg=${AZURE_LOCAL_TEST_RG})" + rad env update "${RAD_ENV}" \ + --azure-subscription-id "${AZURE_SUBSCRIPTION_ID}" \ + --azure-resource-group "${AZURE_LOCAL_TEST_RG}" >/dev/null + log "Running corerp/cloud functional tests against RG ${AZURE_LOCAL_TEST_RG}" + log "AWS-required tests will skip automatically (RADIUS_TEST_USE_LOCAL_CLOUD_CREDS=azure)" + cd "${REPO_ROOT}" + # Run the full corerp/cloud suite. CheckRequiredFeatures will skip tests that + # need AWS or the CSI driver; only the Azure-required tests will execute. + # + # Stream per-test output: `go test -v` buffers each package's output until + # the package finishes, which hides progress for long Azure deployments. + # Prefer gotestsum (installed via `make test-get-envtools` in CI) for + # per-test live output; fall back to `go test -json` piped through a small + # awk filter that prints each PASS/FAIL/SKIP line as it completes. + if command -v gotestsum >/dev/null 2>&1; then + CGO_ENABLED=1 gotestsum --format testname -- \ + ./test/functional-portable/corerp/cloud/... \ + -timeout "${TEST_TIMEOUT:-1h}" \ + -parallel 5 \ + ${GOTEST_OPTS:-} \ + "$@" + else + log "gotestsum not found; using 'go test -json' for streaming output. Install with: go install gotest.tools/gotestsum@latest" + CGO_ENABLED=1 go test \ + ./test/functional-portable/corerp/cloud/... \ + -json \ + -timeout "${TEST_TIMEOUT:-1h}" \ + -parallel 5 \ + ${GOTEST_OPTS:-} \ + "$@" | \ + awk -F'"' ' + /"Action":"run"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "RUN " $(i+2); break } } + /"Action":"pass"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "PASS " $(i+2); break } } + /"Action":"fail"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "FAIL " $(i+2); break } } + /"Action":"skip"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "SKIP " $(i+2); break } } + /"Action":"output"/ { for (i=1;i<=NF;i++) if ($i=="Output") { gsub(/\\n/,"",$(i+2)); if ($(i+2) != "") print " " $(i+2); break } } + ' + fi +} + +cmd_teardown() { + require_cmd az + # Optional: --all-orphans deletes every radlocal--* RG in the current + # subscription, regardless of state file. Useful after a debug-stop wiped + # state without tearing down RGs. + if [[ "${1:-}" == "--all-orphans" ]]; then + require_az_login + local sub user_slug matches + sub="$(resolve_subscription)" + user_slug="$(echo "${USER:-local}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g;s/^-//;s/-$//')" + matches="$(az group list --subscription "${sub}" --query "[?starts_with(name, 'radlocal-${user_slug}-')].name" -o tsv)" + if [[ -z "${matches}" ]]; then + ok "no radlocal-${user_slug}-* RGs to delete." + else + log "Deleting orphan RGs (no-wait):" + printf ' %s\n' ${matches} + for orphan in ${matches}; do + az group delete --subscription "${sub}" --name "${orphan}" --yes --no-wait \ + || warn "failed to start delete for ${orphan}" + done + fi + stop_tf_module_server_port_forward + rm -f "${STATE_FILE}" + return 0 + fi + if [[ ! -f "${STATE_FILE}" ]]; then + warn "no state file at ${STATE_FILE}; nothing to tear down. Use '$0 teardown --all-orphans' to GC stale radlocal-* RGs." + return 0 + fi + # shellcheck disable=SC1090 + source "${STATE_FILE}" + + local sub="${AZURE_SUBSCRIPTION_ID}" + local rg="${AZURE_LOCAL_TEST_RG}" + + if [[ -z "${AZURE_LOCAL_PREPROVISIONED_RG:-}" && "${rg}" == radlocal-* ]]; then + log "Deleting resource group ${rg} (no-wait)" + az group delete \ + --subscription "${sub}" \ + --name "${rg}" \ + --yes --no-wait || warn "az group delete returned non-zero" + else + warn "RG ${rg} was pre-provisioned (or not radlocal-* prefix); leaving it intact." + fi + + log "Clearing Azure scope on rad env '${RAD_ENV}'" + rad env update "${RAD_ENV}" --clear-azure 2>/dev/null || \ + warn "rad env update --clear-azure failed or unsupported; clear manually if needed." + + stop_tf_module_server_port_forward + rm -f "${STATE_FILE}" + ok "Teardown complete." +} + +cmd_all() { + cmd_setup + local rc=0 + if [[ "${AZURE_LOCAL_KEEP_ON_FAILURE:-0}" == "1" || "${AZURE_LOCAL_KEEP_ON_FAILURE:-}" =~ ^[Tt]rue$ ]]; then + log "AZURE_LOCAL_KEEP_ON_FAILURE is set; teardown will be SKIPPED if tests fail (post-mortem mode)." + cmd_run "$@" || rc=$? + if [[ "${rc}" -ne 0 ]]; then + warn "Tests exited with rc=${rc}; preserving RG ${AZURE_LOCAL_TEST_RG:-?} and state file ${STATE_FILE} for inspection." + warn "Run 'make test-functional-azure-local-teardown' when finished." + return "${rc}" + fi + cmd_teardown + return 0 + fi + # Default: ensure teardown runs even if tests fail. + trap cmd_teardown EXIT + cmd_run "$@" || rc=$? + trap - EXIT + cmd_teardown + return "${rc}" +} + +usage() { + cat >&2 < [-- extra go test args] + + setup Create RG, deploy fixtures, write state file. + run Source state file (auto-recover from existing RG if missing) and + run corerp/cloud functional tests. Extra args after the command + are passed through to 'go test' (e.g. -run '^Test_X$' -v). + teardown Delete RG and remove state file. + all setup -> run -> teardown (teardown runs even on failure). + +Examples: + # Re-run only specific failing tests against the existing RG: + $0 run -run '^(Test_TerraformRecipe_AzureResourceGroup|Test_Extender_RecipeAWS_LogGroup)\$' -v + +Environment variables: + AZURE_LOCAL_KEEP_ON_FAILURE=1 When set with 'all', skip teardown on test + failure so the RG can be inspected. The + state file is also preserved; clean up later + with 'teardown'. + AZURE_LOCAL_PREPROVISIONED_RG Reuse an existing RG (skip Cosmos deploy and + skip RG deletion). + AZURE_LOCAL_TEST_RG Force recovery from a specific RG when the + state file is missing. +EOF + exit 2 +} + +cmd="${1:-}" +shift || true +case "${cmd}" in + setup) cmd_setup "$@" ;; + run) cmd_run "$@" ;; + teardown) cmd_teardown "$@" ;; + all) cmd_all "$@" ;; + *) usage ;; +esac diff --git a/build/scripts/ensure-encryption-key.sh b/build/scripts/ensure-encryption-key.sh new file mode 100755 index 00000000000..fd9e658e893 --- /dev/null +++ b/build/scripts/ensure-encryption-key.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Ensure the radius-encryption-key Secret exists in the radius-system namespace +# of the k3d-radius-debug cluster. dynamic-rp (and other Radius components that +# use the encryption key provider) refuse to start without it. +# +# The Helm chart (deploy/Chart/templates/dynamic-rp/secret.yaml) normally +# creates this. The OS-process debug stack skips Helm, so we recreate the same +# secret format here. +# +# This script is idempotent: if the secret already exists, it does nothing. + +set -euo pipefail + +CONTEXT="${KUBE_CONTEXT:-k3d-radius-debug}" +NAMESPACE="${RADIUS_NAMESPACE:-radius-system}" +SECRET_NAME="radius-encryption-key" + +if ! command -v kubectl >/dev/null 2>&1; then + echo "❌ kubectl not found" + exit 1 +fi + +if ! kubectl --context "$CONTEXT" cluster-info >/dev/null 2>&1; then + echo "❌ cluster $CONTEXT not reachable" + exit 1 +fi + +# Ensure namespace exists +kubectl --context "$CONTEXT" get namespace "$NAMESPACE" >/dev/null 2>&1 \ + || kubectl --context "$CONTEXT" create namespace "$NAMESPACE" >/dev/null + +# If the secret already exists, leave it alone. +if kubectl --context "$CONTEXT" -n "$NAMESPACE" get secret "$SECRET_NAME" >/dev/null 2>&1; then + echo "✅ Secret $NAMESPACE/$SECRET_NAME already exists" + exit 0 +fi + +# Generate a 32-byte random key and build the JSON keystore the same way the +# Helm chart does. +now_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +expiry_iso="$(date -u -v+90d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ)" +key_b64="$(head -c 32 /dev/urandom | base64 | tr -d '\n')" + +keystore_json=$(cat </dev/null + +echo "✅ Created secret $NAMESPACE/$SECRET_NAME (random 32-byte key, 90-day expiry)" diff --git a/build/scripts/mirror-test-images.sh b/build/scripts/mirror-test-images.sh new file mode 100755 index 00000000000..b707d22de24 --- /dev/null +++ b/build/scripts/mirror-test-images.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Copy upstream multi-arch container images into the +# ghcr.io/radius-project/mirror/* namespace, preserving the full OCI index +# (i.e. all platforms) so the same tag works on amd64 (CI) and arm64 +# (Apple Silicon dev machines / k3d). Uses `docker buildx imagetools create`, +# which performs a server-side blob mount when source and destination are on +# the same registry, and otherwise a streaming copy. +# +# Requirements: +# - docker (with buildx, included in Docker Desktop) +# - You must be logged in to ghcr.io with a token that has write:packages. +# Easiest: `echo $GH_PACKAGES_TOKEN | docker login ghcr.io -u --password-stdin` +# The token used by `gh auth login` does NOT include write:packages by default; +# create a Classic PAT with the `write:packages` scope. +# +# Usage: +# build/scripts/mirror-test-images.sh # mirror the default list below +# build/scripts/mirror-test-images.sh --dry-run # show what would be copied +# build/scripts/mirror-test-images.sh src=dst # mirror a single mapping +# +# Each mapping is SRC=DST_TAG where: +# SRC = fully-qualified upstream reference (e.g. docker.io/library/rabbitmq:3.12-management-alpine) +# DST_TAG = destination repo+tag under ghcr.io/radius-project/mirror/ +# (e.g. rabbitmq:3.12-management-alpine) + +set -euo pipefail + +DEST_PREFIX="ghcr.io/radius-project/mirror" + +# Default mappings. Every source MUST be multi-arch (at least linux/amd64 + +# linux/arm64) for the destination to be usable from both CI and arm64 dev +# machines. Verify with `docker buildx imagetools inspect ` before adding. +DEFAULT_MAPPINGS=( + # These mirror the exact same tags already referenced by tests / recipes. + # The previous mirror entries were pushed as single-arch (linux/amd64 only) + # blobs, which breaks tests on arm64 dev machines (e.g. rabbitmq 3.10 BEAM + # crashes under QEMU). Re-mirroring with buildx imagetools create copies the + # full upstream OCI index so all platforms work. + "docker.io/library/rabbitmq:3.10=rabbitmq:3.10" + "docker.io/library/redis:6.2=redis:6.2" + "docker.io/library/mongo:4.2=mongo:4.2" + "docker.io/library/postgres:latest=postgres:latest" + "docker.io/library/debian:latest=debian:latest" +) + +dry_run=0 +mappings=() +for arg in "$@"; do + case "$arg" in + --dry-run) dry_run=1 ;; + -h|--help) sed -n '1,30p' "$0"; exit 0 ;; + *=*) mappings+=("$arg") ;; + *) echo "unknown argument: $arg" >&2; exit 2 ;; + esac +done + +if [[ ${#mappings[@]} -eq 0 ]]; then + mappings=("${DEFAULT_MAPPINGS[@]}") +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required" >&2; exit 1 +fi +if ! docker buildx version >/dev/null 2>&1; then + echo "docker buildx is required (included with Docker Desktop)" >&2; exit 1 +fi + +echo "Mirroring ${#mappings[@]} image(s) to ${DEST_PREFIX}/*" +for m in "${mappings[@]}"; do + src="${m%%=*}" + dst_suffix="${m#*=}" + dst="${DEST_PREFIX}/${dst_suffix}" + echo + echo "==> ${src}" + echo " -> ${dst}" + + # Verify source is multi-arch before copying. The destination will inherit + # whatever platforms the source has, so a single-arch source means we did + # not solve the original problem. + platforms="$(docker buildx imagetools inspect --raw "${src}" 2>/dev/null \ + | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) +except Exception: + print(""); sys.exit(0) +m=d.get("manifests") +if not m: + print(""); sys.exit(0) +print(",".join(sorted({x["platform"]["os"]+"/"+x["platform"]["architecture"] + for x in m if "platform" in x and x["platform"].get("os")!="unknown"}))) +')" + if [[ -z "${platforms}" ]]; then + echo " !! source is single-arch (no manifest index); refusing to mirror" >&2 + exit 1 + fi + if [[ "${platforms}" != *"linux/amd64"* || "${platforms}" != *"linux/arm64"* ]]; then + echo " !! source must include both linux/amd64 and linux/arm64; got: ${platforms}" >&2 + exit 1 + fi + echo " platforms: ${platforms}" + + if [[ "${dry_run}" -eq 1 ]]; then + continue + fi + docker buildx imagetools create --tag "${dst}" "${src}" +done + +echo +echo "Done." diff --git a/build/scripts/start-radius.sh b/build/scripts/start-radius.sh index 1cf9a3707b0..e2b1c41297e 100755 --- a/build/scripts/start-radius.sh +++ b/build/scripts/start-radius.sh @@ -130,6 +130,18 @@ fi # Ensure logs directory exists mkdir -p "$DEBUG_ROOT/logs" +# Use a writable Terraform global cache directory for local OS-process runs. +# The default `/terraform` path only exists inside the Radius container image +# and would fail with "read-only file system" on host filesystems. +export TERRAFORM_TEST_GLOBAL_DIR="${TERRAFORM_TEST_GLOBAL_DIR:-$DEBUG_ROOT/terraform-global}" +mkdir -p "$TERRAFORM_TEST_GLOBAL_DIR" + +# Allow plain-HTTP OCI pulls for loopback registries (the local debug stack +# uses `registry:2` on http://localhost:5000). pkg/rp/util/registry.go reads +# this env var and forces PlainHTTP=true when the recipe templatePath host +# resolves to a loopback address. Non-loopback registries are unaffected. +export RADIUS_INSECURE_LOOPBACK_REGISTRIES="${RADIUS_INSECURE_LOOPBACK_REGISTRIES:-true}" + # Check prerequisites check_prerequisites @@ -281,16 +293,17 @@ echo "Starting UCP with dlv on port 40001..." dlv exec "$DEBUG_ROOT/bin/ucpd" --listen=127.0.0.1:40001 --headless=true --api-version=2 --accept-multiclient --continue -- --config-file="$SCRIPT_DIR/../configs/ucp.yaml" > "$DEBUG_ROOT/logs/ucp.log" 2>&1 & echo $! > "$DEBUG_ROOT/logs/ucp.pid" -# Wait for UCP to start and complete initialization (this can take 60+ seconds) +# Wait for UCP to start and complete initialization (this can take 60+ seconds). +# A 200 from /apis/api.ucp.dev/v1alpha3 means the API server is listening, which +# only happens after manifest registration completes (see ucp api/server.go). +# Don't grep the log file: dlv buffers child stdout, so the manifest line can sit +# unflushed in the buffer for minutes even though UCP is fully ready. echo "Waiting for UCP to initialize (this may take up to 2 minutes)..." max_attempts=60 attempt=0 while [ $attempt -lt $max_attempts ]; do if curl -s "http://localhost:9000/apis/api.ucp.dev/v1alpha3" > /dev/null 2>&1; then - # Check if initialization is complete by looking for the success message in logs - if grep -q "Successfully registered manifests" "$DEBUG_ROOT/logs/ucp.log" 2>/dev/null; then - break - fi + break fi # Show progress every 10 seconds diff --git a/build/test.mk b/build/test.mk index c11f4a3f3fd..16c240f7b4b 100644 --- a/build/test.mk +++ b/build/test.mk @@ -33,6 +33,67 @@ TEST_TIMEOUT ?=1h RADIUS_CONTAINER_LOG_PATH ?=./dist/container_logs REL_VERSION ?=latest DOCKER_REGISTRY ?=ghcr.io/radius-project/dev + +# Auto-detect the local debug OCI registry started by `make debug-publish-recipes`. +# When it is running and the user has not explicitly set BICEP_RECIPE_REGISTRY, +# point the functional tests at it so the locally-published test recipes are used +# instead of the (private) ghcr.io fallback. +ifeq ($(origin BICEP_RECIPE_REGISTRY), undefined) +ifneq ($(shell docker ps --format '{{.Names}}' 2>/dev/null | grep -x radius-debug-registry),) +export BICEP_RECIPE_REGISTRY := localhost:5000 +export BICEP_RECIPE_TAG_VERSION ?= latest +$(info Using local debug recipe registry: BICEP_RECIPE_REGISTRY=$(BICEP_RECIPE_REGISTRY) BICEP_RECIPE_TAG_VERSION=$(BICEP_RECIPE_TAG_VERSION)) +# When the debug-built rad CLI is available, point the functional tests at it +# (via RAD_PATH, honored by test/radcli/cli.go) so they exercise the HEAD CLI +# matching the locally-running control plane, not any system-installed rad. +ifneq ($(wildcard $(CURDIR)/debug_files/bin/rad),) +export RAD_PATH := $(CURDIR)/debug_files/bin +$(info Using debug-built rad CLI: RAD_PATH=$(RAD_PATH)) +endif +endif +endif + +# Auto-detect the in-cluster Git HTTP backend started by `make debug-install-git-http-backend`. +# When its port-forward PID file exists and the process is alive, export the +# GIT_HTTP_* variables that the kubernetes-noncloud Flux tests expect. +ifeq ($(origin GIT_HTTP_SERVER_URL), undefined) +ifneq ($(wildcard $(CURDIR)/debug_files/logs/git-http-port-forward.pid),) +ifneq ($(shell pid=$$(cat $(CURDIR)/debug_files/logs/git-http-port-forward.pid 2>/dev/null); kill -0 $$pid 2>/dev/null && echo up),) +export GIT_HTTP_SERVER_URL := http://localhost:30080 +export GIT_HTTP_USERNAME ?= testuser +export GIT_HTTP_PASSWORD ?= not-a-secret-password +export GIT_HTTP_EMAIL ?= testuser@radapp.io +$(info Using local git-http-backend: GIT_HTTP_SERVER_URL=$(GIT_HTTP_SERVER_URL)) +endif +endif +endif + +# When the Radius controller is running as a host OS process (local debug flow), +# the in-cluster Flux source-controller URL cannot be resolved from the host, so +# Test_Flux_* would fail to fetch artifacts. Auto-skip those tests in that case. +ifeq ($(origin RADIUS_SKIP_FLUX_TESTS), undefined) +ifneq ($(wildcard $(CURDIR)/debug_files/logs/controller.pid),) +ifneq ($(shell pid=$$(cat $(CURDIR)/debug_files/logs/controller.pid 2>/dev/null); kill -0 $$pid 2>/dev/null && echo up),) +export RADIUS_SKIP_FLUX_TESTS := 1 +$(info Radius controller running as host OS process: skipping Flux tests (RADIUS_SKIP_FLUX_TESTS=1)) +endif +endif +endif + +# Auto-detect the OS-process UCP started by `make debug-start`. When it's live, +# route the functional tests' rad CLI subprocess AND the in-process +# cli.LoadConfig("") calls at the project-local debug config instead of +# ~/.rad/config.yaml. Without this, tests pick up whichever workspace happens +# to be "default" in the user's home directory (e.g. an AKS workspace) and fail +# with DNS errors when the cluster URL is unreachable from the host. +ifeq ($(origin RAD_CONFIG_FILE), undefined) +ifneq ($(wildcard $(CURDIR)/debug_files/logs/ucp.pid),) +ifneq ($(shell pid=$$(cat $(CURDIR)/debug_files/logs/ucp.pid 2>/dev/null); kill -0 $$pid 2>/dev/null && echo up),) +export RAD_CONFIG_FILE := $(CURDIR)/build/configs/rad-debug-config.yaml +$(info Using debug rad config: RAD_CONFIG_FILE=$(RAD_CONFIG_FILE)) +endif +endif +endif ENVTEST_ASSETS_DIR=$(shell pwd)/bin K8S_VERSION=1.30.* ENV_SETUP=$(GOBIN)/setup-envtest$(BINARY_EXT) @@ -50,8 +111,13 @@ GOTEST_TOOL ?= go test else # Use these options by default but allow an override via env-var GOTEST_OPTS ?= -# We need the double dash here to separate the 'gotestsum' options from the 'go test' options -GOTEST_TOOL ?= gotestsum $(GOTESTSUM_OPTS) -- +# When set, a per-target JSON timing file is emitted as $(GOTESTSUM_JSONFILE_DIR)/.jsonl. +# This avoids the file being overwritten by each sub-target in test-functional-all-*. +# Example: GOTESTSUM_JSONFILE_DIR=/tmp/timings make test-functional-all-noncloud +GOTESTSUM_JSONFILE_DIR ?= +# Recursive '=' so $@ resolves in each recipe's context. +# We need the double dash here to separate the 'gotestsum' options from the 'go test' options. +GOTEST_TOOL = gotestsum $(GOTESTSUM_OPTS)$(if $(GOTESTSUM_JSONFILE_DIR), --jsonfile=$(GOTESTSUM_JSONFILE_DIR)/$@.jsonl) -- endif .PHONY: test @@ -162,6 +228,44 @@ test-functional-samples: test-functional-samples-noncloud ## Runs all Samples fu test-functional-samples-noncloud: ## Runs Samples functional tests that do not require cloud resources CGO_ENABLED=1 $(GOTEST_TOOL) ./test/functional-portable/samples/noncloud/... -timeout ${TEST_TIMEOUT} -v -parallel 5 $(GOTEST_OPTS) +# ---------------------------------------------------------------------------- +# Local Azure functional tests +# +# These targets orchestrate an ephemeral Azure resource group, deploy the test +# fixtures (Cosmos Mongo for Test_AzureConnections), run the Test_Azure* subset +# of corerp-cloud tests against your locally-running Radius stack (make +# debug-start) using ambient `az login` credentials, and tear everything down. +# +# Prerequisites: +# - `az login` succeeded for the target subscription. +# - `make debug-start` is running (OS-process Radius). +# - Deployment Engine is running locally on :5017 (NOT in a container) so it +# can use the az CLI fallback. See debug_files/logs/de-external.marker. +# +# NOTE: AWS is intentionally out of scope for this iteration. +# ---------------------------------------------------------------------------- +AZURE_LOCAL_TESTENV := ./build/scripts/azure-local-testenv.sh + +.PHONY: test-functional-azure-local-setup +test-functional-azure-local-setup: ## Provision an ephemeral Azure RG and fixtures for local Azure functional tests. + @$(AZURE_LOCAL_TESTENV) setup + +.PHONY: test-functional-azure-local-run +test-functional-azure-local-run: ## Run Test_Azure* against the locally-running Radius stack using the env from setup. + @$(AZURE_LOCAL_TESTENV) run + +.PHONY: test-functional-azure-local-teardown +test-functional-azure-local-teardown: ## Delete the ephemeral Azure RG and clear local Azure test state. + @$(AZURE_LOCAL_TESTENV) teardown + +.PHONY: test-functional-azure-local +test-functional-azure-local: ## Setup -> run Test_Azure* -> teardown (teardown runs even on test failure). + @$(AZURE_LOCAL_TESTENV) all + +.PHONY: test-functional-azure-local-keep +test-functional-azure-local-keep: ## Same as test-functional-azure-local but skips teardown on failure (post-mortem). + @AZURE_LOCAL_KEEP_ON_FAILURE=1 $(AZURE_LOCAL_TESTENV) all + .PHONY: test-validate-bicep test-validate-bicep: ## Validates that all .bicep files compile cleanly BICEP_PATH="${HOME}/.rad/bin/bicep" ./build/validate-bicep.sh diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index 26712db09da..fc6d0667be0 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -32,9 +32,12 @@ import ( "github.com/radius-project/radius/pkg/cli/bicep" "github.com/radius-project/radius/pkg/cli/clierrors" app_delete "github.com/radius-project/radius/pkg/cli/cmd/app/delete" + app_delete_preview "github.com/radius-project/radius/pkg/cli/cmd/app/delete/preview" app_graph "github.com/radius-project/radius/pkg/cli/cmd/app/graph" app_list "github.com/radius-project/radius/pkg/cli/cmd/app/list" + app_list_preview "github.com/radius-project/radius/pkg/cli/cmd/app/list/preview" app_show "github.com/radius-project/radius/pkg/cli/cmd/app/show" + app_show_preview "github.com/radius-project/radius/pkg/cli/cmd/app/show/preview" app_status "github.com/radius-project/radius/pkg/cli/cmd/app/status" bicep_generate_kubernetes_manifest "github.com/radius-project/radius/pkg/cli/cmd/bicep/generatekubernetesmanifest" bicep_publish "github.com/radius-project/radius/pkg/cli/cmd/bicep/publish" @@ -400,12 +403,18 @@ func initSubCommands() { workspaceCmd.AddCommand(workspaceSwitchCmd) appDeleteCmd, _ := app_delete.NewCommand(framework) + previewAppDeleteCmd, _ := app_delete_preview.NewCommand(framework) + wirePreviewSubcommand(appDeleteCmd, previewAppDeleteCmd) applicationCmd.AddCommand(appDeleteCmd) appListCmd, _ := app_list.NewCommand(framework) + previewAppListCmd, _ := app_list_preview.NewCommand(framework) + wirePreviewSubcommand(appListCmd, previewAppListCmd) applicationCmd.AddCommand(appListCmd) appShowCmd, _ := app_show.NewCommand(framework) + previewAppShowCmd, _ := app_show_preview.NewCommand(framework) + wirePreviewSubcommand(appShowCmd, previewAppShowCmd) applicationCmd.AddCommand(appShowCmd) appStatusCmd, _ := app_status.NewCommand(framework) diff --git a/deploy/Chart/templates/controller/configmaps.yaml b/deploy/Chart/templates/controller/configmaps.yaml index 281a05404e4..d2dcd51e7d2 100644 --- a/deploy/Chart/templates/controller/configmaps.yaml +++ b/deploy/Chart/templates/controller/configmaps.yaml @@ -53,7 +53,6 @@ data: tracerProvider: enabled: true serviceName: "controller" - zipkin: + zipkin: url: {{ .Values.global.zipkin.url }} {{- end }} - \ No newline at end of file diff --git a/deploy/Chart/templates/controller/deployment.yaml b/deploy/Chart/templates/controller/deployment.yaml index ceae6bea950..47c8f6f2912 100644 --- a/deploy/Chart/templates/controller/deployment.yaml +++ b/deploy/Chart/templates/controller/deployment.yaml @@ -59,8 +59,8 @@ spec: containers: - name: controller image: "{{ include "radius.image" (dict "image" .Values.controller.image "tag" (.Values.controller.tag | default .Values.global.imageTag | default $appversion) "global" .Values.global) }}" - imagePullPolicy: 'Always' - args: + imagePullPolicy: {{ .Values.controller.imagePullPolicy | default "Always" | quote }} + args: - '--config-file' - '/etc/config/controller-config.yaml' env: @@ -132,4 +132,4 @@ spec: {{- if .Values.global.appendRootCA.cert }} - name: ssl-certs emptyDir: {} - {{- end }} \ No newline at end of file + {{- end }} diff --git a/deploy/Chart/templates/controller/rbac.yaml b/deploy/Chart/templates/controller/rbac.yaml index 27f929beb4b..113691dd359 100644 --- a/deploy/Chart/templates/controller/rbac.yaml +++ b/deploy/Chart/templates/controller/rbac.yaml @@ -93,4 +93,4 @@ roleRef: subjects: - kind: ServiceAccount name: controller - namespace: {{ .Release.Namespace }} \ No newline at end of file + namespace: {{ .Release.Namespace }} diff --git a/deploy/Chart/templates/controller/service.yaml b/deploy/Chart/templates/controller/service.yaml index 90883b2c436..a3702063b68 100644 --- a/deploy/Chart/templates/controller/service.yaml +++ b/deploy/Chart/templates/controller/service.yaml @@ -13,4 +13,4 @@ spec: protocol: TCP targetPort: 9443 selector: - app.kubernetes.io/name: controller + app.kubernetes.io/name: controller diff --git a/deploy/Chart/templates/controller/validating-webhook-configuration.yaml b/deploy/Chart/templates/controller/validating-webhook-configuration.yaml index 5aa78833a9e..75fd766b3f6 100644 --- a/deploy/Chart/templates/controller/validating-webhook-configuration.yaml +++ b/deploy/Chart/templates/controller/validating-webhook-configuration.yaml @@ -44,4 +44,4 @@ webhooks: - UPDATE resources: - recipes - sideEffects: None \ No newline at end of file + sideEffects: None diff --git a/deploy/Chart/templates/rp/annotation-protection-admission.yaml b/deploy/Chart/templates/rp/annotation-protection-admission.yaml new file mode 100644 index 00000000000..f68e9fd4e57 --- /dev/null +++ b/deploy/Chart/templates/rp/annotation-protection-admission.yaml @@ -0,0 +1,41 @@ +{{- if .Values.rp.security.annotationProtection.enabled }} +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: radius-rp-annotation-protection + labels: + app.kubernetes.io/name: applications-rp + app.kubernetes.io/part-of: radius +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["UPDATE"] + resources: ["deployments"] + validations: + - expression: >- + request.userInfo.username == "system:serviceaccount:{{ .Release.Namespace }}:{{ .Values.rp.security.annotationProtection.allowedServiceAccountName }}" || + ( + (!has(oldObject.metadata.annotations) || !oldObject.metadata.annotations.exists(k, k == "radapp.io/status")) && + (!has(object.metadata.annotations) || !object.metadata.annotations.exists(k, k == "radapp.io/status")) + ) || + ( + has(oldObject.metadata.annotations) && oldObject.metadata.annotations.exists(k, k == "radapp.io/status") && + has(object.metadata.annotations) && object.metadata.annotations.exists(k, k == "radapp.io/status") && + object.metadata.annotations["radapp.io/status"] == oldObject.metadata.annotations["radapp.io/status"] + ) + message: "modifying annotation radapp.io/status is restricted to the Radius controller service account system:serviceaccount:{{ .Release.Namespace }}:{{ .Values.rp.security.annotationProtection.allowedServiceAccountName }}" +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: radius-rp-annotation-protection-binding + labels: + app.kubernetes.io/name: applications-rp + app.kubernetes.io/part-of: radius +spec: + policyName: radius-rp-annotation-protection + validationActions: [Deny] +{{- end }} diff --git a/deploy/Chart/templates/rp/configmaps.yaml b/deploy/Chart/templates/rp/configmaps.yaml index 78aeb8ac984..c8e2e5e3ebd 100644 --- a/deploy/Chart/templates/rp/configmaps.yaml +++ b/deploy/Chart/templates/rp/configmaps.yaml @@ -50,7 +50,7 @@ data: tracerProvider: enabled: true serviceName: "applications-rp" - zipkin: + zipkin: url: {{ .Values.global.zipkin.url }} {{- end }} bicep: diff --git a/deploy/Chart/templates/rp/deployment.yaml b/deploy/Chart/templates/rp/deployment.yaml index c015d9c2d7a..142f65fe0e2 100644 --- a/deploy/Chart/templates/rp/deployment.yaml +++ b/deploy/Chart/templates/rp/deployment.yaml @@ -64,11 +64,11 @@ spec: echo "Terraform init container starting..." echo "Running on architecture: $(uname -m)" echo "Alpine version: $(cat /etc/alpine-release)" - + # Create terraform directory mkdir -p "{{ .Values.rp.terraform.path }}" echo "Created directory: {{ .Values.rp.terraform.path }}" - + # Detect architecture for terraform download ARCH=$(uname -m) case $ARCH in @@ -77,16 +77,16 @@ spec: *) echo "ERROR: Unsupported architecture: $ARCH"; exit 1 ;; esac echo "Terraform architecture: $TERRAFORM_ARCH" - + # Install wget and unzip if not available (Alpine doesn't include them by default) if ! which wget >/dev/null 2>&1 || ! which unzip >/dev/null 2>&1; then echo "Installing wget and unzip..." apk add --no-cache wget unzip || { echo "ERROR: Failed to install wget/unzip"; exit 3; } fi - + # Determine download URL TERRAFORM_URL="{{ .Values.global.terraform.downloadUrl }}" - + # If no custom URL provided, fetch latest version from HashiCorp API if [[ -z "$TERRAFORM_URL" ]]; then echo "Fetching latest Terraform version from HashiCorp API..." @@ -98,36 +98,36 @@ spec: echo "Latest Terraform version: $LATEST_VERSION" TERRAFORM_URL="https://releases.hashicorp.com/terraform/${LATEST_VERSION}/terraform_${LATEST_VERSION}_linux_${TERRAFORM_ARCH}.zip" fi - + echo "Download URL: $TERRAFORM_URL" - + # Basic connectivity and environment info echo "Environment debug info:" echo "- Date: $(date)" echo "- PWD: $(pwd)" echo "- Available commands: $(which wget || echo 'no wget') $(which unzip || echo 'no unzip')" - + # Install wget and unzip if not available (Alpine doesn't include them by default) if ! which wget >/dev/null 2>&1 || ! which unzip >/dev/null 2>&1; then echo "Installing wget and unzip..." apk add --no-cache wget unzip || { echo "ERROR: Failed to install wget/unzip"; exit 3; } fi - + # Download and extract terraform using wget cd /tmp echo "Downloading terraform using wget..." wget "${TERRAFORM_URL}" -O terraform.zip || { echo "ERROR: Failed to download terraform"; exit 4; } - + echo "Extracting terraform using unzip..." unzip terraform.zip || { echo "ERROR: Failed to extract terraform"; exit 5; } - + echo "Installing terraform binary..." cp terraform "{{ .Values.rp.terraform.path }}/terraform" || { echo "ERROR: Failed to copy terraform"; exit 6; } chmod +x "{{ .Values.rp.terraform.path }}/terraform" || { echo "ERROR: Failed to make terraform executable"; exit 7; } - + # Create marker file to indicate pre-mounted binary is available echo "pre-mounted" > "{{ .Values.rp.terraform.path }}/.terraform-source" - + echo "Terraform binary successfully pre-downloaded and installed" volumeMounts: - name: terraform diff --git a/deploy/Chart/values.yaml b/deploy/Chart/values.yaml index e44a57783da..c3f79810715 100644 --- a/deploy/Chart/values.yaml +++ b/deploy/Chart/values.yaml @@ -85,8 +85,8 @@ controller: de: image: deployment-engine - # Pin Deployment Engine to the v0.56.0 image for the v0.57.1 patch release. - tag: 0.56 + # Default tag uses Chart AppVersion. + # tag: latest resources: requests: # request memory is the average memory usage + 10% buffer. @@ -141,6 +141,14 @@ rp: deleteRetryDelaySeconds: 60 terraform: path: "/terraform" + security: + annotationProtection: + # Enable ValidatingAdmissionPolicy to protect radapp.io/status from user tampering. + # Keep disabled by default for compatibility with clusters that do not support + # admissionregistration.k8s.io/v1 ValidatingAdmissionPolicy. + enabled: false + # Service account allowed to modify protected annotations. + allowedServiceAccountName: "applications-rp" dashboard: enabled: true diff --git a/docs/contributing/contributing-code/contributing-code-debugging/noncloud-test-learnings.md b/docs/contributing/contributing-code/contributing-code-debugging/noncloud-test-learnings.md new file mode 100644 index 00000000000..5bee7df8999 --- /dev/null +++ b/docs/contributing/contributing-code/contributing-code-debugging/noncloud-test-learnings.md @@ -0,0 +1,141 @@ +# Non-Cloud Functional Test Debug — Learnings + +Working notes captured while stabilizing +`make test-functional-corerp-noncloud` against the local OS-process Radius +debug stack (see +[radius-os-processes-debugging.md](./radius-os-processes-debugging.md)). +They cover the bugs hit, why CI didn't catch them, and follow-ups worth +picking up. The fixes themselves landed in the PR these notes shipped with; +this file is the longer-form record so the next person doesn't have to +re-derive any of it. + +## TL;DR — fixes applied + +| # | Area | File | Change | +|---|---|---|---| +| 1 | Engine panic loop | [pkg/recipes/engine/engine.go](../../../../pkg/recipes/engine/engine.go) | Guard Execute/Delete double-call; treat env 404 in `deleteCore` as no-op | +| 2 | HTTP body re-read panic | [pkg/azure/clientv2/unfold.go](../../../../pkg/azure/clientv2/unfold.go) | `readResponseBody` now restores body via `io.NopCloser(bytes.NewReader(data))` so it is idempotent | +| 3 | Test cleanup index bug | [test/rp/rptest.go](../../../../test/rp/rptest.go) | Iterate cleanup in descending order to avoid skip-on-delete | +| 4 | Validation race | [test/validation/shared.go](../../../../test/validation/shared.go) | Replaced `ListEnvironments`/`ListApplications` + linear search with direct `GetEnvironment`/`GetApplication` | +| 5 | gotestsum overwrite | [build/test.mk](../../../../build/test.mk) | New `GOTESTSUM_JSONFILE_DIR` → per-target `--jsonfile=$DIR/.jsonl` (uses recursive `=` so `$@` resolves per recipe) | +| 6 | Flux automation | [build/debug.mk](../../../../build/debug.mk) | `debug-install-flux` (idempotent, retried) wired into `debug-start` | +| 7 | Local Flux version | host | Removed brew 2.8.7, installed 2.6.4 binary to `~/bin/flux` to match CI ([.github/actions/install-flux/action.yaml](../../../../.github/actions/install-flux/action.yaml)) | +| 8 | Local OCI registry HTTP | [pkg/rp/util/registry.go](../../../../pkg/rp/util/registry.go), [build/scripts/start-radius.sh](../../../../build/scripts/start-radius.sh) | Recipe pulls force `PlainHTTP=true` when `RADIUS_INSECURE_LOOPBACK_REGISTRIES=true` and templatePath host is loopback. `start-radius.sh` exports the var so dlv-launched ARP/UCP/etc. inherit it. CI / unit tests / production are unaffected (env var unset → behavior identical to before). | +| 9 | Postgres pagination TZ bug | [pkg/components/database/postgres/postgresclient.go](../../../../pkg/components/database/postgres/postgresclient.go) | `created_at > $5::TIMESTAMP` → `::TIMESTAMPTZ`. The pagination token round-trips a UTC RFC3339Nano string; casting to naive `TIMESTAMP` made postgres reinterpret it in the session's local timezone, shifting the boundary by the local UTC offset and dropping rows from page N+1. Symptom: `Applications.Core/containers` LIST returned 10/12 → `getGraph` saw empty resources → `Test_ApplicationGraph` failed. Regression test in [postgresclient_test.go](../../../../pkg/components/database/postgres/postgresclient_test.go) forces `timezone=America/Los_Angeles` on the pool. | + +## Root causes + +### 1. Engine panic loop (was the headline bug) + +- `engine.Execute`/`Delete` re-entered the same code path on retry after a transient error, dereferencing a nil `definition` pointer at `engine.go:124`. +- Queue worker recovered the panic and re-dequeued the message → loop. +- Symptom externally: test packages taking 644s of "doing nothing" while ARP log filled with `recovering from panic`. +- Fix: short-circuit when `definition == nil` and convert env-not-found 404 in `deleteCore` to a successful no-op. + +### 2. HTTP body re-read panic in Azure client + +- `readResponseBody` consumed `resp.Body` but did not put it back. A downstream handler tried to read again → nil deref. +- Fix: after `io.ReadAll`, set `resp.Body = io.NopCloser(bytes.NewReader(data))`. Makes the helper idempotent. + +### 3. Validation race (read-after-write) + +- `ValidateRPResources` previously paginated `ListByScope` and linear-searched for the just-deployed resource. +- `rad deploy` returns "Deployment Complete" as soon as the LRO terminal status is written, but `ListByScope` reads can lag by a few ms on a long-lived local debug env (UCP store eventual visibility). +- One-shot assertion → flaky `application X was not found`. +- Why CI doesn't see it: each CI job uses a freshly-initialized env, so the list page is small/cold and consistency wins the race. +- Fix: direct `GetEnvironment`/`GetApplication` by name — same UCP node, read-after-write consistent on the resource ID. + +### 4. Cleanup descending-index + +- Cleanup was `for i := range resources { delete(resources[i]) }`, then mutating the slice on success → skipped neighbors. +- Fix: iterate `for i := len(resources)-1; i >= 0; i--`. + +### 5. gotestsum `--jsonfile` overwrite + +- `GOTESTSUM_OPTS` was a single shared string; every sub-target wrote to the same path → only the last package's JSON survived. +- Fix in [build/test.mk](../../../../build/test.mk): + ```make + GOTESTSUM_JSONFILE_DIR ?= + GOTEST_TOOL = gotestsum $(GOTESTSUM_OPTS)$(if $(GOTESTSUM_JSONFILE_DIR), --jsonfile=$(GOTESTSUM_JSONFILE_DIR)/$@.jsonl) -- + ``` + Recursive `=` (not `?=`) is **required** so `$@` is expanded in each recipe's context. +- Usage: `GOTESTSUM_JSONFILE_DIR=/tmp/timings make test-functional-all-noncloud`. + +### 6. Flux version mismatch + +- `brew install fluxcd/tap/flux` pulls 2.8.7; CI pins 2.6.4 via `.github/actions/install-flux/install-flux.sh`. +- 2.8.7's `flux install` shipped different CRD manifests that broke `Test_Flux_Basic` against source-controller. +- Fix: uninstalled brew flux; downloaded `flux_2.6.4_darwin_arm64.tar.gz` to `~/bin/flux`; reinstalled `source-controller` in `flux-system` with the matching version. + +### 7. Reproducibility: `debug-install-flux` + +- `make debug-start` now installs the right Flux version into the k3d cluster automatically with retries (`DEBUG_FLUX_VERSION ?= 2.6.4`, `DEBUG_FLUX_NAMESPACE ?= flux-system`). + +## Remaining known failures (not fixed; tracked separately) + +| Test(s) | Failure mode | Likely cause | +|---|---|---| +| `Test_CommunicationCycle`, `Test_RedeployWith*` (mechanics) | Hang ≥1h on `rad deploy` | net/http `persistConn` waiting; suspected ARP/recipe deadlock after a previous resource is left in `Updating` state | +| `Test_Run_Logger` | Hang | `rad run --application` is long-lived by design; test lacks a timeout | +| `Test_DynamicRP_Recipe` family | `BCP053: type "userTypeAlphaProperties" does not contain property "port"` | Local `debug-env-init` registers user-defined types differently than CI's `resource-types-contrib` loader | + +## Make / build lessons + +- **`=` vs `?=` for `$@`**: a variable used inside a recipe that needs the current target must be `=` (recursive) or `:=` (simple, evaluated at definition time — wrong here). `?=` only sets a default but is still recursive — works too, just confusing. +- **Lazy expansion**: `$(if $(VAR), ...)` lets the same `GOTEST_TOOL` line behave correctly whether or not `GOTESTSUM_JSONFILE_DIR` is set, without duplicating the rule. + +## Go / runtime lessons + +- Any helper that consumes an `io.ReadCloser` from an HTTP response **must** restore it if the caller may read it again. Pattern: + ```go + data, err := io.ReadAll(resp.Body) + if err != nil { return nil, err } + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(data)) + ``` +- Panic recovery in a queue worker is dangerous without a poison-message break-out. The same message will be redelivered forever; pair recovery with a `dequeueCount` cap. +- `require.True(t, found, ...)` against a list result is a flake magnet; prefer direct `Get` with a 404-tolerant `require.NoErrorf` for membership checks. + +## Test-suite triage tips + +- Run with `GOTESTSUM_JSONFILE_DIR=/tmp/timings` then: + ```bash + jq -r 'select(.Action=="fail" and .Test and (.Test|contains("/")|not)) | "\(.Package|split("/")|last)\t\(.Test)\t\(.Elapsed)s"' /tmp/timings/*.jsonl + ``` +- ARP log noise check after a run: + ```bash + grep -c 'recovering from panic' debug_files/logs/applications-rp.log + ``` + After fixes 1–2 this should be **0**. + +## Open todos / nice-to-haves + +- Add a `dequeueCount` poison-pill threshold in the ARP async worker. +- Add a `--timeout` to `rad run` in the test harness for `Test_Run_Logger`. +- Investigate the mechanics-test deadlock with a goroutine dump capture step. +- Reconcile local `debug-env-init` with CI's resource-types-contrib so dynamicrp Bicep compiles locally. +- **Make namespaces explicit in every functional test.** Today many tests rely on the *implicit* default — ARP composes `-` and several tests deploy into the shared `default` env / let the workspace's default namespace win. This causes cross-test bleed in long-lived local stacks (etcd/postgres persist across `debug-start`) and silently joins names >63 chars in some combinations. Audit + fix: every `Applications.Core/environments@*` block in `test/**/testdata/*.bicep` must declare a unique `compute.namespace`, and every `Applications.Core/applications@*` block must declare a `kubernetesNamespace` extension. Add a guardrail in `test/rp/rptest.go` (or extend `testbicep_scan_test.go`) that refuses test fixtures without both. +- ~~Investigate `Test_ApplicationGraph` returning an empty graph despite a successful deploy~~ — **resolved** by fix #9 (postgres `TIMESTAMPTZ` pagination). Was unrelated to the implicit-namespace issue. + +## Why the postgres pagination bug never tripped CI + +Two conditions had to coincide: + +1. **Non-UTC postgres session.** CI runs the postgres container with default `TZ=UTC`; local macOS host postgres inherits `America/Los_Angeles`. In UTC the naive `::TIMESTAMP` cast lands at the same instant as `::TIMESTAMPTZ` — drift is zero. +2. **List crosses page boundary.** The handler default is `top=10`. CI uses fresh k3d clusters per job and rarely accumulates >10 of any one resource type. Local debug stacks persist data across `debug-start` runs, so the container count grew to 33 and every list paginated. + +Reproducer (live session): `SET TIME ZONE 'America/Los_Angeles'; SELECT count(*) ... created_at > ''::TIMESTAMP` → 0 rows; same query with `::TIMESTAMPTZ` → 23 rows. + +## Functional test timing observations (fast-follow) + +From `GOTESTSUM_JSONFILE_DIR=/tmp/timings make test-functional-corerp-noncloud` after all fixes (48 pass, 2 skip, 0 fail, **wall = 2m 24s**, sum-elapsed = 21m 38s → ~9× compression from parallelism): + +- **`resources` package is the long pole.** 41/48 tests, 133s wall (mechanics finishes in 56s). Splitting it into two binaries (recipes vs containers/gateways) would let a second parallel package soak up half the load and meaningfully cut wall-clock on local *and* CI. +- **Container/gateway tests cluster at ~25s.** A dozen sit in the 25–26s band — looks like a flat floor from synchronous pod-readiness polling rather than test work. Profiling one of these (e.g. shorter readiness backoff or earlier-exit on `Ready` condition) would chop seconds off many tests at once. +- **`Test_ContainerVersioning` is the single slowest at 62.7s** — 2× the median. Worth a focused look; likely sequential deploys that could overlap. +- **Three `Test_Redeploy*` mechanics tests all hit 54.5s and finish within 3ms of each other** — confirming `t.Parallel()` is working; wall-clock cost is one redeploy, not three. Don't "optimize" by serializing. +- The recently-fixed `Test_ApplicationGraph` ran in 26.9s — back to the herd, no retry/backoff cost. +- One-liner for future runs: + ```bash + jq -r 'select(.Action=="pass" and .Test and (.Test|contains("/")|not)) | "\(.Elapsed)\t\(.Package|split("/")|last)\t\(.Test)"' /tmp/timings/*.jsonl | sort -rn | head -20 + ``` diff --git a/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md b/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md index 5cc938ddbcb..7fff4451684 100644 --- a/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md +++ b/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md @@ -7,7 +7,8 @@ Run Radius components as OS processes with full debugger support - set breakpoin 1. [Quick Start](#quick-start) 2. [Prerequisites](#prerequisites) 3. [Debugging Workflow](#debugging-workflow) -4. [Troubleshooting](#troubleshooting) +4. [Using a Local Deployment Engine](#using-a-local-deployment-engine) +5. [Troubleshooting](#troubleshooting) ## Overview @@ -235,6 +236,190 @@ make debug-logs # Tail all component logs make debug-help # Show all debug commands ``` +## Using a Local Deployment Engine + +By default `make debug-start` runs the Deployment Engine (DE) inside the k3d +cluster from the published `ghcr.io/radius-project/deployment-engine:latest` +image. If you are working on DE itself you can run it as a local OS process +instead and have the debug stack pick it up automatically. + +### Auto-detection + +`make debug-start` checks whether something is already listening on TCP +**port 5017** (`lsof -nP -iTCP:5017 -sTCP:LISTEN`). If it is, the in-cluster DE +deployment is skipped and a marker file is written to +`debug_files/logs/de-external.marker`. `make debug-stop` honours the marker and +leaves your local DE process alone. + +There is nothing else to configure on the Radius side — UCP/Applications RP +will reach DE at `http://localhost:5017` and DE will reach UCP at +`http://localhost:9000/apis/api.ucp.dev/v1alpha3`. + +### Running DE locally + +From your `deployment-engine` checkout, in a shell where you want DE attached +to a debugger or just running with hot-reload: + +```bash +# UCP endpoint exposed by `make debug-start` +export RADIUSBACKENDURL=http://localhost:9000/apis/api.ucp.dev/v1alpha3 +export ASPNETCORE_URLS=http://+:5017 + +# Provider toggles that the in-cluster DE config sets by default +export AZURE_ENABLED=true +export AWS_ENABLED=false +export KUBERNETES_ENABLED=true + +# IMPORTANT: do NOT set ARM_AUTH_METHOD when you want DE to use ambient +# Azure credentials (az CLI / DefaultAzureCredential). Setting it to +# UCPCredential forces DE to fetch credentials from UCP, which is the +# correct value when DE runs in-cluster but defeats the local path. +unset ARM_AUTH_METHOD SKIP_ARM + +dotnet run --project src/DeploymentEngine +``` + +Then start (or restart) the rest of the stack: + +```bash +make debug-start +# Output will include: +# ℹ Detected Deployment Engine listening on localhost:5017 — using external instance +``` + +### Switching back to the in-cluster DE + +Stop your local DE process so port 5017 is free, then: + +```bash +rm -f debug_files/logs/de-external.marker +make debug-stop +make debug-start +``` + +### Running Azure functional tests against the local stack + +When DE is running locally with ambient `az login` credentials, you can run +the Azure subset of the cloud functional tests without registering any Azure +service principal in UCP. The test helper +`AssertCredentialExists` honours the `RADIUS_TEST_USE_LOCAL_CLOUD_CREDS` +environment variable as a local-dev escape hatch (set to `azure`, `aws`, +`azure,aws`, or `1` for all clouds). The container-DE / CI path is unchanged +and still requires `rad credential register azure …`. + +A make target orchestrates an ephemeral Azure resource group, deploys the +test fixtures (Cosmos Mongo for `Test_AzureConnections`), runs the entire +`corerp/cloud/...` suite (AWS-required tests skip automatically because +`RADIUS_TEST_USE_LOCAL_CLOUD_CREDS=azure` only covers Azure), and tears +everything down even if the tests fail: + +```bash +az login +az account set --subscription + +make debug-start # OS-process Radius, picks up local DE +make test-functional-azure-local # setup → run → teardown +``` + +Sub-targets if you want manual control: + +```bash +make test-functional-azure-local-setup # create RG, deploy fixtures +make test-functional-azure-local-run # run corerp/cloud tests against the stack +make test-functional-azure-local-teardown # delete RG, clear state +``` + +For post-mortem debugging of failing tests, use the `-keep` variant. It runs +setup → run → teardown as normal, but **skips the teardown step if any test +fails** so you can inspect the RG and the running stack: + +```bash +make test-functional-azure-local-keep +# On failure, RG is preserved. When done: +make test-functional-azure-local-teardown +``` + +The setup step creates a resource group named +`radlocal-${USER}-$(date +%s)` (tagged `creator`/`creationTime`/`purpose=radius-local-test`) +and writes state to `debug_files/logs/azure-local.env`. To reuse a long-lived +resource group instead of paying the ~3-5 minute Cosmos provisioning cost on +every run: + +```bash +AZURE_LOCAL_PREPROVISIONED_RG= make test-functional-azure-local-setup +``` + +The teardown step refuses to delete a pre-provisioned RG. + +#### Re-running individual tests + +`run` accepts arbitrary `go test` flags after the sub-command, so you can +quickly re-run one failing test without re-doing setup or teardown: + +```bash +./build/scripts/azure-local-testenv.sh run -run '^Test_TerraformRecipe_AzureResourceGroup$' -v +``` + +If `debug_files/logs/azure-local.env` was wiped (e.g. by `make debug-stop`), +`run` auto-recovers state by listing `radlocal-${USER}-*` resource groups in +the current subscription and picking the newest. It re-applies the Azure +scope on the `default` rad environment too — `make debug-start` resets the +embedded Postgres DB which clears the env's provider config. + +#### Cleaning up orphaned resource groups + +If a previous run left RGs behind (cancelled tests, lost state file, multiple +attempts), garbage-collect everything you own with one command: + +```bash +./build/scripts/azure-local-testenv.sh teardown --all-orphans +``` + +This deletes every `radlocal-${USER}-*` RG in the current subscription +(`--no-wait`), stops the `tf-module-server` port-forward, and removes the +state file. Pre-provisioned RGs (`AZURE_LOCAL_PREPROVISIONED_RG`) are not +touched. + +#### Terraform module server bootstrap + +`Test_TerraformRecipe_AzureResourceGroup` consumes a recipe served from +`http://localhost:8999`. Both `setup` and `run` call `ensure_tf_module_server` +which: + +1. Probes `http://localhost:8999/azure-rg.zip` and short-circuits if reachable. +2. Otherwise runs `make publish-test-terraform-recipes` (deploys the nginx + `tf-module-server` Deployment + Service into the + `radius-test-tf-module-server` namespace). +3. Starts a `kubectl port-forward svc/tf-module-server 8999:80` in the + background (PID stored under `debug_files/logs/tf-module-server-pf.pid`). +4. Waits for `/azure-rg.zip` to return 200. + +Teardown (and `--all-orphans`) stop the port-forward. + +#### Terraform recipes and Azure CLI credentials + +The Azure terraform provider configuration in +[`pkg/recipes/terraform/config/providers/azure.go`](../../../../pkg/recipes/terraform/config/providers/azure.go) +falls back to **`use_cli = true`** when no credential is registered with UCP +(404 from `/planes/azure/azurecloud/providers/System.Azure/credentials/default`). +This makes terraform pick up the same `az login` session the host RP process +already uses. No `rad credential register azure …` is required for local dev. + +In CI a workload-identity credential is registered as before; that path is +unchanged. + +> **Note:** AWS local-credentials and Azure MSSQL fixtures are intentionally +> out of scope for this flow. Tests that require `AZURE_MSSQL_*` env vars +> auto-skip when those vars are absent. + +> **Known limitation — Flux tests:** `Test_Flux_Basic` and `Test_Flux_Complex` +> in `test/functional-portable/kubernetes/noncloud` fail under this +> OS-process flow. The Radius `FluxController` runs on the host and fetches +> artifacts from the in-cluster Flux source-controller using the cluster-DNS +> URL `http://source-controller.flux-system.svc.cluster.local./...`, which +> the host cannot resolve. These tests require the controller to run inside +> the cluster (the standard `rad install kubernetes` path or CI). + ## Troubleshooting ### Components Won't Start diff --git a/docs/contributing/contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md b/docs/contributing/contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md index 173b15ca639..a5a98838126 100644 --- a/docs/contributing/contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md +++ b/docs/contributing/contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md @@ -46,6 +46,31 @@ Radius leverages the [Developer Certificate of Origin](https://github.com/apps/d Visual Studio Code has a setting, `git.alwaysSignOff` to automatically add a Signed-off-by line to commit messages. Search for "sign-off" in VS Code settings to find it and enable it. +## Signing your commits + +> 💡 Commit signing is **separate from** the DCO `Signed-off-by` line described above. The `Signed-off-by` line is a textual attestation, while commit signing proves that the commit was signed with the private key corresponding to your configured public key. Both are recommended. + +We require all contributors to **cryptographically sign their commits** so that they show as **Verified** on GitHub. On GitHub, **Verified** means GitHub could validate the signature and that the signing key is associated with the account. This gives reviewers and the community additional confidence in the integrity and provenance of commits, which is an important supply-chain safeguard. + +GitHub supports three types of commit signatures: GPG, SSH, and S/MIME. Pick whichever is easiest for you — SSH signing is usually the simplest if you already use an SSH key with GitHub. + +Follow the official GitHub documentation to set this up: + +- [About commit signature verification](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) +- [Telling Git about your signing key](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key) +- [Signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) +- [Displaying verification statuses for all of your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/displaying-verification-statuses-for-all-of-your-commits) + +Once configured, you can have Git sign every commit automatically by setting: + +```sh +git config --global commit.gpgsign true +``` + +(Use the same setting for SSH or S/MIME signing — Git will use whichever signing format you have configured.) + +After pushing, your commits should display a **Verified** badge next to them on GitHub. + ## Creating the pull request Please ensure you are contributing from a fork of the repository. If you have not set this up, please refer to the [forking guide](../../contributing-code-forks/index.md). diff --git a/docs/contributing/contributing-pull-requests/README.md b/docs/contributing/contributing-pull-requests/README.md index 10722514459..c86793332c3 100644 --- a/docs/contributing/contributing-pull-requests/README.md +++ b/docs/contributing/contributing-pull-requests/README.md @@ -68,6 +68,10 @@ Fixes: # We **squash** pull-requests as part of the merge process, which means that intermediate commits will have their messages appended. We prefer to have a single commit in the git history for each PR. +## Signing your commits + +See [Signing your commits](../contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md#signing-your-commits) in the first commit guide. + ## Automated tests Our GitHub Actions workflows will run against your pull request to validate the changes. This will run the unit tests, integration tests, and functional tests. diff --git a/go.mod b/go.mod index 4f11508fe8d..2b590ff8db0 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.16 github.com/aws/aws-sdk-go-v2/service/cloudcontrol v1.29.15 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.71.11 - github.com/aws/aws-sdk-go-v2/service/ec2 v1.302.0 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.303.0 github.com/aws/aws-sdk-go-v2/service/ecr v1.57.2 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 github.com/aws/smithy-go v1.25.1 @@ -44,10 +44,10 @@ require ( github.com/fluxcd/pkg/apis/meta v1.27.0 github.com/fluxcd/pkg/http/fetch v0.25.0 github.com/fluxcd/pkg/tar v1.2.0 - github.com/fluxcd/source-controller/api v1.8.4 + github.com/fluxcd/source-controller/api v1.8.5 github.com/getkin/kin-openapi v0.138.0 github.com/go-chi/chi/v5 v5.2.5 - github.com/go-git/go-git/v5 v5.19.0 + github.com/go-git/go-git/v5 v5.19.1 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/go-openapi/errors v0.22.7 diff --git a/go.sum b/go.sum index bb2619420b6..84d2a9d2fa8 100644 --- a/go.sum +++ b/go.sum @@ -135,8 +135,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudcontrol v1.29.15 h1:E3HjmGRKmA5R7YUzdi github.com/aws/aws-sdk-go-v2/service/cloudcontrol v1.29.15/go.mod h1:qdsQO5+urrlkcsolFWgiNQ0lpFB0UCQbTKK9j79b1Wg= github.com/aws/aws-sdk-go-v2/service/cloudformation v1.71.11 h1:gIRdzLv98ugE0nvMkub5yp4uziPFHF66ERrQ9JN+D54= github.com/aws/aws-sdk-go-v2/service/cloudformation v1.71.11/go.mod h1:BMpnKVWK+343lUuI2ZM5bm282z+p61ZK9kwRg6/wBm4= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.302.0 h1:7c0jQaj+QKYUo3pgtEm9fQIePJH6QJA3bVKIgCCLdvM= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.302.0/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.303.0 h1:qkTLlFVQDSk0tbOqn49pxZjIVY2jy3n0FBXh+PphNkk= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.303.0/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY= github.com/aws/aws-sdk-go-v2/service/ecr v1.57.2 h1:rHEW02JFJUV2/ttjzyPIvbD0YraqpyU2w6m6DfQUmdg= github.com/aws/aws-sdk-go-v2/service/ecr v1.57.2/go.mod h1:gNS8pNht4VMzPd4UtQUL3NTUQbjEPLLmb9MqmqrqsCM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= @@ -277,8 +277,8 @@ github.com/fluxcd/pkg/tar v1.2.0 h1:T6WFB5M0YRHktlrgdKNskqpdp76TVDdWTOeuWz33CFs= github.com/fluxcd/pkg/tar v1.2.0/go.mod h1:Wlalp5vIVe+BbckkKkqExKcoHAeeWJPAzwK7ONeFcS0= github.com/fluxcd/pkg/testserver v0.14.0 h1:wVv/JPY3i4OEJ8xakfriuN2oHiUyZZ+BfhC/6RCD2nI= github.com/fluxcd/pkg/testserver v0.14.0/go.mod h1:6D6/SeGl8jT8L8pb5+k+mkE5CtqV1ozC5uqXRuTEBas= -github.com/fluxcd/source-controller/api v1.8.4 h1:ZJIh7OFhjxZgsD81ahxxbu3ggA7qySIjKWbK5+PKmOI= -github.com/fluxcd/source-controller/api v1.8.4/go.mod h1:sio4t49RDx+S1etHRFAEEw8qfVuw0KKlOg8bRVlEYPM= +github.com/fluxcd/source-controller/api v1.8.5 h1:mLKc9YVMk46JCt1BQbkG6irkrpBZp95kiXh2+GYB6KQ= +github.com/fluxcd/source-controller/api v1.8.5/go.mod h1:sio4t49RDx+S1etHRFAEEw8qfVuw0KKlOg8bRVlEYPM= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -303,8 +303,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc= -github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= diff --git a/hack/bicep-types-radius/src/autorest.bicep/package.json b/hack/bicep-types-radius/src/autorest.bicep/package.json index fa572bc2921..5a9a20de841 100644 --- a/hack/bicep-types-radius/src/autorest.bicep/package.json +++ b/hack/bicep-types-radius/src/autorest.bicep/package.json @@ -19,16 +19,16 @@ }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^25.7.0", - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@types/node": "^25.9.1", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", "colors": "^1.4.0", "dir-compare": "^5.0.0", - "eslint": "^10.3.0", + "eslint": "^10.4.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-jest": "^29.15.2", "jest": "^30.4.2", - "ts-jest": "^29.4.9", + "ts-jest": "^29.4.10", "ts-node": "^10.9.2", "typescript": "^5.6.3", "bicep-types-repo": "git+https://github.com/Azure/bicep-types.git#556bf5edad58e47ca57c6ddb1af155c3bcfdc5c7" diff --git a/hack/bicep-types-radius/src/autorest.bicep/pnpm-lock.yaml b/hack/bicep-types-radius/src/autorest.bicep/pnpm-lock.yaml index d2d7ff92f4c..5c34cc8ddc4 100644 --- a/hack/bicep-types-radius/src/autorest.bicep/pnpm-lock.yaml +++ b/hack/bicep-types-radius/src/autorest.bicep/pnpm-lock.yaml @@ -28,14 +28,14 @@ importers: specifier: ^30.0.0 version: 30.0.0 '@types/node': - specifier: ^25.7.0 - version: 25.7.0 + specifier: ^25.9.1 + version: 25.9.1 '@typescript-eslint/eslint-plugin': - specifier: ^8.59.3 - version: 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3) + specifier: ^8.59.4 + version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.59.3 - version: 8.59.3(eslint@10.3.0)(typescript@5.9.3) + specifier: ^8.59.4 + version: 8.59.4(eslint@10.4.0)(typescript@5.9.3) bicep-types-repo: specifier: git+https://github.com/Azure/bicep-types.git#556bf5edad58e47ca57c6ddb1af155c3bcfdc5c7 version: https://codeload.github.com/Azure/bicep-types/tar.gz/556bf5edad58e47ca57c6ddb1af155c3bcfdc5c7 @@ -46,23 +46,23 @@ importers: specifier: ^5.0.0 version: 5.0.0 eslint: - specifier: ^10.3.0 - version: 10.3.0 + specifier: ^10.4.0 + version: 10.4.0 eslint-plugin-header: specifier: ^3.1.1 - version: 3.1.1(eslint@10.3.0) + version: 3.1.1(eslint@10.4.0) eslint-plugin-jest: specifier: ^29.15.2 - version: 29.15.2(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.15.2(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3) jest: specifier: ^30.4.2 - version: 30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + version: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) ts-jest: - specifier: ^29.4.9 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)))(typescript@5.9.3) + specifier: ^29.4.10 + version: 29.4.10(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.7.0)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) typescript: specifier: ^5.6.3 version: 5.9.3 @@ -281,8 +281,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.5.5': - resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -524,8 +524,11 @@ packages: '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - '@types/node@25.7.0': - resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} + '@types/node@25.9.0': + resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -536,16 +539,16 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.59.3': - resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.3 + '@typescript-eslint/parser': ^8.59.4 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.3': - resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -557,8 +560,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.3': - resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -567,8 +570,8 @@ packages: resolution: {integrity: sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.59.3': - resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.58.1': @@ -577,14 +580,14 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.3': - resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.3': - resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -594,8 +597,8 @@ packages: resolution: {integrity: sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.3': - resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.58.1': @@ -604,8 +607,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.59.3': - resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -617,8 +620,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.3': - resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -628,8 +631,8 @@ packages: resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.59.3': - resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.1': @@ -852,8 +855,8 @@ packages: brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1039,8 +1042,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.3.0: - resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1685,11 +1688,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -1793,8 +1791,8 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-jest@29.4.9: - resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} + ts-jest@29.4.10: + resolution: {integrity: sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -1863,8 +1861,8 @@ packages: engines: {node: '>=0.8.0'} hasBin: true - undici-types@7.21.0: - resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -1964,7 +1962,7 @@ snapshots: dependencies: '@azure-tools/async-io': 3.0.254 js-yaml: 4.1.1 - semver: 7.7.4 + semver: 7.8.0 '@azure-tools/tasks@3.0.255': {} @@ -2177,9 +2175,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0)': dependencies: - eslint: 10.3.0 + eslint: 10.4.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2192,7 +2190,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.5.5': + '@eslint/config-helpers@0.6.0': dependencies: '@eslint/core': 1.2.1 @@ -2245,13 +2243,13 @@ snapshots: '@jest/console@30.4.1': dependencies: '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 jest-message-util: 30.4.1 jest-util: 30.4.1 slash: 3.0.0 - '@jest/core@30.4.2(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3))': + '@jest/core@30.4.2(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))': dependencies: '@jest/console': 30.4.1 '@jest/pattern': 30.4.0 @@ -2259,7 +2257,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.4.0 @@ -2267,7 +2265,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@25.9.0)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 @@ -2295,7 +2293,7 @@ snapshots: dependencies: '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-mock: 30.4.1 '@jest/expect-utils@30.2.0': @@ -2317,7 +2315,7 @@ snapshots: dependencies: '@jest/types': 30.4.1 '@sinonjs/fake-timers': 15.4.0 - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-message-util: 30.4.1 jest-mock: 30.4.1 jest-util: 30.4.1 @@ -2335,12 +2333,12 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-regex-util: 30.0.1 '@jest/pattern@30.4.0': dependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-regex-util: 30.4.0 '@jest/reporters@30.4.1': @@ -2351,7 +2349,7 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -2431,7 +2429,7 @@ snapshots: '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.7.0 + '@types/node': 25.9.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -2441,7 +2439,7 @@ snapshots: '@jest/schemas': 30.4.1 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.7.0 + '@types/node': 25.9.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -2550,9 +2548,13 @@ snapshots: '@types/lodash@4.17.24': {} - '@types/node@25.7.0': + '@types/node@25.9.0': dependencies: - undici-types: 7.21.0 + undici-types: 7.24.6 + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 '@types/stack-utils@2.0.3': {} @@ -2562,15 +2564,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.3(eslint@10.3.0)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/type-utils': 8.59.3(eslint@10.3.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.3(eslint@10.3.0)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.3 - eslint: 10.3.0 + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@10.4.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 10.4.0 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2578,14 +2580,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 - eslint: 10.3.0 + eslint: 10.4.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2599,10 +2601,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.3(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.4(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -2613,26 +2615,26 @@ snapshots: '@typescript-eslint/types': 8.58.1 '@typescript-eslint/visitor-keys': 8.58.1 - '@typescript-eslint/scope-manager@8.59.3': + '@typescript-eslint/scope-manager@8.59.4': dependencies: - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 '@typescript-eslint/tsconfig-utils@8.58.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.3(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.4(eslint@10.4.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.3(eslint@10.3.0)(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@5.9.3) debug: 4.4.3 - eslint: 10.3.0 + eslint: 10.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -2640,7 +2642,7 @@ snapshots: '@typescript-eslint/types@8.58.1': {} - '@typescript-eslint/types@8.59.3': {} + '@typescript-eslint/types@8.59.4': {} '@typescript-eslint/typescript-estree@8.58.1(typescript@5.9.3)': dependencies: @@ -2657,12 +2659,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.3(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.4(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.3(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/project-service': 8.59.4(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.0 @@ -2672,24 +2674,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.1(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.1(eslint@10.4.0)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) '@typescript-eslint/scope-manager': 8.58.1 '@typescript-eslint/types': 8.58.1 '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) - eslint: 10.3.0 + eslint: 10.4.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.3(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.4(eslint@10.4.0)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) - eslint: 10.3.0 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + eslint: 10.4.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2699,9 +2701,9 @@ snapshots: '@typescript-eslint/types': 8.58.1 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.59.3': + '@typescript-eslint/visitor-keys@8.59.4': dependencies: - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/types': 8.59.4 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.1': {} @@ -2889,7 +2891,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -3003,17 +3005,17 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-header@3.1.1(eslint@10.3.0): + eslint-plugin-header@3.1.1(eslint@10.4.0): dependencies: - eslint: 10.3.0 + eslint: 10.4.0 - eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)))(typescript@5.9.3): + eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.58.1(eslint@10.3.0)(typescript@5.9.3) - eslint: 10.3.0 + '@typescript-eslint/utils': 8.58.1(eslint@10.4.0)(typescript@5.9.3) + eslint: 10.4.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3) - jest: 30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(typescript@5.9.3) + jest: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3029,12 +3031,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.3.0: + eslint@10.4.0: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.5.5 + '@eslint/config-helpers': 0.6.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.1 '@humanfs/node': 0.16.8 @@ -3297,7 +3299,7 @@ snapshots: '@jest/expect': 30.4.1 '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -3317,15 +3319,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)): + jest-cli@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)): dependencies: - '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) jest-util: 30.4.1 jest-validate: 30.4.1 yargs: 17.7.2 @@ -3336,7 +3338,39 @@ snapshots: - supports-color - ts-node - jest-config@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)): + jest-config@30.4.2(@types/node@25.9.0)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2 + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.9.0 + ts-node: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -3362,8 +3396,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 25.7.0 - ts-node: 10.9.2(@types/node@25.7.0)(typescript@5.9.3) + '@types/node': 25.9.1 + ts-node: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -3399,7 +3433,7 @@ snapshots: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-mock: 30.4.1 jest-util: 30.4.1 jest-validate: 30.4.1 @@ -3407,7 +3441,7 @@ snapshots: jest-haste-map@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -3466,13 +3500,13 @@ snapshots: jest-mock@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-util: 30.2.0 jest-mock@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-util: 30.4.1 jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): @@ -3508,7 +3542,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 @@ -3537,7 +3571,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 @@ -3584,7 +3618,7 @@ snapshots: jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -3593,7 +3627,7 @@ snapshots: jest-util@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -3612,7 +3646,7 @@ snapshots: dependencies: '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.7.0 + '@types/node': 25.9.0 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -3621,18 +3655,18 @@ snapshots: jest-worker@30.4.1: dependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 '@ungap/structured-clone': 1.3.1 jest-util: 30.4.1 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)): + jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)): dependencies: - '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) '@jest/types': 30.4.1 import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + jest-cli: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -3715,7 +3749,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.2: dependencies: @@ -3860,8 +3894,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - semver@7.8.0: {} shebang-command@2.0.0: @@ -3953,16 +3985,16 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.10(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3)) + jest: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.7.4 + semver: 7.8.0 type-fest: 4.41.0 typescript: 5.9.3 yargs-parser: 21.1.1 @@ -3973,14 +4005,14 @@ snapshots: babel-jest: 30.4.1(@babel/core@7.29.0) jest-util: 30.4.1 - ts-node@10.9.2(@types/node@25.7.0)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.7.0 + '@types/node': 25.9.1 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -4009,7 +4041,7 @@ snapshots: uglify-js@3.19.3: optional: true - undici-types@7.21.0: {} + undici-types@7.24.6: {} unrs-resolver@1.11.1: dependencies: diff --git a/hack/bicep-types-radius/src/generator/package.json b/hack/bicep-types-radius/src/generator/package.json index 723bae74451..ff1bedf0fb2 100644 --- a/hack/bicep-types-radius/src/generator/package.json +++ b/hack/bicep-types-radius/src/generator/package.json @@ -33,15 +33,15 @@ "@types/jest": "^30.0.0", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.24", - "@types/node": "^25.7.0", + "@types/node": "^25.9.1", "@types/yargs": "^17.0.35", - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", "async": "^3.2.6", "autorest": "^3.8.0", "bicep-types-repo": "git+https://github.com/Azure/bicep-types.git#556bf5edad58e47ca57c6ddb1af155c3bcfdc5c7", "colors": "^1.4.0", - "eslint": "^10.3.0", + "eslint": "^10.4.0", "eslint-plugin-header": "^3.1.1", "js-yaml": "^4.1.1", "lodash": "^4.18.1", diff --git a/hack/bicep-types-radius/src/generator/pnpm-lock.yaml b/hack/bicep-types-radius/src/generator/pnpm-lock.yaml index bc0f9207423..b9bb020bbd5 100644 --- a/hack/bicep-types-radius/src/generator/pnpm-lock.yaml +++ b/hack/bicep-types-radius/src/generator/pnpm-lock.yaml @@ -30,17 +30,17 @@ importers: specifier: ^4.17.24 version: 4.17.24 '@types/node': - specifier: ^25.7.0 - version: 25.7.0 + specifier: ^25.9.1 + version: 25.9.1 '@types/yargs': specifier: ^17.0.35 version: 17.0.35 '@typescript-eslint/eslint-plugin': - specifier: ^8.59.3 - version: 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.2))(eslint@10.3.0)(typescript@6.0.2) + specifier: ^8.59.4 + version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.2))(eslint@10.4.0)(typescript@6.0.2) '@typescript-eslint/parser': - specifier: ^8.59.3 - version: 8.59.3(eslint@10.3.0)(typescript@6.0.2) + specifier: ^8.59.4 + version: 8.59.4(eslint@10.4.0)(typescript@6.0.2) async: specifier: ^3.2.6 version: 3.2.6 @@ -54,11 +54,11 @@ importers: specifier: ^1.4.0 version: 1.4.0 eslint: - specifier: ^10.3.0 - version: 10.3.0 + specifier: ^10.4.0 + version: 10.4.0 eslint-plugin-header: specifier: ^3.1.1 - version: 3.1.1(eslint@10.3.0) + version: 3.1.1(eslint@10.4.0) js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -67,7 +67,7 @@ importers: version: 4.18.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.7.0)(typescript@6.0.2) + version: 10.9.2(@types/node@25.9.1)(typescript@6.0.2) typescript: specifier: ^6.0.2 version: 6.0.2 @@ -112,8 +112,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.5.5': - resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -238,8 +238,11 @@ packages: '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - '@types/node@25.7.0': - resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} + '@types/node@25.9.0': + resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -250,63 +253,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.59.3': - resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.3 + '@typescript-eslint/parser': ^8.59.4 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.3': - resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.3': - resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.59.3': - resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.59.3': - resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.3': - resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.59.3': - resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.59.3': - resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.3': - resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.59.3': - resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -372,8 +375,8 @@ packages: resolution: {gitHosted: true, tarball: https://codeload.github.com/Azure/bicep-types/tar.gz/556bf5edad58e47ca57c6ddb1af155c3bcfdc5c7} version: 0.0.0 - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -465,8 +468,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.3.0: - resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -800,8 +803,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.21.0: - resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -860,9 +863,9 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0)': dependencies: - eslint: 10.3.0 + eslint: 10.4.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -875,7 +878,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.5.5': + '@eslint/config-helpers@0.6.0': dependencies: '@eslint/core': 1.2.1 @@ -916,7 +919,7 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-regex-util: 30.0.1 '@jest/schemas@30.0.5': @@ -929,7 +932,7 @@ snapshots: '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.7.0 + '@types/node': 25.9.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -994,9 +997,13 @@ snapshots: '@types/lodash@4.17.24': {} - '@types/node@25.7.0': + '@types/node@25.9.0': + dependencies: + undici-types: 7.24.6 + + '@types/node@25.9.1': dependencies: - undici-types: 7.21.0 + undici-types: 7.24.6 '@types/stack-utils@2.0.3': {} @@ -1006,15 +1013,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.2))(eslint@10.3.0)(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.2))(eslint@10.4.0)(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.3(eslint@10.3.0)(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/type-utils': 8.59.3(eslint@10.3.0)(typescript@6.0.2) - '@typescript-eslint/utils': 8.59.3(eslint@10.3.0)(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.59.3 - eslint: 10.3.0 + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0)(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@10.4.0)(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 10.4.0 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) @@ -1022,56 +1029,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.3(eslint@10.3.0)(typescript@6.0.2)': + '@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.2)': dependencies: - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 - eslint: 10.3.0 + eslint: 10.4.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.3(typescript@6.0.2)': + '@typescript-eslint/project-service@8.59.4(typescript@6.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.2) - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.2) + '@typescript-eslint/types': 8.59.4 debug: 4.4.3 typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.59.3': + '@typescript-eslint/scope-manager@8.59.4': dependencies: - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 - '@typescript-eslint/tsconfig-utils@8.59.3(typescript@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.2)': dependencies: typescript: 6.0.2 - '@typescript-eslint/type-utils@8.59.3(eslint@10.3.0)(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.59.4(eslint@10.4.0)(typescript@6.0.2)': dependencies: - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.2) - '@typescript-eslint/utils': 8.59.3(eslint@10.3.0)(typescript@6.0.2) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@6.0.2) debug: 4.4.3 - eslint: 10.3.0 + eslint: 10.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.59.3': {} + '@typescript-eslint/types@8.59.4': {} - '@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.2)': dependencies: - '@typescript-eslint/project-service': 8.59.3(typescript@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.2) - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/project-service': 8.59.4(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.2) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.0 @@ -1081,20 +1088,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.3(eslint@10.3.0)(typescript@6.0.2)': + '@typescript-eslint/utils@8.59.4(eslint@10.4.0)(typescript@6.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.2) - eslint: 10.3.0 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.2) + eslint: 10.4.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.59.3': + '@typescript-eslint/visitor-keys@8.59.4': dependencies: - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/types': 8.59.4 eslint-visitor-keys: 5.0.1 acorn-jsx@5.3.2(acorn@8.16.0): @@ -1142,7 +1149,7 @@ snapshots: bicep-types-repo@https://codeload.github.com/Azure/bicep-types/tar.gz/556bf5edad58e47ca57c6ddb1af155c3bcfdc5c7: {} - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -1204,9 +1211,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-header@3.1.1(eslint@10.3.0): + eslint-plugin-header@3.1.1(eslint@10.4.0): dependencies: - eslint: 10.3.0 + eslint: 10.4.0 eslint-scope@9.1.2: dependencies: @@ -1219,12 +1226,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.3.0: + eslint@10.4.0: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.5.5 + '@eslint/config-helpers': 0.6.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.1 '@humanfs/node': 0.16.8 @@ -1374,7 +1381,7 @@ snapshots: jest-mock@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 25.7.0 + '@types/node': 25.9.0 jest-util: 30.2.0 jest-regex-util@30.0.1: {} @@ -1382,7 +1389,7 @@ snapshots: jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 25.7.0 + '@types/node': 25.9.0 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -1431,7 +1438,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimist@1.2.8: {} @@ -1525,14 +1532,14 @@ snapshots: dependencies: typescript: 6.0.2 - ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.2): + ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.7.0 + '@types/node': 25.9.1 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -1551,7 +1558,7 @@ snapshots: typescript@6.0.2: {} - undici-types@7.21.0: {} + undici-types@7.24.6: {} uri-js@4.4.1: dependencies: diff --git a/pkg/azure/clientv2/unfold.go b/pkg/azure/clientv2/unfold.go index 65bedea68c4..ef8e8476a63 100644 --- a/pkg/azure/clientv2/unfold.go +++ b/pkg/azure/clientv2/unfold.go @@ -17,6 +17,7 @@ limitations under the License. package clientv2 import ( + "bytes" "encoding/json" "errors" "fmt" @@ -201,12 +202,17 @@ func readResponseBody(resp *http.Response) ([]byte, error) { if resp.Body == nil { return []byte{}, nil } - defer resp.Body.Close() data, err := io.ReadAll(resp.Body) + // Always close the original body; if we successfully read it, replace it + // with an in-memory reader so subsequent reads return the same bytes + // (otherwise unfolding the same *azcore.ResponseError twice would yield + // an empty body and a nil ErrorDetails). + _ = resp.Body.Close() if err != nil { return nil, fmt.Errorf("error reading response body: %w", err) } + resp.Body = io.NopCloser(bytes.NewReader(data)) return data, nil } diff --git a/pkg/cli/bicep/resources.go b/pkg/cli/bicep/resources.go index b371b1ae8a1..b99e4215bfa 100644 --- a/pkg/cli/bicep/resources.go +++ b/pkg/cli/bicep/resources.go @@ -85,9 +85,9 @@ func ExtractResourceTypes(template map[string]any) []ResourceTypeEntry { } entry := ResourceTypeEntry{FullType: resourceType} - if idx := strings.Index(resourceType, "@"); idx >= 0 { - entry.Type = resourceType[:idx] - entry.APIVersion = resourceType[idx+1:] + if before, after, ok0 := strings.Cut(resourceType, "@"); ok0 { + entry.Type = before + entry.APIVersion = after } else { entry.Type = resourceType } diff --git a/pkg/cli/cmd/app/delete/preview/delete.go b/pkg/cli/cmd/app/delete/preview/delete.go new file mode 100644 index 00000000000..0267ed7c59d --- /dev/null +++ b/pkg/cli/cmd/app/delete/preview/delete.go @@ -0,0 +1,281 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clients" + generated "github.com/radius-project/radius/pkg/cli/clients_new/generated" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/connections" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" +) + +const ( + msgApplicationDeletedPreview = "Application deleted" + msgDeletingApplicationPreview = "Deleting application %s...\n" + msgDeletingResources = "Deleting %d resource(s) associated with application %s...\n" + bicepWarning = "'%v' is a Bicep filename or path and not the name of a Radius Application. Specify the name of a valid application and try again" +) + +// NewCommand creates an instance of the command and runner for the `rad app delete --preview` command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete Radius Application (preview)", + Long: `Delete application and its associated resources using the Radius.Core preview API surface.`, + Args: cobra.MaximumNArgs(1), + Example: ` +# Delete current application +rad app delete --preview + +# Delete current application and bypass confirmation prompt +rad app delete --yes --preview + +# Delete specified application +rad app delete my-app --preview +`, + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddApplicationNameFlag(cmd) + commonflags.AddConfirmationFlag(cmd) + commonflags.AddForceFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the preview `rad app delete` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + InputPrompter prompt.Interface + ConnectionFactory connections.Factory + Workspace *workspaces.Workspace + RadiusCoreClientFactory *corerpv20250801.ClientFactory + + Confirm bool + Force bool + ApplicationName string +} + +// NewRunner creates a new instance of the preview delete runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + InputPrompter: factory.GetPrompter(), + ConnectionFactory: factory.GetConnectionFactory(), + } +} + +// Validate runs validation for the preview delete command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config) + if err != nil { + return err + } + r.Workspace = workspace + + scope, err := cli.RequireScope(cmd, *r.Workspace) + if err != nil { + return err + } + r.Workspace.Scope = scope + + r.ApplicationName, err = cli.RequireApplicationArgs(cmd, args, *workspace) + if err != nil { + return err + } + + if strings.HasSuffix(r.ApplicationName, ".bicep") { + return clierrors.Message(bicepWarning, r.ApplicationName) + } + + r.Confirm, err = cmd.Flags().GetBool("yes") + if err != nil { + return err + } + + r.Force, err = cmd.Flags().GetBool("force") + if err != nil { + return err + } + + return nil +} + +// Run executes the preview delete command logic. +// +// This discovers resources owned by the application using the management client's +// resource enumeration (ownership-based via properties.application), deletes them +// in parallel, then deletes the application via the Radius.Core preview API. +func (r *Runner) Run(ctx context.Context) error { + if r.RadiusCoreClientFactory == nil { + factory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return err + } + r.RadiusCoreClientFactory = factory + } + + appClient := r.RadiusCoreClientFactory.NewApplicationsClient() + + // Check if the application exists + _, err := appClient.Get(ctx, r.ApplicationName, &corerpv20250801.ApplicationsClientGetOptions{}) + if clients.Is404Error(err) { + r.Output.LogInfo("Application '%s' does not exist or has already been deleted.", r.ApplicationName) + return nil + } else if err != nil { + return err + } + + if !r.Confirm { + promptMsg := fmt.Sprintf("Are you sure you want to delete application '%s'?", r.ApplicationName) + confirmed, err := prompt.YesOrNoPrompt(promptMsg, prompt.ConfirmNo, r.InputPrompter) + if err != nil { + return err + } + if !confirmed { + r.Output.LogInfo("Application %q NOT deleted", r.ApplicationName) + return nil + } + } + + if r.Force { + r.Output.LogInfo("WARNING: Force deleting an application. Resources in non-terminal states may leave orphaned external resources that require manual cleanup.") + } + + // Use the management client to discover and delete owned resources. + // This uses ownership-based filtering (properties.application matches our app ID) + // rather than GetGraph which returns a connectivity graph that may include shared resources. + managementClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + + // Build the fully qualified Radius.Core application ID for ownership matching + applicationID := r.Workspace.Scope + "/providers/Radius.Core/applications/" + r.ApplicationName + + resourcesList, err := listResourcesOwnedByApplication(ctx, managementClient, applicationID) + if err != nil && !clients.Is404Error(err) { + return err + } + + // Delete associated resources in parallel + if len(resourcesList) > 0 { + r.Output.LogInfo(msgDeletingResources, len(resourcesList), r.ApplicationName) + + g, groupCtx := errgroup.WithContext(ctx) + for _, resource := range resourcesList { + if resource.ID != nil && resource.Type != nil { + // Log before launching the goroutine; output.Interface implementations + // (including the MockOutput used in tests) are not guaranteed to be + // thread-safe, and ordering the log here keeps output deterministic. + r.Output.LogInfo(" Deleting %s...", *resource.ID) + resourceType := *resource.Type + resourceID := *resource.ID + g.Go(func() error { + _, err := managementClient.DeleteResource(groupCtx, resourceType, resourceID, r.Force) + if err != nil && !clients.Is404Error(err) { + return err + } + return nil + }) + } + } + + if err := g.Wait(); err != nil { + return clierrors.Message("Failed to delete resources for application '%s': %v", r.ApplicationName, err) + } + } + + // Delete the application itself via the preview API. + // Re-check for 404 in case the app was concurrently deleted during resource cleanup. + r.Output.LogInfo(msgDeletingApplicationPreview, r.ApplicationName) + + _, err = appClient.Delete(ctx, r.ApplicationName, &corerpv20250801.ApplicationsClientDeleteOptions{}) + if clients.Is404Error(err) { + r.Output.LogInfo("Application '%s' does not exist or has already been deleted.", r.ApplicationName) + return nil + } else if err != nil { + return err + } + + r.Output.LogInfo(msgApplicationDeletedPreview) + return nil +} + +// listResourcesOwnedByApplication lists resources whose properties.application field +// matches the given application ID. This is an ownership-based query that only returns +// resources explicitly owned by the application, unlike GetGraph which returns a +// connectivity graph that may include shared/environment resources. +func listResourcesOwnedByApplication(ctx context.Context, client clients.ApplicationsManagementClient, applicationID string) ([]generated.GenericResource, error) { + resourceTypesList, err := client.ListAllResourceTypesNames(ctx, "local") + if err != nil { + return nil, err + } + + var results []generated.GenericResource + for _, resourceType := range resourceTypesList { + resources, err := client.ListResourcesOfType(ctx, resourceType) + if err != nil { + return nil, err + } + + for _, resource := range resources { + if isResourceOwnedByApplication(resource, applicationID) { + results = append(results, resource) + } + } + } + + return results, nil +} + +// isResourceOwnedByApplication checks if a resource's properties.application field +// matches the given application ID (case-insensitive). +func isResourceOwnedByApplication(resource generated.GenericResource, applicationID string) bool { + obj, found := resource.Properties["application"] + if !found { + return false + } + + associatedAppID, ok := obj.(string) + if !ok || associatedAppID == "" { + return false + } + + return strings.EqualFold(associatedAppID, applicationID) +} diff --git a/pkg/cli/cmd/app/delete/preview/delete_test.go b/pkg/cli/cmd/app/delete/preview/delete_test.go new file mode 100644 index 00000000000..64d6b829bba --- /dev/null +++ b/pkg/cli/cmd/app/delete/preview/delete_test.go @@ -0,0 +1,508 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/radius-project/radius/pkg/cli/clients" + generated "github.com/radius-project/radius/pkg/cli/clients_new/generated" + "github.com/radius-project/radius/pkg/cli/connections" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/test/radcli" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + + testcases := []radcli.ValidateInput{ + { + Name: "Delete command with flag", + Input: []string{"-a", "test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "Delete command with positional arg", + Input: []string{"test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "Delete command with fallback workspace", + Input: []string{"--application", "test-app", "--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: radcli.LoadEmptyConfig(t), + }, + }, + { + Name: "Delete command with incorrect args", + Input: []string{"foo", "bar"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "Delete command with Bicep filename", + Input: []string{"app.bicep", "--yes"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +// mockManagementClientNoResources returns a mock that has no resources to delete. +func mockManagementClientNoResources(ctrl *gomock.Controller) clients.ApplicationsManagementClient { + mock := clients.NewMockApplicationsManagementClient(ctrl) + mock.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{}, nil). + AnyTimes() + return mock +} + +// mockManagementClientWithResources returns a mock that has resources owned by the app. +// The force flag controls the expected force argument on DeleteResource. +func mockManagementClientWithResources(ctrl *gomock.Controller, appID string, force bool) clients.ApplicationsManagementClient { + mock := clients.NewMockApplicationsManagementClient(ctrl) + + resourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/my-redis" + resourceType := "Applications.Datastores/redisCaches" + + mock.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mock.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{ + { + ID: &resourceID, + Type: &resourceType, + Properties: map[string]any{ + "application": appID, + }, + }, + }, nil). + Times(1) + mock.EXPECT(). + DeleteResource(gomock.Any(), resourceType, resourceID, force). + Return(true, nil). + Times(1) + return mock +} + +func Test_Run(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + t.Run("Success: application deleted (no resources)", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + mockMgmt := mockManagementClientNoResources(ctrl) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.LogOutput{ + Format: msgDeletingApplicationPreview, + Params: []any{"test-app"}, + }, + output.LogOutput{ + Format: msgApplicationDeletedPreview, + }, + }, outputSink.Writes) + }) + + t.Run("Success: application deleted with cascade resource deletion", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + mockMgmt := mockManagementClientWithResources(ctrl, appID, false) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // Verify resource count message and final deleted message are present + require.GreaterOrEqual(t, len(outputSink.Writes), 3) + + // First output should be the resource count message + firstLog, ok := outputSink.Writes[0].(output.LogOutput) + require.True(t, ok) + require.Equal(t, msgDeletingResources, firstLog.Format) + + // Last output should be the application deleted message + lastLog, ok := outputSink.Writes[len(outputSink.Writes)-1].(output.LogOutput) + require.True(t, ok) + require.Equal(t, msgApplicationDeletedPreview, lastLog.Format) + }) + + t.Run("Success: application not found (404)", func(t *testing.T) { + notFoundServer := func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *corerpv20250801.ApplicationsClientGetOptions, + ) (resp azfake.Responder[corerpv20250801.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, notFoundServer) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.LogOutput{ + Format: "Application '%s' does not exist or has already been deleted.", + Params: []any{"test-app"}, + }, + }, outputSink.Writes) + }) + + t.Run("Success: user declines confirmation", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + promptMock := prompt.NewMockInterface(ctrl) + promptMock.EXPECT(). + GetListInput([]string{prompt.ConfirmNo, prompt.ConfirmYes}, fmt.Sprintf("Are you sure you want to delete application '%s'?", "test-app")). + Return(prompt.ConfirmNo, nil). + Times(1) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + Output: outputSink, + InputPrompter: promptMock, + ApplicationName: "test-app", + Confirm: false, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.LogOutput{ + Format: "Application %q NOT deleted", + Params: []any{"test-app"}, + }, + }, outputSink.Writes) + }) + + t.Run("Success: --force passes force=true to child resource deletes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + mockMgmt := mockManagementClientWithResources(ctrl, appID, true) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + Force: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // First log should be the force warning. + require.NotEmpty(t, outputSink.Writes) + firstLog, ok := outputSink.Writes[0].(output.LogOutput) + require.True(t, ok) + require.Contains(t, firstLog.Format, "Force deleting an application") + }) + + t.Run("Failure: child resource delete failure surfaces error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + resourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/my-redis" + resourceType := "Applications.Datastores/redisCaches" + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mockMgmt.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{{ + ID: &resourceID, + Type: &resourceType, + Properties: map[string]any{"application": appID}, + }}, nil). + Times(1) + mockMgmt.EXPECT(). + DeleteResource(gomock.Any(), resourceType, resourceID, false). + Return(false, fmt.Errorf("simulated delete failure")). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to delete resources for application 'test-app'") + }) + + t.Run("Failure: ListAllResourceTypesNames failure surfaces error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return(nil, fmt.Errorf("simulated list error")). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "simulated list error") + }) + + t.Run("Success: resources owned by other applications are filtered out", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + otherAppID := workspace.Scope + "/providers/Radius.Core/applications/other-app" + ownedResourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/owned" + unrelatedResourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/unrelated" + orphanResourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/orphan" + resourceType := "Applications.Datastores/redisCaches" + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mockMgmt.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{ + // Owned by our app — should be deleted. + {ID: &ownedResourceID, Type: &resourceType, Properties: map[string]any{"application": appID}}, + // Owned by another app — must NOT be deleted. + {ID: &unrelatedResourceID, Type: &resourceType, Properties: map[string]any{"application": otherAppID}}, + // No application property — must NOT be deleted. + {ID: &orphanResourceID, Type: &resourceType, Properties: map[string]any{}}, + }, nil). + Times(1) + // Only the owned resource is deleted. + mockMgmt.EXPECT(). + DeleteResource(gomock.Any(), resourceType, ownedResourceID, false). + Return(true, nil). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + }) + + t.Run("Success: case-insensitive ownership match", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + // Resource records the application ID in a different case from the constructed ID. + ownedAppID := strings.ToUpper(workspace.Scope) + "/providers/Radius.Core/applications/TEST-APP" + resourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/my-redis" + resourceType := "Applications.Datastores/redisCaches" + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mockMgmt.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{{ + ID: &resourceID, + Type: &resourceType, + Properties: map[string]any{"application": ownedAppID}, + }}, nil). + Times(1) + mockMgmt.EXPECT(). + DeleteResource(gomock.Any(), resourceType, resourceID, false). + Return(true, nil). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + }) + + t.Run("Success: user accepts confirmation prompt", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + promptMock := prompt.NewMockInterface(ctrl) + promptMock.EXPECT(). + GetListInput([]string{prompt.ConfirmNo, prompt.ConfirmYes}, fmt.Sprintf("Are you sure you want to delete application '%s'?", "test-app")). + Return(prompt.ConfirmYes, nil). + Times(1) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + mockMgmt := mockManagementClientNoResources(ctrl) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + InputPrompter: promptMock, + ApplicationName: "test-app", + Confirm: false, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // Must reach the final "Application deleted" log. + lastLog, ok := outputSink.Writes[len(outputSink.Writes)-1].(output.LogOutput) + require.True(t, ok) + require.Equal(t, msgApplicationDeletedPreview, lastLog.Format) + }) +} diff --git a/pkg/cli/cmd/app/list/preview/list.go b/pkg/cli/cmd/app/list/preview/list.go new file mode 100644 index 00000000000..bae2ec6526a --- /dev/null +++ b/pkg/cli/cmd/app/list/preview/list.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/cmd" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" +) + +// NewCommand creates an instance of the command and runner for the `rad app list` preview command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "list", + Short: "List Radius Applications (preview)", + Long: `List Radius.Core applications using the preview API surface.`, + Args: cobra.NoArgs, + RunE: framework.RunCommand(runner), + Example: `rad app list`, + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddOutputFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the preview `rad app list` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + Format string + RadiusCoreClientFactory *corerpv20250801.ClientFactory +} + +// NewRunner creates a new instance of the preview list runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the preview list command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config) + if err != nil { + return err + } + r.Workspace = workspace + + scope, err := cli.RequireScope(cmd, *r.Workspace) + if err != nil { + return err + } + r.Workspace.Scope = scope + + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + r.Format = format + + return nil +} + +// Run executes the preview list command logic. +func (r *Runner) Run(ctx context.Context) error { + if r.RadiusCoreClientFactory == nil { + factory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return err + } + r.RadiusCoreClientFactory = factory + } + + client := r.RadiusCoreClientFactory.NewApplicationsClient() + pager := client.NewListByScopePager(&corerpv20250801.ApplicationsClientListByScopeOptions{}) + + var applications []*corerpv20250801.ApplicationResource + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return err + } + applications = append(applications, page.Value...) + } + + return r.Output.WriteFormatted(r.Format, applications, objectformats.GetResourceTableFormat()) +} diff --git a/pkg/cli/cmd/app/list/preview/list_test.go b/pkg/cli/cmd/app/list/preview/list_test.go new file mode 100644 index 00000000000..adbdcf935d3 --- /dev/null +++ b/pkg/cli/cmd/app/list/preview/list_test.go @@ -0,0 +1,198 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "net/http" + "testing" + + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/stretchr/testify/require" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/test/radcli" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + + testcases := []radcli.ValidateInput{ + { + Name: "List command with incorrect args", + Input: []string{"group"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "List command with bad workspace", + Input: []string{"-w", "doesnotexist"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "List command with valid workspace", + Input: []string{"-w", radcli.TestWorkspaceName}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "List command with fallback workspace", + Input: []string{"--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: radcli.LoadEmptyConfig(t), + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + testcases := []struct { + name string + serverFactory func() fake.ApplicationsServer + expectedOutput []any + expectError bool + }{ + { + name: "applications returned", + serverFactory: test_client_factory.WithApplicationsServerNoError, + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: []*corerpv20250801.ApplicationResource{ + {Name: new("test-app-1")}, + {Name: new("test-app-2")}, + }, + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + { + name: "empty list", + serverFactory: func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + NewListByScopePager: func(_ *corerpv20250801.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[corerpv20250801.ApplicationsClientListByScopeResponse]) { + resp.AddPage(http.StatusOK, corerpv20250801.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: corerpv20250801.ApplicationResourceListResult{ + Value: []*corerpv20250801.ApplicationResource{}, + }, + }, nil) + return + }, + } + }, + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: []*corerpv20250801.ApplicationResource(nil), + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + { + name: "multi-page list", + serverFactory: func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + NewListByScopePager: func(_ *corerpv20250801.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[corerpv20250801.ApplicationsClientListByScopeResponse]) { + resp.AddPage(http.StatusOK, corerpv20250801.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: corerpv20250801.ApplicationResourceListResult{ + Value: []*corerpv20250801.ApplicationResource{{Name: new("page1-a")}, {Name: new("page1-b")}}, + }, + }, nil) + resp.AddPage(http.StatusOK, corerpv20250801.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: corerpv20250801.ApplicationResourceListResult{ + Value: []*corerpv20250801.ApplicationResource{{Name: new("page2-a")}}, + }, + }, nil) + return + }, + } + }, + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: []*corerpv20250801.ApplicationResource{ + {Name: new("page1-a")}, + {Name: new("page1-b")}, + {Name: new("page2-a")}, + }, + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + { + name: "pager error surfaces", + serverFactory: func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + NewListByScopePager: func(_ *corerpv20250801.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[corerpv20250801.ApplicationsClientListByScopeResponse]) { + resp.AddResponseError(http.StatusInternalServerError, "InternalServerError") + return + }, + } + }, + expectError: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, tc.serverFactory) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + if tc.expectError { + require.Error(t, err) + require.Empty(t, outputSink.Writes) + return + } + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, outputSink.Writes) + }) + } +} diff --git a/pkg/cli/cmd/app/show/preview/show.go b/pkg/cli/cmd/app/show/preview/show.go new file mode 100644 index 00000000000..f1e8523c084 --- /dev/null +++ b/pkg/cli/cmd/app/show/preview/show.go @@ -0,0 +1,130 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" +) + +// NewCommand creates an instance of the command and runner for the `rad app show` preview command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "show", + Short: "Show Radius Application details (preview)", + Long: `Show Radius.Core application details using the preview API surface.`, + Args: cobra.MaximumNArgs(1), + Example: ` +# Show current application +rad app show + +# Show specified application +rad app show my-app + +# Show specified application in a specified resource group +rad app show my-app --group my-group +`, + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddApplicationNameFlag(cmd) + commonflags.AddOutputFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the `rad app show` preview command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + ApplicationName string + Format string + RadiusCoreClientFactory *corerpv20250801.ClientFactory +} + +// NewRunner creates a new instance of the `rad app show` preview runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad app show` preview command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config) + if err != nil { + return err + } + r.Workspace = workspace + + r.ApplicationName, err = cli.RequireApplicationArgs(cmd, args, *workspace) + if err != nil { + return err + } + + r.Workspace.Scope, err = cli.RequireScope(cmd, *r.Workspace) + if err != nil { + return err + } + + r.Format, err = cli.RequireOutput(cmd) + if err != nil { + return err + } + + return nil +} + +// Run runs the `rad app show` preview command. +func (r *Runner) Run(ctx context.Context) error { + if r.RadiusCoreClientFactory == nil { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return err + } + r.RadiusCoreClientFactory = clientFactory + } + + client := r.RadiusCoreClientFactory.NewApplicationsClient() + + resp, err := client.Get(ctx, r.ApplicationName, &corerpv20250801.ApplicationsClientGetOptions{}) + if clients.Is404Error(err) { + return clierrors.Message("The application %q was not found or has been deleted.", r.ApplicationName) + } else if err != nil { + return err + } + + return r.Output.WriteFormatted(r.Format, resp.ApplicationResource, objectformats.GetResourceTableFormat()) +} diff --git a/pkg/cli/cmd/app/show/preview/show_test.go b/pkg/cli/cmd/app/show/preview/show_test.go new file mode 100644 index 00000000000..9dac08e3601 --- /dev/null +++ b/pkg/cli/cmd/app/show/preview/show_test.go @@ -0,0 +1,223 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "net/http" + "testing" + + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/stretchr/testify/require" + + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/test/radcli" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + + testcases := []radcli.ValidateInput{ + { + Name: "Show Command with flag", + Input: []string{"-a", "test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Show Command with positional arg", + Input: []string{"test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Show Command with fallback workspace", + Input: []string{"--application", "test-app", "--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: radcli.LoadEmptyConfig(t), + }, + }, + { + Name: "Show Command with incorrect args", + Input: []string{"foo", "bar"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + testcases := []struct { + name string + appFactory func() fake.ApplicationsServer + applicationName string + expectedOutput []any + }{ + { + name: "application found", + appFactory: test_client_factory.WithApplicationsServerNoError, + applicationName: "test-app", + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: corerpv20250801.ApplicationResource{ + Name: new("test-app"), + }, + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, tc.appFactory) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: tc.applicationName, + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, outputSink.Writes) + }) + } + + t.Run("Error: application not found (404)", func(t *testing.T) { + notFoundServer := func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *corerpv20250801.ApplicationsClientGetOptions, + ) (resp azfake.Responder[corerpv20250801.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, notFoundServer) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: "test-app", + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + require.Equal(t, clierrors.Message("The application %q was not found or has been deleted.", "test-app"), err) + require.Empty(t, outputSink.Writes) + }) + + t.Run("Error: non-404 error is propagated", func(t *testing.T) { + serverError := func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *corerpv20250801.ApplicationsClientGetOptions, + ) (resp azfake.Responder[corerpv20250801.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetResponseError(http.StatusInternalServerError, "InternalServerError") + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, serverError) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: "test-app", + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + // Should NOT be the 404 user-facing message. + require.NotEqual(t, clierrors.Message("The application %q was not found or has been deleted.", "test-app"), err) + require.Empty(t, outputSink.Writes) + }) + + t.Run("Success: json output format", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: "test-app", + Format: "json", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.FormattedOutput{ + Format: "json", + Obj: corerpv20250801.ApplicationResource{Name: new("test-app")}, + Options: objectformats.GetResourceTableFormat(), + }, + }, outputSink.Writes) + }) +} diff --git a/pkg/cli/cmd/env/create/preview/create_test.go b/pkg/cli/cmd/env/create/preview/create_test.go index a9bbfcda942..f08fece8c45 100644 --- a/pkg/cli/cmd/env/create/preview/create_test.go +++ b/pkg/cli/cmd/env/create/preview/create_test.go @@ -200,6 +200,7 @@ func Test_Run(t *testing.T) { err = runner.Run(context.Background()) require.NoError(t, err) + require.Equal(t, expectedOutput, outputSink.Writes) }) diff --git a/pkg/cli/cmd/env/update/preview/update.go b/pkg/cli/cmd/env/update/preview/update.go index 80b2e3d39e7..1202e27194b 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -228,7 +228,7 @@ func normalizeRecipePacks(recipepacks []string) []string { seen := map[string]struct{}{} result := []string{} for _, value := range recipepacks { - for _, p := range strings.Split(value, ",") { + for p := range strings.SplitSeq(value, ",") { trimmed := strings.TrimSpace(p) if trimmed == "" { continue diff --git a/pkg/cli/cmd/radinit/preview/init_test.go b/pkg/cli/cmd/radinit/preview/init_test.go index 4a6f8b6e8f1..b6a16693408 100644 --- a/pkg/cli/cmd/radinit/preview/init_test.go +++ b/pkg/cli/cmd/radinit/preview/init_test.go @@ -1150,7 +1150,7 @@ type cloudProviderPromptMatcher struct { } // Matches implements gomock.Matcher -func (*cloudProviderPromptMatcher) Matches(x interface{}) bool { +func (*cloudProviderPromptMatcher) Matches(x any) bool { return x == confirmCloudProviderPrompt || x == confirmCloudProviderAdditionalPrompt } diff --git a/pkg/cli/cmd/resource/delete/delete_test.go b/pkg/cli/cmd/resource/delete/delete_test.go index fd597677bc5..f8c29ea69c9 100644 --- a/pkg/cli/cmd/resource/delete/delete_test.go +++ b/pkg/cli/cmd/resource/delete/delete_test.go @@ -512,7 +512,7 @@ func Test_Run(t *testing.T) { appManagementClient.EXPECT(). GetResource(gomock.Any(), "Applications.Core/containers", "test-container"). Return(generated.GenericResource{ - Properties: map[string]interface{}{ + Properties: map[string]any{ "environment": "/planes/radius/local/resourceGroups/test-group/providers/Applications.Core/environments/my-test-env", "application": "/planes/radius/local/resourceGroups/test-group/providers/Applications.Core/applications/my-test-app", }, diff --git a/pkg/cli/config.go b/pkg/cli/config.go index a865588dab2..4450a6c64ff 100644 --- a/pkg/cli/config.go +++ b/pkg/cli/config.go @@ -168,6 +168,14 @@ func GetWorkspace(v *viper.Viper, name string) (*workspaces.Workspace, error) { func getConfig(configFilePath string) (*viper.Viper, error) { config := viper.New() + // Allow callers (e.g. functional tests, drad) to override the default config + // location without threading an explicit path through every call site. + if configFilePath == "" { + if envPath := os.Getenv("RAD_CONFIG_FILE"); envPath != "" { + configFilePath = envPath + } + } + if configFilePath == "" { // Set config file using the HOME directory. diff --git a/pkg/cli/test_client_factory/radius_core.go b/pkg/cli/test_client_factory/radius_core.go index 43c86098f45..de3b82ba69c 100644 --- a/pkg/cli/test_client_factory/radius_core.go +++ b/pkg/cli/test_client_factory/radius_core.go @@ -30,7 +30,8 @@ import ( ) // NewRadiusCoreTestClientFactory creates a new client factory for testing purposes. -func NewRadiusCoreTestClientFactory(rootScope string, envServer func() corerpfake.EnvironmentsServer, recipepackServer func() corerpfake.RecipePacksServer) (*v20250801preview.ClientFactory, error) { +// applicationsServer is variadic for backwards compatibility; only the first value (if any) is used. +func NewRadiusCoreTestClientFactory(rootScope string, envServer func() corerpfake.EnvironmentsServer, recipepackServer func() corerpfake.RecipePacksServer, applicationsServer ...func() corerpfake.ApplicationsServer) (*v20250801preview.ClientFactory, error) { serverFactory := corerpfake.ServerFactory{} if envServer != nil { serverFactory.EnvironmentsServer = envServer() @@ -44,6 +45,12 @@ func NewRadiusCoreTestClientFactory(rootScope string, envServer func() corerpfak serverFactory.RecipePacksServer = WithRecipePackServerNoError() } + if len(applicationsServer) > 0 && applicationsServer[0] != nil { + serverFactory.ApplicationsServer = applicationsServer[0]() + } else { + serverFactory.ApplicationsServer = WithApplicationsServerNoError() + } + serverFactoryTransport := corerpfake.NewServerFactoryTransport(&serverFactory) clientOptions := &armpolicy.ClientOptions{ @@ -171,6 +178,51 @@ func WithEnvironmentServerNoError() corerpfake.EnvironmentsServer { } } +func WithApplicationsServerNoError() corerpfake.ApplicationsServer { + return corerpfake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *v20250801preview.ApplicationsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.ApplicationsClientGetResponse{ + ApplicationResource: v20250801preview.ApplicationResource{ + Name: new(applicationName), + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + Delete: func( + ctx context.Context, + applicationName string, + options *v20250801preview.ApplicationsClientDeleteOptions, + ) (resp azfake.Responder[v20250801preview.ApplicationsClientDeleteResponse], errResp azfake.ErrorResponder) { + resp.SetResponse(http.StatusNoContent, v20250801preview.ApplicationsClientDeleteResponse{}, nil) + return + }, + NewListByScopePager: func(options *v20250801preview.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[v20250801preview.ApplicationsClientListByScopeResponse]) { + resp.AddPage( + http.StatusOK, + v20250801preview.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: v20250801preview.ApplicationResourceListResult{ + Value: []*v20250801preview.ApplicationResource{ + { + Name: new("test-app-1"), + }, + { + Name: new("test-app-2"), + }, + }, + }, + }, + nil, + ) + return + }, + } +} + // WithEnvironmentServer404OnGet returns an EnvironmentsServer that returns 404 on Get // and success on CreateOrUpdate, simulating a new environment creation scenario. func WithEnvironmentServer404OnGet() corerpfake.EnvironmentsServer { diff --git a/pkg/components/database/postgres/postgresclient.go b/pkg/components/database/postgres/postgresclient.go index be131440a70..0c897fa5de3 100644 --- a/pkg/components/database/postgres/postgresclient.go +++ b/pkg/components/database/postgres/postgresclient.go @@ -225,13 +225,14 @@ func (p *PostgresClient) Query(ctx context.Context, query database.Query, option // NOTE: building SQL by concatenating strings is hard to do safely and should be avoided. // If you need to work on this code MAKE SURE you use SQL parameters // for any user input. + // Cast pagination token to TIMESTAMPTZ to match created_at; TIMESTAMP would be reinterpreted in the session timezone and skip rows. sql := ` SELECT original_id, etag, resource_data, created_at FROM resources WHERE ((root_scope = $1) OR ($2 AND (root_scope LIKE $1 || '%'))) AND resource_type = $3 AND ((routing_scope LIKE $4 || '%') OR $4 IS NULL) AND - (created_at > $5::TIMESTAMP OR $5 IS NULL) + (created_at > $5::TIMESTAMPTZ OR $5 IS NULL) ORDER BY created_at ASC LIMIT $6` diff --git a/pkg/components/database/postgres/postgresclient_test.go b/pkg/components/database/postgres/postgresclient_test.go index 45b749f27e9..e09a00ca7d6 100644 --- a/pkg/components/database/postgres/postgresclient_test.go +++ b/pkg/components/database/postgres/postgresclient_test.go @@ -18,6 +18,7 @@ package postgres import ( "context" + "fmt" "os" "testing" @@ -27,6 +28,8 @@ import ( "github.com/stretchr/testify/require" "github.com/davecgh/go-spew/spew" + "github.com/radius-project/radius/pkg/components/database" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/radius-project/radius/test/testcontext" shared "github.com/radius-project/radius/test/ucp/storetest" ) @@ -85,3 +88,69 @@ func (l *postgresLogger) QueryRow(ctx context.Context, sql string, args ...any) l.t.Logf("Args:\n%s", spew.Sdump(args...)) return l.pool.QueryRow(ctx, sql, args...) } + +// Test_PostgresClient_Pagination_NonUTC_Timezone is a regression test for a bug +// where the pagination token (RFC3339Nano UTC) was cast to TIMESTAMP rather +// than TIMESTAMPTZ in the Query SQL. Under a non-UTC session timezone the cast +// silently reinterpreted the value in the local zone, shifting the comparison +// boundary and causing page N+1 to drop rows. This manifested in production as +// LIST responses returning only the first page (e.g. Applications.Core +// containers list returning 10/12 results and hiding two from getGraph). +func Test_PostgresClient_Pagination_NonUTC_Timezone(t *testing.T) { + ctx, cancel := testcontext.NewWithCancel(t) + t.Cleanup(cancel) + + url := os.Getenv("TEST_POSTGRES_URL") + if url == "" { + t.Skip("TEST_POSTGRES_URL is not set.") + return + } + + cfg, err := pgxpool.ParseConfig(url) + require.NoError(t, err) + // Force a non-UTC session timezone to reproduce the bug. PostgreSQL applies + // this on every connection borrowed from the pool. + cfg.ConnConfig.RuntimeParams["timezone"] = "America/Los_Angeles" + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(t, err) + t.Cleanup(pool.Close) + + _, err = pool.Exec(ctx, "DELETE FROM resources") + require.NoError(t, err) + + client := NewPostgresClient(&postgresLogger{t: t, pool: pool}) + + const total = 5 + saved := make([]database.Object, 0, total) + for i := range total { + idStr := fmt.Sprintf("%s/providers/%s/page%d", shared.ResourceGroup1Scope, shared.ResourceType1, i) + id, err := resources.Parse(idStr) + require.NoError(t, err) + obj := database.Object{ + Metadata: database.Metadata{ID: id.String()}, + Data: map[string]any{"value": fmt.Sprintf("p%d", i)}, + } + require.NoError(t, client.Save(ctx, &obj)) + saved = append(saved, obj) + } + + collected := []database.Object{} + token := "" + for page := 0; page < total+1; page++ { + result, err := client.Query( + ctx, + database.Query{RootScope: shared.ResourceGroup1Scope, ResourceType: shared.ResourceType1}, + database.WithMaxQueryItemCount(2), + database.WithPaginationToken(token), + ) + require.NoError(t, err) + collected = append(collected, result.Items...) + if result.PaginationToken == "" { + break + } + token = result.PaginationToken + } + + shared.CompareObjectLists(t, saved, collected) +} diff --git a/pkg/controller/reconciler/annotations.go b/pkg/controller/reconciler/annotations.go index 7fac20887bd..2464ea0e9d7 100644 --- a/pkg/controller/reconciler/annotations.go +++ b/pkg/controller/reconciler/annotations.go @@ -24,6 +24,7 @@ import ( "strings" radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + "github.com/radius-project/radius/pkg/ucp/resources" appsv1 "k8s.io/api/apps/v1" ) @@ -115,6 +116,11 @@ func readAnnotations(deployment *appsv1.Deployment) (deploymentAnnotations, erro return result, fmt.Errorf("failed to unmarshal status annotation: %w", err) } + err = validateDeploymentStatus(&s) + if err != nil { + return result, fmt.Errorf("invalid status annotation: %w", err) + } + result.Status = &s } @@ -140,6 +146,41 @@ func readAnnotations(deployment *appsv1.Deployment) (deploymentAnnotations, erro return result, nil } +func validateDeploymentStatus(status *deploymentStatus) error { + if status == nil { + return nil + } + + // The reconciler state machine may persist status.scope (and an in-progress operation) before + // status.container is known, so scope-only status is valid. When the environment or application + // changes, status.scope is updated to the new scope while status.container still references the + // previous container (which the reconciler then deletes), so we do not require status.scope to + // match the container's root scope. + if status.Scope != "" { + if _, err := resources.ParseScope(status.Scope); err != nil { + return fmt.Errorf("invalid status.scope: %w", err) + } + } + + if status.Container == "" { + return nil + } + + if status.Scope == "" { + return fmt.Errorf("status.scope must be set when status.container is set") + } + + parsedContainer, err := resources.ParseResource(status.Container) + if err != nil { + return fmt.Errorf("invalid status.container: %w", err) + } + if !strings.EqualFold(parsedContainer.Type(), applicationsCoreContainersResourceType) { + return fmt.Errorf("status.container type %q is not %q", parsedContainer.Type(), applicationsCoreContainersResourceType) + } + + return nil +} + // ApplyToDeployment applies the configuration and status to a Deployment. // // This should be used before saving the Deployment's state. diff --git a/pkg/controller/reconciler/annotations_test.go b/pkg/controller/reconciler/annotations_test.go index 74ef34390b7..61e091c41ba 100644 --- a/pkg/controller/reconciler/annotations_test.go +++ b/pkg/controller/reconciler/annotations_test.go @@ -21,6 +21,7 @@ import ( "fmt" "testing" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -31,7 +32,7 @@ func Test_readAnnotations(t *testing.T) { Scope: "/planes/radius/local/resourceGroups/controller-test", Application: "test-application", Environment: "test-environment", - Container: "test-container", + Container: "/planes/radius/local/resourceGroups/controller-test/providers/Applications.Core/containers/test-container", Operation: nil, Phrase: deploymentPhraseReady, } @@ -43,6 +44,9 @@ func Test_readAnnotations(t *testing.T) { // so that an unmarshaling error can be triggered. invalidDeploymentStatus := []byte(`{"invalid": "json"`) + _, invalidContainerIDErr := resources.ParseResource("not-a-resource-id") + _, invalidScopeIDErr := resources.ParseScope("not-a-scope") + tests := []struct { name string deployment *appsv1.Deployment @@ -156,11 +160,101 @@ func Test_readAnnotations(t *testing.T) { err: fmt.Errorf("failed to unmarshal status annotation: %w", json.Unmarshal(invalidDeploymentStatus, &deploymentStatus{})), }, + { + name: "status-invalid-container-id", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test","container":"not-a-resource-id"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: invalid status.container: %w", invalidContainerIDErr), + }, + { + name: "status-scope-container-mismatch-allowed", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test","container":"/planes/radius/local/resourceGroups/other/providers/Applications.Core/containers/test-container"}`, + }, + }, + }, + // The reconciler intentionally produces this transitional state when the environment or + // application changes: status.scope advances to the new scope while status.container still + // references the previous container until it is deleted. + annotations: deploymentAnnotations{ + ConfigurationHash: "", + Status: &deploymentStatus{ + Scope: "/planes/radius/local/resourceGroups/controller-test", + Container: "/planes/radius/local/resourceGroups/other/providers/Applications.Core/containers/test-container", + }, + }, + err: nil, + }, + { + name: "status-container-wrong-resource-type", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test","container":"/planes/radius/local/resourceGroups/controller-test/providers/Applications.Core/applications/test-app"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: status.container type %q is not %q", "Applications.Core/applications", applicationsCoreContainersResourceType), + }, + { + name: "status-only-scope-set", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test"}`, + }, + }, + }, + annotations: deploymentAnnotations{ + ConfigurationHash: "", + Status: &deploymentStatus{ + Scope: "/planes/radius/local/resourceGroups/controller-test", + }, + }, + err: nil, + }, + { + name: "status-only-container-set", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"container":"/planes/radius/local/resourceGroups/controller-test/providers/Applications.Core/containers/test-container"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: status.scope must be set when status.container is set"), + }, + { + name: "status-invalid-scope-only", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"not-a-scope"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: invalid status.scope: %w", invalidScopeIDErr), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { annotations, err := readAnnotations(tt.deployment) - require.Equal(t, tt.err, err) + if tt.err == nil { + require.NoError(t, err) + } else { + require.EqualError(t, err, tt.err.Error()) + } require.Equal(t, tt.annotations, annotations) }) } diff --git a/pkg/controller/reconciler/const.go b/pkg/controller/reconciler/const.go index eb5104cff78..0b1189ee4f1 100644 --- a/pkg/controller/reconciler/const.go +++ b/pkg/controller/reconciler/const.go @@ -45,6 +45,16 @@ const ( // DeploymentFinalizer is the name of the finalizer added to Deployments. DeploymentFinalizer = "radapp.io/deployment-finalizer" + // EventScopeMismatch is emitted when a deployment annotation references a scope outside + // of the deployment's ownership boundary. + EventScopeMismatch = "ScopeMismatch" + + // EventContainerOwnershipMismatch is emitted when a container does not reference + // the source Kubernetes deployment resource. + EventContainerOwnershipMismatch = "ContainerOwnershipMismatch" + + applicationsCoreContainersResourceType = "Applications.Core/containers" + // RecipeFinalizer is the name of the finalizer added to Recipes. RecipeFinalizer = "radapp.io/recipe-finalizer" diff --git a/pkg/controller/reconciler/deployment_reconciler.go b/pkg/controller/reconciler/deployment_reconciler.go index a940fd2ec02..0287ecb6804 100644 --- a/pkg/controller/reconciler/deployment_reconciler.go +++ b/pkg/controller/reconciler/deployment_reconciler.go @@ -363,7 +363,7 @@ func (r *DeploymentReconciler) reconcileUpdate(ctx context.Context, deployment * func (r *DeploymentReconciler) reconcileDelete(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (ctrl.Result, error) { logger := ucplog.FromContextOrDiscard(ctx) - poller, err := r.startDeleteOperationIfNeeded(ctx, annotations) + poller, err := r.startDeleteOperationIfNeeded(ctx, deployment, annotations) if err != nil { logger.Error(err, "Unable to delete resource.") r.EventRecorder.Event(deployment, corev1.EventTypeWarning, "ResourceError", err.Error()) @@ -415,8 +415,7 @@ func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Con // the old resource and create a new one. logger.Info("Container is already created but is out-of-date") - logger.Info("Starting DELETE operation.") - poller, err := deleteContainer(ctx, r.Radius, annotations.Status.Container) + poller, err := r.startDeleteOperationIfNeeded(ctx, deployment, annotations) if err != nil { return nil, nil, false, err } else if poller != nil { @@ -480,13 +479,42 @@ func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Con return poller, nil, false, nil } -func (r *DeploymentReconciler) startDeleteOperationIfNeeded(ctx context.Context, annotations *deploymentAnnotations) (sdkclients.Poller[v20231001preview.ContainersClientDeleteResponse], error) { +func (r *DeploymentReconciler) startDeleteOperationIfNeeded(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (sdkclients.Poller[v20231001preview.ContainersClientDeleteResponse], error) { logger := ucplog.FromContextOrDiscard(ctx) + + if annotations == nil || annotations.Status == nil { + logger.Info("Container is already deleted (or was never created).") + return nil, nil + } + if annotations.Status.Container == "" { logger.Info("Container is already deleted (or was never created).") return nil, nil } + expectedDeploymentResourceID := makeKubernetesDeploymentResourceID(deployment.Namespace, deployment.Name) + container, err := fetchContainerResource(ctx, r.Radius, annotations.Status.Container) + if clients.Is404Error(err) { + logger.Info("Container was already deleted before cleanup began.", "container", annotations.Status.Container) + annotations.Status.Container = "" + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("failed to fetch container before delete: %w", err) + } + + if !containerHasResourceReference(&container, expectedDeploymentResourceID) { + logger.Info("Refusing cross-ownership delete attempt.", + "container", annotations.Status.Container, + "expectedDeploymentResource", expectedDeploymentResourceID, + ) + r.EventRecorder.Event(deployment, corev1.EventTypeWarning, EventContainerOwnershipMismatch, + fmt.Sprintf("Container %q does not reference deployment resource %q", annotations.Status.Container, expectedDeploymentResourceID)) + + // Clear status to avoid a persistent reconcile loop on a tampered annotation. + annotations.Status.Container = "" + return nil, nil + } + logger.Info("Starting DELETE operation.") poller, err := deleteContainer(ctx, r.Radius, annotations.Status.Container) if err != nil { diff --git a/pkg/controller/reconciler/deployment_reconciler_test.go b/pkg/controller/reconciler/deployment_reconciler_test.go index ee7dea01128..8d6a374d362 100644 --- a/pkg/controller/reconciler/deployment_reconciler_test.go +++ b/pkg/controller/reconciler/deployment_reconciler_test.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "net/http" "testing" "time" @@ -34,7 +35,9 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" crconfig "sigs.k8s.io/controller-runtime/pkg/config" @@ -95,6 +98,118 @@ func SetupDeploymentTest(t *testing.T) (*mockRadiusClient, client.Client) { return radius, mgr.GetClient() } +func Test_DeploymentReconciler_StartDeleteOperationIfNeeded_OwnershipMismatch_BlocksDelete(t *testing.T) { + ctx := testcontext.New(t) + radius := NewMockRadiusClient() + reconciler := &DeploymentReconciler{ + Radius: radius, + EventRecorder: record.NewFakeRecorder(10), + } + + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "current-app", Namespace: "current-namespace"}} + containerID := "/planes/radius/local/resourceGroups/tenant-b/providers/Applications.Core/containers/other-container" + + radius.Update(func() { + radius.containers[containerID] = v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: new("/planes/kubernetes/local/namespaces/other-namespace/providers/apps/Deployment/other-app"), + }}, + }, + } + }) + + annotations := &deploymentAnnotations{Status: &deploymentStatus{Container: containerID}} + poller, err := reconciler.startDeleteOperationIfNeeded(ctx, deployment, annotations) + require.NoError(t, err) + require.Nil(t, poller) + require.Empty(t, annotations.Status.Container) + requireNoDeleteOperation(t, radius, containerID) + + _, err = radius.Containers("/planes/radius/local/resourceGroups/tenant-b").Get(ctx, "other-container", nil) + require.NoError(t, err) +} + +func Test_DeploymentReconciler_StartPutOrDeleteOperationIfNeeded_OwnershipMismatch_BlocksDelete(t *testing.T) { + ctx := testcontext.New(t) + radius := NewMockRadiusClient() + reconciler := &DeploymentReconciler{ + Radius: radius, + EventRecorder: record.NewFakeRecorder(10), + } + + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "current-app", Namespace: "current-namespace"}} + otherContainerID := "/planes/radius/local/resourceGroups/tenant-b/providers/Applications.Core/containers/other-container" + + radius.Update(func() { + radius.containers[otherContainerID] = v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: new("/planes/kubernetes/local/namespaces/other-namespace/providers/apps/Deployment/other-container"), + }}, + }, + } + }) + + annotations := &deploymentAnnotations{ + Configuration: &deploymentConfiguration{}, + Status: &deploymentStatus{ + Scope: "/planes/radius/local/resourceGroups/tenant-a", + Application: "/planes/radius/local/resourceGroups/tenant-a/providers/Applications.Core/applications/current-app", + Container: otherContainerID, + }, + } + + updatePoller, deletePoller, waiting, err := reconciler.startPutOrDeleteOperationIfNeeded(ctx, deployment, annotations) + require.NoError(t, err) + require.NotNil(t, updatePoller) + require.Nil(t, deletePoller) + require.False(t, waiting) + requireNoDeleteOperation(t, radius, otherContainerID) + + _, err = radius.Containers("/planes/radius/local/resourceGroups/tenant-b").Get(ctx, "other-container", nil) + require.NoError(t, err) +} + +func Test_DeploymentReconciler_StartDeleteOperationIfNeeded_OwnershipMatch_AllowsDelete(t *testing.T) { + ctx := testcontext.New(t) + radius := NewMockRadiusClient() + reconciler := &DeploymentReconciler{ + Radius: radius, + EventRecorder: record.NewFakeRecorder(10), + } + + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "current-app", Namespace: "current-namespace"}} + containerID := "/planes/radius/local/resourceGroups/tenant-a/providers/Applications.Core/containers/current-app" + + radius.Update(func() { + radius.containers[containerID] = v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: new(makeKubernetesDeploymentResourceID("current-namespace", "current-app")), + }}, + }, + } + }) + + annotations := &deploymentAnnotations{Status: &deploymentStatus{Container: containerID}} + poller, err := reconciler.startDeleteOperationIfNeeded(ctx, deployment, annotations) + require.NoError(t, err) + require.NotNil(t, poller) + require.Equal(t, containerID, annotations.Status.Container) +} + +func requireNoDeleteOperation(t *testing.T, radius *mockRadiusClient, resourceID string) { + t.Helper() + + radius.lock.Lock() + defer radius.lock.Unlock() + + for _, operation := range radius.operations { + require.False(t, operation.Kind == http.MethodDelete && operation.ResourceID == resourceID, "unexpected delete operation for %s", resourceID) + } +} + // Creates a deployment with Radius enabled. // // Then exercises the cleanup path by deleting the deployment. diff --git a/pkg/controller/reconciler/util.go b/pkg/controller/reconciler/util.go index dd856ed5d1e..c7b73293d97 100644 --- a/pkg/controller/reconciler/util.go +++ b/pkg/controller/reconciler/util.go @@ -226,7 +226,7 @@ func fetchResource(ctx context.Context, radius RadiusClient, resourceID string) } func deleteContainer(ctx context.Context, radius RadiusClient, containerID string) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { - id, err := resources.Parse(containerID) + id, err := parseContainerResourceID(containerID) if err != nil { return nil, err } @@ -252,8 +252,44 @@ func deleteContainer(ctx context.Context, radius RadiusClient, containerID strin return nil, nil } +func makeKubernetesDeploymentResourceID(namespace string, name string) string { + return "/planes/kubernetes/local/namespaces/" + namespace + "/providers/apps/Deployment/" + name +} + +func containerHasResourceReference(container *corerpv20231001preview.ContainerResource, expectedResourceID string) bool { + if container == nil || container.Properties == nil { + return false + } + + for _, resource := range container.Properties.Resources { + if resource == nil || resource.ID == nil { + continue + } + + if strings.EqualFold(*resource.ID, expectedResourceID) { + return true + } + } + + return false +} + +func fetchContainerResource(ctx context.Context, radius RadiusClient, containerID string) (corerpv20231001preview.ContainerResource, error) { + id, err := parseContainerResourceID(containerID) + if err != nil { + return corerpv20231001preview.ContainerResource{}, err + } + + response, err := radius.Containers(id.RootScope()).Get(ctx, id.Name(), nil) + if err != nil { + return corerpv20231001preview.ContainerResource{}, err + } + + return response.ContainerResource, nil +} + func createOrUpdateContainer(ctx context.Context, radius RadiusClient, containerID string, properties *corerpv20231001preview.ContainerProperties) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { - id, err := resources.Parse(containerID) + id, err := parseContainerResourceID(containerID) if err != nil { return nil, err } @@ -284,6 +320,19 @@ func createOrUpdateContainer(ctx context.Context, radius RadiusClient, container return nil, nil } +func parseContainerResourceID(containerID string) (resources.ID, error) { + id, err := resources.ParseResource(containerID) + if err != nil { + return resources.ID{}, err + } + + if !strings.EqualFold(id.Type(), applicationsCoreContainersResourceType) { + return resources.ID{}, fmt.Errorf("resource type %q is not %q", id.Type(), applicationsCoreContainersResourceType) + } + + return id, nil +} + func generateDeploymentResourceName(resourceId string) (string, error) { id, err := resources.ParseResource(resourceId) if err != nil { diff --git a/pkg/controller/reconciler/util_test.go b/pkg/controller/reconciler/util_test.go index 546d27c4164..fc9fc4ab223 100644 --- a/pkg/controller/reconciler/util_test.go +++ b/pkg/controller/reconciler/util_test.go @@ -3,6 +3,8 @@ package reconciler import ( "testing" + v20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + "github.com/radius-project/radius/pkg/to" "github.com/stretchr/testify/require" ) @@ -86,3 +88,55 @@ func TestConvertToARMJSONParameters(t *testing.T) { }) } } + +func TestMakeKubernetesDeploymentResourceID(t *testing.T) { + got := makeKubernetesDeploymentResourceID("current-namespace", "current-app") + require.Equal(t, "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", got) +} + +func TestContainerHasResourceReference(t *testing.T) { + tests := []struct { + name string + container *v20231001preview.ContainerResource + expectedResource string + want bool + }{ + { + name: "has matching resource reference", + container: &v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: to.Ptr("/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app"), + }}, + }, + }, + expectedResource: "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", + want: true, + }, + { + name: "missing matching reference", + container: &v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: to.Ptr("/planes/kubernetes/local/namespaces/other-namespace/providers/apps/Deployment/other-app"), + }}, + }, + }, + expectedResource: "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", + want: false, + }, + { + name: "nil container", + container: nil, + expectedResource: "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containerHasResourceReference(tt.container, tt.expectedResource) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/corerp/frontend/controller/applications/testbicep_scan_test.go b/pkg/corerp/frontend/controller/applications/testbicep_scan_test.go new file mode 100644 index 00000000000..cbad68550e6 --- /dev/null +++ b/pkg/corerp/frontend/controller/applications/testbicep_scan_test.go @@ -0,0 +1,96 @@ +// ------------------------------------------------------------ +// Copyright 2026 The Radius Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------ + +package applications + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test_NoSharedDefaultEnvironmentInTestBicep enforces the rule that no test +// .bicep file may declare an Applications.Core/environments resource named +// 'default'. Mutating the shared `default` environment from a parallel test +// (e.g. setting its compute.namespace to a long string) leaks into other tests +// that target `default` and silently breaks them — most visibly by pushing the +// joined `-` past Kubernetes' 63-character namespace +// limit. CI clusters are ephemeral so the bug usually hides there, but local +// `make debug-start` persists state in etcd/postgres across runs and exposes +// it. See pkg/corerp/frontend/controller/applications/updatefilter.go. +// +// If you have a legitimate need to test the default env, do it in a dedicated +// test that creates and tears down its own uniquely-named env. +func Test_NoSharedDefaultEnvironmentInTestBicep(t *testing.T) { + repoRoot := findRepoRoot(t) + testDir := filepath.Join(repoRoot, "test") + + // Matches: + // resource 'Applications.Core/environments@...' = { + // name: 'default' + // Allows arbitrary whitespace and any resource-symbol name. + envBlock := regexp.MustCompile( + `(?s)resource\s+\w+\s+'Applications\.Core/environments@[^']+'\s*=\s*\{[^}]*?name:\s*'default'`) + + var offenders []string + err := filepath.Walk(testDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !strings.HasSuffix(path, ".bicep") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if envBlock.Match(data) { + rel, _ := filepath.Rel(repoRoot, path) + offenders = append(offenders, rel) + } + return nil + }) + require.NoError(t, err) + + require.Emptyf(t, offenders, + "the following test bicep files declare an Applications.Core/environments named 'default', "+ + "which mutates the shared default env and breaks other parallel tests "+ + "(see Test_NoSharedDefaultEnvironmentInTestBicep doc comment):\n %s", + strings.Join(offenders, "\n ")) +} + +// findRepoRoot walks up from this test file's directory until it finds a go.mod. +func findRepoRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok, "runtime.Caller failed") + dir := filepath.Dir(thisFile) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not locate repo root (no go.mod found above %s)", filepath.Dir(thisFile)) + } + dir = parent + } +} diff --git a/pkg/corerp/frontend/controller/applications/updatefilter.go b/pkg/corerp/frontend/controller/applications/updatefilter.go index 7b050f85909..a467c3c26b7 100644 --- a/pkg/corerp/frontend/controller/applications/updatefilter.go +++ b/pkg/corerp/frontend/controller/applications/updatefilter.go @@ -81,10 +81,17 @@ func CreateAppScopedNamespace(ctx context.Context, newResource, oldResource *dat newResource.Properties.Environment, err.Error())), nil } - namespace := fmt.Sprintf("%s-%s", envNamespace, serviceCtx.ResourceID.Name()) + appName := serviceCtx.ResourceID.Name() + namespace := fmt.Sprintf("%s-%s", envNamespace, appName) if valid, msg := kubernetes.IsValidObjectName(namespace); !valid { - return rest.NewBadRequestResponse(fmt.Sprintf("Application namespace '%s' could not be created: %s", - namespace, msg)), nil + // Include the inputs and their lengths so the failure points at the + // concrete fix (shorten env namespace or app name) instead of forcing + // the reader to count characters in a 60+ char composite string. + return rest.NewBadRequestResponse(fmt.Sprintf( + "Application namespace '%s' could not be created: %s "+ + "(envNamespace=%q len=%d, appName=%q len=%d, joined len=%d, max=63). "+ + "Shorten either the environment's namespace or the application name.", + namespace, msg, envNamespace, len(envNamespace), appName, len(appName), len(namespace))), nil } kubeNamespace = kubernetes.NormalizeResourceName(namespace) diff --git a/pkg/recipes/configloader/environment.go b/pkg/recipes/configloader/environment.go index c59549893a6..0085383bfb8 100644 --- a/pkg/recipes/configloader/environment.go +++ b/pkg/recipes/configloader/environment.go @@ -213,9 +213,7 @@ func getConfigurationV20250801(ctx context.Context, environment *v20250801previe // registry credentials (key=`token`). if len(tfProps.Terraformrc.Credentials) > 0 { config.RecipeConfig.Terraform.Credentials = make(map[string]datamodel.TerraformCredentialConfig, len(tfProps.Terraformrc.Credentials)) - for host, cred := range tfProps.Terraformrc.Credentials { - config.RecipeConfig.Terraform.Credentials[host] = cred - } + maps.Copy(config.RecipeConfig.Terraform.Credentials, tfProps.Terraformrc.Credentials) } // Map env vars into the legacy shape. diff --git a/pkg/recipes/engine/engine.go b/pkg/recipes/engine/engine.go index e981ed54e82..28948c46ccf 100644 --- a/pkg/recipes/engine/engine.go +++ b/pkg/recipes/engine/engine.go @@ -21,6 +21,7 @@ import ( "fmt" "time" + "github.com/radius-project/radius/pkg/azure/clientv2" "github.com/radius-project/radius/pkg/components/metrics" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/configloader" @@ -58,8 +59,8 @@ func (e *engine) Execute(ctx context.Context, opts ExecuteOptions) (*recipes.Rec recipeOutput, definition, err := e.executeCore(ctx, opts.Recipe, opts.PreviousState) if err != nil { result = metrics.FailedOperationState - if recipes.GetErrorDetails(err) != nil { - result = recipes.GetErrorDetails(err).Code + if details := recipes.GetErrorDetails(err); details != nil { + result = details.Code } } @@ -120,8 +121,8 @@ func (e *engine) Delete(ctx context.Context, opts DeleteOptions) error { definition, err := e.deleteCore(ctx, opts.Recipe, opts.OutputResources) if err != nil { result = metrics.FailedOperationState - if recipes.GetErrorDetails(err) != nil { - result = recipes.GetErrorDetails(err).Code + if details := recipes.GetErrorDetails(err); details != nil { + result = details.Code } } @@ -138,6 +139,14 @@ func (e *engine) deleteCore(ctx context.Context, recipe recipes.ResourceMetadata logger := ucplog.FromContextOrDiscard(ctx) configuration, err := e.options.ConfigurationLoader.LoadConfiguration(ctx, recipe) if err != nil { + // If the environment has already been deleted (e.g. test cleanup deleted + // it before the child resource's async delete drained), there is nothing + // left to clean up via a recipe. Treat as a successful no-op so the + // resource deletion can proceed. + if clientv2.Is404Error(err) { + logger.Info("environment not found while loading recipe configuration for delete; treating as no-op") + return nil, nil + } return nil, err } diff --git a/pkg/recipes/terraform/config/providers/azure.go b/pkg/recipes/terraform/config/providers/azure.go index 2a97b8f0e31..395106d0743 100644 --- a/pkg/recipes/terraform/config/providers/azure.go +++ b/pkg/recipes/terraform/config/providers/azure.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" + "github.com/radius-project/radius/pkg/azure/clientv2" "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/components/secret" "github.com/radius-project/radius/pkg/components/secret/secretprovider" @@ -128,8 +129,8 @@ func fetchAzureCredentials(ctx context.Context, azureCredentialsProvider credent logger := ucplog.FromContextOrDiscard(ctx) credentials, err := azureCredentialsProvider.Fetch(ctx, credentials.AzureCloud, "default") if err != nil { - if errors.Is(err, &secret.ErrNotFound{}) { - logger.Info("Azure credentials are not registered, skipping credentials configuration.") + if errors.Is(err, &secret.ErrNotFound{}) || clientv2.Is404Error(err) { + logger.Info("Azure credentials are not registered, falling back to Azure CLI credentials.") return nil, nil } @@ -168,6 +169,16 @@ func (p *azureProvider) generateProviderConfigMap(configMap map[string]any, cred configMap[azureSubIDParam] = subscriptionID } + // When no Radius-managed credentials are registered (e.g. a developer + // running the RP locally without `rad credential register azure ...`), + // fall back to the Azure CLI credentials available on the host process. + // `use_cli = true` is the azurerm provider default but we set it + // explicitly to make the intent clear in the generated terraform config. + if credentials == nil { + configMap[azureUseCLIParam] = true + return configMap + } + switch credentials.Kind { case ucp_datamodel.AzureServicePrincipalCredentialKind: if credentials.ServicePrincipal != nil && diff --git a/pkg/rp/util/registry.go b/pkg/rp/util/registry.go index de6c7657683..f366bd89a19 100644 --- a/pkg/rp/util/registry.go +++ b/pkg/rp/util/registry.go @@ -20,7 +20,10 @@ import ( "context" "encoding/json" "fmt" + "net" "net/url" + "os" + "strings" dockerParser "github.com/novln/docker-parser" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" @@ -30,6 +33,11 @@ import ( "oras.land/oras-go/v2/registry/remote" ) +// insecureLoopbackRegistriesEnvVar opts a process into using plain HTTP for OCI +// recipe pulls whose registry host is a loopback address. Intended for local +// developer stacks where the registry container is unauthenticated HTTP. +const insecureLoopbackRegistriesEnvVar = "RADIUS_INSECURE_LOOPBACK_REGISTRIES" + // ReadFromRegistry reads data from an OCI compliant registry and stores it in a map. It returns an error if the path is invalid, // if the client to the registry fails to be created, if the manifest fails to be fetched, if the bytes fail to be fetched, or if // the data fails to be unmarshalled. @@ -46,7 +54,7 @@ func ReadFromRegistry(ctx context.Context, definition recipes.EnvironmentDefinit repo.Client = client - if definition.PlainHTTP { + if definition.PlainHTTP || shouldForcePlainHTTP(definition.TemplatePath) { repo.PlainHTTP = true } @@ -146,3 +154,30 @@ func GetRegistrySecrets(definition recipes.Configuration, templatePath string, s return secrets[definition.RecipeConfig.Bicep.Authentication[parsedURL.Host].Secret], nil } + +// shouldForcePlainHTTP returns true when the caller has opted into plain-HTTP +// recipe pulls for loopback registries via RADIUS_INSECURE_LOOPBACK_REGISTRIES +// and templatePath points at a loopback host. Used by local OS-process debug +// stacks where the registry container speaks HTTP only. +func shouldForcePlainHTTP(templatePath string) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(insecureLoopbackRegistriesEnvVar))) + if v != "1" && v != "true" && v != "yes" { + return false + } + + ref, err := dockerParser.Parse(templatePath) + if err != nil { + return false + } + host := ref.Registry() + if h, _, splitErr := net.SplitHostPort(host); splitErr == nil { + host = h + } + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return true + } + return false +} diff --git a/pkg/ucp/initializer/radius_core_openapi.go b/pkg/ucp/initializer/radius_core_openapi.go new file mode 100644 index 00000000000..8beb76a350e --- /dev/null +++ b/pkg/ucp/initializer/radius_core_openapi.go @@ -0,0 +1,243 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package initializer + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/radius-project/radius/pkg/cli/manifest" + "github.com/radius-project/radius/swagger" +) + +const ( + radiusCoreNamespace = "Radius.Core" + radiusCoreAPIVersion = "2025-08-01-preview" + radiusCoreOpenAPIFile = "specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json" + openAPIDefinitionRefRoot = "#/definitions/" +) + +var radiusCoreTypeOpenAPIDefinitions = map[string]struct { + resourceDefinition string + propertiesDefinition string +}{ + "applications": { + resourceDefinition: "ApplicationResource", + propertiesDefinition: "ApplicationProperties", + }, + "bicepConfigs": { + resourceDefinition: "BicepConfigResource", + propertiesDefinition: "BicepConfigProperties", + }, + "environments": { + resourceDefinition: "EnvironmentResource", + propertiesDefinition: "EnvironmentProperties", + }, + "recipePacks": { + resourceDefinition: "RecipePackResource", + propertiesDefinition: "RecipePackProperties", + }, + "terraformConfigs": { + resourceDefinition: "TerraformConfigResource", + propertiesDefinition: "TerraformConfigProperties", + }, +} + +type openAPIDocument struct { + Definitions map[string]map[string]any `json:"definitions"` +} + +// hydrateBuiltInResourceProviderMetadata fills built-in manifest metadata that is intentionally not +// duplicated in YAML. Radius.Core schemas are sourced from the generated OpenAPI spec so the CLI and +// dashboard see the same TypeSpec-authored descriptions and property schemas that UCP serves. +func hydrateBuiltInResourceProviderMetadata(rp *manifest.ResourceProvider) error { + if !strings.EqualFold(rp.Namespace, radiusCoreNamespace) { + return nil + } + + doc, err := loadRadiusCoreOpenAPI() + if err != nil { + return err + } + + for typeName, resourceType := range rp.Types { + definitionNames, ok := radiusCoreTypeOpenAPIDefinitions[typeName] + if !ok { + return fmt.Errorf("%s type %s has no OpenAPI metadata mapping", rp.Namespace, typeName) + } + if resourceType == nil { + return fmt.Errorf("mapped %s type %s is nil", rp.Namespace, typeName) + } + + description, err := openAPIDefinitionDescription(doc, definitionNames.resourceDefinition) + if err != nil { + return fmt.Errorf("failed to get description for %s/%s: %w", rp.Namespace, typeName, err) + } + resourceType.Description = &description + + apiVersion, ok := resourceType.APIVersions[radiusCoreAPIVersion] + if !ok { + return fmt.Errorf("mapped %s type %s is missing API version %s", rp.Namespace, typeName, radiusCoreAPIVersion) + } + if apiVersion == nil { + return fmt.Errorf("mapped %s type %s has nil API version %s", rp.Namespace, typeName, radiusCoreAPIVersion) + } + + schema, err := openAPIDefinitionSchema(doc, definitionNames.propertiesDefinition) + if err != nil { + return fmt.Errorf("failed to get schema for %s/%s@%s: %w", rp.Namespace, typeName, radiusCoreAPIVersion, err) + } + apiVersion.Schema = schema + } + + return nil +} + +// loadRadiusCoreOpenAPI reads the embedded TypeSpec-generated Radius.Core OpenAPI document used as +// the source of truth for built-in resource type descriptions and schemas. +func loadRadiusCoreOpenAPI() (*openAPIDocument, error) { + contents, err := swagger.SpecFiles.ReadFile(radiusCoreOpenAPIFile) + if err != nil { + return nil, fmt.Errorf("failed to read embedded Radius.Core OpenAPI spec: %w", err) + } + + var doc openAPIDocument + if err := json.Unmarshal(contents, &doc); err != nil { + return nil, fmt.Errorf("failed to parse embedded Radius.Core OpenAPI spec: %w", err) + } + if len(doc.Definitions) == 0 { + return nil, fmt.Errorf("embedded Radius.Core OpenAPI spec has no definitions") + } + + return &doc, nil +} + +// openAPIDefinitionDescription returns the top-level resource description for a named OpenAPI definition. +func openAPIDefinitionDescription(doc *openAPIDocument, name string) (string, error) { + definition, ok := doc.Definitions[name] + if !ok { + return "", fmt.Errorf("definition %q not found", name) + } + + description, _ := definition["description"].(string) + if description == "" { + return "", fmt.Errorf("definition %q has no description", name) + } + + return description, nil +} + +// openAPIDefinitionSchema returns a fully local-ref-expanded schema for a named OpenAPI definition. +func openAPIDefinitionSchema(doc *openAPIDocument, name string) (map[string]any, error) { + definition, ok := doc.Definitions[name] + if !ok { + return nil, fmt.Errorf("definition %q not found", name) + } + + resolved, err := resolveOpenAPIValue(definition, doc.Definitions, map[string]bool{}) + if err != nil { + return nil, err + } + + schema, ok := resolved.(map[string]any) + if !ok { + return nil, fmt.Errorf("definition %q did not resolve to an object schema", name) + } + + return schema, nil +} + +// resolveOpenAPIValue recursively expands local #/definitions references while preserving external +// references, which are part of the ARM envelope rather than the resource-type property schema. +func resolveOpenAPIValue(value any, definitions map[string]map[string]any, resolving map[string]bool) (any, error) { + switch typed := value.(type) { + case map[string]any: + if ref, ok := typed["$ref"].(string); ok { + if !strings.HasPrefix(ref, openAPIDefinitionRefRoot) { + return cloneMap(typed), nil + } + + definitionName := strings.TrimPrefix(ref, openAPIDefinitionRefRoot) + if resolving[definitionName] { + return nil, fmt.Errorf("circular OpenAPI reference %q", ref) + } + + definition, ok := definitions[definitionName] + if !ok { + return nil, fmt.Errorf("OpenAPI reference %q not found", ref) + } + + resolving[definitionName] = true + resolved, err := resolveOpenAPIValue(definition, definitions, resolving) + delete(resolving, definitionName) + if err != nil { + return nil, err + } + + resolvedMap, ok := resolved.(map[string]any) + if !ok { + return nil, fmt.Errorf("OpenAPI reference %q did not resolve to an object schema", ref) + } + + for key, child := range typed { + if key == "$ref" { + continue + } + + resolvedChild, err := resolveOpenAPIValue(child, definitions, resolving) + if err != nil { + return nil, err + } + resolvedMap[key] = resolvedChild + } + + return resolvedMap, nil + } + + result := map[string]any{} + for key, child := range typed { + resolvedChild, err := resolveOpenAPIValue(child, definitions, resolving) + if err != nil { + return nil, err + } + result[key] = resolvedChild + } + return result, nil + case []any: + result := make([]any, len(typed)) + for i, child := range typed { + resolvedChild, err := resolveOpenAPIValue(child, definitions, resolving) + if err != nil { + return nil, err + } + result[i] = resolvedChild + } + return result, nil + default: + return typed, nil + } +} + +// cloneMap returns a shallow copy so unresolved external refs are not mutated while local refs expand. +func cloneMap(value map[string]any) map[string]any { + result := map[string]any{} + for key, child := range value { + result[key] = child + } + return result +} diff --git a/pkg/ucp/initializer/service.go b/pkg/ucp/initializer/service.go index cd45a42d236..6a90954cb6a 100644 --- a/pkg/ucp/initializer/service.go +++ b/pkg/ucp/initializer/service.go @@ -75,21 +75,58 @@ func (w *Service) Run(ctx context.Context) error { return fmt.Errorf("failed to read manifest directory: %w", err) } + // Read and validate all manifest files, then merge by namespace so that + // multiple per-type files sharing a namespace (e.g., containers.yaml and + // persistentVolumes.yaml both under Radius.Compute) are registered as a + // single resource provider with all types. This makes the on-disk manifest + // directory the source of truth for the database. + merged := map[string]*manifest.ResourceProvider{} for _, fileInfo := range files { if fileInfo.IsDir() { continue } filePath := filepath.Join(manifestDir, fileInfo.Name()) - logger.Info("Registering manifest", "file", filePath) + logger.Info("Loading manifest", "file", filePath) - resourceProvider, err := manifest.ValidateManifest(ctx, filePath) + rp, err := manifest.ValidateManifest(ctx, filePath) if err != nil { return fmt.Errorf("failed to validate manifest %s: %w", filePath, err) } - if err := registerResourceProviderDirect(ctx, dbClient, "local", *resourceProvider); err != nil { - return fmt.Errorf("failed to register manifest %s: %w", filePath, err) + existing, ok := merged[rp.Namespace] + if !ok { + merged[rp.Namespace] = rp + continue + } + + // Merge types from this file into the existing provider for this + // namespace. Error if a type appears in multiple files. + for typeName := range rp.Types { + if _, exists := existing.Types[typeName]; exists { + return fmt.Errorf("duplicate resource type %s/%s found in multiple manifest files", rp.Namespace, typeName) + } + } + for typeName, resourceType := range rp.Types { + existing.Types[typeName] = resourceType + } + + // Preserve location from whichever file specifies one. If multiple + // files set a location, the later file wins. + if len(rp.Location) > 0 { + existing.Location = rp.Location + } + } + + // Register each merged provider. + for _, rp := range merged { + logger.Info("Registering manifest", "namespace", rp.Namespace, "types", len(rp.Types)) + if err := hydrateBuiltInResourceProviderMetadata(rp); err != nil { + return fmt.Errorf("failed to hydrate built-in resource provider metadata for namespace %s: %w", rp.Namespace, err) + } + + if err := registerResourceProviderDirect(ctx, dbClient, "local", *rp); err != nil { + return fmt.Errorf("failed to register manifest for namespace %s: %w", rp.Namespace, err) } } diff --git a/pkg/ucp/initializer/service_test.go b/pkg/ucp/initializer/service_test.go index 436dd4a0a7d..6c1ef98414e 100644 --- a/pkg/ucp/initializer/service_test.go +++ b/pkg/ucp/initializer/service_test.go @@ -163,6 +163,7 @@ func Test_registerResourceProviderDirect(t *testing.T) { require.NoError(t, err) require.NotNil(t, obj) }) + } func Test_Run(t *testing.T) { @@ -213,6 +214,240 @@ types: require.NoError(t, err) require.NotNil(t, obj) }) + + t.Run("errors on duplicate type across manifest files", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Two manifest files with the same namespace and same type name. + manifest1 := ` +namespace: Radius.Compute +types: + containers: + apiVersions: + "2025-08-01-preview": + schema: {} +` + manifest2 := ` +namespace: Radius.Compute +types: + containers: + apiVersions: + "2025-08-01-preview": + schema: {} +` + err := os.WriteFile(filepath.Join(tempDir, "a-containers.yaml"), []byte(manifest1), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tempDir, "b-containers.yaml"), []byte(manifest2), 0600) + require.NoError(t, err) + + svc := newTestService(tempDir) + err = svc.Run(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate resource type Radius.Compute/containers") + }) + + t.Run("merges types from multiple files sharing a namespace", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + manifest1 := ` +namespace: Radius.Compute +types: + containers: + apiVersions: + "2025-08-01-preview": + schema: {} +` + manifest2 := ` +namespace: Radius.Compute +types: + routes: + apiVersions: + "2025-08-01-preview": + schema: {} +` + err := os.WriteFile(filepath.Join(tempDir, "containers.yaml"), []byte(manifest1), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tempDir, "routes.yaml"), []byte(manifest2), 0600) + require.NoError(t, err) + + svc := newTestService(tempDir) + dbClient, err := svc.options.DatabaseProvider.GetClient(context.Background()) + require.NoError(t, err) + + err = svc.Run(context.Background()) + require.NoError(t, err) + + // Verify both types are registered under the same namespace. + _, err = dbClient.Get(context.Background(), "/planes/radius/local/providers/System.Resources/resourceProviders/Radius.Compute/resourceTypes/containers") + require.NoError(t, err) + _, err = dbClient.Get(context.Background(), "/planes/radius/local/providers/System.Resources/resourceProviders/Radius.Compute/resourceTypes/routes") + require.NoError(t, err) + + // Verify the location contains both types. + obj, err := dbClient.Get(context.Background(), "/planes/radius/local/providers/System.Resources/resourceProviders/Radius.Compute/locations/global") + require.NoError(t, err) + location := &datamodel.Location{} + require.NoError(t, obj.As(location)) + assert.Contains(t, location.Properties.ResourceTypes, "containers") + assert.Contains(t, location.Properties.ResourceTypes, "routes") + }) + + t.Run("hydrates Radius.Core schemas from embedded OpenAPI", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + manifestPath := filepath.Join("..", "..", "..", "deploy", "manifest", "built-in-providers", "dev", "radius_core.yaml") + manifestContent, err := os.ReadFile(manifestPath) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tempDir, "radius_core.yaml"), manifestContent, 0600) + require.NoError(t, err) + + svc := newTestService(tempDir) + dbClient, err := svc.options.DatabaseProvider.GetClient(context.Background()) + require.NoError(t, err) + + err = svc.Run(context.Background()) + require.NoError(t, err) + + obj, err := dbClient.Get(context.Background(), "/planes/radius/local/providers/System.Resources/resourceProviderSummaries/Radius.Core") + require.NoError(t, err) + + summaryModel := &datamodel.ResourceProviderSummary{} + require.NoError(t, obj.As(summaryModel)) + require.Len(t, summaryModel.Properties.ResourceTypes, len(radiusCoreTypeOpenAPIDefinitions)) + + expectedDescriptions := map[string]string{ + "applications": "Radius Application resource", + "bicepConfigs": "The Bicep configuration resource, providing reusable Bicep recipe settings for environments.", + "environments": "The environment resource", + "recipePacks": "The recipe pack resource", + "terraformConfigs": "The Terraform configuration resource, providing reusable Terraform recipe settings for environments.", + } + require.Len(t, expectedDescriptions, len(radiusCoreTypeOpenAPIDefinitions)) + for typeName, expectedDescription := range expectedDescriptions { + resourceType := summaryModel.Properties.ResourceTypes[typeName] + require.NotNil(t, resourceType, "resource type %q should be registered", typeName) + require.NotNil(t, resourceType.Description, "resource type %q should have a description", typeName) + assert.Equal(t, expectedDescription, *resourceType.Description) + + apiVersion := resourceType.APIVersions["2025-08-01-preview"] + require.NotNil(t, apiVersion, "resource type %q should have API version 2025-08-01-preview", typeName) + assert.Equal(t, "object", apiVersion.Schema["type"]) + requireRenderableResourceTypeSchema(t, apiVersion.Schema) + } + + applications := summaryModel.Properties.ResourceTypes["applications"] + applicationSchema := applications.APIVersions["2025-08-01-preview"].Schema + + applicationProperties := requireSchemaProperties(t, applicationSchema) + environmentProperty := requireSchemaProperty(t, applicationProperties, "environment") + assert.Equal(t, "string", environmentProperty["type"]) + + statusProperty := requireSchemaProperty(t, applicationProperties, "status") + assert.NotContains(t, statusProperty, "$ref") + assert.Equal(t, "object", statusProperty["type"]) + + environments := summaryModel.Properties.ResourceTypes["environments"] + environmentSchema := environments.APIVersions["2025-08-01-preview"].Schema + environmentProperties := requireSchemaProperties(t, environmentSchema) + providersProperty := requireSchemaProperty(t, environmentProperties, "providers") + assert.NotContains(t, providersProperty, "$ref") + assert.Equal(t, "object", providersProperty["type"]) + + recipePacks := summaryModel.Properties.ResourceTypes["recipePacks"] + recipePackSchema := recipePacks.APIVersions["2025-08-01-preview"].Schema + recipePackProperties := requireSchemaProperties(t, recipePackSchema) + recipesProperty := requireSchemaProperty(t, recipePackProperties, "recipes") + additionalProperties, ok := recipesProperty["additionalProperties"].(map[string]any) + require.True(t, ok) + recipeDefinitionProperties := requireSchemaProperties(t, additionalProperties) + recipeKindProperty := requireSchemaProperty(t, recipeDefinitionProperties, "recipeKind") + assert.NotContains(t, recipeKindProperty, "$ref") + assert.Equal(t, "string", recipeKindProperty["type"]) + + terraformConfigs := summaryModel.Properties.ResourceTypes["terraformConfigs"] + terraformConfigSchema := terraformConfigs.APIVersions["2025-08-01-preview"].Schema + terraformConfigProperties := requireSchemaProperties(t, terraformConfigSchema) + terraformrcProperty := requireSchemaProperty(t, terraformConfigProperties, "terraformrc") + assert.NotContains(t, terraformrcProperty, "$ref") + assert.Equal(t, "object", terraformrcProperty["type"]) + + bicepConfigs := summaryModel.Properties.ResourceTypes["bicepConfigs"] + bicepConfigSchema := bicepConfigs.APIVersions["2025-08-01-preview"].Schema + bicepConfigProperties := requireSchemaProperties(t, bicepConfigSchema) + registryAuthenticationsProperty := requireSchemaProperty(t, bicepConfigProperties, "registryAuthentications") + assert.NotContains(t, registryAuthenticationsProperty, "$ref") + assert.Equal(t, "object", registryAuthenticationsProperty["type"]) + + obj, err = dbClient.Get(context.Background(), "/planes/radius/local/providers/System.Resources/resourceProviders/Radius.Core/resourceTypes/applications/apiVersions/2025-08-01-preview") + require.NoError(t, err) + + apiVersionModel := &datamodel.APIVersion{} + require.NoError(t, obj.As(apiVersionModel)) + assert.Equal(t, applicationSchema, apiVersionModel.Properties.Schema) + }) +} + +func Test_hydrateBuiltInResourceProviderMetadata(t *testing.T) { + t.Parallel() + + t.Run("fails when mapped Radius.Core type is missing expected API version", func(t *testing.T) { + t.Parallel() + + rp := &manifest.ResourceProvider{ + Namespace: "Radius.Core", + Types: map[string]*manifest.ResourceType{ + "applications": { + APIVersions: map[string]*manifest.ResourceTypeAPIVersion{ + "2024-01-01-preview": {Schema: map[string]any{}}, + }, + }, + }, + } + + err := hydrateBuiltInResourceProviderMetadata(rp) + require.Error(t, err) + require.Contains(t, err.Error(), "mapped Radius.Core type applications is missing API version 2025-08-01-preview") + }) + + t.Run("fails when Radius.Core manifest type has no OpenAPI metadata mapping", func(t *testing.T) { + t.Parallel() + + rp := &manifest.ResourceProvider{ + Namespace: "Radius.Core", + Types: map[string]*manifest.ResourceType{ + "widgets": { + APIVersions: map[string]*manifest.ResourceTypeAPIVersion{ + "2025-08-01-preview": {Schema: map[string]any{}}, + }, + }, + }, + } + + err := hydrateBuiltInResourceProviderMetadata(rp) + require.Error(t, err) + require.Contains(t, err.Error(), "Radius.Core type widgets has no OpenAPI metadata mapping") + }) + + t.Run("ignores non Radius.Core providers", func(t *testing.T) { + t.Parallel() + + rp := &manifest.ResourceProvider{ + Namespace: "MyCompany.Resources", + Types: map[string]*manifest.ResourceType{ + "applications": { + APIVersions: map[string]*manifest.ResourceTypeAPIVersion{}, + }, + }, + } + + err := hydrateBuiltInResourceProviderMetadata(rp) + require.NoError(t, err) + }) } func Test_saveResource(t *testing.T) { @@ -293,3 +528,67 @@ func createTestResourceProviderMultiType() manifest.ResourceProvider { }, } } + +func requireSchemaProperties(t *testing.T, schema map[string]any) map[string]any { + t.Helper() + + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, properties) + return properties +} + +func requireSchemaProperty(t *testing.T, properties map[string]any, name string) map[string]any { + t.Helper() + + property, ok := properties[name].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, property) + return property +} + +func requireRenderableResourceTypeSchema(t *testing.T, schema map[string]any) { + t.Helper() + + properties := requireSchemaProperties(t, schema) + for name, property := range properties { + propertySchema, ok := property.(map[string]any) + require.True(t, ok, "property %q should be an object schema", name) + require.NotContains(t, propertySchema, "$ref", "property %q should have expanded refs", name) + require.IsType(t, "", propertySchema["type"], "property %q should have a concrete type", name) + + if propertySchema["type"] == "object" { + requireRenderableNestedObjectSchema(t, name, propertySchema) + } + } +} + +func requireRenderableNestedObjectSchema(t *testing.T, path string, schema map[string]any) { + t.Helper() + + nestedSchema := schema + if _, ok := nestedSchema["properties"].(map[string]any); !ok { + additionalProperties, ok := schema["additionalProperties"].(map[string]any) + if !ok { + return + } + nestedSchema = additionalProperties + } + + properties, ok := nestedSchema["properties"].(map[string]any) + if !ok { + return + } + require.NotEmpty(t, properties) + for name, property := range properties { + propertyPath := path + "." + name + propertySchema, ok := property.(map[string]any) + require.True(t, ok, "property %q should be an object schema", propertyPath) + require.NotContains(t, propertySchema, "$ref", "property %q should have expanded refs", propertyPath) + require.IsType(t, "", propertySchema["type"], "property %q should have a concrete type", propertyPath) + + if propertySchema["type"] == "object" { + requireRenderableNestedObjectSchema(t, propertyPath, propertySchema) + } + } +} diff --git a/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go b/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go index 4555bf015e5..830e3dbdbc1 100644 --- a/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go +++ b/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go @@ -20,6 +20,7 @@ import ( "encoding/json" "net/http" "os" + "strings" "testing" "time" @@ -27,6 +28,7 @@ import ( "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) const ( @@ -166,6 +168,11 @@ func Test_ResourceProvider_RegisterManifests(t *testing.T) { // the code path used by resource type manifests copied from // resource-types-contrib, which omit location so that UCP routes requests via // DefaultDownstreamEndpoint (dynamic-rp). +// +// The test directory contains two manifest files (containers.yaml and +// persistentVolumes.yaml) that share the same namespace (Radius.Compute). +// This verifies that the initializer correctly merges types from multiple +// files into the same resource provider and location. func Test_ResourceProvider_RegisterManifests_NoLocation(t *testing.T) { server := testhost.Start(t, testhost.TestHostOptionFunc(func(options *ucp.Options) { options.Config.Initialization.ManifestDirectory = "testdata/manifests-no-location" @@ -176,7 +183,8 @@ func Test_ResourceProvider_RegisterManifests_NoLocation(t *testing.T) { noLocationNamespace := "Radius.Compute" noLocationResourceProviderURL := "/planes/radius/local/providers/System.Resources/resourceproviders/" + noLocationNamespace + radiusAPIVersion - noLocationResourceTypeURL := "/planes/radius/local/providers/System.Resources/resourceproviders/" + noLocationNamespace + "/resourcetypes/containers" + radiusAPIVersion + noLocationContainersURL := "/planes/radius/local/providers/System.Resources/resourceproviders/" + noLocationNamespace + "/resourcetypes/containers" + radiusAPIVersion + noLocationPersistentVolumesURL := "/planes/radius/local/providers/System.Resources/resourceproviders/" + noLocationNamespace + "/resourcetypes/persistentVolumes" + radiusAPIVersion noLocationLocationURL := "/planes/radius/local/providers/System.Resources/resourceproviders/" + noLocationNamespace + "/locations/global" + radiusAPIVersion require.EventuallyWithTf(t, func(collect *assert.CollectT) { @@ -184,10 +192,14 @@ func Test_ResourceProvider_RegisterManifests_NoLocation(t *testing.T) { response := server.MakeRequest(http.MethodGet, noLocationResourceProviderURL, nil) assert.Equal(collect, 200, response.Raw.StatusCode, "resource provider Radius.Compute should be registered") - // Verify the resource type was registered. - response = server.MakeRequest(http.MethodGet, noLocationResourceTypeURL, nil) + // Verify both resource types from separate manifest files are registered + // under the same namespace. + response = server.MakeRequest(http.MethodGet, noLocationContainersURL, nil) assert.Equal(collect, 200, response.Raw.StatusCode, "resource type Radius.Compute/containers should be registered") + response = server.MakeRequest(http.MethodGet, noLocationPersistentVolumesURL, nil) + assert.Equal(collect, 200, response.Raw.StatusCode, "resource type Radius.Compute/persistentVolumes should be registered") + // Verify the location was created with no address, so UCP uses // DefaultDownstreamEndpoint for routing. response = server.MakeRequest(http.MethodGet, noLocationLocationURL, nil) @@ -202,9 +214,93 @@ func Test_ResourceProvider_RegisterManifests_NoLocation(t *testing.T) { props, _ := locationBody["properties"].(map[string]any) assert.Nil(collect, props["address"], "location address should be absent for no-location manifests") + + // Verify the location contains both resource types from the two manifest + // files. Without the namespace merge fix, the location would only contain + // the types from the last file processed (alphabetically). + resourceTypes, _ := props["resourceTypes"].(map[string]any) + if !assert.Contains(collect, resourceTypes, "containers", "location should contain containers type") { + return + } + if !assert.Contains(collect, resourceTypes, "persistentVolumes", "location should contain persistentVolumes type") { + return + } }, registerManifestWaitDuration, registerManifestWaitInterval, "no-location manifest registration did not complete in time") } +// Test_ResourceProvider_DefaultsRegistered verifies that all resource types +// listed in deploy/manifest/defaults.yaml are registered after startup when +// the initializer loads the real manifest files from built-in-providers/dev/. +// +// This catches regressions where: +// - A manifest file fails to load at startup +// - Multiple files sharing a namespace overwrite each other's types +// - A type is added to defaults.yaml but its manifest is missing or invalid +func Test_ResourceProvider_DefaultsRegistered(t *testing.T) { + // Read defaults.yaml to get the list of expected resource types. + data, err := os.ReadFile("../../../../deploy/manifest/defaults.yaml") + require.NoError(t, err, "failed to read defaults.yaml") + + var defaults struct { + DefaultRegistration []string `yaml:"defaultRegistration"` + } + require.NoError(t, yaml.Unmarshal(data, &defaults), "failed to parse defaults.yaml") + require.NotEmpty(t, defaults.DefaultRegistration, "defaults.yaml should list at least one resource type") + + // Start the test host with the real manifest directory. + server := testhost.Start(t, testhost.TestHostOptionFunc(func(options *ucp.Options) { + options.Config.Initialization.ManifestDirectory = "../../../../deploy/manifest/built-in-providers/dev" + })) + defer server.Close() + + createRadiusPlane(server) + + // Build expected types grouped by namespace for location verification. + namespaceTypes := map[string][]string{} + for _, entry := range defaults.DefaultRegistration { + parts := strings.SplitN(entry, "/", 2) + require.Len(t, parts, 2, "invalid entry in defaults.yaml: %s", entry) + namespaceTypes[parts[0]] = append(namespaceTypes[parts[0]], parts[1]) + } + + require.EventuallyWithTf(t, func(collect *assert.CollectT) { + // Verify each resource type is queryable via the API. + for _, entry := range defaults.DefaultRegistration { + parts := strings.SplitN(entry, "/", 2) + namespace := parts[0] + typeName := parts[1] + + typeURL := "/planes/radius/local/providers/System.Resources/resourceproviders/" + namespace + "/resourcetypes/" + typeName + radiusAPIVersion + response := server.MakeRequest(http.MethodGet, typeURL, nil) + if !assert.Equal(collect, 200, response.Raw.StatusCode, "resource type %s should be registered", entry) { + return + } + } + + // Verify each namespace's location contains all of its types. + for namespace, types := range namespaceTypes { + locationURL := "/planes/radius/local/providers/System.Resources/resourceproviders/" + namespace + "/locations/global" + radiusAPIVersion + response := server.MakeRequest(http.MethodGet, locationURL, nil) + if !assert.Equal(collect, 200, response.Raw.StatusCode, "location for %s should exist", namespace) { + return + } + + var body map[string]any + if !assert.NoError(collect, json.Unmarshal(response.Body.Bytes(), &body)) { + return + } + + props, _ := body["properties"].(map[string]any) + resourceTypes, _ := props["resourceTypes"].(map[string]any) + for _, typeName := range types { + if !assert.Contains(collect, resourceTypes, typeName, "location for %s should contain type %s", namespace, typeName) { + return + } + } + } + }, registerManifestWaitDuration, registerManifestWaitInterval, "default resource type registration did not complete in time") +} + // removeSystemData removes the systemData property from the response body recursively. // This matches the behavior of TestResponse.removeSystemData in the testhost package. func removeSystemData(body map[string]any) { diff --git a/pkg/ucp/integrationtests/resourceproviders/testdata/manifests-no-location/persistentVolumes.yaml b/pkg/ucp/integrationtests/resourceproviders/testdata/manifests-no-location/persistentVolumes.yaml new file mode 100644 index 00000000000..d239c44bdd0 --- /dev/null +++ b/pkg/ucp/integrationtests/resourceproviders/testdata/manifests-no-location/persistentVolumes.yaml @@ -0,0 +1,13 @@ +namespace: Radius.Compute +types: + persistentVolumes: + apiVersions: + "2025-08-01-preview": + schema: + type: object + properties: + environment: + type: string + application: + type: string + required: [environment, application] diff --git a/test/createAzureTestResources.bicep b/test/createAzureTestResources.bicep index 2bb507fa2fd..40d542d90ee 100644 --- a/test/createAzureTestResources.bicep +++ b/test/createAzureTestResources.bicep @@ -1,7 +1,10 @@ param location string = resourceGroup().location +@description('Name of the Cosmos DB (MongoDB) account. Cosmos account names are globally unique, so callers running in parallel or recreating the deployment shortly after a delete must override this with a unique value.') +param cosmosAccountName string = 'account-radiustest' + resource account 'Microsoft.DocumentDB/databaseAccounts@2020-04-01' = { - name: 'account-radiustest' + name: cosmosAccountName location: location kind: 'MongoDB' tags: { diff --git a/test/functional-portable/cli/noncloud/cli_test.go b/test/functional-portable/cli/noncloud/cli_test.go index 4d33ff89034..104fd42189f 100644 --- a/test/functional-portable/cli/noncloud/cli_test.go +++ b/test/functional-portable/cli/noncloud/cli_test.go @@ -323,6 +323,20 @@ func Test_Run_Logger(t *testing.T) { // Read the text line-by-line while the command is running, but store it so we can report failures. output := bytes.Buffer{} + sawExpectedLine := false + + // Watchdog: if the expected log line never appears within the window, cancel the + // command so the test fails fast with a useful message instead of hanging until the + // outer test timeout. The bicep template prints "hello from the streaming logs!" in + // a while-true loop with a 10s sleep, so on a healthy cluster the first line should + // arrive well within a few minutes (image pull + pod schedule + first iteration). + const watchdogTimeout = 3 * time.Minute + watchdog := time.AfterFunc(watchdogTimeout, func() { + t.Logf("Test_Run_Logger watchdog: expected log line not observed within %s; cancelling 'rad run'", watchdogTimeout) + cancel() + }) + defer watchdog.Stop() + scanner := bufio.NewScanner(stdout) scanner.Split(bufio.ScanLines) for scanner.Scan() { @@ -330,6 +344,8 @@ func Test_Run_Logger(t *testing.T) { output.WriteString(line) output.WriteString("\n") if strings.Contains(line, "hello from the streaming logs!") { + sawExpectedLine = true + watchdog.Stop() cancel() // Stop the command, but keep reading to drain all output. } } @@ -351,6 +367,9 @@ func Test_Run_Logger(t *testing.T) { // We should have an error, but only because we canceled the context. require.Errorf(t, err, "rad run should have been canceled") require.Equal(t, err, ctx.Err(), "rad run should have been canceled") + require.Truef(t, sawExpectedLine, + "watchdog fired after %s without seeing 'hello from the streaming logs!' — container likely never produced logs. Captured output:\n%s", + watchdogTimeout, output.String()) } func Test_Run_Portforward(t *testing.T) { diff --git a/test/functional-portable/corerp/cloud/resources/extender_test.go b/test/functional-portable/corerp/cloud/resources/extender_test.go index a599bc6243e..2bb0211dcfb 100644 --- a/test/functional-portable/corerp/cloud/resources/extender_test.go +++ b/test/functional-portable/corerp/cloud/resources/extender_test.go @@ -32,10 +32,12 @@ import ( func Test_Extender_RecipeAWS_LogGroup(t *testing.T) { awsAccountID := os.Getenv("AWS_ACCOUNT_ID") awsRegion := os.Getenv("AWS_REGION") - // Error the test if the required environment variables are not set - // for running locally set the environment variables + // Skip the test if the required environment variables are not set + // (in CI these are provided alongside AWS credentials; locally the + // AWS feature gate via RequiredFeatures will also skip the test when + // AWS credentials are not registered with UCP). if awsAccountID == "" || awsRegion == "" { - t.Error("This test needs the env variables AWS_ACCOUNT_ID and AWS_REGION to be set") + t.Skip("This test needs the env variables AWS_ACCOUNT_ID and AWS_REGION to be set") } template := "testdata/corerp-resources-extender-aws-logs-recipe.bicep" diff --git a/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go b/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go index 0cb42b3cfe9..479fb4ddecd 100644 --- a/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go +++ b/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go @@ -25,6 +25,7 @@ package resource_test import ( "context" + "os" "strings" "testing" @@ -72,7 +73,9 @@ func Test_TerraformRecipe_AzureResourceGroup(t *testing.T) { }, SkipObjectValidation: true, PostStepVerify: func(ctx context.Context, t *testing.T, test rp.RPTest) { - resourceID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/extenders/" + name + // Use the active workspace's scope so this works against any + // resource group (CI uses 'kind-radius', local debug uses 'default'). + resourceID := test.Options.Workspace.Scope + "/providers/Applications.Core/extenders/" + name secretSuffix, err := corerp.GetSecretSuffix(resourceID, envName, appName) require.NoError(t, err) @@ -86,7 +89,7 @@ func Test_TerraformRecipe_AzureResourceGroup(t *testing.T) { }) test.PostDeleteVerify = func(ctx context.Context, t *testing.T, test rp.RPTest) { - resourceID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/extenders/" + name + resourceID := test.Options.Workspace.Scope + "/providers/Applications.Core/extenders/" + name corerp.TestSecretDeletion(t, ctx, test, appName, envName, resourceID, secretNamespace, secretPrefix) } @@ -103,6 +106,12 @@ func Test_TerraformRecipe_AzureResourceGroup(t *testing.T) { // - Upload the files from test/testrecipes/test-terraform-recipes/kubernetes-redis/modules to a private repository and update the module source in testutil.GetTerraformPrivateModuleSource() // - Create a PAT to access the private repository and update testutil.GetGitPAT() to return the generated PAT. func Test_TerraformPrivateGitModule_KubernetesRedis(t *testing.T) { + // This test pulls a Terraform module from a private GitHub repo using a + // personal access token supplied via the GH_TOKEN env var (set in CI). + // Without that secret the deployment cannot succeed, so skip locally. + if strings.TrimSpace(os.Getenv("GH_TOKEN")) == "" { + t.Skip("Test_TerraformPrivateGitModule_KubernetesRedis requires GH_TOKEN to access a private terraform module repo") + } template := "testdata/corerp-resources-terraform-private-git-repo-redis.bicep" name := "corerp-resources-terraform-private-redis" appName := "corerp-resources-terraform-private-app" diff --git a/test/functional-portable/corerp/noncloud/resources/application_test.go b/test/functional-portable/corerp/noncloud/resources/application_test.go index 211374e61db..5566ba4b97c 100644 --- a/test/functional-portable/corerp/noncloud/resources/application_test.go +++ b/test/functional-portable/corerp/noncloud/resources/application_test.go @@ -18,7 +18,9 @@ package resource_test import ( "context" + "encoding/json" "sort" + "strings" "testing" "github.com/radius-project/radius/test/rp" @@ -29,6 +31,7 @@ import ( aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/cli/clients" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -110,9 +113,17 @@ func Test_ApplicationGraph(t *testing.T) { res, err := appGraphClient.GetGraph(ctx, "corerp-application-simple1", map[string]any{}, nil) require.NoError(t, err) - // assert that the graph is as expected + // assert that the graph is as expected. The fixture was authored against the + // CI cluster's resource group name `kind-radius`; rewrite it on read so the + // test passes regardless of which resource group the local environment uses. expected := []*v20231001preview.ApplicationGraphResource{} - testutil.MustUnmarshalFromFile("corerp-resources-application-graph-out.json", &expected) + scope, err := resources.ParseScope(appManagementClient.RootScope) + require.NoError(t, err) + rg := scope.FindScope("resourcegroups") + require.NotEmpty(t, rg, "expected a resource group in RootScope %q", appManagementClient.RootScope) + rawFixture := testutil.ReadFixture("corerp-resources-application-graph-out.json") + rawFixture = []byte(strings.ReplaceAll(string(rawFixture), "/resourcegroups/kind-radius/", "/resourcegroups/"+rg+"/")) + require.NoError(t, json.Unmarshal(rawFixture, &expected)) // For easier comparison, we sort the resources by name. sort.Slice(res.Resources, func(i, j int) bool { diff --git a/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep b/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep index d200878ee8c..f5ef0712776 100644 --- a/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep +++ b/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep @@ -9,14 +9,18 @@ param port int = 3000 @description('Specifies the image for the container resource.') param magpieimage string +// NOTE: do NOT name this 'default'. Tests must never mutate the shared `default` +// environment because other tests deploy applications into it; the joined +// `-` would exceed k8s' 63-char namespace limit and +// break unrelated tests (e.g. mechanics/communication-cycle). resource env 'Applications.Core/environments@2023-10-01-preview' = { - name: 'default' + name: 'corerp-simulatedenv' location: 'global' properties: { compute: { kind: 'kubernetes' resourceId: 'self' - namespace: 'corerp-resources-simulatedenv-env' + namespace: 'corerp-simulatedenv' } simulated: true } diff --git a/test/functional-portable/corerp/util.go b/test/functional-portable/corerp/util.go index 60631718720..d699abae469 100644 --- a/test/functional-portable/corerp/util.go +++ b/test/functional-portable/corerp/util.go @@ -18,6 +18,7 @@ package corerp import ( "context" + "strings" "testing" "github.com/stretchr/testify/require" @@ -25,8 +26,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/radius-project/radius/pkg/cli" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/terraform/config/backends" + "github.com/radius-project/radius/pkg/ucp/resources" + resources_radius "github.com/radius-project/radius/pkg/ucp/resources/radius" "github.com/radius-project/radius/test/rp" ) @@ -42,10 +46,37 @@ func TestSecretDeletion(t *testing.T, ctx context.Context, test rp.RPTest, appNa require.Equal(t, secret, &corev1.Secret{}) } -// GetSecretSuffix returns the secret suffix for a given resource +// CurrentWorkspaceResourceGroup loads the rad workspace currently configured for tests and +// returns the resource group from its scope. If no workspace is configured it falls back +// to the historical default "kind-radius" used by the cloud test harness. +func CurrentWorkspaceResourceGroup() string { + config, err := cli.LoadConfig("") + if err != nil { + return "kind-radius" + } + workspace, err := cli.GetWorkspace(config, "") + if err != nil || workspace == nil || workspace.Scope == "" { + return "kind-radius" + } + scope, err := resources.ParseScope(workspace.Scope) + if err != nil { + return "kind-radius" + } + if rg := scope.FindScope(resources_radius.ScopeResourceGroups); rg != "" { + return rg + } + return "kind-radius" +} + +// GetSecretSuffix returns the secret suffix for a given resource. The resource group +// embedded in the environment / application / resource IDs is normalized to the +// current workspace's resource group so the computed suffix matches the secret name +// produced by the Terraform recipe at runtime. func GetSecretSuffix(resourceID, envName, appName string) (string, error) { - envID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/environments/" + envName - appID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/applications/" + appName + rg := CurrentWorkspaceResourceGroup() + envID := "/planes/radius/local/resourcegroups/" + rg + "/providers/Applications.Core/environments/" + envName + appID := "/planes/radius/local/resourcegroups/" + rg + "/providers/Applications.Core/applications/" + appName + resourceID = rewriteResourceGroup(resourceID, rg) resourceRecipe := recipes.ResourceMetadata{ EnvironmentID: envID, @@ -63,3 +94,19 @@ func GetSecretSuffix(resourceID, envName, appName string) (string, error) { return kubernetes["secret_suffix"].(string), nil } + +// rewriteResourceGroup rewrites the resource group segment of a Radius resource ID to +// the supplied resource group name. Returns the input unchanged if it cannot be parsed. +func rewriteResourceGroup(resourceID, rg string) string { + parsed, err := resources.ParseResource(resourceID) + if err != nil { + return resourceID + } + current := parsed.FindScope(resources_radius.ScopeResourceGroups) + if current == "" || current == rg { + return resourceID + } + // Case-insensitive replacement of the resourcegroups segment value while preserving the + // rest of the ID exactly. + return strings.ReplaceAll(resourceID, "/"+current+"/", "/"+rg+"/") +} diff --git a/test/functional-portable/kubernetes/noncloud/flux_test.go b/test/functional-portable/kubernetes/noncloud/flux_test.go index 14fec527c7d..b1956b9ae30 100644 --- a/test/functional-portable/kubernetes/noncloud/flux_test.go +++ b/test/functional-portable/kubernetes/noncloud/flux_test.go @@ -137,6 +137,10 @@ func Test_Flux_Complex(t *testing.T) { // testFluxIntegration is a helper function that runs a test for the integration of Radius and Flux. func testFluxIntegration(t *testing.T, testName string, steps []GitOpsTestStep, namespaces []string) { + if os.Getenv("RADIUS_SKIP_FLUX_TESTS") != "" { + t.Skip("Skipping Flux test: RADIUS_SKIP_FLUX_TESTS is set (Radius controller cannot resolve in-cluster Flux source-controller URL when running as a host OS process).") + } + ctx := testcontext.New(t) opts := rp.NewRPTestOptions(t) diff --git a/test/rp/rptest.go b/test/rp/rptest.go index b60af586f74..31fabb2dfbb 100644 --- a/test/rp/rptest.go +++ b/test/rp/rptest.go @@ -508,7 +508,13 @@ func (ct RPTest) Test(t *testing.T) { continue } - for _, resource := range step.RPResources.Resources { + // Delete resources in reverse declaration order so that the Environment + // (typically declared first) is deleted last. Otherwise the Environment + // can be removed while child resources (Applications/Containers/portable + // resources) are still mid-cascade, causing their recipe-driven cleanup + // to fail loading the (now-gone) environment configuration. + for i := len(step.RPResources.Resources) - 1; i >= 0; i-- { + resource := step.RPResources.Resources[i] t.Logf("deleting %s", resource.Name) if ct.FastCleanup { diff --git a/test/ucp/ucptest.go b/test/ucp/ucptest.go index ff246b30f3d..395227a99b1 100644 --- a/test/ucp/ucptest.go +++ b/test/ucp/ucptest.go @@ -25,6 +25,7 @@ import ( "sync" "testing" + "github.com/radius-project/radius/pkg/cli" "github.com/radius-project/radius/pkg/cli/kubernetes" "github.com/radius-project/radius/pkg/sdk" "github.com/radius-project/radius/test" @@ -100,11 +101,30 @@ func (ucptest UCPTest) Test(t *testing.T) { } }) - config, err := kubernetes.NewCLIClientConfig("") - require.NoError(t, err, "failed to read kubeconfig") - - connection, err := sdk.NewKubernetesConnectionFromConfig(config) - require.NoError(t, err, "failed to create kubernetes connection") + // Prefer the active rad workspace's connection so the tests honor the + // workspace's UCP override (e.g. http://localhost:9000 when Radius runs + // as host OS processes via `make debug-start`). When no override is + // configured (the CI default) `Workspace.Connect()` falls through to + // the standard kubernetes APIService aggregation path and behaves + // identically to `sdk.NewKubernetesConnectionFromConfig`. + var ( + connection sdk.Connection + err error + ) + if config, cfgErr := cli.LoadConfig(""); cfgErr == nil { + if workspace, wsErr := cli.GetWorkspace(config, ""); wsErr == nil && workspace != nil { + t.Logf("Loaded workspace: %s (%s)", workspace.Name, workspace.FmtConnection()) + connection, err = workspace.Connect(ctx) + } + } + if connection == nil { + // Fall back to the legacy direct-from-kubeconfig path when no rad + // workspace is available. + var kcfg, cerr = kubernetes.NewCLIClientConfig("") + require.NoError(t, cerr, "failed to read kubeconfig") + connection, err = sdk.NewKubernetesConnectionFromConfig(kcfg) + } + require.NoError(t, err, "failed to create UCP connection") // Transport will be nil for some default cases as http.Client does not require it to be set. // Since the tests call the transport directly then just pass in the default. diff --git a/test/validation/shared.go b/test/validation/shared.go index a09f81ca954..6c50bdde309 100644 --- a/test/validation/shared.go +++ b/test/validation/shared.go @@ -18,8 +18,8 @@ package validation import ( "context" "encoding/json" - "fmt" "net/http" + "os" "strings" "testing" @@ -129,33 +129,15 @@ func DeleteRPResourceSilent(ctx context.Context, cli *radcli.CLI, client clients func ValidateRPResources(ctx context.Context, t *testing.T, expected *RPResourceSet, client clients.ApplicationsManagementClient) { for _, expectedResource := range expected.Resources { if expectedResource.Type == EnvironmentsResource { - envs, err := client.ListEnvironments(ctx) - require.NoError(t, err) - require.NotEmpty(t, envs) - - found := false - for _, env := range envs { - if *env.Name == expectedResource.Name { - found = true - break - } - } - - require.True(t, found, fmt.Sprintf("environment %s was not found", expectedResource.Name)) + // Use GetEnvironment instead of ListEnvironments to avoid a read-after-write + // race where the resource exists but is not yet visible via the paginated list. + _, err := client.GetEnvironment(ctx, expectedResource.Name) + require.NoErrorf(t, err, "environment %s was not found", expectedResource.Name) } else if expectedResource.Type == ApplicationsResource { - apps, err := client.ListApplications(ctx) - require.NoError(t, err) - require.NotEmpty(t, apps) - - found := false - for _, app := range apps { - if *app.Name == expectedResource.Name { - found = true - break - } - } - - require.True(t, found, fmt.Sprintf("application %s was not found", expectedResource.Name)) + // Use GetApplication instead of ListApplications to avoid a read-after-write + // race where the resource exists but is not yet visible via the paginated list. + _, err := client.GetApplication(ctx, expectedResource.Name) + require.NoErrorf(t, err, "application %s was not found", expectedResource.Name) } else { res, err := client.GetResource(ctx, expectedResource.Type, expectedResource.Name) require.NoError(t, err) @@ -197,7 +179,20 @@ func ValidateRPResources(ctx context.Context, t *testing.T, expected *RPResource } // AssertCredentialExists checks if the credential is registered in the workspace and returns a boolean value. +// +// Local-dev escape hatch: when RADIUS_TEST_USE_LOCAL_CLOUD_CREDS lists the credential +// (comma-separated; supported values: "azure", "aws", or "1"/"true" for all clouds), +// the UCP credential check is bypassed and the test is allowed to run as if the +// credential were registered. This is used by the `test-functional-azure-local` +// make target, which runs Radius components with ambient cloud credentials +// (az CLI / AWS profile) instead of credentials registered in UCP. The container-DE +// path used by CI and most contributors is unaffected. func AssertCredentialExists(t *testing.T, credential string) bool { + if localCloudCredAllowed(credential) { + t.Logf("RADIUS_TEST_USE_LOCAL_CLOUD_CREDS includes %q; bypassing UCP credential check", credential) + return true + } + ctx := testcontext.New(t) config, err := cli.LoadConfig("") @@ -216,3 +211,23 @@ func AssertCredentialExists(t *testing.T, credential string) bool { return cred.CloudProviderStatus.Enabled } + +// localCloudCredAllowed reports whether the given credential ("azure", "aws") is +// covered by the RADIUS_TEST_USE_LOCAL_CLOUD_CREDS escape hatch. Accepted values: +// - "1" / "true": all clouds. +// - comma-separated list of cloud names, e.g. "azure" or "azure,aws". +func localCloudCredAllowed(credential string) bool { + v := strings.TrimSpace(os.Getenv("RADIUS_TEST_USE_LOCAL_CLOUD_CREDS")) + if v == "" { + return false + } + if v == "1" || strings.EqualFold(v, "true") { + return true + } + for _, item := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(item), credential) { + return true + } + } + return false +} diff --git a/typespec/pnpm-lock.yaml b/typespec/pnpm-lock.yaml index 8f7d94f687e..415422916c6 100644 --- a/typespec/pnpm-lock.yaml +++ b/typespec/pnpm-lock.yaml @@ -10,28 +10,28 @@ importers: dependencies: '@azure-tools/typespec-autorest': specifier: ^0.68.0 - version: 0.68.0(b819e1ab840e6a61a3b88d15a28dfa56) + version: 0.68.0(c5bab902953352c35377dc6449d40f6a) '@azure-tools/typespec-azure-core': specifier: ^0.68.0 - version: 0.68.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))) + version: 0.68.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))) '@azure-tools/typespec-azure-resource-manager': specifier: ^0.68.0 - version: 0.68.0(910004cd6bdbefd6f3ee443ace0f674f) + version: 0.68.0(52866d6d3c7b9e5037d7874e951a4e0f) '@typespec/compiler': specifier: ^1.12.0 - version: 1.12.0(@types/node@25.7.0) + version: 1.12.0(@types/node@25.9.0) '@typespec/http': specifier: ^1.12.0 - version: 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) + version: 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) '@typespec/openapi': specifier: ^1.12.0 - version: 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) + version: 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) '@typespec/rest': specifier: ^0.82.0 - version: 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) + version: 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) '@typespec/versioning': specifier: ^0.82.0 - version: 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) + version: 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) devDependencies: autorest: specifier: 3.8.0 @@ -346,8 +346,8 @@ packages: '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} - '@types/node@25.7.0': - resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} + '@types/node@25.9.0': + resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==} '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} @@ -967,8 +967,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - undici-types@7.21.0: - resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1091,48 +1091,48 @@ snapshots: transitivePeerDependencies: - encoding - '@azure-tools/typespec-autorest@0.68.0(b819e1ab840e6a61a3b88d15a28dfa56)': + '@azure-tools/typespec-autorest@0.68.0(c5bab902953352c35377dc6449d40f6a)': dependencies: - '@azure-tools/typespec-azure-core': 0.68.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))) - '@azure-tools/typespec-azure-resource-manager': 0.68.0(910004cd6bdbefd6f3ee443ace0f674f) - '@azure-tools/typespec-client-generator-core': 0.63.4(3f023a22ca67c5705546b78cde9ea446) - '@typespec/compiler': 1.12.0(@types/node@25.7.0) - '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) - '@typespec/openapi': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) - '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) - '@typespec/versioning': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) + '@azure-tools/typespec-azure-core': 0.68.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))) + '@azure-tools/typespec-azure-resource-manager': 0.68.0(52866d6d3c7b9e5037d7874e951a4e0f) + '@azure-tools/typespec-client-generator-core': 0.63.4(236b066dadf221e75d88ff8c367f710b) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) + '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) + '@typespec/openapi': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) + '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) + '@typespec/versioning': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) optionalDependencies: - '@typespec/xml': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) + '@typespec/xml': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) - '@azure-tools/typespec-azure-core@0.68.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))))': + '@azure-tools/typespec-azure-core@0.68.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) - '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) - '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) + '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) + '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) - '@azure-tools/typespec-azure-resource-manager@0.68.0(910004cd6bdbefd6f3ee443ace0f674f)': + '@azure-tools/typespec-azure-resource-manager@0.68.0(52866d6d3c7b9e5037d7874e951a4e0f)': dependencies: - '@azure-tools/typespec-azure-core': 0.68.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))) - '@typespec/compiler': 1.12.0(@types/node@25.7.0) - '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) - '@typespec/openapi': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) - '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) - '@typespec/versioning': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) + '@azure-tools/typespec-azure-core': 0.68.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) + '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) + '@typespec/openapi': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) + '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) + '@typespec/versioning': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) change-case: 5.4.4 pluralize: 8.0.0 - '@azure-tools/typespec-client-generator-core@0.63.4(3f023a22ca67c5705546b78cde9ea446)': - dependencies: - '@azure-tools/typespec-azure-core': 0.68.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))) - '@typespec/compiler': 1.12.0(@types/node@25.7.0) - '@typespec/events': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) - '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) - '@typespec/openapi': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) - '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))) - '@typespec/sse': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/events@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) - '@typespec/streams': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) - '@typespec/versioning': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) - '@typespec/xml': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) + '@azure-tools/typespec-client-generator-core@0.63.4(236b066dadf221e75d88ff8c367f710b)': + dependencies: + '@azure-tools/typespec-azure-core': 0.68.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))(@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) + '@typespec/events': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) + '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) + '@typespec/openapi': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) + '@typespec/rest': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))) + '@typespec/sse': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/events@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) + '@typespec/streams': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) + '@typespec/versioning': 0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) + '@typespec/xml': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) change-case: 5.4.4 pluralize: 8.0.0 yaml: 2.8.4 @@ -1167,122 +1167,122 @@ snapshots: '@inquirer/ansi@2.0.5': {} - '@inquirer/checkbox@5.1.5(@types/node@25.7.0)': + '@inquirer/checkbox@5.1.5(@types/node@25.9.0)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/confirm@6.0.13(@types/node@25.7.0)': + '@inquirer/confirm@6.0.13(@types/node@25.9.0)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.7.0) - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/core@11.1.10(@types/node@25.7.0)': + '@inquirer/core@11.1.10(@types/node@25.9.0)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) cli-width: 4.1.0 fast-wrap-ansi: 0.2.0 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/editor@5.1.2(@types/node@25.7.0)': + '@inquirer/editor@5.1.2(@types/node@25.9.0)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.7.0) - '@inquirer/external-editor': 3.0.0(@types/node@25.7.0) - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) + '@inquirer/external-editor': 3.0.0(@types/node@25.9.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/expand@5.0.14(@types/node@25.7.0)': + '@inquirer/expand@5.0.14(@types/node@25.9.0)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.7.0) - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/external-editor@3.0.0(@types/node@25.7.0)': + '@inquirer/external-editor@3.0.0(@types/node@25.9.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 '@inquirer/figures@2.0.5': {} - '@inquirer/input@5.0.13(@types/node@25.7.0)': + '@inquirer/input@5.0.13(@types/node@25.9.0)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.7.0) - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/number@4.0.13(@types/node@25.7.0)': + '@inquirer/number@4.0.13(@types/node@25.9.0)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.7.0) - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/password@5.0.13(@types/node@25.7.0)': + '@inquirer/password@5.0.13(@types/node@25.9.0)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.7.0) - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 - - '@inquirer/prompts@8.4.3(@types/node@25.7.0)': - dependencies: - '@inquirer/checkbox': 5.1.5(@types/node@25.7.0) - '@inquirer/confirm': 6.0.13(@types/node@25.7.0) - '@inquirer/editor': 5.1.2(@types/node@25.7.0) - '@inquirer/expand': 5.0.14(@types/node@25.7.0) - '@inquirer/input': 5.0.13(@types/node@25.7.0) - '@inquirer/number': 4.0.13(@types/node@25.7.0) - '@inquirer/password': 5.0.13(@types/node@25.7.0) - '@inquirer/rawlist': 5.2.9(@types/node@25.7.0) - '@inquirer/search': 4.1.9(@types/node@25.7.0) - '@inquirer/select': 5.1.5(@types/node@25.7.0) + '@types/node': 25.9.0 + + '@inquirer/prompts@8.4.3(@types/node@25.9.0)': + dependencies: + '@inquirer/checkbox': 5.1.5(@types/node@25.9.0) + '@inquirer/confirm': 6.0.13(@types/node@25.9.0) + '@inquirer/editor': 5.1.2(@types/node@25.9.0) + '@inquirer/expand': 5.0.14(@types/node@25.9.0) + '@inquirer/input': 5.0.13(@types/node@25.9.0) + '@inquirer/number': 4.0.13(@types/node@25.9.0) + '@inquirer/password': 5.0.13(@types/node@25.9.0) + '@inquirer/rawlist': 5.2.9(@types/node@25.9.0) + '@inquirer/search': 4.1.9(@types/node@25.9.0) + '@inquirer/select': 5.1.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/rawlist@5.2.9(@types/node@25.7.0)': + '@inquirer/rawlist@5.2.9(@types/node@25.9.0)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.7.0) - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/search@4.1.9(@types/node@25.7.0)': + '@inquirer/search@4.1.9(@types/node@25.9.0)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/select@5.1.5(@types/node@25.7.0)': + '@inquirer/select@5.1.5(@types/node@25.9.0)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.7.0) + '@inquirer/core': 11.1.10(@types/node@25.9.0) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.7.0) + '@inquirer/type': 4.0.5(@types/node@25.9.0) optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 - '@inquirer/type@4.0.5(@types/node@25.7.0)': + '@inquirer/type@4.0.5(@types/node@25.9.0)': optionalDependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 '@inversifyjs/common@1.5.2': {} @@ -1380,23 +1380,23 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.7.0 + '@types/node': 25.9.0 form-data: 4.0.5 '@types/node@10.17.60': {} - '@types/node@25.7.0': + '@types/node@25.9.0': dependencies: - undici-types: 7.21.0 + undici-types: 7.24.6 '@types/retry@0.12.5': {} '@types/triple-beam@1.3.5': {} - '@typespec/compiler@1.12.0(@types/node@25.7.0)': + '@typespec/compiler@1.12.0(@types/node@25.9.0)': dependencies: '@babel/code-frame': 7.29.0 - '@inquirer/prompts': 8.4.3(@types/node@25.7.0) + '@inquirer/prompts': 8.4.3(@types/node@25.9.0) ajv: 8.20.0 change-case: 5.4.4 env-paths: 4.0.0 @@ -1414,44 +1414,44 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@typespec/events@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))': + '@typespec/events@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) - '@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))': + '@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) optionalDependencies: - '@typespec/streams': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) + '@typespec/streams': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) - '@typespec/openapi@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))': + '@typespec/openapi@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) - '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) + '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) - '@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))': + '@typespec/rest@0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) - '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) + '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) - '@typespec/sse@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/events@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)))': + '@typespec/sse@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/events@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) - '@typespec/events': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) - '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.7.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))) - '@typespec/streams': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0)) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) + '@typespec/events': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) + '@typespec/http': 1.12.0(@typespec/compiler@1.12.0(@types/node@25.9.0))(@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))) + '@typespec/streams': 0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0)) - '@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))': + '@typespec/streams@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) - '@typespec/versioning@0.82.0(@typespec/compiler@1.12.0(@types/node@25.7.0))': + '@typespec/versioning@0.82.0(@typespec/compiler@1.12.0(@types/node@25.9.0))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) - '@typespec/xml@0.77.0(@typespec/compiler@1.12.0(@types/node@25.7.0))': + '@typespec/xml@0.77.0(@typespec/compiler@1.12.0(@types/node@25.9.0))': dependencies: - '@typespec/compiler': 1.12.0(@types/node@25.7.0) + '@typespec/compiler': 1.12.0(@types/node@25.9.0) ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: @@ -1955,7 +1955,7 @@ snapshots: tslib@2.8.1: {} - undici-types@7.21.0: {} + undici-types@7.24.6: {} uri-js@4.4.1: dependencies: diff --git a/versions.yaml b/versions.yaml index 4238ab326ae..dd02f887510 100644 --- a/versions.yaml +++ b/versions.yaml @@ -1,9 +1,11 @@ supported: + - channel: '0.58' + version: 'v0.58.0' - channel: '0.57' version: 'v0.57.1' +deprecated: - channel: '0.56' version: 'v0.56.1' -deprecated: - channel: '0.55' version: 'v0.55.0' - channel: '0.54'