From 1f174ab21a5b55de59225e91edf7f4e3e9d3c60d Mon Sep 17 00:00:00 2001 From: Peli de Halleux <4175913+pelikhan@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:49:35 -0800 Subject: [PATCH] Retire Azure Functions support in favor of Container Apps Remove the Functions deployment backend, runtime, templates, local harness and CI maintenance. Default Azure bundles and release assets to Container Apps and update policy, documentation and regression contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/aw/actions-lock.json | 5 - .github/workflows/azure-local-integration.yml | 59 - .github/workflows/cao.azure.json | 2 +- .github/workflows/ci-daily.yml | 4 - .github/workflows/go-extension.yml | 29 +- .github/workflows/podman.yml | 76 +- .../self-care-database-architecture.lock.yml | 2 +- .../self-care-database-architecture.md | 12 +- .github/workflows/shared/cao.schema.json | 2 +- .github/workflows/shared/policy.mjs | 3 +- AGENTS.md | 16 +- ARCHITECTURE.md | 26 +- adr/parquet-unified-session-storage.md | 2 +- docs/authentication.md | 2 +- docs/cao-cli.md | 9 +- docs/components/HeroBackendDiagram.astro | 2 +- docs/configuration.md | 2 +- docs/dashboard-data-architecture.md | 69 +- docs/deployment-azure.md | 1240 +------- docs/deployment-topology.md | 65 +- docs/deployment.md | 12 +- internal/azcli/azcli.go | 259 -- internal/azcli/azcli_test.go | 199 -- internal/cli/deploy_azure.go | 581 ---- internal/cli/deploy_azure_apply.go | 293 -- internal/cli/deploy_azure_apply_test.go | 723 ----- internal/cli/deploy_azure_assurance_test.go | 247 -- internal/cli/deploy_azure_config.go | 416 --- internal/cli/deploy_azure_images.go | 452 --- internal/cli/deploy_azure_jobs.go | 153 - internal/cli/deploy_azure_permissions.go | 76 - internal/cli/deploy_azure_preflight.go | 962 ------ internal/cli/deploy_azure_preflight_test.go | 421 --- internal/cli/deploy_azure_restart.go | 92 - internal/cli/deploy_azure_restart_test.go | 26 - internal/cli/deploy_azure_retry.go | 165 -- internal/cli/deploy_azure_retry_test.go | 397 --- internal/cli/deploy_azure_snapshot.go | 73 - internal/cli/deploy_azure_telemetry_test.go | 142 - internal/cli/deploy_azure_template.go | 278 -- .../cli/deploy_azure_template_facts_test.go | 122 - internal/cli/deploy_azure_test.go | 716 ----- internal/cli/deploy_azure_verify.go | 164 -- internal/cli/deploy_bundle.go | 23 +- internal/cli/deploy_bundle_test.go | 1 + internal/cli/evidence.go | 31 +- internal/cli/evidence_azure.go | 101 - internal/cli/evidence_release.go | 14 +- internal/cli/evidence_test.go | 86 +- internal/cli/native_policy.go | 2 +- internal/cli/root.go | 2 +- internal/topology/README.md | 29 +- internal/topology/assets.go | 37 +- internal/topology/assets/AGENT-MCP.md | 11 +- .../assets/azure-baseline.generated/adx.bicep | 101 - .../azure-baseline.generated/collector.bicep | 333 --- .../function-roles.bicep | 72 - .../azure-baseline.generated/main.bicep | 812 ------ .../assets/azure-baseline.generated/main.json | 2598 ----------------- .../azure-baseline.generated/network.bicep | 140 - .../OPTIONAL-FEATURES.md.tmpl | 6 +- .../assets/container-apps-docs/README.md.tmpl | 17 +- .../topology/assets/docs/GITHUB-APPS.md.tmpl | 52 - internal/topology/assets/docs/README.md.tmpl | 70 - internal/topology/assets/docs/TODO.md.tmpl | 55 - internal/topology/assets/example.yaml | 31 - .../topology/assets/examples/full-azure.yaml | 28 +- internal/topology/azure.go | 75 - internal/topology/azure_test.go | 86 - internal/topology/bundle.go | 28 +- internal/topology/cmd/generate-assets/main.go | 13 +- internal/topology/compact.go | 2 +- internal/topology/container_apps.go | 14 +- internal/topology/container_apps_test.go | 4 +- internal/topology/diagrams.go | 57 +- internal/topology/graph.go | 53 +- internal/topology/mcp_documentation_test.go | 11 +- internal/topology/model.go | 2 +- internal/topology/rules.go | 4 +- internal/topology/samples_test.go | 5 +- internal/topology/solve.go | 201 +- internal/topology/topology_test.go | 16 +- package.json | 4 - scripts/azure-local/README.md | 394 --- scripts/azure-local/azure-local.sh | 678 ----- scripts/azure-local/platform.py | 116 - scripts/azure-local/prepare-test-binaries.sh | 14 - scripts/azure-local/profile.mjs | 83 - scripts/build-cao-azure-assets.mjs | 8 +- .../cao-azure-assets.json | 2 +- scripts/run-unit-area.mjs | 2 +- server/ADMISSION.md | 4 +- server/ADX-TRACES.md | 2 +- server/Dockerfile | 25 +- server/README.md | 318 +- server/SECURITY.md | 173 +- server/SESSION-ARCHIVE.md | 2 +- server/SOC2.md | 11 +- server/azure/collector.bicep | 7 +- server/azure/function-roles.bicep | 72 - server/azure/main.bicep | 812 ------ server/azure/main.json | 2598 ----------------- server/azure/network.bicep | 140 - server/cmd/cao-dashboard/azure_functions.go | 137 - .../cmd/cao-dashboard/azure_functions_test.go | 311 -- server/cmd/cao-dashboard/main.go | 1 - server/cmd/cao-dashboard/main_test.go | 2 +- server/internal/server/azure.go | 453 --- .../server/events_authorization_test.go | 2 +- server/internal/server/host_modules.go | 5 +- server/internal/server/host_policy_test.go | 19 +- server/internal/server/host_profile.go | 7 - server/internal/server/host_profile_test.go | 33 +- server/internal/server/oauth_test.go | 6 +- .../server/operational_factory_test.go | 6 +- server/internal/server/proxy.go | 197 ++ ...n_test.go => proxy_classification_test.go} | 0 server/internal/server/redis_memory_test.go | 9 - server/internal/server/server_test.go | 137 +- server/internal/server/site_test.go | 2 +- server/internal/simulator/azure_cli_test.go | 280 -- .../internal/simulator/azure_contract_test.go | 63 - .../simulator/azure_deployment_test.go | 302 -- .../simulator/azure_docker_relay_test.go | 137 - .../simulator/azure_functions_test.go | 339 --- .../simulator/azure_isolation_test.go | 126 - .../internal/simulator/azure_journey_test.go | 351 --- .../internal/simulator/azure_storage_test.go | 204 -- .../simulator/setup_azure_integration_test.go | 345 --- .../telemetry/azure_deployment_test.go | 243 -- specs/dashboard-data.md | 2 +- specs/server-hosted-cli.md | 4 +- specs/server-soc2-compliance.md | 2 +- specs/session-archive.md | 2 +- tests/helpers/azure-local-go.mjs | 80 - .../azure-local-dev-browser.test.mjs | 88 - .../azure-local-functions.test.mjs | 49 - .../azure-local-protocols.test.mjs | 21 - .../integration/azure-local-services.test.mjs | 160 - tests/unit/azure-local-dev.test.mjs | 291 -- tests/unit/azure-local-go.test.mjs | 25 - tests/unit/azure-local-harness.test.mjs | 377 --- tests/unit/azure-local-platform.test.mjs | 118 - tests/unit/cao-azure-assets.test.mjs | 18 +- tests/unit/cao-ci-tiers.test.mjs | 2 +- tests/unit/cao-package-contract.test.mjs | 2 +- tests/unit/control-policy.test.mjs | 4 +- .../dashboard-container-contract.test.mjs | 1 - tests/unit/podman-ci.test.mjs | 36 +- tests/unit/server-azure-adx.test.mjs | 88 +- tests/unit/server-azure-bicep.test.mjs | 662 +---- .../server-azure-service-coverage.test.mjs | 62 - tests/unit/unit-area-selection.test.mjs | 1 - .../workflow-contract-ci-triggers.test.mjs | 46 +- ...ct-selfcare-database-architecture.test.mjs | 20 +- 155 files changed, 861 insertions(+), 24301 deletions(-) delete mode 100644 .github/workflows/azure-local-integration.yml delete mode 100644 internal/azcli/azcli.go delete mode 100644 internal/azcli/azcli_test.go delete mode 100644 internal/cli/deploy_azure.go delete mode 100644 internal/cli/deploy_azure_apply.go delete mode 100644 internal/cli/deploy_azure_apply_test.go delete mode 100644 internal/cli/deploy_azure_assurance_test.go delete mode 100644 internal/cli/deploy_azure_config.go delete mode 100644 internal/cli/deploy_azure_images.go delete mode 100644 internal/cli/deploy_azure_jobs.go delete mode 100644 internal/cli/deploy_azure_permissions.go delete mode 100644 internal/cli/deploy_azure_preflight.go delete mode 100644 internal/cli/deploy_azure_preflight_test.go delete mode 100644 internal/cli/deploy_azure_restart.go delete mode 100644 internal/cli/deploy_azure_restart_test.go delete mode 100644 internal/cli/deploy_azure_retry.go delete mode 100644 internal/cli/deploy_azure_retry_test.go delete mode 100644 internal/cli/deploy_azure_snapshot.go delete mode 100644 internal/cli/deploy_azure_telemetry_test.go delete mode 100644 internal/cli/deploy_azure_template.go delete mode 100644 internal/cli/deploy_azure_template_facts_test.go delete mode 100644 internal/cli/deploy_azure_test.go delete mode 100644 internal/cli/deploy_azure_verify.go delete mode 100644 internal/cli/evidence_azure.go delete mode 100644 internal/topology/assets/azure-baseline.generated/adx.bicep delete mode 100644 internal/topology/assets/azure-baseline.generated/collector.bicep delete mode 100644 internal/topology/assets/azure-baseline.generated/function-roles.bicep delete mode 100644 internal/topology/assets/azure-baseline.generated/main.bicep delete mode 100644 internal/topology/assets/azure-baseline.generated/main.json delete mode 100644 internal/topology/assets/azure-baseline.generated/network.bicep rename internal/topology/assets/{docs => container-apps-docs}/OPTIONAL-FEATURES.md.tmpl (97%) delete mode 100644 internal/topology/assets/docs/GITHUB-APPS.md.tmpl delete mode 100644 internal/topology/assets/docs/README.md.tmpl delete mode 100644 internal/topology/assets/docs/TODO.md.tmpl delete mode 100644 internal/topology/assets/example.yaml delete mode 100644 internal/topology/azure.go delete mode 100644 internal/topology/azure_test.go delete mode 100644 scripts/azure-local/README.md delete mode 100755 scripts/azure-local/azure-local.sh delete mode 100644 scripts/azure-local/platform.py delete mode 100644 scripts/azure-local/prepare-test-binaries.sh delete mode 100644 scripts/azure-local/profile.mjs rename internal/cli/deploy_azure_assets.json => scripts/cao-azure-assets.json (96%) delete mode 100644 server/azure/function-roles.bicep delete mode 100644 server/azure/main.bicep delete mode 100644 server/azure/main.json delete mode 100644 server/azure/network.bicep delete mode 100644 server/cmd/cao-dashboard/azure_functions.go delete mode 100644 server/cmd/cao-dashboard/azure_functions_test.go delete mode 100644 server/internal/server/azure.go create mode 100644 server/internal/server/proxy.go rename server/internal/server/{azure_proxy_classification_test.go => proxy_classification_test.go} (100%) delete mode 100644 server/internal/simulator/azure_cli_test.go delete mode 100644 server/internal/simulator/azure_contract_test.go delete mode 100644 server/internal/simulator/azure_deployment_test.go delete mode 100644 server/internal/simulator/azure_docker_relay_test.go delete mode 100644 server/internal/simulator/azure_functions_test.go delete mode 100644 server/internal/simulator/azure_isolation_test.go delete mode 100644 server/internal/simulator/azure_journey_test.go delete mode 100644 server/internal/simulator/azure_storage_test.go delete mode 100644 server/internal/simulator/setup_azure_integration_test.go delete mode 100644 server/internal/telemetry/azure_deployment_test.go delete mode 100644 tests/helpers/azure-local-go.mjs delete mode 100644 tests/integration/azure-local-dev-browser.test.mjs delete mode 100644 tests/integration/azure-local-functions.test.mjs delete mode 100644 tests/integration/azure-local-protocols.test.mjs delete mode 100644 tests/integration/azure-local-services.test.mjs delete mode 100644 tests/unit/azure-local-dev.test.mjs delete mode 100644 tests/unit/azure-local-go.test.mjs delete mode 100644 tests/unit/azure-local-harness.test.mjs delete mode 100644 tests/unit/azure-local-platform.test.mjs delete mode 100644 tests/unit/server-azure-service-coverage.test.mjs diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index b4e5a78fa9..de1450d933 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -20,11 +20,6 @@ "version": "v7.0.1", "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" }, - "github/gh-aw-actions/setup-cli@v1.2.2": { - "repo": "github/gh-aw-actions/setup-cli", - "version": "v1.2.2", - "sha": "53d0749e19424a918acd82b4d140264ce48f2f14" - }, "github/gh-aw-actions/setup@v1.2.2": { "repo": "github/gh-aw-actions/setup", "version": "v1.2.2", diff --git a/.github/workflows/azure-local-integration.yml b/.github/workflows/azure-local-integration.yml deleted file mode 100644 index 42cbe5f9c8..0000000000 --- a/.github/workflows/azure-local-integration.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Local Azure services integration - -on: - push: - branches: [main] - paths: - - .github/workflows/azure-local-integration.yml - - dashboard/site/** - - scripts/azure-local/** - - server/** - - tests/integration/azure-local-functions.test.mjs - - tests/integration/azure-local-services.test.mjs - - tests/unit/server-azure-*.test.mjs - - tests/unit/azure-local-*.test.mjs - - internal/cli/** - - internal/azcli/** - - go.mod - - go.sum - - package.json - - .github/workflows/cao.json - - .github/workflows/cao.azure.json - - "!dashboard/site/**/*.md" - - "!server/**/*.md" - workflow_dispatch: - workflow_call: - -permissions: - contents: read - -jobs: - azure-local: - name: Functions, Storage, Postgres and Azure service contracts - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 - with: - go-version: 1.27.3 - cache: false - - name: Prepare Go dependencies before hermetic service tests - run: go mod download && go -C server mod download - - name: Check portable Azure harness - run: node --test tests/unit/azure-local-*.test.mjs - - name: Run local Azure integration - run: npm run test:integration:azure-services - - name: Upload local Azure logs - if: ${{ always() && failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: azure-local-integration - path: .tmp/azure-local/*/logs/ - if-no-files-found: warn - retention-days: 14 diff --git a/.github/workflows/cao.azure.json b/.github/workflows/cao.azure.json index 19aabc9fa5..216d87f337 100644 --- a/.github/workflows/cao.azure.json +++ b/.github/workflows/cao.azure.json @@ -4,7 +4,7 @@ "web": { "host": { "target": { - "module": "azure-functions" + "module": "container" }, "operational-store": { "backend": "postgres" diff --git a/.github/workflows/ci-daily.yml b/.github/workflows/ci-daily.yml index bbc4622f74..6a97c5e330 100644 --- a/.github/workflows/ci-daily.yml +++ b/.github/workflows/ci-daily.yml @@ -57,9 +57,6 @@ jobs: actions-runtime: needs: revision uses: ./.github/workflows/actions-runtime.yml # zizmor: ignore[self-repository] - Local reusable workflows use syntax supported by Actions and actionlint. - azure: - needs: revision - uses: ./.github/workflows/azure-local-integration.yml # zizmor: ignore[self-repository] - Local reusable workflows use syntax supported by Actions and actionlint. mobile: needs: revision uses: ./.github/workflows/actions.yml # zizmor: ignore[self-repository] - Local reusable workflows use syntax supported by Actions and actionlint. @@ -149,7 +146,6 @@ jobs: - extension - bootstrap - actions-runtime - - azure - mobile - views - deployed diff --git a/.github/workflows/go-extension.yml b/.github/workflows/go-extension.yml index 471692ad0a..29812892f3 100644 --- a/.github/workflows/go-extension.yml +++ b/.github/workflows/go-extension.yml @@ -152,23 +152,11 @@ jobs: ./gh-cao pages --json > /dev/null extension-setup-simulator: - name: Setup against local GitHub and Azure simulators + name: Setup against local GitHub simulators # Keep the full external-tool topology matrix off the PR critical path. if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 - services: - postgres: - image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea - env: - POSTGRES_DB: cao - POSTGRES_HOST_AUTH_METHOD: trust - ports: ["5432:5432"] - options: >- - --health-cmd "pg_isready -U postgres -d cao" - --health-interval 5s - --health-timeout 5s - --health-retries 12 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -191,21 +179,6 @@ jobs: GH_ENTERPRISE_TOKEN: "" GITHUB_ENTERPRISE_TOKEN: "" run: npm run test:integration:setup - - name: Prepare offline Azure simulation prerequisites - run: | - go mod download - go -C server mod download - docker pull mcr.microsoft.com/azure-storage/azurite:3.37.0@sha256:830430c1da1a2d537e08f3e6764dd1f5ae00cf0346bcaf625b968ec3f0971fd5 - - name: Run Azure setup with official Azurite and local control-plane simulation - env: - POSTGRES_URL: postgres://postgres@127.0.0.1:5432/cao?sslmode=disable - GH_TOKEN: "" - GITHUB_TOKEN: "" - GH_ENTERPRISE_TOKEN: "" - GITHUB_ENTERPRISE_TOKEN: "" - run: | - npm run test:integration:setup-azure - npm run test:integration:azure-install-journey extension-authtests: name: Doctor authenticated integration tests diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index 565d862ae1..5abac9a390 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -5,16 +5,10 @@ on: paths: - .github/workflows/podman.yml - scripts/container-*.sh - - scripts/azure-local/** - internal/** - server/** - tests/unit/container-engine.test.mjs - tests/unit/podman-ci.test.mjs - - tests/unit/azure-local-*.test.mjs - - tests/integration/azure-local-*.test.mjs - - tests/helpers/azure-local-go.mjs - - tests/integration/azure-local-*.test.mjs - - tests/helpers/azure-local-go.mjs - go.mod - go.sum - package.json @@ -25,14 +19,10 @@ on: paths: - .github/workflows/podman.yml - scripts/container-*.sh - - scripts/azure-local/** - internal/** - server/** - tests/unit/container-engine.test.mjs - tests/unit/podman-ci.test.mjs - - tests/unit/azure-local-*.test.mjs - - tests/integration/azure-local-*.test.mjs - - tests/integration/azure-local-*.test.mjs - go.mod - go.sum - package.json @@ -50,30 +40,26 @@ concurrency: jobs: podman: - name: Rootless Podman lifecycle and local Azure services + name: Rootless Podman lifecycle runs-on: ubuntu-24.04 timeout-minutes: 25 env: CAO_CONTAINER_ENGINE: podman CAO_TEST_PODMAN: "1" steps: - - &checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - &node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - &binaries - name: Restore exact-source race-instrumented executables + - name: Restore exact-source race-instrumented executable id: binaries uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .tmp/podman-bin - key: podman-bin-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.github/workflows/podman.yml', 'scripts/azure-local/prepare-test-binaries.sh', 'server/**', 'internal/**', 'go.mod', 'go.sum') }} - - &go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + key: podman-bin-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.github/workflows/podman.yml', 'server/**', 'internal/**', 'go.mod', 'go.sum') }} + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 if: steps.binaries.outputs.cache-hit != 'true' with: go-version: 1.27.3 @@ -81,8 +67,7 @@ jobs: cache-dependency-path: | go.sum server/go.sum - - &podman-host - name: Prepare rootless Podman with a delegated user session + - name: Prepare rootless Podman with a delegated user session run: | missing=() for tool in podman newuidmap slirp4netns fuse-overlayfs crun; do @@ -97,11 +82,12 @@ jobs: sudo systemctl start "user@$(id -u).service" echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV" echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV" - - &build - name: Build current-source executables on cache miss + - name: Build current-source executable on cache miss if: steps.binaries.outputs.cache-hit != 'true' - run: bash scripts/azure-local/prepare-test-binaries.sh - - name: Run independent rootless and harness checks concurrently + run: | + mkdir -p .tmp/podman-bin/server + go -C server test -race -c -o "$GITHUB_WORKSPACE/.tmp/podman-bin/server/hostedcli.test" ./internal/hostedcli + - name: Run independent rootless and container checks concurrently run: | boundary() { image="$(sed -n 's/^const DefaultContainerImage = "\(.*\)"$/\1/p' server/internal/hostedcli/container_runner.go)" @@ -114,45 +100,9 @@ jobs: } boundary & boundary_pid=$! - node --test tests/unit/container-engine.test.mjs tests/unit/azure-local-*.test.mjs & + node --test tests/unit/container-engine.test.mjs & contracts_pid=$! status=0 wait "$boundary_pid" || status=$? wait "$contracts_pid" || status=$? exit "$status" - azure-protocols: - name: Azure deployment protocol contracts - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - *checkout - - *node - - *binaries - - *go - - *build - - name: Verify deployment protocols without waiting for service startup - env: - AZURE_LOCAL_TEST_BINARIES: ${{ github.workspace }}/.tmp/podman-bin - run: node tests/integration/azure-local-protocols.test.mjs - azure-services: - name: Real Azure services with rootless Podman - runs-on: ubuntu-24.04 - timeout-minutes: 20 - env: - CAO_CONTAINER_ENGINE: podman - steps: - - *checkout - - *node - - *binaries - - *go - - name: Restore checksum-verified Azure Functions Core Tools - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: .tmp/azure-local/tools - key: podman-core-tools-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/azure-local/azure-local.sh') }} - - *podman-host - - *build - - name: Verify real Functions, Storage and PostgreSQL independently - env: - AZURE_LOCAL_TEST_BINARIES: ${{ github.workspace }}/.tmp/podman-bin - run: bash scripts/azure-local/azure-local.sh run-services diff --git a/.github/workflows/self-care-database-architecture.lock.yml b/.github/workflows/self-care-database-architecture.lock.yml index 9723584254..01e0ae4235 100644 --- a/.github/workflows/self-care-database-architecture.lock.yml +++ b/.github/workflows/self-care-database-architecture.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e61801cfcb2c9428d0a5c2b8371d2aa2ed1891b8c614234fca9009274f89d7d9","body_hash":"323640eef405fc593052ec2d3b9188dd8f1a389b15406dfe6fdf1508cefd2d9a","compiler_version":"v1.2.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.103"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e61801cfcb2c9428d0a5c2b8371d2aa2ed1891b8c614234fca9009274f89d7d9","body_hash":"26b20cd5d49562cb4b94cc340322993de9d963b336551b95550958ec357a4839","compiler_version":"v1.2.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.103"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_CI_TRIGGER_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_READ_APP_PRIVATE_KEY","GH_AW_GITHUB_READ_PAT","GH_AW_GITHUB_TOKEN","GH_AW_GITHUB_WRITE_APP_PRIVATE_KEY","GH_AW_GITHUB_WRITE_PAT","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"64ab729a6294000f5aa4a4bc0b73d1f32bc45510","version":"v3.3.0"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"},{"repo":"github/gh-aw-actions/setup","sha":"53d0749e19424a918acd82b4d140264ce48f2f14","version":"v1.2.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.29.11","digest":"sha256:8774a73c351395b41d2ad0af4ddb4689007c67de3cd08bd9f43bb8a0e300690e","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.29.11@sha256:8774a73c351395b41d2ad0af4ddb4689007c67de3cd08bd9f43bb8a0e300690e"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.29.11","digest":"sha256:88d2d0e315a504d93fd4fb8847fd0f6b7f4469f41a6fc6047b3dc9654c56ab2c","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.29.11@sha256:88d2d0e315a504d93fd4fb8847fd0f6b7f4469f41a6fc6047b3dc9654c56ab2c"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.29.11","digest":"sha256:97782bc0d8ff7f80f19ae2fdc5e1668517aa732a6fa077e64b8d3919238496d2","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.29.11@sha256:97782bc0d8ff7f80f19ae2fdc5e1668517aa732a6fa077e64b8d3919238496d2"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.29.11","digest":"sha256:861739ecf924dd2db5e5fb652db2ad82aaafd1615623297ddabc8f41a36b2847","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.29.11@sha256:861739ecf924dd2db5e5fb652db2ad82aaafd1615623297ddabc8f41a36b2847"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.5.6","digest":"sha256:342cd624e1f179d91be2de7a146fc4f387092e62e6c8a47506d7932e113d2311","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.5.6@sha256:342cd624e1f179d91be2de7a146fc4f387092e62e6c8a47506d7932e113d2311"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f","pinned_image":"ghcr.io/github/gh-aw-node@sha256:11c2c544876cdbaffefe0f7f1283b74838c209654b4934a78e23ed11f750490f"},{"image":"ghcr.io/github/github-mcp-server:v2.1.0","digest":"sha256:f6056fc10bfffcc8c81a6417a2e9ca12808647e8dd6049642a4feaadee0e0f8a","pinned_image":"ghcr.io/github/github-mcp-server:v2.1.0@sha256:f6056fc10bfffcc8c81a6417a2e9ca12808647e8dd6049642a4feaadee0e0f8a"}],"mcp_servers":[{"name":"safeoutputs","tools":["create_pull_request","noop","report_incomplete","report_missing_data","report_missing_tool"]}],"threat_detection":{"mode":"enabled"},"admission":false,"agent_metadata":{"d0b9e7072036c3cc9c38":{"name":"W3C Specification"},"fbd10cd783979952bdc7":{"name":"Agentic Workflows"}}} # This file was automatically generated by gh-aw (v1.2.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/self-care-database-architecture.md b/.github/workflows/self-care-database-architecture.md index f6c8c49b37..8e19f2af2f 100644 --- a/.github/workflows/self-care-database-architecture.md +++ b/.github/workflows/self-care-database-architecture.md @@ -165,7 +165,7 @@ prompts, tool payloads or sensitive data. Do not spawn agents or sessions. provisioned resources or illustrative examples into implemented guarantees. 3. Inspect at most 30 relevant implementation/test files under `dashboard/site/src/data/`, `server/internal/`, `server/spec/`, - `server/azure/` and `activity/` + `internal/topology/assets/container-apps/`, `server/azure/` and `activity/` to verify material changed claims. Trace consumer, owned contract, adapter and regression tests before describing a boundary. Normative conflicts remain explicit qualifications; do not silently reconcile contradictory @@ -215,19 +215,19 @@ does not complete the proposed ADR or activate native collection. Maintain **Azure service mapping as the final section** of the guide, after document ownership. Map domain responsibilities and owned interfaces to concrete Azure services with a concise table, a bounded accessible topology diagram and -nearby source citations. Verify `server/azure/main.bicep` and relevant modules, +nearby source citations. Verify `internal/topology/assets/container-apps/main.bicep` and relevant modules, operational contracts, archive `BlobStore`/`QueryService` adapters and regression tests rather than deriving deployment from service names. Cover the complete Go handler, canonical and operational PostgreSQL, owner/runtime jobs, optional -collection/backfill, ACR, Key Vault/managed identity, Functions runtime storage, +collection/backfill, ACR, Key Vault/managed identity, private lake Blob, ADX, observability and network boundaries. Label each mapping as baseline-provisioned, conditionally provisioned, -operator-managed or unsupported for the selected host. Functions runtime storage +operator-managed or unsupported for the selected host; ephemeral container storage is not the session lake; `adxEnabled` provisions a destination, not exporter or archive activation, external tables or runtime data roles. Preserve the persistent -single-owner archive/exporter restriction and rejection of Functions platform -listeners, ephemeral/per-request hosts and standalone collection jobs as archive +single-owner archive/exporter restriction and rejection of +ephemeral/per-request hosts and standalone collection jobs as archive hosts. Do not invent Redis/Service Bus substitutions, Entra dashboard sign-in, managed-identity archive authentication, automatic Go telemetry export or production assurance. Changes to mappings must update affected diagrams and diff --git a/.github/workflows/shared/cao.schema.json b/.github/workflows/shared/cao.schema.json index 0e889e59a8..edab56b1b4 100644 --- a/.github/workflows/shared/cao.schema.json +++ b/.github/workflows/shared/cao.schema.json @@ -341,7 +341,7 @@ "properties": { "module": { "type": "string", - "enum": ["generic", "container", "azure-functions"] + "enum": ["generic", "container"] }, "name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, "authentication": { "type": "string", "enum": ["github-oauth"] }, diff --git a/.github/workflows/shared/policy.mjs b/.github/workflows/shared/policy.mjs index 0019bc5cf4..452b0e9b20 100644 --- a/.github/workflows/shared/policy.mjs +++ b/.github/workflows/shared/policy.mjs @@ -24,7 +24,7 @@ const TARGET_KEYS = [ "module", "name", "authentication", "listener", "require-https", "trust-platform-proxy", "supports-single-replica", "replicas", ]; -const TARGET_MODULES = ["generic", "container", "azure-functions"]; +const TARGET_MODULES = ["generic", "container"]; const REDIS_KEYS = [ "module", "url-env", "host-env", "port-env", "username-env", "password-env", "namespace-env", "session", "isolate-process-namespace", @@ -677,7 +677,6 @@ function validateHost(host, path) { throw new PolicyError(`${operationalPath} memory requires single-process and allow-volatile acknowledgements`); } if (!targetSupportsSingleReplica || host.target.replicas !== 1 || - host.target.module === "azure-functions" || (host.target.module === "generic" && host.target.listener !== "process")) { throw new PolicyError(`${operationalPath} memory requires a single-process, single-replica process-listener target`); } diff --git a/AGENTS.md b/AGENTS.md index 9d14d35e0e..66e880ee41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Apply the guidance for every role that is present. Do not infer a role from the - `ARCHITECTURE.md` is the human-oriented architecture source of truth. Read it with the relevant files under `specs/` for complete boundaries, relationships, generation locations, validation guidance, and normative contracts; do not treat agent-specific instruction files as architecture sources. - In the catalog, root and campaign `aw.yml` manifests define campaign contents. The root manifest installs validation; the CAO runtime package supplies shared activity and dashboard modules. The legacy Activity and Dashboard component manifests are removed. Editable gh-aw workflow sources are `.github/workflows/*.md`; shared control is `.github/workflows/shared/control.md` and its dependencies. - For authoritative information about the dashboard data model, read the checked-out `specs/dashboard-data.md` at the revision being changed or reviewed. Use https://github.com/githubnext/gh-cao/blob/main/specs/dashboard-data.md only when no checkout is available; main may not describe an unmerged feature. -- Hosted dashboards are served by the Go server, not GitHub Pages or a separate static frontend. Keep the built dashboard, assets, OAuth routes, query API, and revision events behind the same hosted handler and origin, including container, Azure Functions, and external-listener deployments. The server-injected `server-http` bootstrap selects server-side PostgreSQL queries; do not fall back to browser IndexedDB, static data shards, or a Pages app when hosted services fail. Local development uses the same Go server with real operational canonical JSONL artifacts. +- Hosted dashboards are served by the Go server, not GitHub Pages or a separate static frontend. Keep the built dashboard, assets, OAuth routes, query API, and revision events behind the same hosted handler and origin, including container and external-listener deployments. Azure uses the Container Apps offline review bundle and separate operator-run deployment; Azure Functions support is retired. The server-injected `server-http` bootstrap selects server-side PostgreSQL queries; do not fall back to browser IndexedDB, static data shards, or a Pages app when hosted services fail. Local development uses the same Go server with real operational canonical JSONL artifacts. - Pages is a synthetic enterprise demo only. Generate raw samples through production normalization, canonical ingestion, Dashboard Language queries, and shared views; never add a real-data input or presenter-fixture shortcut. Keep the declarative **Synthetic enterprise demo** callout, `demo-data/` data URLs, `synthetic-demo` storage-key suffix, and `demo` service-worker caches. Publish no SQLite or agent summary and enable no live services. Rebuilds replace current Pages artifacts, not historical deployments or legacy browser data; never automatically delete storage outside the demo scope. - Operational Activity/Dashboard artifacts remain bounded, access-controlled read-only inputs for local analysis and hosted ingestion. Snapshot downloads require an explicit direct operational `payload-hashes.json` URL or authenticated Actions artifact acquisition; do not default to Pages or invent a hosted export endpoint. Setup does not configure Pages or request public-publication consent. - For every dashboard UI change, start with a JSON-only solution in Dashboard Language. Define queries, views, card templates, marks, encodings, layouts, controls, routes, and reusable view references in the dashboard document before changing JavaScript. Reuse or extend the shared declarative renderer when the vocabulary is insufficient; add specialized JavaScript UI only when the behavior cannot be expressed declaratively, and document that limitation in the change. @@ -97,14 +97,11 @@ Run `npm run check` for complete repository validation. It executes, in order: ` Always pass `--schedule-seed githubnext/gh-cao` when running `gh aw compile` manually (the `compile` script already includes it); omitting the seed causes non-deterministic cron scattering in lock files. -### Local Azure server testing (Linux and macOS) +### Azure hosting validation -- Run `npm run test:integration:azure-services` from the repository root with a local Docker daemon running. On macOS, start Docker Desktop or Colima first. The harness builds the Go server natively and runs the real Functions host against disposable Azurite and PostgreSQL containers; no Azure login or credentials are required. -- Linux and macOS on x64 or arm64 are supported, including macOS's bundled Bash and Python 3.9. Keep Core Tools downloads platform-specific and checksum-pinned. Do not reintroduce GNU-only commands, Linux-only shell process tracking, or unverified installed-tool fallbacks; use `scripts/azure-local/platform.py` for process identity, process groups, and installation locking. -- Populate both Go module caches with `go mod download && go -C server mod download` before the hermetic service tests. Missing services, skipped selections, or unavailable backends are failures, not passing evidence. -- Run `node --test tests/unit/azure-local-*.test.mjs` and `shellcheck scripts/azure-local/azure-local.sh` after harness changes. Preserve exact process ownership checks, profile isolation, private development logs, and cleanup of only the harness-owned resources. -- The separate `npm run test:integration:setup-azure` and `npm run test:integration:azure-install-journey` suites require `POSTGRES_URL` pointing to an explicitly selected disposable numeric-loopback PostgreSQL instance. They simulate deployment APIs and do not establish real-Azure acceptance. -- Use `npm run dashboard:local:azure` only with explicitly configured development GitHub OAuth credentials and organizations. Do not substitute synthetic fixtures or bypass membership checks in this profile. See [the local Azure guide](scripts/azure-local/README.md) for prerequisites, evidence limits, and cleanup. +- Azure hosting uses `gh cao deploy init --deployment azure-container-apps`, offline bundle generation/checking, and separately reviewed operator-run deployment. See [the Azure guide](docs/deployment-azure.md). +- Exercise the existing Go/PostgreSQL server for local development. Azure Functions runtime, Core Tools/Azurite local harness and native Azure installation simulators are retired. +- Run focused topology/template and hosted Go tests for the changed boundary. Offline checks and local server tests do not establish real Azure, managed-identity, OAuth, private-network or deployment acceptance; retain deployment-specific evidence separately. ### Dashboard site (`dashboard/site/`) @@ -152,7 +149,6 @@ After UI changes, when Playwright loads the full website, capture and include de | `cid.yml` | Dashboard site lint, typecheck, unit tests, sharded E2E | PR / push to `dashboard/site/**` | | `svg-contrast-check.yml` | Playwright SVG WCAG contrast validation | PR / push to SVG files | | `csh.yml` | ShellCheck (warning severity) on all tracked `.sh` files | PR / push to `.sh` files | -| `azure-local-integration.yml` | Portable harness regressions and real Functions/Azurite/PostgreSQL integration | Relevant push to main / manual / reusable call | | `docs.yml` | Documentation and synthetic-only demo build, independent of operational freshness | Daily schedule / push to main | ### Choosing which tests to run @@ -164,7 +160,7 @@ After UI changes, when Playwright loads the full website, capture and include de - Debugging operational data → use `gh cao dashboard` for an automatically managed Docker/Podman PostgreSQL container with an ignored `.github/cao/dashboard//postgres/` bind-mounted cache. Explicit `--postgres-url` or `CAO_POSTGRES_URL` bypasses management and requires provisioned storage. Verified canonical JSONL can be acquired from an explicitly selected Actions artifact or trusted direct manifest and ingested with `cao-dashboard ingest --source`; there is no database-file import or Pages source. - Editing shared snapshot normalization under `dashboard/report/` -> run the focused normalization tests and `npm run compile`. - Editing workflow `.md` files → `npm run compile` (add `compile:locks` if lock files should update) -- Editing the local Azure harness or Azure hosting/service boundaries → run the focused Azure tests above and `npm run test:integration:azure-services`; run the setup/install journey suites when deployment behavior changes +- Editing Azure hosting/service boundaries → run focused topology/template and hosted Go tests; separately verify changed deployment assumptions in the target Azure environment - Editing SVGs → `npm run check:svg` - Editing documentation under `docs/` → `npm run docs:build` - Editing the PostgreSQL schema (`server/spec/storage.tsp`, `server/spec/postgres-emitter.mjs`, or generated `server/internal/postgresx/schema.*`) → from `server/spec/`, run `npm run generate` and `npm test`, commit all changed generated files (including `generated/openapi.json`), then run `npm run check` as a required gate. The check compares generated artifacts against the commit, so it cannot pass before those changes are committed. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 103359524d..c6375efd98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -634,7 +634,7 @@ capabilities before configuring authentication or ingestion. Target modules own authentication, listener ownership, HTTPS, and trusted-proxy requirements. Redis modules own environment mappings, session semantics, process namespace isolation, replica constraints, and collection support. Local, host-neutral -container, Azure Functions, and Upstash are compositions rather than provider +container and Upstash are compositions rather than provider checks in shared request handling. Startup rejects configurations or services that do not satisfy the selected per-feature coordination scope and restart-persistence capabilities. Business @@ -1054,7 +1054,7 @@ The hosted dashboard has one strict storage split: The Go HTTP(S) server serves the hosted dashboard itself, including its built HTML, JavaScript and other assets, OAuth routes, query API, and revision events -on one origin. Container, Azure Functions, and externally owned listeners all +on one origin. Container and externally owned listeners all expose the complete CAO handler, not an API behind a separately hosted Pages application. Hosted startup requires a readable, non-empty built `index.html`. The server injects the `server-http` backend bootstrap into dashboard entry @@ -1502,7 +1502,7 @@ and removal remain authenticated and never hydrate notification payloads. | `cmd/gh-cao/`, `internal/cli/`, `internal/ghcli/`, `dashboard/catalog.go` | Entirely Go operator-facing `gh cao` executable, including onboarding, policy management, PostgreSQL-backed queries, computations and MCP; embeds the authoritative JSON contracts and uses the bundled server's canonical read boundary. Actions packaging remains Node-owned. | | `internal/dashboardquery/` | Independent Go module owning bounded native Dashboard Language evaluation and the canonical read-session contract; PostgreSQL reads stay behind the server-owned adapter. | | `dashboard/` | Dashboard campaign, report/source adapters, canvas-only authoring bridge, and shared browser UI served operationally by Go or as a synthetic-only Pages demo. | -| `server/` | Optional host-neutral Go HTTP(S) service, deployed-artifact ingester, authenticated canonical API, webhook/rebuild control, Postgres entity storage and default operational state, explicit Redis or single-process memory alternatives, server-side Dashboard Language query engine, externally hosted handler facade, container image, and Azure Functions deployment profile. | +| `server/` | Optional host-neutral Go HTTP(S) service, deployed-artifact ingester, authenticated canonical API, webhook/rebuild control, Postgres entity storage and default operational state, explicit Redis or single-process memory alternatives, server-side Dashboard Language query engine, externally hosted handler facade, container image, and Azure Container Apps deployment templates. | | `server/cmd/cao-webhook-gateway/`, `server/internal/webhookgateway/`, `server/internal/webhookgatewayhost/` | Optional isolated gateway entry point, consumer contracts and pure decisions, and host adapters; durable state extends the existing PostgreSQL operational adapter. | | `server/spec/` | Editable TypeSpec HTTP/SSE, fresh Postgres storage and optional ADX export contracts; generates OpenAPI 3.1, wire JSON Schemas, standalone SQL DDL, Go bindings and ADX setup KQL; checks routes, payloads and canonical field coverage. | | `dashboard/site/src/data/` | Canonical browser data model, adapters, normalization, storage, and declarative query engine. | @@ -1936,21 +1936,21 @@ not authorize cross-repository evidence access. rotation can retain the previous key until sessions and revocations drain. Refreshed sessions use an atomic compare-and-swap so logout cannot be undone by a concurrent OAuth refresh. -- The Azure deployment baseline provisions private PostgreSQL for both canonical - and operational storage, without Redis. One Log Analytics workspace and one - workspace-backed Application Insights component are shared by the deployment. +- The `azure-container-apps` topology (`internal/topology/`) uses the + existing process-owned container host with PostgreSQL operational state, + managed HTTPS ingress and explicitly verified private proxy peers. It reuses + private PostgreSQL and the separate provisioning/ingestion job modules, but + uses Log Analytics without Application Insights or Redis. A private manual ingestion job publishes explicitly selected adopter snapshots or an explicit empty dataset; builds never download a catalog-owned dataset implicitly. Instance policy, credentials, and target enrollment remain adopter-owned inputs, not public deployment defaults. -- The additive `azure-container-apps` topology (`internal/topology/`) uses the - existing process-owned container host with PostgreSQL operational state, - managed HTTPS ingress and explicitly verified private proxy peers. It reuses - private PostgreSQL and the separate provisioning/ingestion job modules, but - omits Functions, runtime Storage, the Premium plan and Application Insights. Web activation is default-off until independently verified provisioning and - ingestion succeed. Topology generation remains offline/review-only; the native - `deploy azure --apply` command still targets the Functions baseline. + ingestion succeed. `deploy init --deployment azure-container-apps`, + `deploy generate` and `deploy check` produce an offline review bundle; + operators separately execute reviewed Container Apps templates. Generation + does not deploy or establish runtime acceptance. Azure Functions runtime, + deployment commands and local harness support are retired. - Explicit `CAO_GITHUB_LOCAL_USER` development OAuth replaces membership authorization with one configured GitHub login and a valid numeric account ID. Classic login requests `read:user` without `read:org`; GitHub App clients diff --git a/adr/parquet-unified-session-storage.md b/adr/parquet-unified-session-storage.md index 4177ccee80..e565332e07 100644 --- a/adr/parquet-unified-session-storage.md +++ b/adr/parquet-unified-session-storage.md @@ -164,7 +164,7 @@ coordination service. The initial supported profile is a persistent host with a suitable converter and durable staging lifecycle. Do not assume an ephemeral container filesystem or -per-request Azure Functions invocation supplies a durable spool or publisher. +per-request invocation supplies a durable spool or publisher. Destination outage, capacity, or archive admission failures remain explicit archive failures and must not claim readiness or alter core dashboard evidence. diff --git a/docs/authentication.md b/docs/authentication.md index 23465855cc..0121eb70a8 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -522,7 +522,7 @@ Enroll each workflow in `control-plane.token-broker.copilot-requests.workflows` The broker uses those OIDC and enrollment checks directly; a prior activation-admission decision is not required. Identity checks, enrollment, issuance and revocation produce OpenTelemetry spans and correlated outcome logs through the server's configured providers, independently of `DEBUG`. Credentials, identities, policy contents and upstream diagnostics are never recorded. -The App's private key is read from an Azure Key Vault secret through managed identity. It is never accepted from policy, workflow inputs, or request bodies. The initial profile requires hosted HTTPS with one process-owned listener, acknowledged by `CAO_TOKEN_BROKER_SINGLE_PROCESS=true`. Azure Functions, external listeners, and multiple replicas are unsupported. A crash can lose revocation state, so issued tokens may remain valid until their upstream expiry, at most one hour. CAO admission budgets are not a spending cap; configure organization Copilot budgets separately. See [server/SECURITY.md](https://github.com/githubnext/gh-cao/blob/main/server/SECURITY.md#optional-copilot-requests-broker) for the full environment settings and acceptance steps. +The App's private key is read from an Azure Key Vault secret through managed identity. It is never accepted from policy, workflow inputs, or request bodies. The initial profile requires hosted HTTPS with one process-owned listener, acknowledged by `CAO_TOKEN_BROKER_SINGLE_PROCESS=true`. External listeners and multiple replicas are unsupported. A crash can lose revocation state, so issued tokens may remain valid until their upstream expiry, at most one hour. CAO admission budgets are not a spending cap; configure organization Copilot budgets separately. See [server/SECURITY.md](https://github.com/githubnext/gh-cao/blob/main/server/SECURITY.md#optional-copilot-requests-broker) for the full environment settings and acceptance steps. ## Credential Reference diff --git a/docs/cao-cli.md b/docs/cao-cli.md index 78f047ccc8..293bed42b7 100644 --- a/docs/cao-cli.md +++ b/docs/cao-cli.md @@ -187,7 +187,7 @@ gh cao evidence release \ --output /secure/evidence/release-evidence.json ``` -Optionally add `--promotion-run RUN_ID`, `--azure-receipt PATH`, or +Optionally add `--promotion-run RUN_ID` or `--verify-provenance`. The last option invokes `gh attestation verify` with exact source/image/reusable-publisher constraints and requires the package run attempt to match. Existing GitHub/registry read authentication is required; do not put @@ -455,7 +455,7 @@ to `30s` (range `1s`–`1h`) and `--max-storage-bytes` defaults to 50,000,000,00 Review and apply `cao-dashboard adx schema` to the destination separately before deployment. Map the JSON keys to environment settings in your existing single-replica hosted PostgreSQL deployment; this is not a shell script or -`gh cao deploy azure` configuration file, and setup does not apply it. +deployment-bundle configuration file, and setup does not apply it. Inject the omitted `CAO_ADX_CLIENT_SECRET` directly from the deployment secret manager, never a flag, reviewed policy, or committed document. The generated profile intentionally disables any existing traces/mining when applied; separate @@ -518,7 +518,6 @@ to invoke it as `gh cao`. | Onboarding and authentication | `setup`, `setup auth github-app\|enterprise-app\|hosted-cli\|token` | Native Go; setup prepares missing monitoring runtime and policy, scope, and authentication, preserving separate read/write and hosted-executor credentials. It does not configure Pages. | | Issue-service webhook setup | `setup linear`, `setup jira`, `setup ado` (alias `setup azure-devops`) | Native Go; offline enrollment preparation and provider instructions, with separate receive secrets and outbound safe-output credentials. | | Cooperative admission | `setup admission`, `admission status`, `admission disable` | Native Go; configure admission with `setup admission`. Configures or inspects non-secret Actions variables, not server deployment or enrollment authority. | -| Cloud deployment | `deploy azure` | Native Go; shared TOSCA dependency resolution with an embedded Azure template, read-only preflight and planning, or explicit application of a reviewed development deployment plan. | | Deployment bundles | `deploy init`, `deploy generate`, `deploy check` | Native Go; offline resolution of generated TOSCA 1.3 requirement graphs, embedded review-only Bicep bundles, Mermaid manifests and dependency/drift checking. No deployment or App changes. See [bundle generation](deployment-topology.md). | | Lifecycle | `repos add OWNER/REPO`, `update`, `upgrade-gh-aw` | Native Go; gh-aw still owns workflow generation and campaign installation records. | | Read-only analysis | `download`, `pages`, `queries`, `query-info`, `prompt`, `query`, `gh runs\|issues\|prs`, `computation intelligence\|runtime-health`, `api-cost`, `mcp` | Native Go using PostgreSQL canonical read sessions, hosted queries where declared, and declarative Dashboard Language evaluation. | @@ -538,7 +537,7 @@ discovery, not another operation. `pages` remains dashboard-page discovery in bo the operator and Node runtimes. `cao-dashboard` owns server hosting and administration, including -`serve`, `serve-hosted`, `serve-pages`, `azure-functions`, `ingest`, `collect`, +`serve`, `serve-hosted`, `serve-pages`, `ingest`, `collect`, `backfill`, `execute-cli`, `hosted-cli queue`, `database`, `admission validate`, `audit`, `doctor`, `app-key-check`, `compile-queries`, `benchmark-queries`, `otel-smoke`, `simulate-api`, and `simulate-webhooks`. Its read-only `doctor` @@ -894,7 +893,7 @@ gh aw run dependabot --ref main \ | `gh cao setup auth token ...` | Configure explicitly consented owner-scoped fine-grained PAT pairs when an App is unavailable. | | `gh cao add OWNER/REPO/CAMPAIGN` | Install one campaign and merge its declared workers into policy without broadening rollout or enabling live mode. | | `gh cao repos add OWNER/REPO` | Verify GitHub CLI read access and stage one exact repository in local control-plane policy, with review-mode overrides for existing campaigns. | -| `gh cao deploy azure --plan --config FILE --out FILE` | Review a fresh Azure Functions deployment using the embedded template without writing to Azure; `--apply --plan-file FILE` then repeats preflight and refuses topology and change-set drift. See [Azure deployment](deployment-azure.md#native-fresh-deployment-command). | +| `gh cao deploy init --deployment azure-container-apps --out FILE` | Prepare requirements for an offline Container Apps review bundle; follow with `deploy generate` and `deploy check`. Operators deploy reviewed templates separately. See [Azure deployment](deployment-azure.md). | | `gh cao update` | Upgrade gh-aw when required, update installed campaigns, and refresh worker declarations while preserving operator-owned settings. | | `gh cao update --campaign SLUG` | Update exactly one installed campaign, retaining runtime materialization and worker-declaration refresh. Use `root` for the CAO root package. | | `gh cao upgrade-gh-aw VERSION` | Install an exact gh-aw release, upgrade local Agentic Workflow files, and update the pinned policy version after the upgrade succeeds. | diff --git a/docs/components/HeroBackendDiagram.astro b/docs/components/HeroBackendDiagram.astro index d995d178e6..6609f0b70b 100644 --- a/docs/components/HeroBackendDiagram.astro +++ b/docs/components/HeroBackendDiagram.astro @@ -77,7 +77,7 @@ CAO server - Azure Functions + Azure Container Apps
diff --git a/docs/configuration.md b/docs/configuration.md index e0dfb4d732..1a1512b7a7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -404,7 +404,7 @@ Steering can refine evidence, priorities, and selection within resolved policy. ## Optional Observability -The dispatcher span is built into `shared/control.md`; exporter configuration determines where gh-aw sends it. Set the `GH_AW_DEFAULT_OTLP_ENDPOINT` Actions variable and `GH_AW_DEFAULT_OTLP_HEADERS` Actions secret at repository, organization, or enterprise scope. Export is disabled when the endpoint or matching headers are absent. Hosted dashboard servers configure their own OpenTelemetry export; see [Azure](deployment-azure.md#monitoring-the-deployment). +The dispatcher span is built into `shared/control.md`; exporter configuration determines where gh-aw sends it. Set the `GH_AW_DEFAULT_OTLP_ENDPOINT` Actions variable and `GH_AW_DEFAULT_OTLP_HEADERS` Actions secret at repository, organization, or enterprise scope. Export is disabled when the endpoint or matching headers are absent. Independently configured hosted Go servers have their own OpenTelemetry settings; the [Container Apps review bundle](deployment-azure.md) supports container logs, not OTLP. ```bash CONTROL_REPO="acme/central-agentic-ops" diff --git a/docs/dashboard-data-architecture.md b/docs/dashboard-data-architecture.md index 414b94304a..ce5834fee7 100644 --- a/docs/dashboard-data-architecture.md +++ b/docs/dashboard-data-architecture.md @@ -85,7 +85,7 @@ flowchart TB | Local/headless | Go over a selected canonical PostgreSQL namespace | The Activity pipeline owns ordinary snapshot creation, retention, and repair | Operator analysis, CLI consumption, and local development | | Production hosted | Go over PostgreSQL using bounded, parameterized SQL | Server-side canonical namespace plus separately owned operational state | Authenticated shared dashboards and optionally native acquisition | -Containers, Azure Functions, and external-listener hosts use the **complete +Containers and external-listener hosts use the **complete Go handler on one origin**: HTML, assets, OAuth, query APIs, and revision events. The server injects `server-http`; clients never receive database credentials. Hosted startup rejects missing dashboard assets, and hosted @@ -357,8 +357,7 @@ yet execute `session-query`. Queries never fall back to dashboard storage. **Deployment qualification:** the archive is independently default-off and its ADR remains proposed pending required acceptance evidence. Persistent -single-owner hosts are required; the Azure Functions platform-listener profile, -ephemeral/per-request hosts, standalone collection jobs and multi-replica +single-owner hosts are required; ephemeral/per-request hosts, standalone collection jobs and multi-replica archives are unsupported. Hosted activation requires real Azure conformance bound to the approved Blob/ADX destination, not a successful upload, simulator or acceptance flag. The native producer does not activate the native collector, @@ -639,23 +638,26 @@ policy admission, or required validation are unavailable. ## 13. Azure service mapping Map **domain services to deployment resources**, not every interface to a new -Azure product. The experimental `server/azure/main.bicep` baseline composes the -Go service on Azure Functions with PostgreSQL for both analytical and operational -adapters. Optional resources and operator-managed analytics remain separate -decisions; this map neither provisions them nor authorizes production use. +Azure product. The experimental Container Apps review bundle composes the +hosted Go service with private PostgreSQL for both canonical and operational +adapters. Generate and check it offline with `gh cao deploy init --deployment +azure-container-apps`, `deploy generate` and `deploy check`; operators execute +reviewed templates separately. Optional infrastructure remains explicitly +selected and staged. This map neither provisions resources nor authorizes +production use; offline checks are not real destination acceptance. ```mermaid flowchart TB - accTitle: Azure baseline and separately approved lake host - accDescr: The baseline hosts one Go handler in Functions and uses private PostgreSQL, Key Vault, ACR and runtime storage. A separately approved persistent lake host uses private Blob and ADX; the Functions runtime storage is not a session lake. - Browser["Authorized browser"] --> Functions["Azure Functions: complete Go handler"] - Functions --> PG[("PostgreSQL Flexible Server")] + accTitle: Container Apps baseline and separately approved lake host + accDescr: Container Apps hosts the complete Go handler with private PostgreSQL, Key Vault, ACR and Log Analytics. A separately approved persistent lake host uses private Blob and ADX; ephemeral container storage is not a session lake. + Browser["Authorized browser"] --> App["Azure Container Apps: complete Go handler"] + App --> PG[("PostgreSQL Flexible Server")] Jobs["Container Apps provisioning and ingestion jobs"] --> PG - Functions --> KV["Key Vault via managed identity"] + App --> KV["Key Vault via managed identity"] Jobs --> KV - ACR["Container Registry: reviewed digest-pinned images"] --> Functions + ACR["Azure Container Registry: reviewed digest-pinned images"] --> App ACR --> Jobs - Functions --> Runtime[("Functions runtime Storage account")] + App --> Logs["Log Analytics: container logs"] subgraph optional["Separate operator-approved analytics topology"] Host["Persistent single-owner OAuth host"] --> Blob[("Private session-lake Blob objects")] Host --> ADX["ADX external-table query service"] @@ -665,32 +667,33 @@ flowchart TB | Domain responsibility | Azure service mapping | Provisioning and capability boundary | | --- | --- | --- | -| Same-origin UI, OAuth, query APIs and revision events | Azure Functions custom Linux container on an Elastic Premium App Service plan | Baseline hosts the complete Go handler; GitHub remains the OAuth and membership provider, not Entra. No separate frontend or browser database credentials | +| Same-origin UI, OAuth, query APIs and revision events | Azure Container Apps Consumption, one hosted Go replica | Baseline hosts the complete Go handler; GitHub remains the OAuth and membership provider, not Entra. No separate frontend or browser database credentials; independently verify trusted private proxy peers | | Canonical entity/query adapter | Azure Database for PostgreSQL Flexible Server | Private delegated subnet and verified TLS; typed canonical tables are a rebuildable read model, not raw-session storage | | Focused operational services: OAuth sessions, revocations, queues, leases, quotas, deduplication and durable audit | The same PostgreSQL Flexible Server through the separate operational adapter | `cao_operational_*` tables retain separate lifecycle, atomicity and protected-state bounds. Baseline requires neither Redis nor Azure Service Bus; they are not implied adapter substitutions | | Schema ownership and selected snapshot ingestion | Separate manual Azure Container Apps provisioning and ingestion jobs | Distinct identities and owner/runtime credentials; explicit verified snapshot or empty evidence. Jobs do not become archive publishers | -| Optional collection and backfill | `collector.bicep` Container Apps and backfill job when explicitly configured | Separate enrollment, acquisition profile and durable ownership gates; provisioning a worker does not activate native acquisition or authorize more repositories | -| Image distribution | Azure Container Registry, managed-identity pulls | Baseline builds/selects Functions, provisioning and ingestion images; digest pinning alone is not production scan, SBOM or provenance assurance | +| Acquisition | Explicitly selected Actions snapshots | Base profile does not support collector acquisition; optional host infrastructure does not activate collection or authorize more repositories | +| Image distribution | Azure Container Registry, managed-identity pulls | Operators prepare/select dashboard, provisioning and ingestion images; digest pinning alone is not production scan, SBOM or provenance assurance | | Secret resolution | Azure Key Vault and managed identities with secret-scoped roles | Secrets remain outside policy and browser payloads. Managed-identity Key Vault access does not itself authenticate Blob/ADX archive adapters, which require their separately configured application credentials | -| Functions platform state | Azure Storage account | Runtime state only, **not the session lake**. A private archive Blob root, content approval, credentials and publication lifecycle are separate operator configuration | -| Private unified-session lake: `BlobStore` | Private Azure Blob Storage for immutable Parquet, manifests, lease and conditional pointer publication | Operator-managed, independently default-off; not provisioned or enabled by the Functions runtime storage resource. Use an approved persistent single-owner host, not the Functions platform listener | -| Private lake analytics: `QueryService` | Azure Data Explorer external table over approved Blob Parquet | Real destination-bound conformance, exact generation membership and runtime/setup role separation are mandatory. `adx.bicep` optionally provisions a paid cluster/private endpoint with `adxEnabled=true`, but no external table, exporter or archive activation | -| Direct canonical-snapshot or unified-trace analytics | Separately selected ADX streaming exporters | Independent scope, approval, durable state and single-exporter gates; not Blob archive queries and not supported on the Functions platform-listener profile | -| Operational observability | One shared Log Analytics workspace and workspace-backed Application Insights component | Container logs and configured telemetry, not session-lake content or canonical query storage. Go telemetry requires an explicitly configured operator-managed OTLP collector; host Insights settings alone do not enable Go export | -| Private database/destination connectivity | Virtual network, delegated subnets, private DNS; optional ADX private endpoint | Database, Functions and Container Apps access are separated. ADX needs its own reviewed network/DNS path; a private endpoint is not a data-access grant | - -**Provisioning is not activation.** The baseline Storage account cannot be -relabeled as a lake, and enabling `adxEnabled` does not inject exporter settings, -grant runtime data roles, install archive external tables or satisfy single-owner -and acceptance gates. Review backup/retention, cost, network reach, privacy +| Private unified-session lake: `BlobStore` | Private Azure Blob Storage for immutable Parquet, manifests, lease and conditional pointer publication | Independently default-off; optional staged infrastructure is not activation. Use an approved persistent single-owner host; ephemeral container storage is not the session lake | +| Private lake analytics: `QueryService` | Azure Data Explorer external table over approved Blob Parquet | Real destination-bound conformance, exact generation membership and runtime/setup role separation are mandatory. Optional infrastructure provides no external table, exporter or archive activation | +| Direct canonical-snapshot or unified-trace analytics | Separately selected ADX streaming exporters | Independent scope, approval, durable state and single-exporter gates; not Blob archive queries. Base Container Apps topology supports no base ADX analytics | +| Optional webhook gateway | Separate staged receiver and dispatcher hosts | Exact-revision policy, credentials, connectivity and runtime acceptance are independent gates; deployment does not grant dispatch authority | +| Operational observability | Log Analytics container logs | No Application Insights or OTLP in the base profile; logs are not session-lake content, canonical storage or proof of application-exporter delivery | +| Private database/destination connectivity | Virtual network, delegated subnets, private DNS; separately reviewed optional destination connectivity | Database and Container Apps access are separated. ADX needs its own reviewed network/DNS path; a private endpoint is not a data-access grant | + +**Provisioning is not activation.** Keep `runtimeEnabled: false` until separate +provisioning and explicit snapshot/empty ingestion succeed. ARM dependencies +do not execute or verify jobs. Optional infrastructure does not inject exporter +settings, grant runtime data roles, install archive external tables or satisfy +single-owner and acceptance gates. Review backup/retention, cost, network reach, privacy cleanup and live adapter evidence for each selected service independently. Managed service names alone promise no HA, numerical SLO, RPO/RTO or compliance. -**Source:** [Azure deployment guide](deployment-azure.md#azure-resources) and -[optional ADX destination](deployment-azure.md#optional-azure-data-explorer-destination); -`server/azure/main.bicep`, `network.bicep`, `provisioning.bicep`, -`ingestion.bicep`, `collector.bicep`, `function-roles.bicep`, -`worker-roles.bicep`, and `adx.bicep`; +**Source:** [Azure deployment guide](deployment-azure.md#azure-resources-and-boundaries) and +[Container Apps topology](deployment-topology.md#azure-container-apps); +`internal/topology/assets/container-apps/main.bicep`, +`server/azure/provisioning.bicep`, `server/azure/ingestion.bicep`, +and `server/azure/worker-roles.bicep`; [Operational contracts](https://github.com/githubnext/gh-cao/blob/main/specs/server-operational-storage.md#contracts-and-composition); [Archive service adapters](https://github.com/githubnext/gh-cao/blob/main/server/internal/sessionarchiveblob/services.go). diff --git a/docs/deployment-azure.md b/docs/deployment-azure.md index 3a2fe7bfc9..5a0524b70c 100644 --- a/docs/deployment-azure.md +++ b/docs/deployment-azure.md @@ -1,1148 +1,112 @@ --- title: Deploying the dashboard to Azure -description: Run the Central Agentic Ops dashboard server on Azure Functions with PostgreSQL for dashboard data and operational state. +description: Prepare a review-only Azure Container Apps deployment bundle for the hosted Go dashboard and private PostgreSQL. --- -> [!WARNING] -> **Experimental:** The Azure deployment is an experimental baseline, not a turnkey or certified production service. Parameters, app settings, and resources can change between releases. Treat `server/azure/main.bicep`, the OAuth policy, the PostgreSQL topology, and the Key Vault access model as a starting point for your own review. Don't use the deployment with real users until your organization completes security, compliance, privacy, network, load, cost, monitoring, incident-response, backup, and rollback reviews. - -## About the Azure deployment - -For an alternative without Azure Functions, its runtime Storage account or the -Elastic Premium plan, select the [Azure Container Apps topology](deployment-topology.md#azure-container-apps). -It uses Container Apps Consumption with the hosted Go server, private PostgreSQL, -Key Vault, ACR and separate provisioning/ingestion jobs. This is a review-only -topology bundle with separate activation gates, not a new `deploy azure --apply` -backend; the native deployment command below remains Functions-only. - -The Azure deployment runs `cao-dashboard azure-functions` from the `server/` directory as a custom Linux container on Azure Functions. The `azure-functions-runtime` Docker target and `cao-functions` image repository remain unchanged; there is no separate `cao-functions` executable. Bicep sets `appCommandLine: 'azure-functions'` as the arguments to the image's `/app/cao-dashboard` entrypoint. The Functions Core Tools custom-handler configuration uses `defaultExecutablePath: "cao-dashboard"` with `arguments: ["azure-functions"]`. Browsers connect only to the Function App, over HTTPS on the same origin. The Function App: - -- Signs users in with GitHub OAuth. -- Authorizes explicitly allowed GitHub users or members of organizations or teams that you allow. -- Reads secrets through Key Vault references. -- Runs bounded [Dashboard Language](dashboard-language.md) queries against dashboard data in PostgreSQL. -- Uses a separate PostgreSQL operational adapter for sessions, rate limits, queues, deduplication, coordination, and bounded caches. - -```mermaid -flowchart LR - accTitle: Azure dashboard deployment boundaries - accDescr: The browser connects only to the Function App. The app uses GitHub OAuth, managed-identity Key Vault access and PostgreSQL. An optional operator-managed collector exports telemetry to Application Insights. - Browser["Authorized browser"] -->|"HTTPS"| Function["Function App
managed-identity ACR container"] - Function -->|"OAuth, refresh, membership"| GitHub["GitHub OAuth + API"] - Function -->|"managed identity"| KeyVault["Key Vault"] - Function -->|"PostgreSQL protocol"| Postgres["PostgreSQL
canonical + isolated operational tables"] - Function -->|"opt-in OTLP/HTTP over TLS"| Collector["Operator-managed OTel collector"] - Collector -->|"Azure Monitor exporter"| Insights["Application Insights"] -``` - -## Prerequisites - -### YAML topology planning - -For review-only configuration generation from feature choices, use the -[native Go deployment bundle generator](deployment-topology.md) with -`gh cao deploy init`, `gh cao deploy generate` and `gh cao deploy check`. -It emits the unchanged Azure Bicep module set, Key Vault parameter references, -a checked dependency graph, GitHub application setup instructions, validation -scripts and operator TODOs. Its offline checker detects dependency and output -drift; it does not create resources, install Apps, prove external prerequisites, -or replace the guarded native deployment plan below. -Default-off `sessionDataLake` and `webhookGateway` choices additionally emit a -separate Bicep deployment for isolated persistent hosts and dedicated private -Blob/ADX resources. These hosts remain staged until reviewed policy, -credentials and real destination/runtime acceptance gates are satisfied; the -Functions baseline is unchanged. -`MANIFEST.md` includes Mermaid diagrams; the deployment template is generated -as TOSCA Simple Profile 1.3, and its requirement assignments define the bounded -dependency graph. The CLI does not parse arbitrary TOSCA documents or cloud archives. - -### Native fresh-deployment command - -`gh cao deploy azure --apply` supports **fresh development deployments only**. -Set `"environment": "development"` in the reviewed non-secret deployment config, -then regenerate and review the plan. Missing environment declarations, old plans -without that declaration, and `"environment": "production"` fail closed before -any Azure call during apply. Read-only planning still permits an omitted or -production environment; a successful plan does not authorize production apply. -The declaration is an operator commitment, not automatic classification of an -Azure subscription: never label production resources as development to bypass it. -Review the non-secret deployment config, run `--plan`, then apply that exact plan. Apply repeats -preflight and what-if and refuses any change-set drift before writing to Azure. -Updates, resuming a partially created deployment, and upgrades are follow-up -work; a subsequent `--plan` against a successfully deployed instance must fail -closed as an existing instance. - -Preflight and planning use the Go-embedded Azure ARM template and the same -TOSCA requirement-graph resolver as offline bundle generation; neither needs a source -checkout or Bicep installation. Plans include the resolved topology, and apply -rejects topology drift before Azure writes. Older plans must be regenerated -with the current CLI. `--template` remains an explicit compiled ARM override; -its parameters must also satisfy the embedded provider contract. - -Development apply still requires a gh-cao checkout with `--source-dir` for -building images. Supply the operator's -repository with `--repo-dir` (default `.`); its reviewed -`.github/workflows/cao.json` and `.github/workflows/cao.azure.json` are layered -into an owner-only temporary build context. Only allowlisted product files, -those two policy files, and explicitly selected evidence are uploaded to ACR, -not the operator's working directory or its credentials. - -Development and release builds share the product-input allowlist in -`internal/cli/deploy_azure_assets.json`. The Azure Dockerfile targets precede -catalog inventory and collection stages so ACR's legacy builder needs only -those minimal inputs. - -`resources.functionApp` is also the Container Apps job prefix: use 2-19 -lowercase letters, digits, or hyphens, starting with a letter, ending with a -letter or digit, and without consecutive hyphens. The longest derived name, -`PREFIX-db-provision`, must fit Azure's 32-character job-name limit. - -```bash -gh cao deploy azure --plan --config /private/operator/deploy-azure.json \ - --out /private/operator/plan.json - -gh cao deploy azure --apply --config /private/operator/deploy-azure.json \ - --plan-file /private/operator/plan.json \ - --source-dir /src/gh-cao --repo-dir /private/operator \ - --empty-evidence --receipt /private/operator/deploy-azure-receipt.json -``` - -Evidence is mandatory and explicit: choose either `--empty-evidence` or -`--snapshot-dir DIR`. Snapshot mode checks the `payload-hashes.json` SHA-256 -manifest and copies only allowlisted, verified payloads and -`inventory-sources.json`. Empty mode builds `azure-empty-ingest-runtime`; -it never silently substitutes for a missing or invalid snapshot. - -Apply creates the resource group if necessary, deploys the exact template ACR -resource, builds the Functions, database-provisioning, and ingestion Dockerfile targets, records their -digests, and locks the version-plus-config-hash tags against writes and deletes. -It then deploys the full template with separate Functions, provisioning, and -ingestion digest parameters, not those tags. After deployment it reads back the -configured digest references for all three targets before starting either job; -missing, mismatched, or additional job containers fail closed. When using an -explicit template override, regenerate `server/azure/main.json` from the reviewed -Bicep sources before planning, then review the exact compiled template. Apply rejects templates -without the three image digest parameters before any Azure call. -New managed-identity Key Vault grants can take time to propagate. Apply retries -the exact reviewed ARM request at most three times only when every nested -failure is the recognized job secret-fetch error in the ingestion or -database-provisioning module. Each attempt has the same 45-minute bound as the -initial full deployment; the command's overall timeout also applies. Retries use -cancellation-aware backoff, never change access grants, and fail closed on mixed, -unknown, authorization, or policy errors. An interrupted attempt issues an -explicit cancellation of that exact ARM deployment using a separate, two-minute -bounded context before returning. Cancellation failures are reported rather than -assuming ARM stopped. -After the full deployment succeeds, it refuses an overlapping ingestion -execution and waits on the exact execution it starts. The separate -`-db-provision` execution must succeed before runtime-only ingestion starts; -bootstrap container failures stop the deployment before verification. -Configure environment or Key Vault references for -`secrets.postgresAdministratorPassword`, `secrets.postgresOwnerPassword`, -`secrets.postgresRuntimePassword`, `secrets.githubClientSecret`, and -`secrets.sessionSecret`. The owner and runtime passwords each require at least -32 characters and must be different from each other and the administrator -password. - -Verification checks health, readiness, signed-out API denial, both GitHub OAuth -redirects, and independent SCM denial. Main-site HTTP 403 triggers an ingress -read-back and a distinct diagnostic if tenant automation changed the settings; -the command never fights or repairs such changes. `--timeout` bounds the overall -apply (default 45 minutes). The secret-free receipt defaults to -`.cao/deploy-azure-receipt.json` and records the reviewed plan digest, deployment -identity, source/context identity, immutable image references, configured image -read-back, exact job execution, verification results, and timestamps. Existing receipts are not -overwritten. - -#### Development image assurance boundary - -Source-built Azure images are **not production-assured**. The receipt explicitly -records `environment: development`, -`imageAssurance: not-evaluated-development-only`, and per-target scan (`not-performed`), SBOM -(`not-generated`), and provenance (`local-context-not-attested`) dispositions. -These are absence-of-assurance records, not passing gates or vulnerability -waivers. No receipt or approval-file option enables production promotion. - -`sourceRevision` identifies the development checkout's base commit, not a claim -that the uploaded files equal that commit. `sourceWorktree` records its observed -clean/dirty state (including untracked files, which are not uploaded). -`contextSha256` binds the copied allowlisted product files, operator policy, -selected evidence, and normalized executable modes. Dirty inputs therefore have -their own context identity even when the base commit stays unchanged. Status and -revision changes detected during copying abort the build; use a stable checkout -because local status observations are not signed, atomic source attestations. -Configured digest read-back is not measurement of the platform's running image. - -Production promotion requires a separately reviewed independent pipeline binding -**each exact Functions, ingestion, and provisioning digest** to source/context -provenance (including dirty-context handling), scan policy and results with -reviewed dispositions, an SBOM, approval, and deployed digest read-back. The -official package workflow's Trivy/Grype/Dockle gates, SPDX SBOM, sealed candidate, -and publish attestations provide prior art; neither its published server image -nor the Azure asset-bundle attestation covers subsequent ACR target rebuilds. -Selecting scanner policy, vulnerability exceptions, evidence retention, approvers, -and enforcement in the production subscription remains the operator's -responsibility. This CLI neither implements nor accepts that independent gate. - -Key Vault purge protection remains enabled by default. Only explicitly -disposable environments should set `"keyVault": {"purgeProtection": false}` in -the reviewed deployment config. This cannot downgrade an already protected -vault. Deleting a disposable resource group still requires separately purging -its soft-deleted vault; production vaults must retain purge protection. - -### Azure resources - -The `server/azure/main.bicep` template creates the following resources in one resource group. - -| Service | Configuration | Purpose | -| --- | --- | --- | -| Azure Functions | Linux custom-container Function App on runtime `~4`. HTTPS only, TLS 1.2 or later, FTPS off, always on, at least one instance, and a system-assigned managed identity. | Hosts the Go HTTP handler | -| Azure Container Registry | Basic SKU with the admin account disabled | Stores the Functions and bootstrap-ingestion images; the Function App pulls with managed identity | -| App Service plan | Elastic Premium `EP1` | Keeps the Function App always on | -| Key Vault | Role-based access control, 90-day soft delete, and purge protection | Stores the OAuth client secret, session secrets, PostgreSQL URL, and optional collection secrets | -| Storage account | `Standard_LRS`, HTTPS only, TLS 1.2, and no public blob access | Stores Azure Functions runtime state only | -| PostgreSQL Flexible Server | PostgreSQL 16, private delegated subnet, TLS certificate verification, seven-day backups | Stores canonical data and isolated operational tables without Redis | -| Virtual network and private DNS | Separate Functions, PostgreSQL, and Container Apps subnets | Provides private database access for all roles | -| Container Apps environment and manual provisioning/ingestion jobs | Internal environment, separate identities, secret-scoped Key Vault access, managed-identity ACR pulls | Provisions database authority separately from runtime-only snapshot ingestion | -| Application Insights and Log Analytics | Exactly one workspace-backed component and one shared workspace | Receives container logs and configured application telemetry | -| Azure Data Explorer (optional) | Paid cluster, streaming ingestion, seven-day database soft-delete retention, private endpoint and VNet-linked private DNS; public access disabled | Provisions an analytics destination only when `adxEnabled=true`; does not enable an exporter | - -### Optional Azure Data Explorer destination - -ADX is **not required** for the dashboard. `server/azure/adx.bicep` covers the -optional destination; `main.bicep` includes it only with `adxEnabled=true`. -The default remains false: no Kusto resources, private endpoint, ADX DNS zone, -or additional subnet are created. Existing free or operator-managed destinations -do not need this module; leave it disabled and use their reviewed endpoint. -The native deployment preflight does not require `Microsoft.Kusto` -registration, Kusto write permissions, or regional Kusto availability while -this destination is disabled. Application Insights and Log Analytics do not -require this resource provider. - -For a new destination, use the manual Bicep deployment below with these additional -reviewed parameters (the native `gh cao deploy azure` configuration does not yet -expose ADX parameters): - -| Parameter | Default / review requirement | -| --- | --- | -| `adxEnabled` | `false`; explicitly set `true` to provision a **paid** destination | -| `adxClusterName` | Stable generated name; a custom name must be globally unique, 4–22 lowercase letters/digits | -| `adxDatabaseName` | `CAO`; use an ASCII letter followed by letters, digits or underscores, at most 100 characters | -| `adxSkuName`, `adxSkuTier`, `adxCapacity` | `Dev(No SLA)_Standard_E2a_v4`, `Basic`, `1`; development only, **not** the free offering. Review regional SKU availability, compatible tier/capacity, cost and production requirements before deployment. Disk encryption is enabled: do not select legacy Dv2 SKUs, which do not support it | - -Register `Microsoft.Kusto` before opting in. Run `bicep build` on -`server/azure/main.bicep`, then review an `az deployment group what-if` using -the same complete parameter set and secret-manager references as the deployment. -This module does not create a free cluster or silently upgrade one. - -The destination uses a non-delegated `adx` subnet (`10.42.4.0/24`) in the -deployment VNet, preserving `subnetNetworkSecurityGroupId` when supplied. -Its private endpoint uses the Kusto `cluster` subresource and a -`privatelink.REGION.kusto.windows.net` DNS zone linked to that VNet. -Public network access is disabled. Place the setup client and eventual exporter -on that VNet or an explicitly connected network with private DNS resolution; -verify DNS, private endpoint approval, HTTPS reachability and Entra token access. -Reuse of an existing regional private DNS zone requires a separately reviewed -network adaptation rather than creating a conflicting zone/link. -Keep `adxEnabled=true` on subsequent deployments while the private endpoint is -in use. Setting it false is not a teardown: incremental ARM deployments retain -omitted resources, and removing an occupied subnet can fail. Review data -retention, explicit resource deletion and DNS cleanup separately. - -Provisioning is **not activation**. The outputs `adxClusterId`, -`adxQueryEndpoint` and `adxDatabaseName` identify the destination (empty when -disabled); they contain no credentials. Before exporting: - -1. Assign a separate setup principal the required Kusto database-admin - permissions through reviewed ADX principal assignments. Print - `cao-dashboard adx schema`, review and apply its generated KQL from the - private network, then wait for streaming mappings/policies to propagate. - The module creates no tables, mappings, functions or runtime DDL. -2. Register a dedicated Entra runtime application. Grant database ingestion, - read and extent/size-inspection permissions, verify them using that exact - principal, and keep setup/admin privileges separate. The module grants no - runtime data access. Managed-identity Key Vault access does not authenticate - the ADX exporter: it currently requires explicit application credentials. -3. Use `gh cao setup adx` with the **query** endpoint output (not an `ingest-` - endpoint), database, application identity, exact canonical repository IDs, - explicit data/destination approval and single-replica acknowledgment. - Inject `CAO_ADX_CLIENT_SECRET` through the supported host's secret manager, - never policy, checked-in parameters or command-line arguments. -4. Apply those settings only to an approved, persistent, single-replica OAuth - server with canonical PostgreSQL and restart-durable operational state. - **The Azure Functions platform-listener profile and the separate collection - jobs are not supported exporter hosts.** This template deliberately injects - no `CAO_ADX_*` settings and does not assert single-replica safety. - -The [ADX snapshot guide](https://github.com/githubnext/gh-cao/blob/main/server/ADX-INGEST.md) -defines scope, approval, storage budgets, retry/gap behavior and verification. -Confirm real ingestion and deduplicated query results before claiming readiness; -Bicep compilation is not live Azure evidence. ADX is separate from Application -Insights/Log Analytics and does not replace PostgreSQL. Traces, mining and -Blob/session archives remain independently disabled and require their own -approved topology, durable private storage and acceptance evidence; provisioning -an ADX cluster does not provide them. - -### Other requirements - -- An Azure subscription and resource group where you can create these resources and assign the **Key Vault Secrets User** role. -- The Azure CLI with Bicep support. -- Go 1.27.3 or newer and Node.js 24, to build the deployment package. -- Register the subscription resource providers before deploying. Container Apps - requires both `Microsoft.App` and `Microsoft.ContainerService`, even though - this template does not create an AKS cluster. A subscription administrator may - need to perform registration: - - ```bash - for provider in Microsoft.App Microsoft.ContainerService Microsoft.Web \ - Microsoft.DBforPostgreSQL Microsoft.Network Microsoft.Storage \ - Microsoft.ContainerRegistry Microsoft.KeyVault Microsoft.ManagedIdentity \ - Microsoft.Insights Microsoft.OperationalInsights; do - az provider register --namespace "$provider" --wait - done - ``` - -- Docker, or an Azure Container Registry build agent. -- A GitHub OAuth app, or a GitHub App configured for the user authorization-code flow. For membership authorization, the GitHub App needs organization **Members: read** and an approved installation in each allowed organization. PATs and pasted user tokens aren't supported. -- At least one allowed GitHub user, organization, or team in `ORGANIZATION/TEAM-SLUG` format. -- An adopter-owned reviewed `cao.json` and a verified local operational snapshot, or an explicit choice to initialize an empty dashboard. Obtain snapshots from authorized Activity artifacts, not the synthetic Pages demo. Campaigns are not required for empty initialization. Private repository snapshots must be collected with credentials authorized for those repositories and kept private. -- An ingress choice. The template defaults to the public-main model: the - Function App's `publicNetworkAccess` is `Enabled`, unmatched main-site traffic - is `Allow` with no custom rules, and GitHub OAuth sign-in plus the allowed - user, organization, or team list is the access control. No VPN, proxy, or - corporate-network rule is required. SCM/Kudu remains independently - default-deny. For a deliberately private deployment, set - `functionPublicNetworkAccess=Disabled` or `functionAccessRestrictionsDefaultAction=Deny` - with explicit rules. -- After deployment, confirm that tenant automation has not rewritten ingress - (for example, `publicNetworkAccess=Disabled` or corporate-network-only service - tag rules). Such out-of-band changes aren't Azure Policy denials and aren't - prevented by the template; restore the public-main settings described in - [Explicit public-main access](#explicit-public-main-access-with-independently-restricted-scm) - and ask the automation owner for an exemption if it recurs. - -### Ingress changes on an existing app - -The public-main default does not override a subscription that genuinely requires -private-only ingress. Establish the applicable subscription and inherited policy requirements -before changing the deployed access mode. - -If the applicable requirements permit restricted public access, the smallest -repair may be an app access-mode change retaining the existing approved rules, -default-deny behavior, HTTPS, and OAuth authorization. Full-template deployment -must carry main-site rules in `functionAccessRestrictions`, SCM/Kudu rules in -`functionScmAccessRestrictions`, and the existing SCM reuse setting in -`functionScmAccessRestrictionsUseMain`. The reuse setting defaults to `false`; -set it to `true` only to preserve an approved shared-rule configuration. Keep -distinct SCM rules out of the main-site array so deployment access does not -widen application access. Omitting either rule array uses an empty list, not -the deployed rules. Main-site unmatched requests use -`functionAccessRestrictionsDefaultAction`, which defaults to `Allow` and accepts -only `Allow` or `Deny`. SCM's independent default remains `Deny`. -`functionPublicNetworkAccess=Enabled` -does not itself grant permission, and allow rules cannot override `Disabled`. - -Do not redeploy the full bootstrap for an ingress-only repair or image update. -Preserve the hostname and OAuth callback, review only the intended settings -delta, and verify authorized and unauthorized access afterward. The template -does not provision private endpoints or new client connectivity. Its Functions -VNet integration is outbound connectivity to PostgreSQL, not browser access. - -#### Explicit public-main access with independently restricted SCM - -For an explicitly authorized public-main deployment, use `Enabled`, main-site -default action `Allow`, and an empty main-site custom-rule list. Do not model -public access as a terminal `Allow`/`Any` rule under default `Deny`: the platform -can normalize that rule, so an accepted update is not evidence of actual access. -The following generic ARM parameter fragment can be merged into the adopter's -protected deployment parameters for a new deployment or reviewed redeployment: - -```json -{ - "functionPublicNetworkAccess": { "value": "Enabled" }, - "functionAccessRestrictionsDefaultAction": { "value": "Allow" }, - "functionAccessRestrictions": { "value": [] }, - "functionScmAccessRestrictionsUseMain": { "value": false }, - "functionScmAccessRestrictions": { - "value": [ - { - "name": "approved-admin-network", - "ipAddress": "192.0.2.0/24", - "action": "Allow", - "priority": 100 - } - ] - } -} -``` - -The documentation-only address above must be replaced with the independently -approved SCM restrictions; preserve the complete existing SCM rule list. -Keep SCM reuse `false` for this model so public main-site access is not inherited -by SCM/Kudu. Unmatched SCM requests remain denied. This changes network admission -only: HTTPS, GitHub OAuth authorization, and application administrator permissions -remain required and unchanged. It does not make administrative operations public. - -For a from-scratch deployment, record the reviewed source revision and parameter -set, supply all other required bootstrap inputs, and verify the resulting -main-site `Allow`/empty-rule configuration and independent SCM rules after -provisioning. From a public client, confirm health/readiness and the authorized -GitHub sign-in flow; verify unauthorized users and non-admin users cannot access -protected data or administrative operations. From outside the approved admin -network, verify SCM is denied, and verify approved SCM access separately. -Repeat these checks after redeployment. Neither template tests nor a successful -deployment/PATCH response prove live reachability or authorization. - -The optional collection profile also needs a container image of the collector, a GitHub App, and Azure Container Apps. For more information, see [Using the optional collection profile](#using-the-optional-collection-profile). - -## Deploying the dashboard - -In the following steps, replace `FUNCTION-APP-NAME` with a globally unique name for your Function App. -Use a **new resource group and globally unique names** to create a separate instance. -Never repoint an existing instance's OAuth callback. Reusing the same names and -secret inputs makes redeployment update this instance rather than create duplicate -telemetry resources. - -Keep instance configuration and private snapshots outside the public source tree. -Prepare an isolated build context from the reviewed source revision, replace -`.github/workflows/cao.json` there with your own reviewed policy, and leave -`cao.azure.json` as the generic PostgreSQL host overlay. For a zero-campaign -instance, disable campaigns in your own policy; do not reuse the catalog's -dogfood policy. Do not commit this build context or its data. - -### Retention for existing deployments - -Before deployment, upgrade, secret rotation, rollback, or job start, read -the existing effective `CAO_POSTGRES_RUN_RETENTION_DAYS` and -`CAO_POSTGRES_LINKED_RETENTION_DAYS` on the Function App, ingestion job, and -every enabled collector/backfill role. Include stopped jobs and active revisions -that can still open the same database. When a setting is absent, determine its -effective fallback from that role's deployed source revision, not this -template's default. If the effective value cannot be established, stop the -rollout rather than guessing. - -Pass an explicit `postgresRunRetentionDays` value **no lower than the greatest -existing effective run or linked retention across those roles and the complete -snapshot window**. For a new empty database, choose an explicit reviewed window. -The generic 45-day template default is not evidence of an installation's current -retention and is not an upgrade recommendation. Keep the reviewed value in -adopter-owned parameters for full-template, ingestion, or collector deployments. - -Before any new role starts, verify that both retention settings on **all** -database-owning roles carry that value. For settings/image-only updates, carry -both settings forward explicitly without replaying the full bootstrap. -Initialization performs partition maintenance even without ingestion: omitting -or reducing retention can irreversibly delete history at startup. Increasing it -later does not restore dropped partitions. - -### PostgreSQL authority - -Fresh deployments use the [PostgreSQL authority model](https://github.com/githubnext/gh-cao/blob/main/server/POSTGRES.md): -`cao_owner`, `cao_runtime`, `cao_retention`, and `cao_evidence` are non-login -group roles. Only `cao_bootstrap_login` and `cao_runtime_login` authenticate; -both are non-superusers without database/role creation, replication, or RLS -bypass. Each login belongs only to its corresponding group. The `cao` schema -belongs to `cao_owner`. PUBLIC loses database CONNECT/TEMPORARY and schema CREATE; -runtime receives explicit CONNECT and TEMPORARY for ingestion staging. - -The separate manual `FUNCTION-APP-NAME-db-provision` job alone can read the -administrator URL and bootstrap password. It creates/verifies roles and the -schema, then authenticates as the bootstrap login with -`PGOPTIONS='-c role=cao_owner -c search_path=cao'`, bootstraps both stores, and -applies the checked-in `server/postgres/operational-grants.sql` and -`canonical-grants.sql`. These files are copied into the `azure-db-provision` -Dockerfile target, not duplicated in ARM. Its PostgreSQL 16 client base is -digest-pinned; the built image must be pushed to this deployment's ACR before -starting the job. No deployed container pulls from Docker Hub. - -Generate distinct administrator, owner, and runtime passwords in your secret -manager and preserve them across redeployments. Supply the latter two as -`postgresOwnerPassword` and `postgresRuntimePassword` **secure** Bicep inputs. -Secure parameters and Key Vault secret values are not exposed in deployment -history or outputs; do not pass plaintext secrets as command-line arguments or -write them to ordinary parameter files, logs, source, or build arguments. -This approach avoids giving the provisioning identity Key Vault write authority -or generating passwords whose recovery depends on a job execution. The job -rejects reused authority passwords and role-password line breaks; `psql` hashes password -changes client-side without putting plaintext in SQL or process arguments. - -The Function App, ingestion, collector, and backfill receive only the -runtime-login URL, with `search_path=cao` and certificate-verified TLS. Their -identities have only explicitly listed secret-level **Key Vault Secrets User** -assignments, never vault-wide read access. Only the provisioning identity gets -the administrator secret. For an existing deployment, remove its previous -vault-wide role assignments as a separately reviewed prerequisite: incremental -ARM deployment does **not** remove old RBAC grants or revoke distributed -administrator credentials. - -No Azure audit-retention job currently exists. The retention/evidence group -roles and their explicit grants are provisioned, but there are deliberately no -retention/evidence logins or mounted credentials. Audit retention is not run by -web, ingestion, collector, or bootstrap. Before enabling retention or evidence -export, separately provision a restricted login and job following -`server/POSTGRES.md`; do not use the owner for routine audit retention. -Canonical partition maintenance must run at least daily as a short-lived owner -operation with the same retention settings, not as runtime. Until a dedicated -owner-only maintenance job is configured, schedule this manual provisioning -job at least daily with the unchanged passwords and reviewed configuration. -Monitor failures; runtime does not repair missing partitions. - -Canonical Runs and run-owned records use UTC midnight-to-midnight daily -partitions named `_dYYYYMMDD`. Owner maintenance provisions the current -day and 28 upcoming days, so a missed or failed daily job must be repaired -before that runway is exhausted; missing ingestion days are a hard error, not -silent data loss. It removes only whole days that end at or before each -retention cutoff, so the partial cutoff day remains and scheduling lag can -extend physical retention. Existing weekly (`
_wYYYYMMDD`) layouts are -rejected without in-place conversion: provision a fresh canonical schema or -database and re-ingest authoritative inputs. See `server/POSTGRES.md` for the -normative detail. - -### Bootstrap steps - -1. Register a GitHub OAuth app, or use a GitHub App's user authorization flow. Set its **Authorization callback URL** to `https://FUNCTION-APP-NAME.azurewebsites.net/auth/callback`. Store the client secret in your secret manager for use in a later step. For more information, see [Creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) or [Authenticating with a GitHub App on behalf of a user](https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-with-a-github-app-on-behalf-of-a-user) in the GitHub documentation. - - Alternatively, reuse a GitHub App with the same callback URL and organization - **Members: read**. With its `Iv...` client ID, CAO requests `offline_access` - for expiring user tokens and refresh tokens without changing expiration for - other clients of that App. This requires GitHub.com or GHES 3.23 or later. - Manage client secrets and key pairs in the App's **Credentials** tab on - current GitHub versions; no App private key is needed for browser sign-in. - - > [!CAUTION] - > Never commit the client secret to a repository. - - A GitHub App used for organization or team authorization must have - **Organization members: Read-only**, and its organization installation must - accept that permission. A classic OAuth App must be granted organization - access when the organization restricts third-party OAuth applications. - Without the applicable grant, GitHub withholds organization and team - membership even for active members, and CAO correctly denies access. - -1. Generate a session secret of at least 32 random characters. - - ```bash - openssl rand -base64 48 - ``` - -1. Create the resource group and registry. The Bicep template subsequently manages the same registry declaratively. - - ```bash - az group create --name cao-dashboard --location REGION - az acr create \ - --resource-group cao-dashboard \ - --name CONTAINER-REGISTRY-NAME \ - --sku Basic \ - --admin-enabled false - ``` - -1. Build the Function App image from a trusted checkout. Use an immutable release or commit tag in production instead of `DEPLOYMENT-TAG`. - - ```bash - az acr login --name CONTAINER-REGISTRY-NAME - docker build \ - --platform linux/amd64 \ - --build-arg CAO_PROFILE=cao.azure.json \ - --target azure-functions-runtime \ - --file server/Dockerfile \ - --tag CONTAINER-REGISTRY-NAME.azurecr.io/cao-functions:DEPLOYMENT-TAG \ - . - docker push CONTAINER-REGISTRY-NAME.azurecr.io/cao-functions:DEPLOYMENT-TAG - ``` - -1. Build and push the short-lived provisioning image to the same ACR: - - ```bash - docker build --platform linux/amd64 \ - --target azure-db-provision --file server/Dockerfile \ - --tag CONTAINER-REGISTRY-NAME.azurecr.io/cao-db-provision:DEPLOYMENT-TAG . - docker push CONTAINER-REGISTRY-NAME.azurecr.io/cao-db-provision:DEPLOYMENT-TAG - ``` - -1. Build and push **one** ingestion image with the same deployment tag. - For an empty instance, the ACR task builds and pushes only; it does not ingest: - - ```bash - az acr run --registry CONTAINER-REGISTRY-NAME \ - --file server/azure/empty-ingest-task.yaml \ - --set tag=DEPLOYMENT-TAG . - ``` - - For your own verified snapshot, copy it into an `adopter-snapshot/` directory - in the isolated build context and use the explicit source argument: - - ```bash - docker build --platform linux/amd64 \ - --target azure-ingest-runtime --file server/Dockerfile \ - --build-arg CAO_PROFILE=cao.azure.json \ - --build-arg CAO_INGEST_SOURCE=adopter-snapshot \ - --tag CONTAINER-REGISTRY-NAME.azurecr.io/cao-ingest:DEPLOYMENT-TAG . - docker push CONTAINER-REGISTRY-NAME.azurecr.io/cao-ingest:DEPLOYMENT-TAG - ``` - - The image never downloads upstream data automatically. Missing source input - fails the build. Both images are private artifacts: restrict registry access - and retention appropriately. - - **Building from a release asset bundle.** Operators without a full catalog - checkout can use the attested `cao-azure-REVISION.tgz` attached to each - release beside the runtime archive. It contains only the Docker build inputs - for `azure-functions-runtime`, `azure-ingest-runtime`, - `azure-empty-ingest-runtime`, and `azure-db-provision`, the compiled `server/azure/main.json` - template, and a `cao-azure.json` manifest with the source revision and the - SHA-256 digest of every file. It never contains adopter policy - (`cao.json`, `cao.azure.json`), credentials, tests, or documentation. Verify - and extract it, then supply your policy and snapshot as named build - contexts: - - ```bash - gh release download RELEASE-TAG --repo OWNER/REPOSITORY --pattern 'cao-azure-*' - sha256sum --check cao-azure-REVISION.tgz.sha256 - gh attestation verify cao-azure-REVISION.tgz --repo OWNER/REPOSITORY \ - --signer-workflow OWNER/REPOSITORY/.github/workflows/cao-runtime.yml \ - --source-digest REVISION --signer-digest REVISION --deny-self-hosted-runners - mkdir cao-azure && tar -xzf cao-azure-REVISION.tgz -C cao-azure - docker build --platform linux/amd64 \ - --target azure-functions-runtime --file cao-azure/server/Dockerfile \ - --build-arg CAO_PROFILE=cao.azure.json \ - --build-context cao-policy=POLICY-DIRECTORY \ - --tag CONTAINER-REGISTRY-NAME.azurecr.io/cao-functions:DEPLOYMENT-TAG \ - cao-azure - docker build --platform linux/amd64 \ - --target azure-ingest-runtime --file cao-azure/server/Dockerfile \ - --build-arg CAO_PROFILE=cao.azure.json \ - --build-context cao-policy=POLICY-DIRECTORY \ - --build-context cao-ingest-source=SNAPSHOT-DIRECTORY \ - --tag CONTAINER-REGISTRY-NAME.azurecr.io/cao-ingest:DEPLOYMENT-TAG \ - cao-azure - ``` - - `POLICY-DIRECTORY` must contain your reviewed `cao.json` and - `cao.azure.json` at its root. `SNAPSHOT-DIRECTORY` holds your verified - ingestion snapshot. Use `--target azure-empty-ingest-runtime` without the - `cao-ingest-source` context for an empty instance. Deploy the bundled - `cao-azure/server/azure/main.json` with `--template-file`. When those - contexts are omitted, the build reads `.github/workflows/cao.json`, - `.github/workflows/cao.azure.json`, and `CAO_INGEST_SOURCE` from the main - build context, so builds from a checkout behave as before. - -1. Review the template with `az deployment group what-if` before using - `az deployment group create`. Replace the placeholders with your own values. - The example below uses the public-main default: the dashboard is reachable - over public HTTPS, GitHub OAuth and the allowed user, organization, or team - list control access, and SCM/Kudu stays default-deny. No VPN or proxy is - required. If tenant automation has attached a network security group to the - subnets, pass its resource ID as `subnetNetworkSecurityGroupId` so - redeployment keeps it attached. - Do not use it to overwrite an existing app's access rules; follow - [the ingress guidance](#ingress-changes-on-an-existing-app) for an existing app. +Azure Container Apps is the supported Azure hosting path. Azure Functions +runtime, deployment commands, receipts and the local Core Tools harness are +retired. There is no native Azure apply command or replacement deployment +automation. - ```bash - az deployment group create \ - --resource-group cao-dashboard \ - --template-file server/azure/main.bicep \ - --parameters \ - functionAppName=FUNCTION-APP-NAME \ - storageAccountName=STORAGE-ACCOUNT-NAME \ - containerRegistryName=CONTAINER-REGISTRY-NAME \ - functionImageTag=DEPLOYMENT-TAG \ - keyVaultName=KEY-VAULT-NAME \ - postgresServerName=POSTGRES-SERVER-NAME \ - postgresRunRetentionDays=REVIEWED-RETENTION-DAYS \ - allowedHosts='["FUNCTION-APP-NAME.azurewebsites.net"]' \ - githubAllowedOrganizations='["ORGANIZATION"]' \ - githubClientId=OAUTH-CLIENT-ID - ``` - - The Azure CLI prompts you for `githubClientSecret`, `sessionSecret`, and - `postgresAdministratorPassword`, `postgresOwnerPassword`, and - `postgresRuntimePassword`. Enter them at the secure prompts or supply secure - Key Vault parameter references from your operator secret manager. - Preserve all three passwords across deployments. The template stores separate - administrator and runtime TLS URLs and provisioning passwords in Key Vault - without outputting them. - -1. Review the non-secret outputs, especially `githubOAuthRedirectUri`, - `provisioningJobName`, `ingestionJobName`, `logAnalyticsWorkspaceId`, and `applicationInsightsId`. - Confirm the OAuth callback matches the **new** app registration. -1. Start the private provisioning job and wait for `Succeeded` before starting - ingestion or collection: - - ```bash - az containerapp job start --resource-group cao-dashboard \ - --name FUNCTION-APP-NAME-db-provision - az containerapp job execution list --resource-group cao-dashboard \ - --name FUNCTION-APP-NAME-db-provision --output table - ``` - - It runs `database bootstrap --store canonical --namespace azure-dashboard` - with reviewed retention and - `database bootstrap --store operational --namespace azure-dashboard-operational` - with the shared operational capacities, followed by both explicit grant - scripts. Repeating the job with identical configuration is idempotent. - Unexpected privileged role memberships or schema ownership fail closed. - Do not overlap provisioning executions or rotate passwords during active use. -1. Load the dashboard data by starting the private ingestion job. Unlike a public - ACR build agent, this job has a network path to private PostgreSQL. Its managed - identity resolves the URL from Key Vault and pulls the image from private ACR. - Confirm the [existing retention](#retention-for-existing-deployments) - was checked before deployment and the job carries the reviewed value, - no lower than existing effective retention or the snapshot window. - Azure sets both `CAO_POSTGRES_RUN_RETENTION_DAYS` and - `CAO_POSTGRES_LINKED_RETENTION_DAYS` to that value on the app, ingestion job, - and optional collection/backfill roles. This preserves the earlier shared - window only when that existing effective value was carried forward explicitly. - - Runtime roles only open storage; they never create it. The ingestion job - has no bootstrap init containers, administrator secret, or owner credential. - Missing provisioning or grants fails ingestion rather than elevating authority. - - ```bash - az containerapp job start --resource-group cao-dashboard \ - --name FUNCTION-APP-NAME-ingest - az containerapp job execution list --resource-group cao-dashboard \ - --name FUNCTION-APP-NAME-ingest --output table - ``` - - Wait for that execution to report `Succeeded`; starting a job is not success. - On failure, inspect the job logs in the shared workspace and correct the - cause before retrying. Do not run overlapping ingestion jobs or run snapshot - ingestion while the optional collector is projecting. - Every successful ingestion replaces the active canonical namespace. Empty - ingestion deliberately publishes no repositories, organizations, runs, or - campaigns. An uninitialized namespace returns `503` readiness; a valid empty - publication returns `200`. - -1. Verify the deployment from a client using the approved ingress path. - - 1. Confirm that `GET /api/health` succeeds. - 1. Confirm that `GET /api/readiness` returns `200`. It returns `503` until data is loaded. - 1. Sign in with GitHub and open a dashboard view. - 1. Confirm that the dashboard refuses a user who isn't in an allowed organization or team. - -> [!TIP] -> To test the Functions HTTP interface on Linux or macOS (Intel or Apple Silicon) without Azure credentials, run `npm run test:integration:azure-services` with a local Docker daemon running. To use Podman instead, set `CAO_CONTAINER_ENGINE=podman`; only `docker` (the default) and `podman` are accepted, and there is no automatic fallback to another installed engine. The script builds the native Go server and starts platform-matched Azure Functions Core Tools, Azurite, and PostgreSQL. - -### Infrastructure-only bootstrap - -If registration of the new OAuth app is pending, deploy -`server/azure/network.bicep` independently with `location`, `namePrefix` (the -future Function App name), `postgresServerName`, `postgresAdministratorLogin`, -and secure `postgresAdministratorPassword`. This creates only the private -network, DNS, and PostgreSQL database, not an unauthenticated dashboard. -The full template adopts the same resources when supplied the same values. -Do not use placeholder OAuth values to bypass application startup requirements. - -### Updating an existing runtime - -**Moving from the old administrator-backed Azure topology requires a new -database**, following [Moving to a new database](#moving-to-a-new-database). -Do not apply these grants as a privilege scrubber to an existing overgranted -database. Stop/drain existing processes, preserve pending work and audit anchors, -back up operational state, and plan recovery before switching. Creating a new -database does not migrate operational audit, queues, sessions, or revocations; -retain the previous database and independent evidence. Remove old vault-wide -RBAC and retire the previously distributed administrator credential after the -reviewed cutover. A template redeployment alone does neither. - -Compare the deployed source's generated `server/internal/postgresx/schema.sql` -with the release. A changed physical contract requires a new database and -explicit re-ingestion; there is no automatic compatibility migration. An -unchanged contract can still change maintenance behavior. Releases supporting -independent linked retention default to seven days when the setting is absent. -Before starting such a release, check the -[existing retention](#retention-for-existing-deployments) -and explicitly set **both** retention settings on every database-owning role to -the reviewed value. Never replace a larger existing run or linked window with -the generic 45-day template default; otherwise owner bootstrap/maintenance can -drop older partitions even without ingestion. - -Validate old-to-new startup, representative historical records, new ingestion, -and rollback against an isolated local database. With retention preserved, -normal aging and existing curation still run; owner partition maintenance must -be scheduled separately as described above; -"no schema migration" does not mean "no database writes." Never use production -startup as a read-only compatibility probe. Preserve the current image/settings -receipt and verified recovery evidence, review a minimal settings/image delta, -and do not start the ingestion job or replay a bootstrap deployment for an -image-only update. - -### Moving to a new database - -Use a new database when a release changes the physical contract, or when you -deliberately want to start from empty canonical and operational state. Do this -with a reviewed redeployment, not by editing app settings by hand. - -1. Choose a new `postgresDatabaseName` and a new runtime `postgresUrlSecretName`, - for example `cao2` and `cao-postgres-runtime-url-cao2`. The administrator and - provisioning-password secret names derive from this prefix. A new prefix - retains the previous secrets. Role names are server-wide: this topology uses - one role set per dedicated server, not mutually distrusting databases on one - server. Keep role passwords unchanged when adding a database to that server. -1. Run `az deployment group what-if` with both new names and every other - parameter unchanged, including the reviewed `postgresRunRetentionDays`. - Expect a new database, new Key Vault secrets and secret-level RBAC, and changed PostgreSQL URL - references on the Function App, the ingestion job, and any enabled - collection roles. Stop if anything else changes. -1. Deploy. Incremental deployment does not delete the previous database or its - secret. Every role now points at the new, empty database, so the dashboard - reports `503` readiness until the next step succeeds. -1. Start the provisioning job and wait for `Succeeded`, then run ingestion - and wait for `Succeeded`, as in [Bootstrap steps](#bootstrap-steps). - Never restore administrator-backed runtime access for rollback; use a - reviewed recovery procedure that preserves unfinished work and audit evidence. - Then restart the Function App and any - collection roles and verify health, readiness, and sign-in. -1. After the new database is verified and no longer needs rollback, delete the - previous database and disable or delete its Key Vault secret as a separate, - reviewed change. - -## Configuration reference - -### App settings - -The template creates the following app settings. - -| App setting | Source | Description | -| --- | --- | --- | -| `CAO_DASHBOARD_HOSTING` | Fixed value `azure-functions` | Selects Azure hosting mode. | -| `CAO_AZURE_ALLOWED_HOSTS` | `allowedHosts` parameter | Exact public host names that the server trusts in forwarded `Host` headers. | -| `CAO_AZURE_REQUIRE_HTTPS` | Fixed value `true` | Requires `X-Forwarded-Proto: https`. | -| `CAO_POLICY_PATH` | Fixed value `.github/workflows/cao.azure.json` | Selects the reviewed Azure deployment profile. | -| `CAO_POSTGRES_URL` | Key Vault secret named by `postgresUrlSecretName` (default `cao-postgres-runtime-url`) | Restricted runtime-login TLS URL shared by canonical and operational adapters, never the administrator URL. | -| `CAO_POSTGRES_RUN_RETENTION_DAYS` | Explicit reviewed `postgresRunRetentionDays`; template default `45` is not upgrade-safe | Canonical run-history window and partition-retention horizon. Preserve existing effective retention and the snapshot window before startup. | -| `CAO_POSTGRES_LINKED_RETENTION_DAYS` | Same explicit `postgresRunRetentionDays` | Linked-evidence horizon shared by all database-owning roles. Never shorten an existing linked window during redeployment. | -| `REDIS_NAMESPACE` | Fixed value `azure-dashboard` | Legacy name for the canonical PostgreSQL dataset identity; no Redis service is used. | -| `CAO_OPERATIONAL_NAMESPACE` | Fixed value `azure-dashboard-operational` | Isolated namespace for operational PostgreSQL tables. | -| `CAO_OPERATIONAL_CACHE_MAX_*` | Template capacity parameters | Bounds disposable operational cache entries and bytes. | -| `CAO_OPERATIONAL_PROTECTED_MAX_*` | Template capacity parameters | Bounds protected operational records and bytes. | -| `CAO_GITHUB_CLIENT_ID` | `githubClientId` parameter | Client ID of the OAuth app. | -| `CAO_GITHUB_CLIENT_SECRET` | Key Vault secret `github-oauth-client-secret` | Client secret of the OAuth app. | -| `CAO_GITHUB_REDIRECT_URL` | Derived from `functionAppName` | `https://FUNCTION-APP-NAME.azurewebsites.net/auth/callback` | -| `CAO_GITHUB_ALLOWED_ORGS` | `githubAllowedOrganizations` parameter | Organizations whose active members can sign in. | -| `CAO_GITHUB_ALLOWED_TEAMS` | `githubAllowedTeams` parameter | Teams, in `ORGANIZATION/TEAM-SLUG` format, whose active members can sign in. | -| `CAO_GITHUB_ALLOWED_USERS` | `githubAllowedUsers` parameter | Exact GitHub logins authorized directly. Use this for narrow access that must not depend on organization OAuth visibility. | -| `CAO_SESSION_SECRET` | Key Vault secret `cao-session-secret` | Current session encryption key. At least 32 characters. | -| `CAO_SESSION_SECRET_PREVIOUS` | Key Vault, only when `previousSessionSecret` is set | Previous session key, used during rotation. | -| `APPLICATIONINSIGHTS_CONNECTION_STRING` | Application Insights | Platform/Functions-host setting only; does not enable exports in the direct Go container. | -| `AzureWebJobsStorage__accountName` | `storageAccountName` parameter | Storage account used by the Functions runtime. | -| `AzureWebJobsStorage__credential` | Fixed value `managedidentity` | Requires the Function App identity and assigned blob, queue, and table data roles; shared-key access is disabled. | -| `WEBSITES_PORT` | Fixed value `8080` | Port exposed by the CAO Functions container. | -| `WEBSITES_ENABLE_APP_SERVICE_STORAGE` | Fixed value `false` | Keeps the immutable container filesystem authoritative. | - -The operational capacity parameters are shared by the Function App and optional -collection roles. Update the template parameters rather than changing only one -role's app settings. PostgreSQL cache pressure never evicts sessions, accepted -collection work, enrollment, checkpoints, leases, or pending revocations. - -The `cao-dashboard azure-functions` handler also reads these optional settings. - -| App setting | Default | Description | -| --- | --- | --- | -| `CAO_AZURE_SITE_DIRECTORY` | `site` | Directory of the built site. | -| `CAO_AZURE_DASHBOARD_QUERIES` | `SITE-DIRECTORY/dashboard.json` | Path to the Dashboard Language document. | - -### Template parameters - -Besides the parameters in the deployment command, the template accepts: - -- `location`, `hostingPlanName`, `applicationInsightsName`, `containerRegistryName`, `functionImageRepository`, and `functionImageTag`. -- `otlpEndpoint`, `otlpLogsEnabled`, and secure `otlpHeaders`; see - [the explicit Go telemetry route](#explicit-go-telemetry-route). -- `postgresRunRetentionDays`; explicitly preserve the greatest existing effective - run or linked window across all database-owning roles and the complete snapshot - window. Do not rely on the generic default during redeployment. -- `operationalCacheMaxBytes`, `operationalCacheMaxValueBytes`, `operationalCacheMaxEntries`, `operationalProtectedMaxBytes`, and `operationalProtectedMaxEntries`. -- `logAnalyticsWorkspaceName`, `postgresServerName`, `postgresAdministratorLogin`, - `postgresAdministratorPassword`, `postgresOwnerPassword`, - `postgresRuntimePassword`, `postgresSkuName`, and `postgresSkuTier`. -- `postgresDatabaseName` (default `cao`) and `postgresUrlSecretName` (default - `cao-postgres-runtime-url`). The secret name is a prefix for provisioning - secrets too; reserve space for the `-runtime-password` suffix (110 characters - maximum for the prefix). Use lowercase letters, digits, and hyphens. - The new runtime-secret default intentionally does not reuse the old admin URL. - For an existing administrator-backed installation, see - [Moving to a new database](#moving-to-a-new-database). -- `ingestionImageRepository` and `ingestionImageTag` (defaulting to `cao-ingest` - and the Function App image tag). -- `provisioningImageRepository` and `provisioningImageTag` (defaulting to - `cao-db-provision` and the Function App image tag). -- The collection parameters. For more information, see [Using the optional collection profile](#using-the-optional-collection-profile). - -### Startup checks - -In Azure mode, the server doesn't start unless all of the following are configured: - -- A TLS PostgreSQL URL and the reviewed `cao.azure.json` profile. -- Allowed hosts and HTTPS enforcement. -- The OAuth client ID, client secret, and redirect URL. -- A session secret of at least 32 characters. -- At least one allowed user, organization, or team. - -Azure mode never accepts PATs or the local bearer capability. - -## Monitoring the deployment - -### Functions host telemetry - -The template provisions one workspace-backed Application Insights component and -one shared Log Analytics workspace, and sets `APPLICATIONINSIGHTS_CONNECTION_STRING` -on the Function App. The direct Go container does not run the Functions host or -an Azure Monitor SDK: setting this variable alone does **not** export Go traces. -Configure the OpenTelemetry exporter described below. Container Apps console and -system logs are routed to the same workspace through a diagnostic setting. - -### OpenTelemetry traces - -The Go server uses vendor-neutral OpenTelemetry and doesn't include the Azure Monitor SDK. It emits the following spans, which contain only counts, revisions, and durations: - -- A span for every HTTP request, through `otelhttp`. -- A dedicated `GET /auth/callback` span and `cao_dashboard.auth.callback.count` metric for OAuth callbacks, which are excluded from the generic `otelhttp` instrumentation and carry only a fixed outcome (`success` or `failure`) and, on failure, a bounded `error.type`. No OAuth code, state, cookie, token, or provider message is recorded. -- `cao_dashboard.query.execute` for each query. -- `cao_dashboard.ingest.run` for each ingestion. - -#### Inspect telemetry locally - -For development diagnostics, the repository provides loopback-only Compose -backends for the same OTLP/HTTP exports. They are not part of any deployment. - -| Command | Backend | -| --- | --- | -| `npm run dashboard:server:aspire-up` / `aspire-down` | Standalone Aspire dashboard on `http://127.0.0.1:18888`, OTLP/HTTP on port `4318` | -| `npm run dashboard:server:lgtm-up` / `lgtm-down` | Grafana LGTM (Grafana, Loki, Tempo, Prometheus) on `http://127.0.0.1:3000`, OTLP/HTTP on port `4318` | - -These commands use Docker by default. Set `CAO_CONTAINER_ENGINE=podman` to use `podman compose`, which needs a Compose provider (`podman-compose` or `docker-compose`, selectable with `PODMAN_COMPOSE_PROVIDER`). Use the same engine when bringing a stack down. - -Both publish port `4318`, so stop one before starting the other. Their receivers -are unauthenticated and keep data only until the container is removed. Before -`npm run dashboard:local`, clear inherited signal-specific `OTEL_EXPORTER_OTLP_*` -endpoints and headers, then set `OTEL_SDK_DISABLED=false` and -`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`. Add `CAO_OTEL_LOGS_ENABLED=true` -and a `DEBUG` namespace such as `cao:server,cao:telemetry` to export logs. See the -[local telemetry guide](https://github.com/githubnext/gh-cao/blob/main/server/LOCAL-TELEMETRY.md) -for the full procedure and opt-in export tests. - -#### Explicit Go telemetry route - -The supported template overlay connects the Function App and, when deployed, -both collection and backfill workers to an **operator-managed OTLP/HTTP collector**. -It does not deploy that collector or enable any production network, role or -monitoring settings. The baseline remains opt-in: an empty endpoint exports no -Go telemetry, and is not evidence of an adequate monitoring boundary. - -| Template parameter | Runtime wiring | -| --- | --- | -| `otlpEndpoint` | Reviewed HTTPS collector **base URL**, optionally with a path prefix, without credentials, query or fragment. Sets `OTEL_EXPORTER_OTLP_ENDPOINT` on all three roles. The Go SDK appends `/v1/traces`, `/v1/metrics`, or `/v1/logs`. | -| `otlpLogsEnabled` | Defaults to `false`. Sets `CAO_OTEL_LOGS_ENABLED`. When true, selects bounded namespaces: `cao:server,cao:telemetry` on the Function App and `cao:telemetry,cao:collect:recovery` on workers. It deliberately does not export the workers' broad default debug namespaces, which can contain repository identifiers. Stderr remains available; its namespace selection is narrowed on workers when log export is enabled. | -| Secure `otlpHeaders` | Optional standard OTLP header list, supplied only through secret-manager input. The template stores it in `cao-otel-headers` in Key Vault. The Function App uses a Key Vault reference; workers use managed-identity `secretRef` resolution. Only these runtime identities receive secret-scoped read grants. Never put a literal value in a command line, config, parameter file or deployment receipt. | - -The template pins the transport to `http/protobuf`. Traces and metrics require -the endpoint; logs require the endpoint **and** the opt-in. The core remains -vendor-neutral. `OTEL_SDK_DISABLED=true` still overrides the exporters; carry -forward and review any manually configured signal-specific endpoints or headers, -which take precedence over the shared settings. Do not retain overrides that -silently redirect the reviewed route. - -For `gh cao deploy azure`, add this non-secret section to the reviewed deployment -config (merge it with the other required fields), and an optional source reference -under `secrets`: - -```json -{ - "telemetry": { - "otlpEndpoint": "https://collector.example.test/otlp", - "logsEnabled": true - }, - "secrets": { - "otlpHeaders": { - "keyVault": { - "id": "/subscriptions/00000000-0000-4000-8000-000000000001/resourceGroups/observability/providers/Microsoft.KeyVault/vaults/collector-secrets", - "secretName": "cao-otlp-headers" - } - } - } -} +> [!WARNING] +> The templates are an experimental review baseline, not production acceptance. +> Complete your organization's security, privacy, network, load, cost, +> monitoring, backup, incident-response and rollback reviews before live use. + +## Prepare an offline review bundle + +Install the native CAO CLI and the official standalone +[Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) +on `PATH`, then: + +```sh +gh cao deploy init --deployment azure-container-apps --out /private/operator/containers.yaml +# Replace sample identities, networks, image references and secret names. +gh cao deploy generate --config /private/operator/containers.yaml \ + --out /private/operator/containers-plan +gh cao deploy check /private/operator/containers-plan ``` -Use the actual reviewed collector and secret source; the example is synthetic. -An `env` reference populated by the operator secret manager is also supported. -The CLI rejects non-HTTPS URLs, embedded URL credentials, query strings and -fragments, and rejects log/auth settings without an endpoint before calling -Azure. Direct ARM/Bicep deployments must enforce the same constraints in their -parameter review. Deployment plans retain only secret references; secure header -values follow the existing secure-parameter/redaction path. - -For Application Insights delivery, operate a collector distribution containing -`azuremonitorexporter`, enable its OTLP/HTTP receiver behind the reviewed TLS and -authentication boundary, and wire its traces, metrics and logs pipelines to that -exporter. Supply its Application Insights connection string from its own secret -manager. The Function App's `APPLICATIONINSIGHTS_CONNECTION_STRING` does not -configure this external collector. Review collector version/signal support, -certificate trust, private routing or authenticated ingress, outbound reach, -bounded queues/retries, access and retention separately. Never expose an -unauthenticated public OTLP receiver. - -Exporters batch asynchronously. Backend failure is not a health/readiness -dependency and does not gate requests. This is best-effort observability, not -durable audit delivery: platform/container logs and independent audit delivery -remain separate evidence channels. - -#### Required deployment receipts - -Offline tests exercise the compiled template settings through real Go SDK -exporters into a synthetic authenticated TLS receiver, including protobuf trace, -metric and opted-in log receipts, disabled/default settings, and backend failure. -They do **not** prove a tenant collector, Application Insights ingestion or alerts. -Before treating this route as operational monitoring, retain: +Generation compiles the emitted Bicep and produces a hash-bound review bundle +with dependency diagrams, architecture checks, parameter references and an +operator runbook. These commands do not call Azure, create resources, grant +credentials, install GitHub Apps or verify live prerequisites. Operators +separately review and execute the Container Apps templates. See +[deployment bundle generation](deployment-topology.md#azure-container-apps) +for the complete parameter and activation contract. -- Sanitized effective per-role settings (endpoint configured/TLS verified, - log opt-in, selected namespaces, SDK disable and signal-override decisions), - deployment revision and secret **reference/version**, never header values. -- Approved collector topology/configuration revision, signal support, TLS, - authentication, network reach, access and retention review. -- Backend-query receipts for a synthetic request trace, Go runtime metric and - selected non-sensitive log from each deployed role, correlated by time and - reviewed service identity. A successful SDK flush or collector HTTP response - alone is not proof of final backend ingestion. -- A controlled exporter/backend outage and absent-signal alert acknowledgement, - named responder, recovery receipt, and confirmation that request availability - and independent audit delivery were not made dependent on the exporter. +## Azure resources and boundaries -The CLI's deployment/ingress receipt does not assert those monitoring checks. -Until these production receipts exist, the repository wiring is implemented but -operational effectiveness remains unverified. - -If a client sends a `traceparent` header, the server continues that trace. Every API response includes `X-Trace-Id` and `X-Span-Id` headers so that you can correlate requests. - -### Debug logs - -To turn on debug logs, set `DEBUG` to a namespace or pattern: - -- Namespaces: `cao:server`, `cao:query`, `cao:ingest`, `cao:postgresx`, and `cao:cli`. -- Patterns: for example, `cao:*,-cao:query` for everything except query diagnostics. - -The `cao:functions:startup` namespace logs the site directory, query path, and listen address of `cao-dashboard azure-functions`. - -Logs go to standard error. They include operation names, counts, timings, and fixed identifiers such as `oauth branch=OPERATION.OUTCOME`. They never include tokens, credentials, query payloads, or source records. To see matching sign-in events in the browser, add `?debug=auth` to the dashboard URL. - -### Health checks and diagnostics - -| Check | Description | +| Resource | Responsibility | | --- | --- | -| `GET /api/health` | Liveness check. | -| `GET /api/readiness` | Readiness check. Returns `503` until data is loaded. | -| `GET /api/v1/health` | Versioned health check. | -| `CAO_POLICY_PATH=.github/workflows/cao.azure.json cao-dashboard doctor --postgres-url "$CAO_POSTGRES_URL"` | Read-only check of PostgreSQL dashboard data and operational state. Add `--deep` to read every active source, `--format json` for automation, or `--strict` to fail on warnings. | - -Hosted health and readiness probes (including HEAD requests on the `GET` routes) share a 600-requests-per-minute bucket keyed by client address. The loopback local profile leaves these probes unmetered. - -### Agentic workflow traces - -You configure traces for orchestrators and workers separately, in the control repository. For more information, see [Optional observability](configuration.md#optional-observability). - -## Using the optional collection profile - -By default, the server serves the data that the Activity workflow publishes and -doesn't collect anything itself. To have Azure collect instead, set -`collectorImage` to an image built from the reviewed `collector-runtime` Docker -target and supply `collectorNativeInventoryDigest` at deployment. The -authoritative `server/azure/main.bicep` and `server/azure/collector.bicep` -sources compile to `server/azure/main.json`. The optional module adds: - -- Workers and backfill in the same private Container Apps environment as ingestion. -- A fixed number of native Go collection workers sharing the same deployment-scoped PostgreSQL operational queue as the Function App. Set `collectorWorkerReplicas` to the required replica count; the default is `1`. -- A backfill job. -- A user-assigned managed identity with access to Key Vault and `AcrPull` on this deployment's registry. Build the collector image into that registry. - -No evidence lake or Azure Files mount is used by native acquisition. The -baseline and optional collector do not create an additional Insights component -or workspace. - -The collection profile requires `collectorGithubAppId`, `collectorPrivateKey`, -`githubWebhookSecret`, `githubAdminUsers`, `collectorControlRepository`, and -`collectorNativeInventoryDigest`. Provision the exact immutable reviewed -inventory bytes using `Acquisition.PinInventoryConfiguration` in the shared -canonical PostgreSQL dataset namespace (`azure-dashboard` by default) -**before starting workers**; the Azure template does not generate or -provision that snapshot, and this guide does not provide an automated -inventory-provisioning command. -Supply its lowercase SHA-256 digest, not a local inventory path or an -automatically discovered inventory. A missing digest fails optional-module -parameter validation; an invalid, unprovisioned, or mismatched digest fails -worker startup rather than using another snapshot or falling back to Actions. -The image must include `/app/cao-dashboard` and -`/app/queries/database.json`. The separate Value lane is not enabled by -this template (`CAO_COLLECT_NATIVE_VALUES` is unset). - -In this profile the Function App selects `CAO_COLLECT_NATIVE=true` but only -admits webhooks (`CAO_COLLECT_ADMIT_ONLY=true`). It verifies and deduplicates -deliveries to `POST /api/github/webhook`, then queues the work in PostgreSQL. -It never holds the app's private key or requires inventory bytes. Workers and -backfill select native acquisition with the same operational namespace and -the exact reviewed digest. - -The approved ingress path must also permit GitHub webhook delivery. Disabled -public access or client-only allow rules can block it; do not widen access just -to make delivery work. - -1. Point the GitHub App webhook at `https://HOST/api/github/webhook`. -1. Install the GitHub App on the repositories to collect from. Enrollment follows app installations. If you uninstall the app from a repository, CAO stops collecting from it and deletes its evidence. -1. Monitor collection with `GET /api/admin/collection/status` and with the Container Apps logs in Log Analytics. - -> [!NOTE] -> Use either the default profile or the collection profile, not both. If both `CAO_SOURCE_DIRECTORY` and `CAO_COLLECT_APP_ID` are set, the server doesn't start. - -## What this deployment guarantees - -- **No secrets in the browser.** The browser never receives PostgreSQL credentials, GitHub access or refresh tokens, or Key Vault secret values. -- **Secrets in Key Vault.** Every app setting that holds a secret is a versionless Key Vault reference, resolved by managed identity. Template outputs contain no secrets. -- **Authorized access.** Users must sign in with GitHub OAuth and match an allowed user, organization, or team. Authorization is checked again on every token refresh. -- **Protected sessions.** Session cookies are `Secure`, `HttpOnly`, and `SameSite=Lax`. Requests that change state need a CSRF token bound to the session. -- **Encrypted PostgreSQL traffic.** Non-loopback PostgreSQL connections and every fallback require TLS. -- **Bounded queries.** Queries have limits on definitions, joins, predicates, rows, and operations. A query never returns a partial result without reporting it. -- **Consistent rate limits.** Rate limits are enforced atomically in PostgreSQL across all instances. If operational storage can't enforce them, requests fail with `503`. -- **Safe ingestion.** Ingestion transactionally replaces the complete PostgreSQL dataset. If ingestion or a rebuild fails, the previous committed data remains active. - -## What this deployment does not guarantee - -- **Production readiness.** This deployment is experimental. CAO offers no support commitment or SLA. Availability depends on your Azure resources. -- **Complete ingress connectivity.** The database has private VNet access and the Function App defaults to public main-site access behind GitHub OAuth, with SCM/Kudu denied. The template does not establish the subscription's permitted ingress model or provide new client connectivity, a web application firewall, or Azure Front Door. Review the applicable requirements before changing access. OAuth authorization remains required. -- **Automatic data loading.** In the default profile, nothing loads new data into PostgreSQL. Schedule ingestion yourself, or use the collection profile. -- **Live updates.** Server-sent events at `GET /api/v1/events` are best effort. Cold starts, scale-in, idle timeouts, and plan limits can end them. Clients then fall back to `POST /api/v1/refresh`. WebSockets aren't supported. -- **Disaster recovery.** PostgreSQL holds rebuildable dashboard data and restart-persistent operational state in separate tables, with seven-day local backups but no high availability or geo-redundancy in this baseline. Rehearse restore and credential recovery. Rebuild canonical data from the retained artifact or collected evidence. For long-term retention, see [Create a historical archive](dashboard-data-ingestion.md#create-a-historical-archive). -- **Per-repository authorization.** Authorized users can read all of the active data. There's no filtering by repository or source. -- **Low idle cost.** The EP1 plan and PostgreSQL cost money even when no one uses the dashboard. -- **Credential rollback.** Rolling back the package doesn't roll back OAuth, session, or PostgreSQL credentials. - -## Rotating secrets and rolling back - -Every redeployment, restart, and rollback below must preserve -[existing retention](#retention-for-existing-deployments). -Carry the reviewed `postgresRunRetentionDays` in deployment parameters, and keep -both effective retention settings at least that large across every role before -startup. Restoring an older package or parameters file must not restore a shorter -retention window. - -| Task | Procedure | -| --- | --- | -| Rotate the session secret | Redeploy with the old key as `previousSessionSecret` and the new key as `sessionSecret`. Wait for active sessions and queued revocations to finish, then redeploy without `previousSessionSecret`. | -| Rotate the PostgreSQL credential | Redeploy with the new `postgresAdministratorPassword` so the database and Key Vault URL change together, then restart the Function App and collection roles. | -| Rotate the OAuth client secret | Generate a new secret in GitHub, add a new version of the Key Vault secret, and restart the Function App. | -| Roll over a GitHub App private key | Before retiring the old key, run `cao-dashboard app-key-check` against the replacement PEM in an authorized credential-handling environment (see below). Changing the mounted file does not reload a running process, so restart every replica that signs with that App. Do not give the hosted CLI App key to the web admission process. | -| Roll back the application | Redeploy the last known-good package. If PostgreSQL dashboard data is unusable, ingest the retained artifact again. Use a new operational namespace only when intentionally invalidating operational state and active sessions. | - -### Check a replacement App key offline - -`cao-dashboard app-key-check --key-file ` validates an operator-selected, unencrypted RSA PEM (PKCS#1 or PKCS#8, at most 64 KiB) without contacting GitHub or a vault, minting tokens, or changing runtime behavior. Add `--expected-public-key-sha256` to pin the reviewed replacement and `--previous-public-key-sha256` to fail when the key is unchanged. A fingerprint is the lowercase SHA-256 hex of the DER SubjectPublicKeyInfo public key. It prints public metadata only and exits nonzero on any failed check. It does not prove the App ID, GitHub registration, permissions, revocation, or which key a running replica loaded. Key retirement doesn't revoke installation tokens that are already minted. See [App key custody and rollover preflight](https://github.com/githubnext/gh-cao/blob/main/server/SECURITY.md#app-key-custody-and-rollover-preflight) for the full procedure and evidence to retain. - -If you suspect an incident, see [Incident response](operations.md#incident-response). For the complete threat model and list of controls, see the [hosted Azure architecture](https://github.com/githubnext/gh-cao/blob/main/server/README.md#hosted-azure-architecture) in `server/README.md` and the [Azure Functions profile](https://github.com/githubnext/gh-cao/blob/main/server/SECURITY.md#azure-functions-profile) in `server/SECURITY.md`. - -## Further reading - -- [About deployment options](deployment.md) -- [Deploying the dashboard with GitHub Actions](deployment-actions.md) -- [Data ingestion](dashboard-data-ingestion.md) -- [Data model](dashboard-data-model.md) -- [`server/README.md`](https://github.com/githubnext/gh-cao/blob/main/server/README.md) -- [`server/SECURITY.md`](https://github.com/githubnext/gh-cao/blob/main/server/SECURITY.md) -- [`scripts/azure-local/README.md`](https://github.com/githubnext/gh-cao/blob/main/scripts/azure-local/README.md) +| Container Apps Consumption environment and one hosted Go replica | Managed HTTPS ingress; built dashboard, GitHub OAuth, query API and revision events on one origin | +| Private PostgreSQL Flexible Server | Canonical dashboard data and separately owned PostgreSQL operational state | +| Separate manual provisioning and ingestion jobs | Privileged schema provisioning isolated from runtime-only evidence ingestion | +| Key Vault and managed identities | Scoped secret resolution; no secret values in reviewed policy, parameters, logs or browser payloads | +| Azure Container Registry | Operator-approved, immutable dashboard, provisioning and ingestion images | +| VNet, delegated subnets and private DNS | Private database connectivity with separately verified ingress proxy peers | +| Log Analytics | Container logs, not canonical data or evidence of application telemetry delivery | + +The base profile uses public OAuth ingress and explicitly selected Actions +snapshots. It provisions neither Redis nor Application Insights. Optional +session-lake and webhook-gateway infrastructure remains separately selected +and staged; it grants no runtime activation or broader authority. The base +profile does not support collector acquisition, OTLP or base ADX analytics. +Unsupported feature combinations fail closed. + +Keep GitHub user/organization/team authorization explicit. The browser never +receives database credentials or GitHub tokens and never falls back to Pages, +static operational shards or IndexedDB when hosted services fail. Keep the +dashboard and OAuth on the same origin. Configure independently verified +private ingress proxy CIDRs; do not treat sample CIDRs or forwarded headers as +proof of trusted peers. + +## Operator-run activation + +Follow the generated runbook for the exact reviewed configuration: + +1. Review instance-owned policy, OAuth registration and allowlists, secret + references, private networking, retention and immutable image references. + The dashboard image must contain the reviewed `cao.json` with + `target.module: container`, `target.replicas: 1` and + `operational-store.backend: postgres`. +2. Bootstrap ACR and prepare approved images independently. Deploy reviewed + infrastructure with `runtimeEnabled: false`, the default; this stages + infrastructure and manual job definitions without creating the web app. +3. Run privileged database provisioning to successful completion, then run + runtime-only ingestion with an explicitly verified operational snapshot or + explicit empty evidence. Neither ARM dependencies nor bundle checks run or + verify these jobs. Do not silently substitute empty or synthetic demo data. +4. Only after independent job verification, regenerate and approve a bundle + with `runtimeEnabled: true`, then deploy it explicitly. +5. Verify configured image digests, the registered OAuth callback, allowed and + denied sign-in, same-origin API behavior, readiness, trusted proxy handling + and revision-stream reconnection. TCP platform probes prove listener + availability only. + +Keep schema-owner credentials out of the web runtime and ingestion job. +Provisioning, acquisition enrollment and CAO rollout authority are separate: +infrastructure credentials and a successful deployment never authorize new +repositories, operations, safe outputs or live mode. + +## Acceptance and maintenance + +Bundle generation and offline checks are not live Azure acceptance, production +image assurance, backup evidence, OAuth validation or proof of backend parity. +Retain independently reviewed source/image provenance, scan and SBOM results, +deployed digest observations, job outcomes and tenant-specific operational +evidence. The retired Functions deployment receipt and release-evidence receipt +option are not part of this path. + +Review database backup/restore, secret rotation, cleanup-debt draining, log +redaction, cost and rollback with the actual tenant and GitHub organization. +One minimum replica, ACR, Key Vault, PostgreSQL and logs incur costs; this is +not scale-to-zero. Private archives require their own persistent single-owner +publisher and real destination acceptance; an ephemeral container filesystem +is not a durable spool. + +For local development use the existing Go/PostgreSQL server and an explicitly +acquired operational snapshot; no Azure Functions emulator, Azurite/Core Tools +harness or simulated native Azure install journey is maintained. See the +[deployment overview](deployment.md) and +[server guide](https://github.com/githubnext/gh-cao/blob/main/server/README.md). diff --git a/docs/deployment-topology.md b/docs/deployment-topology.md index 476913de90..8dbfabd83e 100644 --- a/docs/deployment-topology.md +++ b/docs/deployment-topology.md @@ -25,7 +25,7 @@ The standalone `bicep` executable must be on `PATH`; installing only the `az bicep` wrapper is not sufficient. No compiler is downloaded automatically. ```sh -gh cao deploy init --out /private/operator/requirements.yaml +gh cao deploy init --deployment azure-container-apps --out /private/operator/requirements.yaml # Edit the example's identities, feature choices and references. gh cao deploy generate --config /private/operator/requirements.yaml \ --out /private/operator/azure-plan @@ -40,9 +40,8 @@ The same input samples are shipped by the command and exercised by Go tests: | --- | --- | --- | --- | | `single-vm` | One persistent VM; hosted Go + OAuth + direct TLS | On that same VM, socket/loopback only | Existing external Key Vault; no new managed database | | `minimal-azure` | One persistent hosted Go VM | Private Azure Flexible Server | Private DNS; existing external Key Vault | -| `azure-functions` | Existing Functions baseline, unchanged Bicep | Private Azure Flexible Server | ACR, target vault, runtime storage, jobs and monitoring | -| `azure-container-apps` | One hosted Go replica on Container Apps Consumption, managed HTTPS | Private Azure Flexible Server | ACR, target vault, provisioning/ingestion jobs and Log Analytics; no Functions, runtime Storage or Premium plan | -| `full-azure` | Functions plus separate archive/receive/dispatch hosts | Private Azure Flexible Server | Collector/backfill, Blob session lake, two separate ADX destinations, gateway, external OTLP | +| `azure-container-apps` | One hosted Go replica on Container Apps Consumption, managed HTTPS | Private Azure Flexible Server | ACR, target vault, provisioning/ingestion jobs and Log Analytics | +| `full-azure` | Container Apps plus separate staged archive/receive/dispatch hosts | Private Azure Flexible Server | Private Blob session lake, separate lake ADX destination and gateway; activation requires independent review | ```sh gh cao deploy init --deployment single-vm --out /private/operator/vm.yaml @@ -57,9 +56,10 @@ silently substitute SQLite or volatile memory. VM runtime activation, disk setup, schema bootstrap, TLS and evidence ingestion are explicit runbook steps. Existing vaults are external prerequisites, not free or verified resources. -The compact profiles deliberately support Actions snapshots only. Select the -Functions profile for optional collection, archive, gateway, ADX and telemetry; -incompatible combinations fail rather than carrying hidden full-stack services. +The supported profiles use Actions snapshots. Container Apps may additionally +stage explicitly selected archive and gateway infrastructure; the base profile +does not support collector acquisition, OTLP or base ADX analytics. +Incompatible combinations fail rather than carrying hidden full-stack services. These are operational artifacts acquired with authorized Actions access, not the public synthetic Pages demo. Production serves the complete Go/PostgreSQL application on one origin; local Go/PostgreSQL uses the same views over a real @@ -74,8 +74,7 @@ gh cao deploy generate --config /private/operator/containers.yaml \ gh cao deploy check /private/operator/containers-plan ``` -This additive profile replaces the Functions HTTP host, runtime Storage and -Elastic Premium plan with a Container Apps **Consumption** environment. The +This profile uses a Container Apps **Consumption** environment. The same Go `serve-hosted` process serves built assets, OAuth, server-side PostgreSQL queries and revision events on one origin. Private PostgreSQL, separate privileged provisioning and runtime-only ingestion identities, scoped Key Vault @@ -107,8 +106,10 @@ OAuth callback registration, deployed digest checks and application readiness; TCP platform probes establish only listener availability. This profile currently supports public OAuth ingress and Actions snapshots only. -Unsupported optional features fail closed. It does not change `gh cao deploy -azure --apply`, which remains Functions-only. Consumption with one minimum +Unsupported optional features fail closed. Azure Functions runtime, deployment +commands, receipts and local harness support are retired; there is no native +apply command. Operators execute the reviewed templates separately. +Consumption with one minimum replica, ACR, Key Vault, PostgreSQL and logs still incur costs; this is neither scale-to-zero nor production acceptance. @@ -182,10 +183,9 @@ read-only scope and rejection tests. Short-lived job credentials stay in process memory/environment, not bootstrap secret files or vaults. Compatible `serve-hosted` deployments must separately opt in with -`--mcp-enabled` and `CAO_MCP_ACTIONS_REPOSITORY`. The unchanged Functions -entrypoint does **not** wire MCP, including in the full Azure sample; -this is explicitly marked as an activation limitation, not silently enabled -by the diagrams. MCP grants no admission, token-broker, dispatch or write authority. +`--mcp-enabled` and `CAO_MCP_ACTIONS_REPOSITORY`. A generated topology or +diagram does not enable MCP. MCP grants no admission, token-broker, dispatch +or write authority. ## Feature choices @@ -193,11 +193,11 @@ by the diagrams. MCP grants no admission, token-broker, dispatch or write author version: 1 backend: azure features: - deployment: azure-functions # single-vm | minimal-azure | azure-functions | azure-container-apps - acquisition: actions # actions | collector, mutually exclusive - ingress: public # public OAuth | private, external connectivity required - analytics: none # none | adx, a paid destination, not an exporter - telemetry: none # none | otlp, an external HTTPS collector + deployment: azure-container-apps # single-vm | minimal-azure | azure-container-apps + acquisition: actions + ingress: public # public OAuth for Container Apps + analytics: none + telemetry: none sessionDataLake: false # dedicated persistent host + private Blob + paid ADX webhookGateway: false # isolated receive and dispatch hosts ``` @@ -211,17 +211,10 @@ list are supplied when omitted; allow at least one organization, team or user. `azure.secrets` maps secure parameter names to secret names under `azure.secretVaultId`. Optional secure parameters use the same mechanism. -Collector mode additionally requires `collectorImage`, numeric -`collectorGithubAppId`, `collectorControlRepository`, -`collectorNativeInventoryDigest`, nonempty `githubAdminUsers`, and secret -references for `collectorPrivateKey` and `githubWebhookSecret`. Provision the -exact reviewed inventory independently before worker startup. Keep the -baseline's manual snapshot-ingestion job stopped in collector mode. - -OTLP needs a credential-free HTTPS `otlpEndpoint`; optionally enable -`otlpLogsEnabled` and reference `otlpHeaders` through Key Vault. -Private Functions ingress needs independently approved connectivity; disabling -public access alone does not provision a private endpoint or a working route. +The Container Apps profile supports public OAuth ingress and Actions snapshots, +not collector acquisition, private ingress, OTLP or base ADX analytics. +Optional archive/gateway infrastructure has its own explicit activation gates +and does not broaden this runtime contract. ## Additional feature infrastructure @@ -267,17 +260,15 @@ Keep the generating CLI release when checking older bundles. The CLI embeds deterministic, reviewable snapshots of every bundled template, example, script and runbook, including the canonical Bicep sources and ARM parameter contract. It never needs a source checkout or downloads resources at runtime. -`deploy azure` uses the same embedded ARM template and TOSCA requirement graph -for its guarded JSON-config preflight, plan and development apply path. -The YAML bundles are review-only outputs, not apply plan files; their optional -profiles do not expand native apply support. The separate `topology` command +The YAML bundles are review-only outputs, not executable apply plans. +No native Azure preflight, plan, apply or receipt command remains. The separate `topology` command has been removed; use `deploy init`, `deploy generate` and `deploy check`. -After changing `server/azure/`, run +After changing shared `server/azure/` modules or the selected profile templates, run `go generate ./internal/topology`. A parity test rejects stale embedded sources; the generator never maintains a second baseline resource implementation. Asset regeneration also compiles the compact and Container Apps ARM parameter contracts, so it requires the official Bicep CLI on `PATH`. `TestDeploymentSamples` compiles -all five shipped samples with the actual compiler and checks VM/database/Functions/ +all four shipped samples with the actual compiler and checks VM/database/ Container Apps resource definitions (including activation-gated definitions). Missing Bicep fails those tests; CI installs a checksum-pinned official compiler. Static rule and bad-configuration regressions run separately. diff --git a/docs/deployment.md b/docs/deployment.md index a2595108c0..3990cd6479 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -42,8 +42,7 @@ PostgreSQL also provides separate operational state by default. | [Synthetic enterprise demo](deployment-actions.md#building-the-synthetic-pages-demo) | GitHub Pages | Web Worker over isolated browser IndexedDB | No operational sign-in or live services | Documentation/demo workflow only; no operational artifacts | | [On-demand local dashboard](setup-quickstarts.md#open-a-local-dashboard-no-deployment) | Bundled loopback Go server | Go over a private PostgreSQL cache | Local capability URL; collection uses authenticated `gh` | Docker/Podman, or an explicitly configured provisioned database | | [Local development](dashboard-data-ingestion.md#use-local-postgresql) | Loopback Go server | Go over canonical PostgreSQL | Private local capability URL | Explicitly acquired operational snapshot | -| [Azure Functions](deployment-azure.md) | Azure Functions | On the server, over PostgreSQL | Explicitly allowed GitHub users, organizations or teams | Function App, Elastic Premium plan, PostgreSQL, Key Vault, ACR, runtime storage, jobs and monitoring | -| [Azure Container Apps](deployment-topology.md#azure-container-apps) | Container Apps Consumption, one hosted Go replica | On the server, over PostgreSQL | Explicitly allowed GitHub users, organizations or teams | Container Apps environment, PostgreSQL, Key Vault, ACR, jobs and Log Analytics; no Functions, runtime Storage or Premium plan | +| [Azure Container Apps](deployment-azure.md) | Container Apps Consumption, one hosted Go replica | On the server, over PostgreSQL | Explicitly allowed GitHub users, organizations or teams | Container Apps environment, PostgreSQL, Key Vault, ACR, jobs and Log Analytics; offline review bundle and separate operator-run deployment | ## Choosing an option @@ -71,7 +70,7 @@ The browser uses server-side PostgreSQL queries; a hosted failure does not switch to browser storage or the Pages deployment. > [!NOTE] -> Your credential profile still matters for server-backed deployments. The CAO Activity workflow collects their evidence in GitHub Actions, using the profile that you configure. Dashboard users sign in separately, through a GitHub OAuth app. The optional Azure collection profile requires a GitHub App and doesn't support PATs. +> Your credential profile still matters for server-backed deployments. Actions snapshot acquisition uses explicitly authorized GitHub access. Dashboard users sign in separately through GitHub OAuth. Independently configured native collection requires its own GitHub App and enrollment; it is not supported by the base Container Apps review bundle. ## About the shared data flow @@ -81,9 +80,10 @@ samples and passes them through production normalization and worker ingestion; it never consumes operational artifacts. [Data ingestion](dashboard-data-ingestion.md) owns the collection, verified payload, retention, and backend-processing details; [Data model](dashboard-data-model.md) owns entities and identities. -The optional [Azure collection profile](deployment-azure.md#using-the-optional-collection-profile) -uses server-side workers driven by GitHub App webhooks instead of relying only -on scheduled Activity snapshots. +Independently configured native collection can use server-side workers driven +by GitHub App webhooks. The [Container Apps review bundle](deployment-azure.md) +supports explicitly selected Actions snapshots, not collector acquisition; +optional staged infrastructure is not native collection activation. ## What every option guarantees diff --git a/internal/azcli/azcli.go b/internal/azcli/azcli.go deleted file mode 100644 index 15cdcff02d..0000000000 --- a/internal/azcli/azcli.go +++ /dev/null @@ -1,259 +0,0 @@ -// Package azcli runs the Azure CLI as a cancellable child process. -// -// Callers must never place secret values in argv. Secure deployment values -// belong in owner-only parameter files or Key Vault references. -package azcli - -import ( - "bytes" - "context" - "errors" - "fmt" - "net/url" - "os" - "os/exec" - "path" - "regexp" - "slices" - "strings" - "time" -) - -// Result is the captured output of one Azure CLI invocation. -type Result struct { - Stdout string - Stderr string -} - -// Executor runs one Azure CLI invocation. Implementations must honor ctx. -type Executor interface { - Run(ctx context.Context, args ...string) (Result, error) -} - -// ExecutorFunc adapts a function to Executor. -type ExecutorFunc func(context.Context, ...string) (Result, error) - -func (f ExecutorFunc) Run(ctx context.Context, args ...string) (Result, error) { - return f(ctx, args...) -} - -// Process runs the az binary found on PATH when the first command executes. -type Process struct { - // Path overrides PATH lookup; empty means "az". - Path string - // WaitDelay bounds how long output pipes may outlive a cancelled process. - WaitDelay time.Duration -} - -// ExitError reports a non-zero Azure CLI exit. Stderr is retained for callers -// that redact it before display. -type ExitError struct { - Code int - Stderr string -} - -func (e *ExitError) Error() string { - message := strings.TrimSpace(e.Stderr) - if message == "" { - return fmt.Sprintf("az exited with status %d", e.Code) - } - return fmt.Sprintf("az exited with status %d: %s", e.Code, message) -} - -func (p Process) Run(ctx context.Context, args ...string) (Result, error) { - if err := ctx.Err(); err != nil { - return Result{}, err - } - path := p.Path - if path == "" { - found, err := exec.LookPath("az") - if err != nil { - return Result{}, fmt.Errorf("find Azure CLI (install az and ensure it is on PATH): %w", err) - } - path = found - } - command := exec.CommandContext(ctx, path, args...) - command.Env = append(os.Environ(), - "AZURE_CORE_ONLY_SHOW_ERRORS=true", - "AZURE_CORE_NO_COLOR=true", - "AZURE_CORE_SURVEY_MESSAGE=false", - ) - command.WaitDelay = p.WaitDelay - if command.WaitDelay == 0 { - command.WaitDelay = 5 * time.Second - } - var stdout, stderr bytes.Buffer - command.Stdout = &stdout - command.Stderr = &stderr - err := command.Run() - result := Result{Stdout: stdout.String(), Stderr: stderr.String()} - if ctxErr := ctx.Err(); ctxErr != nil { - return result, fmt.Errorf("az %s: %w", CommandName(args), ctxErr) - } - var exit *exec.ExitError - if errors.As(err, &exit) { - return result, &ExitError{Code: exit.ExitCode(), Stderr: result.Stderr} - } - if err != nil { - return result, fmt.Errorf("run az %s: %w", CommandName(args), err) - } - return result, nil -} - -// readOnlyCommands lists the only Azure CLI command paths that plan and -// preflight modes may run. `az rest` is handled separately by isReadOnlyRest. -var readOnlyCommands = [][]string{ - {"version"}, - {"account", "show"}, - {"group", "exists"}, - {"resource", "list"}, - {"resource", "show"}, - {"provider", "list"}, - {"role", "assignment", "list"}, - {"ad", "signed-in-user", "show"}, - {"ad", "sp", "show"}, - {"storage", "account", "check-name"}, - {"acr", "check-name"}, - {"keyvault", "list-deleted"}, - {"appservice", "list-locations"}, - {"postgres", "flexible-server", "list-skus"}, - {"functionapp", "config", "appsettings", "list"}, - {"deployment", "group", "what-if"}, - {"deployment", "sub", "what-if"}, -} - -// readOnlyRestPaths are the only ARM paths `az rest` may call. Each is a -// checkNameAvailability action: a POST that reads global name state and -// changes nothing. Effective-permissions GETs are separately allowlisted. -var readOnlyRestPaths = []*regexp.Regexp{ - regexp.MustCompile(`(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/providers/Microsoft\.KeyVault/checkNameAvailability$`), - regexp.MustCompile(`(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/providers/Microsoft\.Web/checkNameAvailability$`), - regexp.MustCompile(`(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/providers/Microsoft\.DBforPostgreSQL/checkNameAvailability$`), - regexp.MustCompile(`(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/providers/Microsoft\.DBforPostgreSQL/locations/[a-z0-9]{2,40}/checkNameAvailability$`), -} - -var readOnlyPermissionsPath = regexp.MustCompile(`(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/resourceGroups/[-a-z0-9_.()]+)?/providers/Microsoft\.Authorization/permissions$`) -var readOnlyGeoRegionsPath = regexp.MustCompile(`(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/providers/Microsoft\.Web/geoRegions$`) - -// IsReadOnly reports whether args name an allowlisted read-only or what-if command. -func IsReadOnly(args []string) bool { - path := CommandPath(args) - if slices.Equal(path, []string{"rest"}) { - return isReadOnlyRest(args[1:]) - } - for _, allowed := range readOnlyCommands { - if slices.Equal(path, allowed) { - return true - } - } - return false -} - -// isReadOnlyRest accepts only `az rest --method post --url ` -// against the ARM endpoint with an api-version query and no other options that -// could change the target or method. -func isReadOnlyRest(options []string) bool { - valued := map[string]string{ - "--method": "method", "-m": "method", - "--url": "url", "--uri": "url", "-u": "url", - "--body": "body", "-b": "body", - "--output": "", "-o": "", "--query": "", - } - seen := map[string]bool{} - var method, target string - for index := 0; index < len(options); index++ { - name, value, inline := strings.Cut(options[index], "=") - role, ok := valued[name] - if !ok { - return false - } - if !inline { - index++ - if index >= len(options) { - return false - } - value = options[index] - } - if role == "" { - continue - } - if seen[role] { - return false - } - seen[role] = true - switch role { - case "method": - method = value - case "url": - target = value - } - } - if target == "" { - return false - } - parsed, err := url.Parse(target) - if err != nil || parsed.RawPath != "" || parsed.Fragment != "" || parsed.User != nil || path.Clean(parsed.Path) != parsed.Path { - return false - } - switch { - case parsed.Scheme == "" && parsed.Host == "": - case parsed.Scheme == "https" && strings.EqualFold(parsed.Host, "management.azure.com"): - default: - return false - } - query, err := url.ParseQuery(parsed.RawQuery) - if err != nil { - return false - } - if len(query["api-version"]) != 1 { - return false - } - if strings.EqualFold(method, "get") { - if seen["body"] { - return false - } - if readOnlyPermissionsPath.MatchString(parsed.Path) { - return len(query) == 1 && query.Get("api-version") == "2022-04-01" - } - return readOnlyGeoRegionsPath.MatchString(parsed.Path) && len(query) == 3 && - query.Get("api-version") == "2024-11-01" && len(query["sku"]) == 1 && - (query.Get("sku") == "EP1" || query.Get("sku") == "EP2" || query.Get("sku") == "EP3") && - len(query["linuxWorkersEnabled"]) == 1 && query.Get("linuxWorkersEnabled") == "true" - } - if !strings.EqualFold(method, "post") || len(query) != 1 { - return false - } - for _, pattern := range readOnlyRestPaths { - if pattern.MatchString(parsed.Path) { - return true - } - } - return false -} - -// ReadOnly refuses any command that is not allowlisted before it reaches next. -func ReadOnly(next Executor) Executor { - return ExecutorFunc(func(ctx context.Context, args ...string) (Result, error) { - if !IsReadOnly(args) { - return Result{}, fmt.Errorf("refusing non-read-only Azure CLI command %q", CommandName(args)) - } - return next.Run(ctx, args...) - }) -} - -// CommandPath returns the leading command words before the first option. -func CommandPath(args []string) []string { - var path []string - for _, arg := range args { - if strings.HasPrefix(arg, "-") { - break - } - path = append(path, arg) - } - return path -} - -// CommandName returns the space-joined command path. -func CommandName(args []string) string { - return strings.Join(CommandPath(args), " ") -} diff --git a/internal/azcli/azcli_test.go b/internal/azcli/azcli_test.go deleted file mode 100644 index e46ad82816..0000000000 --- a/internal/azcli/azcli_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package azcli - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "testing" - "time" -) - -func TestHelperAzProcess(t *testing.T) { - if os.Getenv("AZCLI_HELPER_PROCESS") != "1" { - return - } - args := os.Args - for len(args) > 0 && args[0] != "--" { - args = args[1:] - } - if len(args) > 0 { - args = args[1:] - } - switch strings.Join(args, " ") { - case "ok": - _, _ = fmt.Fprint(os.Stdout, `{"ok":true}`) - _, _ = fmt.Fprint(os.Stderr, os.Getenv("AZURE_CORE_ONLY_SHOW_ERRORS")) - os.Exit(0) - case "fail": - fmt.Fprint(os.Stderr, "synthetic failure") - os.Exit(3) - case "sleep": - time.Sleep(30 * time.Second) - os.Exit(0) - } - os.Exit(2) -} - -func helperProcess(t *testing.T) Executor { - t.Helper() - t.Setenv("AZCLI_HELPER_PROCESS", "1") - return ExecutorFunc(func(ctx context.Context, args ...string) (Result, error) { - return Process{Path: os.Args[0], WaitDelay: time.Second}.Run(ctx, append([]string{"-test.run=TestHelperAzProcess", "--"}, args...)...) - }) -} - -func TestProcessCapturesOutputAndEnvironment(t *testing.T) { - result, err := helperProcess(t).Run(context.Background(), "ok") - if err != nil { - t.Fatal(err) - } - if result.Stdout != `{"ok":true}` || result.Stderr != "true" { - t.Fatalf("unexpected result: %+v", result) - } -} - -func TestProcessReportsExitStatus(t *testing.T) { - _, err := helperProcess(t).Run(context.Background(), "fail") - var exit *ExitError - if !errors.As(err, &exit) || exit.Code != 3 || !strings.Contains(exit.Error(), "synthetic failure") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestProcessHonorsCancellation(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - started := time.Now() - _, err := helperProcess(t).Run(ctx, "sleep") - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("expected deadline error, got %v", err) - } - if elapsed := time.Since(started); elapsed > 10*time.Second { - t.Fatalf("cancellation took %s", elapsed) - } -} - -func TestProcessRejectsCancelledContextWithoutStarting(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if _, err := (Process{Path: "/nonexistent/az"}).Run(ctx, "account", "show"); !errors.Is(err, context.Canceled) { - t.Fatalf("expected cancellation, got %v", err) - } -} - -func TestProcessMissingAzure(t *testing.T) { - t.Setenv("PATH", "") - if _, err := (Process{}).Run(context.Background(), "account", "show"); err == nil || !strings.Contains(err.Error(), "install az") { - t.Fatalf("expected missing az error, got %v", err) - } -} - -func TestReadOnlyGuard(t *testing.T) { - calls := 0 - next := ExecutorFunc(func(context.Context, ...string) (Result, error) { - calls++ - return Result{}, nil - }) - guard := ReadOnly(next) - const sub = "00000000-0000-4000-8000-000000000001" - checkName := func(provider string) string { - return "/subscriptions/" + sub + "/providers/" + provider + "/checkNameAvailability?api-version=2023-07-01" - } - allowed := [][]string{ - {"version", "--output", "json"}, - {"account", "show", "--subscription", "x"}, - {"group", "exists", "--name", "x"}, - {"resource", "list", "--resource-group", "x"}, - {"resource", "show", "--ids", "x"}, - {"provider", "list", "--subscription", "x"}, - {"role", "assignment", "list", "--assignee", "x"}, - {"ad", "signed-in-user", "show"}, - {"ad", "sp", "show", "--id", "x"}, - {"storage", "account", "check-name", "--name", "x"}, - {"acr", "check-name", "--name", "x"}, - {"keyvault", "list-deleted"}, - {"appservice", "list-locations", "--sku", "EP1"}, - {"postgres", "flexible-server", "list-skus", "--location", "x"}, - {"functionapp", "config", "appsettings", "list", "--name", "x"}, - {"deployment", "group", "what-if", "--resource-group", "x"}, - {"deployment", "sub", "what-if", "--location", "x"}, - {"rest", "--method", "post", "--url", checkName("Microsoft.KeyVault"), "--body", `{"name":"x"}`, "--output", "json"}, - {"rest", "--method", "POST", "--url", checkName("Microsoft.Web"), "--body", `{"name":"x"}`}, - {"rest", "-m", "post", "-u", checkName("Microsoft.DBforPostgreSQL"), "-b", `{"name":"x"}`}, - {"rest", "--method=post", "--url=https://management.azure.com" + checkName("Microsoft.Web")}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01"}, - {"rest", "--method", "get", "--url", "https://management.azure.com/subscriptions/" + sub + "/resourceGroups/rg-synthetic/providers/Microsoft.Authorization/permissions?api-version=2022-04-01"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Web/geoRegions?api-version=2024-11-01&sku=EP1&linuxWorkersEnabled=true"}, - {"rest", "--method", "post", "--url", "/subscriptions/" + sub + "/providers/Microsoft.DBforPostgreSQL/locations/westus3/checkNameAvailability?api-version=2024-08-01", "--body", "{}"}, - } - for _, args := range allowed { - if _, err := guard.Run(context.Background(), args...); err != nil { - t.Fatalf("%v: %v", args, err) - } - } - for _, args := range [][]string{ - {"rest", "--method", "post", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01", "--body", "{}"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01&api-version=2022-04-01"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01&bad=%zz"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/resourceGroups/../providers/Microsoft.Authorization/permissions?api-version=2022-04-01"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/resourceGroups/rg%2Fother/providers/Microsoft.Authorization/permissions?api-version=2022-04-01"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Authorization/permissions?api-version=2020-01-01"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Web/geoRegions?api-version=2024-11-01&sku=EP1&linuxWorkersEnabled=false"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Web/geoRegions?api-version=2024-11-01&sku=EP1&linuxWorkersEnabled=true&extra=1"}, - {"rest", "--method", "get", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Web/geoRegions?api-version=2024-11-01&sku=EP1&sku=EP2&linuxWorkersEnabled=true"}, - {"rest", "--url", checkName("Microsoft.Web")}, - {"rest", "--method", "get", "--url", checkName("Microsoft.Web")}, - {"rest", "--method", "put", "--url", checkName("Microsoft.Web")}, - {"rest", "--method", "patch", "--url", checkName("Microsoft.Web")}, - {"rest", "--method", "delete", "--url", checkName("Microsoft.Web")}, - {"rest", "--method", "post", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Storage/checkNameAvailability?api-version=1"}, - {"rest", "--method", "post", "--url", "/subscriptions/" + sub + "/resourceGroups/rg/providers/Microsoft.Web/sites/x/restart?api-version=1"}, - {"rest", "--method", "post", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Web/checkNameAvailability"}, - {"rest", "--method", "post", "--url", checkName("Microsoft.Web") + "&x=1"}, - {"rest", "--method", "post", "--url", "https://attacker.example.test" + checkName("Microsoft.Web")}, - {"rest", "--method", "post", "--url", "http://management.azure.com" + checkName("Microsoft.Web")}, - {"rest", "--method", "post", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Web/checkNameAvailability/../../x?api-version=1"}, - {"rest", "--method", "post", "--url", "/subscriptions/" + sub + "/providers/Microsoft.Web%2FcheckNameAvailability?api-version=1"}, - {"rest", "--method", "post", "--method", "delete", "--url", checkName("Microsoft.Web")}, - {"rest", "--method", "post", "--url", checkName("Microsoft.Web"), "--url", "/subscriptions/" + sub + "/resourceGroups/rg?api-version=1"}, - {"rest", "--method", "post", "--url", checkName("Microsoft.Web"), "--resource", "https://example.test"}, - {"rest", "--method", "post", "--url", checkName("Microsoft.Web"), "extra"}, - {"rest", "--method", "post", "--url"}, - {"rest"}, - {"role", "assignment", "create"}, - {"provider", "register", "--namespace", "x"}, - {"keyvault", "purge", "--name", "x"}, - {"keyvault", "recover", "--name", "x"}, - {"functionapp", "config", "appsettings", "set"}, - {"functionapp", "config", "access-restriction", "add"}, - } { - if _, err := guard.Run(context.Background(), args...); err == nil { - t.Fatalf("%v: write command was allowed", args) - } - } - for _, args := range [][]string{ - {"group", "create", "--name", "x"}, - {"group", "delete", "--name", "x"}, - {"deployment", "group", "create"}, - {"deployment", "sub", "create"}, - {"keyvault", "secret", "set"}, - {"functionapp", "start"}, - {"acr", "import"}, - {"acr", "build"}, - {"bicep", "build"}, - {"resource", "update"}, - {"account", "set"}, - {"account"}, - {}, - } { - if _, err := guard.Run(context.Background(), args...); err == nil { - t.Fatalf("%v: write command was allowed", args) - } - } - if calls != len(allowed) { - t.Fatalf("guard forwarded %d calls, want %d", calls, len(allowed)) - } -} diff --git a/internal/cli/deploy_azure.go b/internal/cli/deploy_azure.go deleted file mode 100644 index f92dcc4080..0000000000 --- a/internal/cli/deploy_azure.go +++ /dev/null @@ -1,581 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "reflect" - "sort" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/githubnext/gh-cao/internal/azcli" - "github.com/githubnext/gh-cao/internal/topology" -) - -const ( - deployAzurePlanKind = "cao.deploy.azure.plan" - deployAzurePlanVersion = 2 - defaultDeployAzurePlanPath = ".cao/deploy-azure-plan.json" -) - -type deployAzureDeps struct { - az azcli.Executor - getenv func(string) (string, bool) - now func() time.Time - template func(path string) deployAzureTemplateSource - http *http.Client - pause func(context.Context, time.Duration) error - source func(string) deployAzureBuildSource - diagnostics io.Writer -} - -func defaultDeployAzureDeps() deployAzureDeps { - return deployAzureDeps{ - az: azcli.Process{}, - getenv: os.LookupEnv, - now: time.Now, - template: func(path string) deployAzureTemplateSource { return deployAzureFileTemplate{path: path} }, - diagnostics: os.Stderr, - } -} - -func deployCommand(deps deployAzureDeps) *cobra.Command { - deploy := &cobra.Command{ - Use: "deploy", - Short: "Generate, check, plan and apply CAO deployments", - Long: `Resolve deployment dependencies from generated TOSCA requirements. - -init, generate and check prepare offline, review-only deployment bundles from -YAML feature choices and emit a TOSCA 1.3 deployment template. Templates, -examples and runbooks are embedded in Go; -generation requires Microsoft's official Bicep CLI on PATH. - -azure plans or explicitly applies a fresh development Functions deployment from -a reviewed JSON config using the same TOSCA requirement resolver and embedded Azure template. -Offline generation never deploys resources or changes GitHub Apps.`, - Args: cobra.NoArgs, - RunE: func(*cobra.Command, []string) error { - return fmt.Errorf("deploy requires init, generate, check, or azure") - }, - } - deploy.AddCommand(deployAzureCommand(deps)) - addDeployBundleCommands(deploy) - return deploy -} - -func deployAzureCommand(deps deployAzureDeps) *cobra.Command { - cmd := &cobra.Command{ - Use: "azure", - Short: "Plan a fresh CAO dashboard deployment to Azure", - Long: `Plan a fresh CAO dashboard deployment to Azure from a reviewed config. - -The config (default ` + defaultDeployAzureConfigPath + `) names the subscription, -resource group, resources, and access policy. It never contains secret values; -secure parameters reference environment variables or Key Vault secrets. -The embedded Azure template and TOSCA requirement-graph resolver require no gh-cao -checkout for preflight or planning. --template selects an explicit ARM override. - ---plan (default) runs read-only preflight checks and what-if, then writes a -secret-free plan file. --preflight runs only the preflight checks (tools, -login, permissions, resource providers, name availability, location, existing -instance, ingress drift, and retention read-back), prints every result, and -fails if any check failed; it writes no plan file. Only fresh deployments are -supported: existing resources fail closed. --apply requires environment: development -in the reviewed config; production source-build deployment is not supported. -It rechecks a reviewed plan, builds immutable images, -deploys, ingests the explicitly selected evidence, and verifies public OAuth ingress. -Updates to existing instances are not supported.`, - Args: cobra.NoArgs, - } - flags := cmd.Flags() - flags.String("config", defaultDeployAzureConfigPath, "Reviewed deployment config") - flags.Bool("plan", false, "Preview with read-only checks and what-if (default mode)") - flags.Bool("preflight", false, "Run read-only preflight checks only; writes no plan file") - flags.Bool("json", false, "Print preflight results as JSON (with --preflight)") - flags.Bool("apply", false, "Apply a reviewed plan to a fresh development deployment (not production-assured)") - flags.String("plan-file", "", "Reviewed plan file required by --apply") - flags.String("template", "", "Explicit compiled ARM override (default: embedded Azure template)") - flags.String("out", defaultDeployAzurePlanPath, "Plan file to write") - flags.String("source-dir", "", "Development gh-cao checkout used as the allowlisted build source") - flags.String("repo-dir", ".", "Operator repository containing reviewed cao.json and cao.azure.json") - flags.String("snapshot-dir", "", "Verified dashboard snapshot directory to ingest") - flags.Bool("empty-evidence", false, "Explicitly initialize without evidence") - flags.String("receipt", ".cao/deploy-azure-receipt.json", "Secret-free deployment receipt") - flags.Duration("timeout", 45*time.Minute, "Overall apply timeout") - cmd.MarkFlagsMutuallyExclusive("snapshot-dir", "empty-evidence") - cmd.MarkFlagsMutuallyExclusive("plan", "apply", "preflight") - cmd.RunE = func(cmd *cobra.Command, _ []string) error { - apply, _ := flags.GetBool("apply") - planFile, _ := flags.GetString("plan-file") - configPath, _ := flags.GetString("config") - templatePath, _ := flags.GetString("template") - if apply { - if planFile == "" { - return errors.New("--apply requires --plan-file from a reviewed --plan run") - } - options := deployAzureApplyOptions{} - options.SourceDir, _ = flags.GetString("source-dir") - options.RepoDir, _ = flags.GetString("repo-dir") - options.SnapshotDir, _ = flags.GetString("snapshot-dir") - options.EmptyEvidence, _ = flags.GetBool("empty-evidence") - options.ReceiptPath, _ = flags.GetString("receipt") - timeout, _ := flags.GetDuration("timeout") - if timeout <= 0 { - return errors.New("--timeout must be positive") - } - ctx, cancel := context.WithTimeout(cmd.Context(), timeout) - defer cancel() - applyDeps := deps - applyDeps.diagnostics = cmd.ErrOrStderr() - if err := deployAzureApply(ctx, applyDeps, configPath, templatePath, planFile, options); err != nil { - return err - } - _, err := fmt.Fprintf(cmd.OutOrStdout(), "Azure development deployment verified (images not production-assured); receipt written to %s\n", options.ReceiptPath) - return err - } - if flags.Changed("plan-file") { - return errors.New("--plan-file is only valid with --apply") - } - asJSON, _ := flags.GetBool("json") - if preflight, _ := flags.GetBool("preflight"); preflight { - if flags.Changed("out") { - return errors.New("--out is not valid with --preflight, which writes no plan file") - } - return deployAzurePreflightRun(cmd.Context(), cmd.OutOrStdout(), deps, configPath, templatePath, asJSON) - } - if asJSON { - return errors.New("--json is only valid with --preflight") - } - out, _ := flags.GetString("out") - return deployAzurePlanRun(cmd.Context(), cmd.OutOrStdout(), deps, configPath, templatePath, out) - } - return cmd -} - -type deployAzurePlan struct { - Kind string `json:"kind"` - SchemaVersion int `json:"schemaVersion"` - CreatedAt string `json:"createdAt"` - Config deployAzurePlanDigest `json:"config"` - Template deployAzurePlanDigest `json:"template"` - Subscription string `json:"subscription"` - TenantID string `json:"tenantId"` - ResourceGroup string `json:"resourceGroup"` - Location string `json:"location"` - Environment string `json:"environment"` - ResourceGroupExists bool `json:"resourceGroupExists"` - Scope string `json:"scope"` - Parameters map[string]any `json:"parameters"` - SecureParameters map[string]string `json:"secureParameters"` - WhatIf deployAzureWhatIfSummary `json:"whatIf"` - Topology topology.Topology `json:"topology"` -} - -type deployAzurePlanDigest struct { - Path string `json:"path,omitempty"` - Origin string `json:"origin,omitempty"` - SHA256 string `json:"sha256"` -} - -type deployAzureWhatIfSummary struct { - Status string `json:"status"` - Counts map[string]int `json:"counts"` - Changes []deployAzureChange `json:"changes"` -} - -type deployAzureChange struct { - ResourceID string `json:"resourceId"` - ChangeType string `json:"changeType"` -} - -func deployAzureLoadTemplate(ctx context.Context, deps deployAzureDeps, path string) (deployAzureTemplate, error) { - if path == "" { - return deployAzureEmbeddedTemplate{}.Load(ctx) - } - return deps.template(path).Load(ctx) -} - -func resolveDeployAzureTopology(ctx context.Context, config deployAzureConfig) (topology.Topology, error) { - features := topology.Features{ - Deployment: "azure-functions", Acquisition: "actions", Ingress: "public", Analytics: "none", Telemetry: "none", - } - if config.Telemetry.OTLPEndpoint != "" { - features.Telemetry = "otlp" - } - var secrets []string - for _, secure := range config.secureParameters() { - secrets = append(secrets, secure.name) - } - return topology.ResolveAzure(ctx, features, config.parameters(), secrets) -} - -func deployAzurePlanRun(ctx context.Context, stdout io.Writer, deps deployAzureDeps, configPath, templatePath, out string) error { - loaded, err := loadDeployAzureConfig(configPath) - if err != nil { - return err - } - config := loaded.config - template, err := deployAzureLoadTemplate(ctx, deps, templatePath) - if err != nil { - return err - } - parameters := config.parameters() - var secureNames []string - for _, secure := range config.secureParameters() { - secureNames = append(secureNames, secure.name) - } - document, err := checkDeployAzureTemplate(template, parameters, secureNames) - if err != nil { - return err - } - resolved, err := resolveDeployAzureTopology(ctx, config) - if err != nil { - return fmt.Errorf("resolve Azure deployment topology: %w", err) - } - secrets, err := resolveDeployAzureSecrets(config, deps.getenv) - if err != nil { - return err - } - az := deployAzureRedacting(azcli.ReadOnly(deps.az), secrets) - - preflight := runDeployAzurePreflight(ctx, az, config, template) - if err := writeDeployAzurePreflightTable(stdout, preflight.checks); err != nil { - return err - } - if err := preflight.err(); err != nil { - return err - } - account, groupExists := preflight.account, preflight.groupExists - - workspace, err := newDeployAzureWorkspace() - if err != nil { - return err - } - defer func() { _ = workspace.Close() }() - parameterPath, err := workspace.write("parameters.json", deployAzureParameterFile(parameters, secrets)) - if err != nil { - return err - } - scope := "resourceGroup" - var args []string - if groupExists { - templateFile, err := workspace.write("template.json", json.RawMessage(template.Content)) - if err != nil { - return err - } - args = []string{"deployment", "group", "what-if", "--subscription", config.Subscription, "--resource-group", config.ResourceGroup, - "--template-file", templateFile} - } else { - scope = "subscription" - names := append(sortedKeys(parameters), secureNames...) - wrapper, err := deployAzureSubscriptionWrapper(template, document, config, names) - if err != nil { - return err - } - templateFile, err := workspace.write("template.json", wrapper) - if err != nil { - return err - } - args = []string{"deployment", "sub", "what-if", "--subscription", config.Subscription, "--location", config.Location, - "--template-file", templateFile} - } - args = append(args, "--parameters", "@"+parameterPath, "--result-format", "ResourceIdOnly", "--no-pretty-print", "--output", "json") - result, err := az.Run(ctx, args...) - if err != nil { - return fmt.Errorf("what-if failed: %w", err) - } - summary, err := parseDeployAzureWhatIf(result.Stdout) - if err != nil { - return err - } - - plan := deployAzurePlan{ - Kind: deployAzurePlanKind, - SchemaVersion: deployAzurePlanVersion, - CreatedAt: deps.now().UTC().Format(time.RFC3339), - Config: deployAzurePlanDigest{Path: filepath.ToSlash(configPath), SHA256: loaded.digest}, - Template: deployAzurePlanDigest{Origin: template.Origin, SHA256: template.Digest}, - Subscription: account.ID, - TenantID: account.TenantID, - ResourceGroup: config.ResourceGroup, - Location: config.Location, - Environment: config.Environment, - ResourceGroupExists: groupExists, - Scope: scope, - Parameters: parameters, - SecureParameters: map[string]string{}, - WhatIf: summary, - Topology: resolved, - } - for _, secure := range config.secureParameters() { - plan.SecureParameters[secure.name] = secure.ref.describe() - } - if err := writeDeployAzurePlan(out, plan); err != nil { - return err - } - var counts []string - for _, kind := range sortedKeys(summary.Counts) { - counts = append(counts, fmt.Sprintf("%s=%d", kind, summary.Counts[kind])) - } - _, err = fmt.Fprintf(stdout, "\nAzure deployment plan written to %s\nsubscription: %s\nresource group: %s (exists: %t)\nwhat-if: %s\nApply supports only reviewed environment: development configs, without production image assurance.\nReview the plan, then run gh cao deploy azure --apply --plan-file %s\n", - out, plan.Subscription, plan.ResourceGroup, groupExists, strings.Join(counts, ", "), out) - return err -} - -func deployAzurePreflightRun(ctx context.Context, stdout io.Writer, deps deployAzureDeps, configPath, templatePath string, asJSON bool) error { - loaded, err := loadDeployAzureConfig(configPath) - if err != nil { - return err - } - config := loaded.config - template, err := deployAzureLoadTemplate(ctx, deps, templatePath) - if err != nil { - return err - } - var secureNames []string - for _, secure := range config.secureParameters() { - secureNames = append(secureNames, secure.name) - } - if _, err := checkDeployAzureTemplate(template, config.parameters(), secureNames); err != nil { - return err - } - if _, err := resolveDeployAzureTopology(ctx, config); err != nil { - return fmt.Errorf("resolve Azure deployment topology: %w", err) - } - // Preflight needs no secret values, but redacts any that are present. - az := deployAzureRedacting(azcli.ReadOnly(deps.az), deployAzureAvailableSecrets(config, deps.getenv)) - preflight := runDeployAzurePreflight(ctx, az, config, template) - if asJSON { - content, err := json.MarshalIndent(preflight.report(), "", " ") - if err != nil { - return err - } - if _, err := fmt.Fprintf(stdout, "%s\n", content); err != nil { - return err - } - } else if err := writeDeployAzurePreflightTable(stdout, preflight.checks); err != nil { - return err - } - return preflight.err() -} - -func deployAzureRedacting(next azcli.Executor, secrets deployAzureSecrets) azcli.Executor { - return azcli.ExecutorFunc(func(ctx context.Context, args ...string) (azcli.Result, error) { - result, err := next.Run(ctx, args...) - result.Stdout = secrets.redact(result.Stdout) - result.Stderr = secrets.redact(result.Stderr) - if err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return result, fmt.Errorf("az %s: %w", azcli.CommandName(args), ctxErr) - } - if errors.Is(err, context.DeadlineExceeded) { - return result, fmt.Errorf("az %s: %w", azcli.CommandName(args), context.DeadlineExceeded) - } - return result, fmt.Errorf("az %s: %s", azcli.CommandName(args), secrets.redact(err.Error())) - } - return result, nil - }) -} - -type deployAzureAccountInfo struct { - ID string `json:"id"` - State string `json:"state"` - TenantID string `json:"tenantId"` - User struct { - Name string `json:"name"` - Type string `json:"type"` - } `json:"user"` -} - -func deployAzureAccount(ctx context.Context, az azcli.Executor, subscription string) (deployAzureAccountInfo, error) { - result, err := az.Run(ctx, "account", "show", "--subscription", subscription, "--output", "json") - if err != nil { - return deployAzureAccountInfo{}, fmt.Errorf("check Azure subscription (run az login): %w", err) - } - var account deployAzureAccountInfo - if err := json.Unmarshal([]byte(result.Stdout), &account); err != nil { - return account, fmt.Errorf("parse az account show: %w", err) - } - if !strings.EqualFold(account.ID, subscription) { - return account, fmt.Errorf("signed-in Azure account resolved subscription %q, want %q", account.ID, subscription) - } - if account.State != "Enabled" { - return account, fmt.Errorf("subscription %s is %q, want Enabled", subscription, account.State) - } - return account, nil -} - -func deployAzureGroupExists(ctx context.Context, az azcli.Executor, config deployAzureConfig) (bool, error) { - result, err := az.Run(ctx, "group", "exists", "--subscription", config.Subscription, "--name", config.ResourceGroup, "--output", "json") - if err != nil { - return false, fmt.Errorf("check resource group: %w", err) - } - switch strings.TrimSpace(result.Stdout) { - case "true": - return true, nil - case "false": - return false, nil - default: - return false, fmt.Errorf("unexpected az group exists output %q", strings.TrimSpace(result.Stdout)) - } -} - -func deployAzureResourceList(ctx context.Context, az azcli.Executor, filters ...string) ([]deployAzureResource, error) { - args := append([]string{"resource", "list"}, filters...) - result, err := az.Run(ctx, append(args, "--output", "json")...) - if err != nil { - return nil, fmt.Errorf("detect existing resources: %w", err) - } - var resources []deployAzureResource - if err := json.Unmarshal([]byte(result.Stdout), &resources); err != nil { - return nil, fmt.Errorf("parse az resource list: %w", err) - } - return resources, nil -} - -// Fresh deployments may only create resources; any change to an existing -// resource means an instance already exists. -var deployAzureFreshChangeTypes = map[string]bool{"Create": true, "Deploy": true, "Ignore": true, "Unsupported": true} - -func parseDeployAzureWhatIf(output string) (deployAzureWhatIfSummary, error) { - var raw struct { - Status string `json:"status"` - Error json.RawMessage `json:"error"` - Changes []struct { - ResourceID string `json:"resourceId"` - ChangeType string `json:"changeType"` - } `json:"changes"` - } - if err := json.Unmarshal([]byte(output), &raw); err != nil { - return deployAzureWhatIfSummary{}, fmt.Errorf("parse what-if result: %w", err) - } - if raw.Status != "Succeeded" { - return deployAzureWhatIfSummary{}, fmt.Errorf("what-if status %q, want Succeeded", raw.Status) - } - summary := deployAzureWhatIfSummary{Status: raw.Status, Counts: map[string]int{}, Changes: []deployAzureChange{}} - for _, change := range raw.Changes { - if !deployAzureFreshChangeTypes[change.ChangeType] { - return deployAzureWhatIfSummary{}, fmt.Errorf("what-if reports %s for %s; only fresh deployments are supported", change.ChangeType, change.ResourceID) - } - summary.Counts[change.ChangeType]++ - summary.Changes = append(summary.Changes, deployAzureChange{ResourceID: change.ResourceID, ChangeType: change.ChangeType}) - } - sort.Slice(summary.Changes, func(i, j int) bool { return summary.Changes[i].ResourceID < summary.Changes[j].ResourceID }) - return summary, nil -} - -func writeDeployAzurePlan(path string, plan deployAzurePlan) error { - content, err := json.MarshalIndent(plan, "", " ") - if err != nil { - return err - } - content = append(content, '\n') - directory := filepath.Dir(path) - if err := os.MkdirAll(directory, 0o755); err != nil { - return fmt.Errorf("create plan directory: %w", err) - } - temporary, err := os.CreateTemp(directory, ".deploy-azure-plan-*") - if err != nil { - return fmt.Errorf("write plan: %w", err) - } - defer func() { _ = os.Remove(temporary.Name()) }() - if _, err := temporary.Write(content); err != nil { - _ = temporary.Close() - return fmt.Errorf("write plan: %w", err) - } - if err := temporary.Chmod(0o644); err != nil { - _ = temporary.Close() - return fmt.Errorf("write plan: %w", err) - } - if err := temporary.Close(); err != nil { - return fmt.Errorf("write plan: %w", err) - } - if err := os.Rename(temporary.Name(), path); err != nil { - return fmt.Errorf("write plan: %w", err) - } - return nil -} - -func readDeployAzurePlan(path string) (deployAzurePlan, error) { - var plan deployAzurePlan - content, err := os.ReadFile(path) - if err != nil { - return plan, fmt.Errorf("read plan file: %w", err) - } - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&plan); err != nil { - return plan, fmt.Errorf("invalid plan file %s: %w", path, err) - } - if _, err := decoder.Token(); !errors.Is(err, io.EOF) { - return plan, fmt.Errorf("invalid plan file %s: unexpected trailing content", path) - } - switch { - case plan.Kind != deployAzurePlanKind || plan.SchemaVersion != deployAzurePlanVersion: - return plan, fmt.Errorf("invalid plan file %s: expected %s version %d; rerun --plan", path, deployAzurePlanKind, deployAzurePlanVersion) - case plan.WhatIf.Status != "Succeeded": - return plan, fmt.Errorf("invalid plan file %s: what-if did not succeed", path) - case plan.Config.SHA256 == "" || plan.Template.SHA256 == "": - return plan, fmt.Errorf("invalid plan file %s: missing config or template digest", path) - } - if _, err := time.Parse(time.RFC3339, plan.CreatedAt); err != nil { - return plan, fmt.Errorf("invalid plan file %s: createdAt: %w", path, err) - } - if err := topology.CheckTopology(plan.Topology); err != nil { - return plan, fmt.Errorf("invalid plan file %s: %w; rerun --plan", path, err) - } - return plan, nil -} - -func deployAzureApply(ctx context.Context, deps deployAzureDeps, configPath, templatePath, planFile string, options ...deployAzureApplyOptions) error { - plan, err := readDeployAzurePlan(planFile) - if err != nil { - return err - } - loaded, err := loadDeployAzureConfig(configPath) - if err != nil { - return err - } - if loaded.digest != plan.Config.SHA256 { - return fmt.Errorf("plan file %s was created from a different config; rerun --plan", planFile) - } - if !strings.EqualFold(loaded.config.Subscription, plan.Subscription) || loaded.config.ResourceGroup != plan.ResourceGroup { - return fmt.Errorf("plan file %s targets a different subscription or resource group; rerun --plan", planFile) - } - template, err := deployAzureLoadTemplate(ctx, deps, templatePath) - if err != nil { - return err - } - if template.Digest != plan.Template.SHA256 { - return fmt.Errorf("plan file %s was created from a different template; rerun --plan", planFile) - } - resolved, err := resolveDeployAzureTopology(ctx, loaded.config) - if err != nil { - return fmt.Errorf("resolve Azure deployment topology: %w", err) - } - if !reflect.DeepEqual(resolved, plan.Topology) { - return fmt.Errorf("plan file %s was created from a different topology; rerun --plan", planFile) - } - if len(options) != 1 { - return errors.New("--apply requires --source-dir and exactly one of --snapshot-dir or --empty-evidence") - } - return deployAzureExecute(ctx, deps, loaded, template, plan, planFile, options[0]) -} - -func sortedKeys[V any](values map[string]V) []string { - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} diff --git a/internal/cli/deploy_azure_apply.go b/internal/cli/deploy_azure_apply.go deleted file mode 100644 index 7e1d957c44..0000000000 --- a/internal/cli/deploy_azure_apply.go +++ /dev/null @@ -1,293 +0,0 @@ -package cli - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "reflect" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -type deployAzureApplyOptions struct { - SourceDir, RepoDir, SnapshotDir, ReceiptPath string - EmptyEvidence bool -} - -type deployAzureReceipt struct { - Kind string `json:"kind"` - SchemaVersion int `json:"schemaVersion"` - PlanSHA256 string `json:"planSha256"` - StartedAt string `json:"startedAt"` - CompletedAt string `json:"completedAt"` - DeploymentName string `json:"deploymentName"` - DeploymentID string `json:"deploymentId"` - SourceRevision string `json:"sourceRevision"` - SourceWorktree string `json:"sourceWorktree"` - Environment string `json:"environment"` - ImageAssurance string `json:"imageAssurance"` - ContextSHA256 string `json:"contextSha256"` - Images []deployAzureBuiltImage `json:"images"` - JobExecution string `json:"jobExecution"` - ProvisionExecution string `json:"provisionExecution,omitempty"` - Verification []deployAzureVerification `json:"verification"` -} - -func deployAzureExecute(ctx context.Context, deps deployAzureDeps, loaded deployAzureLoadedConfig, template deployAzureTemplate, plan deployAzurePlan, planFile string, options deployAzureApplyOptions) error { - if loaded.config.Environment != "development" || plan.Environment != "development" { - return errors.New("source-build apply requires environment: development in the reviewed config and plan; production promotion is unsupported without an independent per-target scan, SBOM and provenance gate") - } - var document deployAzureTemplateDocument - if err := json.Unmarshal(template.Content, &document); err != nil { - return fmt.Errorf("parse digest-capable template: %w", err) - } - for _, name := range []string{"functionImageDigest", "ingestionImageDigest", "provisioningImageDigest"} { - if document.Parameters[name].Type != "string" { - return fmt.Errorf("source-build apply requires a digest-capable template with string parameter %s; regenerate the reviewed template and plan", name) - } - } - if options.SourceDir == "" { - return errors.New("--apply requires --source-dir (a development gh-cao checkout)") - } - if (options.SnapshotDir == "") == !options.EmptyEvidence { - return errors.New("--apply requires exactly one of --snapshot-dir or --empty-evidence") - } - if options.ReceiptPath == "" { - return errors.New("--receipt must not be empty") - } - if _, err := os.Lstat(options.ReceiptPath); err == nil { - return fmt.Errorf("receipt %s already exists; choose a new --receipt path", options.ReceiptPath) - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("inspect receipt: %w", err) - } - secrets, err := resolveDeployAzureSecrets(loaded.config, deps.getenv) - if err != nil { - return err - } - az := deployAzureRedacting(deps.az, secrets) - workspace, err := newDeployAzureWorkspace() - if err != nil { - return err - } - defer func() { _ = workspace.Close() }() - var source deployAzureBuildSource = deployAzureDevSource{directory: options.SourceDir} - if deps.source != nil { - source = deps.source(options.SourceDir) - } - build, err := prepareDeployAzureBuild(ctx, source, workspace, options) - if err != nil { - return err - } - registryTemplate, err := deployAzureRegistryTemplate(template) - if err != nil { - return err - } - // Reuse the complete read-only planner, including freshness and authority - // checks, immediately before the first Azure write. - freshPath := filepath.Join(workspace.directory, "rechecked-plan.json") - recheckTemplate, err := workspace.write("recheck-template.json", json.RawMessage(template.Content)) - if err != nil { - return err - } - if err := deployAzurePlanRun(ctx, io.Discard, deps, loaded.path, recheckTemplate, freshPath); err != nil { - return err - } - fresh, err := readDeployAzurePlan(freshPath) - if err != nil { - return err - } - if fresh.TenantID != plan.TenantID || fresh.Scope != plan.Scope || fresh.ResourceGroupExists != plan.ResourceGroupExists || - fresh.Location != plan.Location || !reflect.DeepEqual(fresh.Parameters, plan.Parameters) || - !reflect.DeepEqual(fresh.SecureParameters, plan.SecureParameters) || !reflect.DeepEqual(fresh.WhatIf, plan.WhatIf) || - !reflect.DeepEqual(fresh.Topology, plan.Topology) { - return errors.New("azure plan drift detected; no Azure changes were made; rerun --plan and review the new change set") - } - config := loaded.config - planBytes, err := os.ReadFile(planFile) - if err != nil { - return err - } - sum := sha256.Sum256(planBytes) - receipt := deployAzureReceipt{ - Kind: "cao.deploy.azure.receipt", SchemaVersion: 1, PlanSHA256: hex.EncodeToString(sum[:]), - StartedAt: deps.now().UTC().Format(time.RFC3339), SourceRevision: build.Revision, ContextSHA256: build.Digest, - SourceWorktree: build.Worktree, Environment: "development", ImageAssurance: "not-evaluated-development-only", - DeploymentName: "cao-" + hex.EncodeToString(sum[:8]), - } - scope := []string{"--subscription", config.Subscription, "--resource-group", config.ResourceGroup} - run := func(verb []string, extra ...string) (azcli.Result, error) { - args := append(append(append([]string{}, verb...), scope...), extra...) - return az.Run(ctx, append(args, "--output", "json")...) - } - if !plan.ResourceGroupExists { - if _, err := az.Run(ctx, "group", "create", "--subscription", config.Subscription, "--name", config.ResourceGroup, "--location", config.Location, "--output", "none"); err != nil { - return fmt.Errorf("create fresh resource group: %w", err) - } - } - registryPath, err := workspace.write("registry.json", registryTemplate) - if err != nil { - return err - } - registryParameters, err := workspace.write("registry-parameters.json", deployAzureParameterFile(map[string]any{ - "containerRegistryName": config.Resources.ContainerRegistry, "location": config.Location, - }, deployAzureSecrets{})) - if err != nil { - return err - } - if _, err := run([]string{"deployment", "group", "create"}, "--name", receipt.DeploymentName+"-registry", "--template-file", registryPath, "--parameters", "@"+registryParameters); err != nil { - return fmt.Errorf("create exact template registry: %w", err) - } - var outputs struct { - Outputs map[string]json.RawMessage `json:"outputs"` - } - if err := json.Unmarshal(template.Content, &outputs); err != nil { - return err - } - _, provision := outputs.Outputs["provisioningJobName"] - receipt.Images, err = deployAzureBuildImages(ctx, az, config, build, options, provision) - if err != nil { - return err - } - templatePath, err := workspace.write("full-template.json", json.RawMessage(template.Content)) - if err != nil { - return err - } - parameters, err := deployAzureImageParameters(config, receipt.Images, options.EmptyEvidence, provision) - if err != nil { - return err - } - parametersPath, err := workspace.write("apply-parameters.json", deployAzureParameterFile(parameters, secrets)) - if err != nil { - return err - } - result, err := deployAzureCreateTemplate(ctx, deps, az, config, receipt.DeploymentName, templatePath, parametersPath) - if err != nil { - return fmt.Errorf("deploy full template (resource group retained for diagnosis): %w", err) - } - var deployment struct { - ID string `json:"id"` - Properties struct { - ProvisioningState string `json:"provisioningState"` - Outputs struct { - FunctionHostName struct { - Value string `json:"value"` - } `json:"functionHostName"` - IngestionJobName struct { - Value string `json:"value"` - } `json:"ingestionJobName"` - ProvisioningJobName struct { - Value string `json:"value"` - } `json:"provisioningJobName"` - } `json:"outputs"` - } `json:"properties"` - } - if err := json.Unmarshal([]byte(result.Stdout), &deployment); err != nil { - return fmt.Errorf("parse deployment result: %w", err) - } - if deployment.ID == "" || deployment.Properties.ProvisioningState != "Succeeded" { - return errors.New("full deployment did not return an ID and Succeeded state") - } - receipt.DeploymentID = deployment.ID - host := deployment.Properties.Outputs.FunctionHostName.Value - job := deployment.Properties.Outputs.IngestionJobName.Value - if host != config.Resources.FunctionApp+".azurewebsites.net" || job != config.Resources.FunctionApp+"-ingest" { - return errors.New("deployment returned unexpected Function App hostname or ingestion job; refusing to contact it") - } - if provision { - provisionJob := deployment.Properties.Outputs.ProvisioningJobName.Value - if provisionJob != config.Resources.FunctionApp+"-db-provision" { - return errors.New("deployment returned an unexpected database provision job; refusing to execute it") - } - } - if err := deployAzureReadBackImages(ctx, az, config, receipt.Images); err != nil { - return err - } - if provision { - provisionJob := deployment.Properties.Outputs.ProvisioningJobName.Value - receipt.ProvisionExecution, err = deployAzureRunJob(ctx, deps, az, config, provisionJob) - if err != nil { - return fmt.Errorf("database provision failed before ingestion: %w", err) - } - } - receipt.JobExecution, err = deployAzureRunJob(ctx, deps, az, config, job) - if err != nil { - return err - } - if err := deployAzureRestartAndAwaitReferences(ctx, deps, az, config); err != nil { - return err - } - receipt.Verification, err = deployAzureVerify(ctx, deps, az, config, host) - if err != nil { - return err - } - receipt.CompletedAt = deps.now().UTC().Format(time.RFC3339) - return writeDeployAzureReceipt(options.ReceiptPath, receipt, secrets) -} - -func deployAzureRegistryTemplate(template deployAzureTemplate) (map[string]any, error) { - var document struct { - Resources json.RawMessage `json:"resources"` - Variables map[string]any `json:"variables"` - } - if err := json.Unmarshal(template.Content, &document); err != nil { - return nil, err - } - var resources []map[string]any - if err := json.Unmarshal(document.Resources, &resources); err != nil { - var symbolic map[string]map[string]any - if err := json.Unmarshal(document.Resources, &symbolic); err != nil { - return nil, fmt.Errorf("parse template resources: %w", err) - } - for _, resource := range symbolic { - resources = append(resources, resource) - } - } - for _, resource := range resources { - if resource["type"] != "Microsoft.ContainerRegistry/registries" { - continue - } - // Copy the exact resource declaration rather than approximating its SKU, - // tags or network properties in an imperative `az acr create`. - delete(resource, "dependsOn") - return map[string]any{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": map[string]any{ - "containerRegistryName": map[string]string{"type": "string"}, - "location": map[string]string{"type": "string"}, - }, - "variables": map[string]any{"tags": document.Variables["tags"]}, - "resources": []any{resource}, - }, nil - } - return nil, errors.New("template has no supported Container Registry resource") -} - -func writeDeployAzureReceipt(path string, receipt deployAzureReceipt, secrets deployAzureSecrets) error { - content, err := json.MarshalIndent(receipt, "", " ") - if err != nil { - return err - } - if secrets.redact(string(content)) != string(content) { - return errors.New("refusing to write a receipt containing a secret") - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return fmt.Errorf("create receipt: %w", err) - } - if _, err := file.Write(append(content, '\n')); err != nil { - _ = file.Close() - return fmt.Errorf("write receipt: %w", err) - } - return file.Close() -} diff --git a/internal/cli/deploy_azure_apply_test.go b/internal/cli/deploy_azure_apply_test.go deleted file mode 100644 index 84454af842..0000000000 --- a/internal/cli/deploy_azure_apply_test.go +++ /dev/null @@ -1,723 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -type testAzureBuildSource struct{} - -func (testAzureBuildSource) Materialize(_ context.Context, directory string) (deployAzureSourceIdentity, error) { - if err := os.WriteFile(filepath.Join(directory, "product.json"), []byte(`{"product":true}`), 0o600); err != nil { - return deployAzureSourceIdentity{}, err - } - return deployAzureSourceIdentity{Revision: strings.Repeat("a", 40), Worktree: "dirty"}, nil -} - -type testAzureTransport func(*http.Request) (*http.Response, error) - -func (f testAzureTransport) RoundTrip(request *http.Request) (*http.Response, error) { - return f(request) -} - -type fakeAzureApply struct { - t *testing.T - plan *fakeAz - calls [][]string - failBuild bool - buildRunID bool - failJob bool - concurrent bool - httpCalls int - polls int - restarted bool - referencesStuck bool - referencePolls int - transientARMFailures int - deploymentOutputs map[string]any - imageReadBack map[string]string - invalidDigest bool -} - -func testAzureImageDigest(repository string) string { - letter := "b" - switch repository { - case "cao-ingest": - letter = "c" - case "cao-db-provision": - letter = "d" - } - return "sha256:" + strings.Repeat(letter, 64) -} - -func (f *fakeAzureApply) Run(ctx context.Context, args ...string) (azcli.Result, error) { - f.t.Helper() - f.calls = append(f.calls, slices.Clone(args)) - for _, secret := range testDeploySecrets() { - if strings.Contains(strings.Join(args, " "), secret) { - f.t.Fatal("secret in Azure argv") - } - } - if azcli.IsReadOnly(args) { - return f.plan.Run(ctx, args...) - } - flag := func(name string) string { - i := slices.Index(args, name) - if i < 0 || i+1 == len(args) { - return "" - } - return args[i+1] - } - switch azcli.CommandName(args) { - case "group create", "acr repository update": - return azcli.Result{}, nil - case "acr build": - if f.failBuild { - if f.buildRunID { - return azcli.Result{}, fmt.Errorf("the run with ID 'ds1' failed %s", testDeployClientSecret) - } - return azcli.Result{}, fmt.Errorf("build failed %s", testDeployClientSecret) - } - if flag("--target") == "" || flag("--platform") != "linux/amd64" { - f.t.Fatal("ACR build missing target or platform") - } - context := args[len(args)-1] - if _, err := os.Stat(filepath.Join(context, ".github", "workflows", "cao.azure.json")); err != nil { - f.t.Fatal("operator policy was not layered into build context") - } - return azcli.Result{Stdout: `{}`}, nil - case "acr task logs": - return azcli.Result{Stdout: "source build diagnostic: required COPY input missing " + testDeploySession}, nil - case "acr repository show": - digest := testAzureImageDigest(strings.Split(flag("--image"), ":")[0]) - if f.invalidDigest { - digest = "invalid" - } - return azcli.Result{Stdout: fmt.Sprintf(`{"digest":%q}`, digest)}, nil - case "deployment group create": - content, err := os.ReadFile(flag("--template-file")) - if err != nil { - f.t.Fatal(err) - } - if strings.HasSuffix(flag("--name"), "-registry") { - var template struct { - Resources []struct { - SKU struct{ Name string } `json:"sku"` - } `json:"resources"` - } - if err := json.Unmarshal(content, &template); err != nil || len(template.Resources) != 1 || template.Resources[0].SKU.Name != "Basic" { - f.t.Fatalf("registry must match exact template SKU: %s", content) - } - return azcli.Result{Stdout: `{}`}, nil - } - parameters, err := os.ReadFile(strings.TrimPrefix(flag("--parameters"), "@")) - if err != nil { - f.t.Fatal(err) - } - for _, secret := range testDeploySecrets() { - if !strings.Contains(string(parameters), secret) { - f.t.Fatal("secure value not passed by file") - } - } - // Only decode digest values; secure/array parameter shapes are unrelated. - var raw struct { - Parameters map[string]json.RawMessage `json:"parameters"` - } - if err := json.Unmarshal(parameters, &raw); err != nil { - f.t.Fatal(err) - } - for parameter, repository := range map[string]string{ - "functionImageDigest": "cao-functions", "ingestionImageDigest": "cao-ingest", "provisioningImageDigest": "cao-db-provision", - } { - var value struct{ Value string } - if err := json.Unmarshal(raw.Parameters[parameter], &value); err != nil || value.Value != testAzureImageDigest(repository) { - f.t.Fatalf("wrong exact target digest for %s: %s (%v)", parameter, raw.Parameters[parameter], err) - } - } - if f.transientARMFailures > 0 { - f.transientARMFailures-- - failure := testAzureRetryFailure(testAzureRetryConfig(), flag("--name"), "snapshot-ingestion", "database-provisioning") - return azcli.Result{Stderr: testAzureRetryJSON(f.t, failure)}, fmt.Errorf("managed identity propagation: %s", testDeployClientSecret) - } - outputs := map[string]any{ - "functionHostName": map[string]any{"value": "fa-synthetic-test.azurewebsites.net"}, - "ingestionJobName": map[string]any{"value": "fa-synthetic-test-ingest"}, - "provisioningJobName": map[string]any{"value": "fa-synthetic-test-db-provision"}, - "collectionEnabled": map[string]any{"type": "Bool", "value": false}, - "retentionDays": map[string]any{"type": "Int", "value": 45}, - "unrelatedObject": map[string]any{"type": "Object", "value": map[string]any{"enabled": true}}, - "unrelatedArray": map[string]any{"type": "Array", "value": []string{"synthetic"}}, - } - for name, value := range f.deploymentOutputs { - outputs[name] = map[string]any{"value": value} - } - result, err := json.Marshal(map[string]any{ - "id": "/subscriptions/" + testDeploySubscription + "/resourceGroups/rg-synthetic-test/providers/Microsoft.Resources/deployments/cao-test", - "properties": map[string]any{"provisioningState": "Succeeded", "outputs": outputs}, - }) - if err != nil { - f.t.Fatal(err) - } - return azcli.Result{Stdout: string(result)}, nil - case "containerapp job execution list": - if f.concurrent { - return azcli.Result{Stdout: `[{"name":"fa-synthetic-test-ingest-other","properties":{"status":"Running"}}]`}, nil - } - return azcli.Result{Stdout: `[]`}, nil - case "containerapp job start": - return azcli.Result{Stdout: fmt.Sprintf(`{"name":%q}`, flag("--name")+"-exact")}, nil - case "containerapp job execution show": - if flag("--job-execution-name") != flag("--name")+"-exact" { - f.t.Fatal("did not wait on exact started execution") - } - f.polls++ - status := "Succeeded" - if f.polls%2 == 1 { - status = "Running" - } - if f.failJob { - status = "Failed" - } - return azcli.Result{Stdout: fmt.Sprintf(`{"name":%q,"properties":{"status":%q}}`, flag("--job-execution-name"), status)}, nil - case "containerapp job replica list": - return azcli.Result{Stdout: `[{"name":"replica","properties":{"initContainers":[{"name":"bootstrap-canonical","runningState":"Terminated","runningStateDetails":"bootstrap permission denied"}]}}]`}, nil - case "functionapp restart": - f.restarted = true - return azcli.Result{}, nil - case "rest": - if flag("--method") != "get" || !strings.HasSuffix(strings.SplitN(flag("--url"), "?", 2)[0], "/sites/fa-synthetic-test/config/configreferences/appsettings") { - f.t.Fatalf("unexpected rest call: %q", args) - } - if !f.restarted { - f.t.Fatal("Key Vault references polled before restart") - } - f.referencePolls++ - denied := f.referencesStuck || f.referencePolls == 1 - status := "Resolved" - if denied { - status = "AccessToKeyVaultDenied" - } - return azcli.Result{Stdout: `{"value":[` + - `{"name":"fa-synthetic-test/CAO_POSTGRES_URL","properties":{"reference":"@Microsoft.KeyVault(SecretUri=https://kv/secrets/pg)","status":"Resolved","details":"` + testDeployClientSecret + `"}},` + - `{"name":"fa-synthetic-test/CAO_SESSION_SECRET","properties":{"reference":"@Microsoft.KeyVault(SecretUri=https://kv/secrets/session)","status":"` + status + `"}}]}`}, nil - case "functionapp show": - return azcli.Result{Stdout: `{"publicNetworkAccess":"Disabled"}`}, nil - case "functionapp config show", "containerapp job show": - name := flag("--name") - repository := "cao-functions" - switch name { - case "fa-synthetic-test-ingest": - repository = "cao-ingest" - case "fa-synthetic-test-db-provision": - repository = "cao-db-provision" - } - reference := "acrsynthetictest.azurecr.io/" + repository + "@" + testAzureImageDigest(repository) - if override, ok := f.imageReadBack[name]; ok { - return azcli.Result{Stdout: override}, nil - } - if azcli.CommandName(args) == "functionapp config show" { - return azcli.Result{Stdout: fmt.Sprintf("%q", "DOCKER|"+reference)}, nil - } - return azcli.Result{Stdout: fmt.Sprintf(`{"containers":[{"image":%q}]}`, reference)}, nil - case "webapp config access-restriction show": - return azcli.Result{Stdout: `{"ipSecurityRestrictionsDefaultAction":"Deny","ipSecurityRestrictions":[]}`}, nil - default: - f.t.Fatalf("unexpected Azure command: %q", args) - return azcli.Result{}, nil - } -} - -func testAzureApplySetup(t *testing.T) (*fakeAzureApply, deployAzureDeps, string, string, deployAzureApplyOptions) { - t.Helper() - fake := &fakeAzureApply{t: t, plan: &fakeAz{t: t}} - deps := deployAzureDeps{ - az: fake, getenv: testDeployEnv, now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) }, - template: func(path string) deployAzureTemplateSource { return deployAzureFileTemplate{path: path} }, - source: func(string) deployAzureBuildSource { return testAzureBuildSource{} }, - pause: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, - http: &http.Client{Transport: testAzureTransport(func(request *http.Request) (*http.Response, error) { - fake.httpCalls++ - status := 200 - header := http.Header{} - switch request.URL.Path { - case "/api/v1/campaigns": - status = 401 - case "/": - status = 302 - header.Set("Location", "/auth/login") - if strings.Contains(request.URL.Host, ".scm.") { - status = 403 - } - case "/auth/login": - status = 302 - header.Set("Location", "https://github.com/login/oauth/authorize?client_id=Iv1.synthetic0001&redirect_uri="+url.QueryEscape("https://fa-synthetic-test.azurewebsites.net/auth/callback")) - } - return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil - })}, - } - configPath := writeDeployConfig(t, testDeployConfig()) - planPath := filepath.Join(t.TempDir(), "reviewed-plan.json") - if err := deployAzurePlanRun(context.Background(), io.Discard, deps, configPath, testDeployTemplatePath(t), planPath); err != nil { - t.Fatal(err) - } - fake.calls = nil - repo := t.TempDir() - writeTestFile(t, filepath.Join(repo, ".github", "workflows", "cao.json"), `{"version":1,"gh-aw-version":"v0.93.1","control-plane":{"campaigns":{}}}`) - writeTestFile(t, filepath.Join(repo, ".github", "workflows", "cao.azure.json"), `{"extends":"cao.json","control-plane":{"web":{"host":{"target":{"module":"azure-functions"},"operational-store":{"backend":"postgres"}}}}}`) - return fake, deps, configPath, planPath, deployAzureApplyOptions{ - SourceDir: "development-source", RepoDir: repo, EmptyEvidence: true, ReceiptPath: filepath.Join(t.TempDir(), "receipt.json"), - } -} - -func TestDeployAzureApplyHappyPathExactOrderingWithHeterogeneousOutputs(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - verifyTransport := deps.http.Transport - deps.http = &http.Client{Transport: testAzureTransport(func(request *http.Request) (*http.Response, error) { - if fake.referencePolls < 2 { - t.Fatal("verification started before Key Vault references resolved after restart") - } - return verifyTransport.RoundTrip(request) - })} - if err := deployAzureApply(context.Background(), deps, config, "", plan, options); err != nil { - t.Fatal(err) - } - var writes []string - for _, call := range fake.calls { - if !azcli.IsReadOnly(call) { - writes = append(writes, azcli.CommandName(call)) - } - } - want := []string{"group create", "deployment group create", - "acr build", "acr repository show", "acr repository update", - "acr build", "acr repository show", "acr repository update", - "acr build", "acr repository show", "acr repository update", - "deployment group create", "functionapp config show", "containerapp job show", "containerapp job show", - "containerapp job execution list", "containerapp job start", - "containerapp job execution show", "containerapp job execution show", - "containerapp job execution list", "containerapp job start", - "containerapp job execution show", "containerapp job execution show", - "functionapp restart", "rest", "rest"} - if !slices.Equal(writes, want) { - t.Fatalf("apply verb ordering = %q, want %q", writes, want) - } - if fake.httpCalls != 6 { - t.Fatalf("verification requests = %d, want 6", fake.httpCalls) - } - content, err := os.ReadFile(options.ReceiptPath) - if err != nil { - t.Fatal(err) - } - var receipt deployAzureReceipt - if err := json.Unmarshal(content, &receipt); err != nil { - t.Fatal(err) - } - if receipt.PlanSHA256 == "" || receipt.JobExecution != "fa-synthetic-test-ingest-exact" || receipt.ProvisionExecution != "fa-synthetic-test-db-provision-exact" || len(receipt.Images) != 3 || len(receipt.Verification) != 6 || receipt.DeploymentID == "" { - t.Fatalf("incomplete receipt: %+v", receipt) - } - for _, secret := range testDeploySecrets() { - if strings.Contains(string(content), secret) { - t.Fatal("receipt contains secret") - } - } - for _, image := range receipt.Images { - if image.Tag == "latest" || !strings.Contains(image.Reference, "@sha256:") || image.ConfiguredReference != image.Reference || - image.Digest != testAzureImageDigest(image.Repository) || image.Scan != "not-performed" || - image.SBOM != "not-generated" || image.Provenance != "local-context-not-attested" { - t.Fatalf("image not immutable: %+v", image) - } - } - if receipt.Environment != "development" || receipt.ImageAssurance != "not-evaluated-development-only" || receipt.SourceWorktree != "dirty" { - t.Fatalf("receipt must not imply production assurance: %+v", receipt) - } -} - -func TestDeployAzureApplyRejectsInvalidRequiredOutputs(t *testing.T) { - for _, tt := range []struct { - name, output, want string - value any - }{ - {"hostname-type", "functionHostName", "parse deployment result", false}, - {"ingestion-type", "ingestionJobName", "parse deployment result", 45}, - {"provision-type", "provisioningJobName", "parse deployment result", []string{"untrusted"}}, - {"hostname-null", "functionHostName", "unexpected Function App hostname", nil}, - {"ingestion-name", "ingestionJobName", "unexpected Function App hostname or ingestion job", "untrusted-ingest"}, - {"provision-name", "provisioningJobName", "unexpected database provision job", "untrusted-provision"}, - } { - t.Run(tt.name, func(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - fake.deploymentOutputs = map[string]any{tt.output: tt.value} - err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options) - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("want %q, got %v", tt.want, err) - } - for _, call := range fake.calls { - if azcli.CommandName(call) == "containerapp job start" { - t.Fatal("job executed after invalid deployment output") - } - } - if fake.httpCalls != 0 { - t.Fatal("verification proceeded after invalid deployment output") - } - if _, err := os.Stat(options.ReceiptPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("invalid output wrote a success receipt: %v", err) - } - }) - } -} - -func TestDeployAzureApplyStopsOnFailures(t *testing.T) { - for _, tt := range []struct { - name, want string - change func(*fakeAzureApply) - }{ - {"drift", "plan drift", func(fake *fakeAzureApply) { fake.plan.whatIf = `{"status":"Succeeded","changes":[]}` }}, - {"build", "ACR build target", func(fake *fakeAzureApply) { fake.failBuild = true }}, - {"bootstrap", "init/bootstrap container bootstrap-canonical", func(fake *fakeAzureApply) { fake.failJob = true }}, - {"bootstrap-log-hint", "--container bootstrap-canonical", func(fake *fakeAzureApply) { fake.failJob = true }}, - {"concurrent", "concurrent ingestion execution", func(fake *fakeAzureApply) { fake.concurrent = true }}, - } { - t.Run(tt.name, func(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - tt.change(fake) - err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options) - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("want %q, got %v", tt.want, err) - } - for _, secret := range testDeploySecrets() { - if strings.Contains(err.Error(), secret) { - t.Fatal("secret in error output") - } - } - if fake.httpCalls != 0 { - t.Fatal("verification proceeded after failed prerequisite") - } - for _, call := range fake.calls { - switch tt.name { - case "drift": - if !azcli.IsReadOnly(call) { - t.Fatalf("Azure write after drift: %q", call) - } - case "build": - if azcli.CommandName(call) == "deployment group create" && !strings.Contains(strings.Join(call, " "), "-registry") { - t.Fatal("full template deployment proceeded after build failure") - } - case "concurrent": - if azcli.CommandName(call) == "containerapp job start" { - t.Fatal("started job despite concurrent execution") - } - } - } - if _, err := os.Stat(options.ReceiptPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("failure wrote a success receipt: %v", err) - } - }) - } -} - -func TestDeployAzureApplyEvidenceIsExplicit(t *testing.T) { - for _, both := range []bool{false, true} { - fake, deps, config, plan, options := testAzureApplySetup(t) - options.EmptyEvidence = both - if both { - options.SnapshotDir = "snapshot" - } - err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options) - if err == nil || !strings.Contains(err.Error(), "exactly one") || len(fake.calls) != 0 { - t.Fatalf("ambiguous evidence must fail before Azure: %v", err) - } - } -} - -func TestDeployAzureVerifyIngressRemediator(t *testing.T) { - fake, deps, configPath, _, _ := testAzureApplySetup(t) - config, err := loadDeployAzureConfig(configPath) - if err != nil { - t.Fatal(err) - } - deps.http = &http.Client{Transport: testAzureTransport(func(request *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 403, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(""))}, nil - })} - _, err = deployAzureVerify(context.Background(), deps, fake, config.config, "fa-synthetic-test.azurewebsites.net") - if err == nil || !strings.Contains(err.Error(), "ingress was changed outside this deployment (possible tenant policy/remediator)") { - t.Fatalf("missing distinct ingress diagnostic: %v", err) - } -} - -func TestDeployAzureBuildAllowlist(t *testing.T) { - for _, name := range []string{".env", ".git/config", ".github/workflows/cao.json", ".github/workflows/cao.azure.json", "unrelated/secret.txt", "server/.env", "dashboard/site/node_modules/a.js", "../escape.go", "server/internal/testdata/fixture.json", "server/internal/server/.env", "dashboard/site/src/signing.pem", "dashboard/site/src/cao.json", "internal/dashboardquery/query_test.go", "activity/inventory.mjs"} { - if deployAzureBuildFileAllowed(name) { - t.Fatalf("unsafe build input allowed: %s", name) - } - } - for _, name := range []string{"server/Dockerfile", "server/go.mod", "server/internal/collectnative/gh-aw-v1.0.1-model-aliases.json", "server/azure/empty-data/gh-aw-logs-runs/empty.jsonl", "dashboard/site/package-lock.json", "dashboard/site/manifest.webmanifest", "internal/dashboardquery/query.go", ".github/workflows/shared/policy.mjs", ".github/workflows/shared/actions-log.mjs", "server/azure/provision.sh"} { - if !deployAzureBuildFileAllowed(name) { - t.Fatalf("required build input excluded: %s", name) - } - } -} - -func TestDeployAzureSnapshotHashAndAllowlist(t *testing.T) { - source := filepath.Join("..", "..", "server", "azure", "empty-data") - destination := filepath.Join(t.TempDir(), "snapshot") - if err := copyDeployAzureSnapshot(source, destination); err != nil { - t.Fatal(err) - } - writeTestFile(t, filepath.Join(destination, "gh-aw-logs-runs", "empty.jsonl"), "corrupt") - if err := copyDeployAzureSnapshot(destination, filepath.Join(t.TempDir(), "copy")); err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") { - t.Fatalf("corrupt snapshot admitted: %v", err) - } -} - -func TestDeployAzureVerifyRejectsWrongOAuthClientAndCallback(t *testing.T) { - for _, redirect := range []string{ - "https://github.com/login/oauth/authorize?client_id=wrong&redirect_uri=https%3A%2F%2Ffa-synthetic-test.azurewebsites.net%2Fauth%2Fcallback", - "https://github.com/login/oauth/authorize?client_id=Iv1.synthetic0001&redirect_uri=https%3A%2F%2Fother.example.test%2Fauth%2Fcallback", - "https://github.com.evil.example.test/login/oauth/authorize?client_id=Iv1.synthetic0001", - } { - fake, deps, configPath, _, _ := testAzureApplySetup(t) - config, err := loadDeployAzureConfig(configPath) - if err != nil { - t.Fatal(err) - } - original := deps.http.Transport - requests := 0 - deps.http.Transport = testAzureTransport(func(request *http.Request) (*http.Response, error) { - requests++ - response, err := original.RoundTrip(request) - if err == nil && request.URL.Path == "/auth/login" { - response.Header.Set("Location", redirect) - } - return response, err - }) - _, err = deployAzureVerify(context.Background(), deps, fake, config.config, config.config.Resources.FunctionApp+".azurewebsites.net") - if err == nil || !strings.Contains(err.Error(), "unexpected authentication redirect") || requests != 64 { - t.Fatalf("unsafe redirect was accepted or retries were not bounded: requests=%d err=%v", requests, err) - } - } -} - -func TestDeployAzureJobWaitCancellation(t *testing.T) { - fake, deps, configPath, _, _ := testAzureApplySetup(t) - config, err := loadDeployAzureConfig(configPath) - if err != nil { - t.Fatal(err) - } - deps.pause = func(context.Context, time.Duration) error { return context.DeadlineExceeded } - execution, err := deployAzureRunJob(context.Background(), deps, fake, config.config, "fa-synthetic-test-ingest") - if execution != "fa-synthetic-test-ingest-exact" || !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("job wait did not preserve execution or deadline: %s %v", execution, err) - } -} - -func TestDeployAzureConfigPurgeProtectionAndTag(t *testing.T) { - content, err := json.Marshal(testDeployConfig()) - if err != nil { - t.Fatal(err) - } - - config, err := parseDeployAzureConfig(content) - if err != nil { - t.Fatal(err) - } - if _, supplied := config.parameters()["keyVaultPurgeProtection"]; supplied { - t.Fatal("omitted purge protection must preserve the template default") - } - protect := false - config.KeyVault.PurgeProtection = &protect - if config.parameters()["keyVaultPurgeProtection"] != false { - t.Fatal("explicit disposable opt-out was not bound") - } - tag := deployAzureImageTag(config) - config.RetentionDays++ - if tag == deployAzureImageTag(config) || !strings.HasPrefix(tag, config.Image.Tag+"-") { - t.Fatal("immutable tag does not include reviewed version and configuration identity") - } -} - -func TestDeployAzurePostgresNameCheckUsesSupportedAPIVersion(t *testing.T) { - content, err := os.ReadFile(testDeployTemplatePath(t)) - if err != nil { - t.Fatal(err) - } - - preflight := deployAzurePreflight{ - config: deployAzureConfig{Subscription: testDeploySubscription, Location: "westus3"}, - template: deployAzureTemplateFactsFrom(content, nil), - } - args := preflight.restNameArgs("Microsoft.DBforPostgreSQL", "Microsoft.DBforPostgreSQL/flexibleServers", "synthetic-test-pg") - if !strings.Contains(strings.Join(args, " "), "/locations/westus3/checkNameAvailability?api-version=2024-08-01") { - t.Fatalf("name-check must use the actual PostgreSQL resource API version, got %q", args) - } -} - -func TestDeployAzureBuildFailureRetainsRedactedDiagnostics(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - fake.failBuild, fake.buildRunID = true, true - err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options) - if err == nil || !strings.Contains(err.Error(), "required COPY input missing") || !strings.Contains(err.Error(), "ACR build ds1 log tail") { - t.Fatalf("failed build did not retain diagnostics: %v", err) - } - - for _, secret := range testDeploySecrets() { - if strings.Contains(err.Error(), secret) { - t.Fatal("build diagnostics contain a secret") - } - } - if fake.httpCalls != 0 { - t.Fatal("verification proceeded after build failure") - } -} - -func TestDeployAzureInvalidAdopterPolicyAbortsBeforeWrites(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - writeTestFile(t, filepath.Join(options.RepoDir, ".github", "workflows", "cao.json"), `{"version":1,"control-plane":{"campaigns":{}}}`) - err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options) - if err == nil || !strings.Contains(err.Error(), "gh-aw-version is required") { - t.Fatalf("invalid operator policy was not rejected: %v", err) - } - for _, args := range fake.calls { - if !azcli.IsReadOnly(args) { - t.Fatalf("invalid operator policy caused Azure write: %q", args) - } - } -} - -func TestDeployAzureBuildMetadataSeparatesTagsFromSemanticVersions(t *testing.T) { - revision := strings.Repeat("a", 40) - for _, tag := range []string{"v1.2.3", "1.2.3-preview.1", "u7-dev", revision} { - version := deployAzureBuildVersion(tag, revision) - if !nativeExactVersion.MatchString("v" + strings.TrimPrefix(version, "v")) { - t.Fatalf("image tag %s produced invalid dashboard version %s", tag, version) - } - if tag == "u7-dev" || tag == revision { - if version != "0.0.0-main."+revision { - t.Fatalf("non-semver tag lost its explicit source revision: %s", version) - } - } else if version != tag { - t.Fatalf("reviewed release version changed: %s", version) - } - } -} - -type testRestrictiveAzureBuildSource struct{} - -func (testRestrictiveAzureBuildSource) Materialize(_ context.Context, directory string) (deployAzureSourceIdentity, error) { - nested := filepath.Join(directory, "server", "azure") - if err := os.MkdirAll(nested, 0o700); err != nil { - return deployAzureSourceIdentity{}, err - } - if err := os.WriteFile(filepath.Join(nested, "provision.sql"), []byte("select 1;"), 0o600); err != nil { - return deployAzureSourceIdentity{}, err - } - if err := os.WriteFile(filepath.Join(nested, "provision.sh"), []byte("#!/bin/sh\n"), 0o700); err != nil { - return deployAzureSourceIdentity{}, err - } - return deployAzureSourceIdentity{Revision: strings.Repeat("b", 40), Worktree: "clean"}, nil -} - -// Remote ACR builds preserve context modes and CAO images run as a non-root -// user, so owner-only context files become unreadable inside the image. -func TestDeployAzureBuildContextIsReadableByNonRootImageUser(t *testing.T) { - _, _, _, _, options := testAzureApplySetup(t) - workspace, err := newDeployAzureWorkspace() - if err != nil { - t.Fatal(err) - } - defer func() { _ = os.RemoveAll(workspace.directory) }() - build, err := prepareDeployAzureBuild(context.Background(), testRestrictiveAzureBuildSource{}, workspace, options) - if err != nil { - t.Fatal(err) - } - if info, err := os.Stat(workspace.directory); err != nil || info.Mode().Perm() != 0o700 { - t.Fatalf("private workspace must stay owner-only: %v %v", info.Mode(), err) - } - for name, want := range map[string]os.FileMode{ - ".": 0o755, - "server": 0o755, - "server/azure": 0o755, - "server/azure/provision.sql": 0o644, - "server/azure/provision.sh": 0o755, - ".github/workflows": 0o755, - ".github/workflows/cao.json": 0o644, - ".github/workflows/cao.azure.json": 0o644, - } { - info, err := os.Stat(filepath.Join(build.Directory, filepath.FromSlash(name))) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != want { - t.Fatalf("%s mode %v, want %v", name, info.Mode().Perm(), want) - } - } -} - -func TestDeployAzureVerifyReportsHealthSignals(t *testing.T) { - for body, want := range map[string]string{ - `Application Error`: "may not have started", - `{"status":"unhealthy","redis":{"connected":false},"data":{"available":false}}`: "operational cache is disconnected", - `{"status":"unhealthy","redis":{"connected":true},"data":{"available":false}}`: "dashboard data unavailable", - } { - fake, deps, configPath, _, _ := testAzureApplySetup(t) - config, err := loadDeployAzureConfig(configPath) - if err != nil { - t.Fatal(err) - } - deps.http = &http.Client{Transport: testAzureTransport(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 503, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}, nil - })} - _, err = deployAzureVerify(context.Background(), deps, fake, config.config, "fa-synthetic-test.azurewebsites.net") - if err == nil || !strings.Contains(err.Error(), "health returned HTTP 503") || !strings.Contains(err.Error(), want) { - t.Fatalf("body %q: missing health signal %q: %v", body, want, err) - } - } -} - -func TestDeployAzureApplyFailsClosedWhenKeyVaultReferencesStayUnresolved(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - fake.referencesStuck = true - pauses := 0 - deps.pause = func(ctx context.Context, _ time.Duration) error { - if pauses++; pauses > 4 { - return context.DeadlineExceeded - } - return ctx.Err() - } - err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options) - if err == nil || !strings.Contains(err.Error(), "CAO_SESSION_SECRET=AccessToKeyVaultDenied") || strings.Contains(err.Error(), "CAO_POSTGRES_URL") { - t.Fatalf("unresolved reference error = %v", err) - } - for _, leak := range append(testDeploySecrets(), "SecretUri", "@Microsoft.KeyVault") { - if strings.Contains(err.Error(), leak) { - t.Fatalf("reference failure leaked %q: %v", leak, err) - } - } - if !fake.restarted || fake.httpCalls != 0 { - t.Fatalf("restart=%v verification requests=%d; want restart and no verification", fake.restarted, fake.httpCalls) - } - if _, statErr := os.Stat(options.ReceiptPath); !os.IsNotExist(statErr) { - t.Fatal("receipt written after unresolved Key Vault references") - } -} - -func TestDeployAzureUnresolvedReferencesReportsNamesAndStatusesOnly(t *testing.T) { - pending, err := deployAzureUnresolvedReferences(`{"value":[{"name":"app/B","properties":{"reference":"@Microsoft.KeyVault(SecretUri=https://kv/secrets/b)","status":"Resolved"}},{"name":"app/A","properties":{"status":""}}]}`) - if err != nil || !slices.Equal(pending, []string{"A=Unknown"}) { - t.Fatalf("pending = %q, %v", pending, err) - } - if _, err := deployAzureUnresolvedReferences(`{"value":[]}`); err == nil { - t.Fatal("empty reference list must not count as resolved") - } -} diff --git a/internal/cli/deploy_azure_assurance_test.go b/internal/cli/deploy_azure_assurance_test.go deleted file mode 100644 index 6440bd8857..0000000000 --- a/internal/cli/deploy_azure_assurance_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "io" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -func TestDeployAzureSourceBuildRequiresReviewedDevelopmentEnvironment(t *testing.T) { - for _, environment := range []string{"", "production", "Development", "staging"} { - t.Run("environment="+environment, func(t *testing.T) { - fake, deps, _, _, options := testAzureApplySetup(t) - config := testDeployConfig() - config["environment"] = environment - configPath := writeDeployConfig(t, config) - planPath := filepath.Join(t.TempDir(), "plan.json") - planErr := deployAzurePlanRun(context.Background(), io.Discard, deps, configPath, testDeployTemplatePath(t), planPath) - fake.calls = nil - if environment == "" || environment == "production" { - if planErr != nil { - t.Fatal(planErr) - } - err := deployAzureApply(context.Background(), deps, configPath, testDeployTemplatePath(t), planPath, options) - if err == nil || !strings.Contains(err.Error(), "production promotion is unsupported") { - t.Fatalf("missing production restriction: %v", err) - } - } else if planErr == nil || !strings.Contains(planErr.Error(), "environment must be") { - t.Fatalf("invalid environment admitted: %v", planErr) - } - if len(fake.calls) != 0 { - t.Fatalf("environment restriction must fail before any Azure call: %q", fake.calls) - } - if _, err := os.Stat(options.ReceiptPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("blocked environment wrote receipt: %v", err) - } - }) - } -} - -func TestDeployAzureSourceBuildRejectsOldPlanAndTemplateBeforeAzure(t *testing.T) { - for _, missing := range []string{"environment", "functionImageDigest", "ingestionImageDigest", "provisioningImageDigest"} { - t.Run(missing, func(t *testing.T) { - fake, deps, config, planPath, options := testAzureApplySetup(t) - plan, err := readDeployAzurePlan(planPath) - if err != nil { - t.Fatal(err) - } - template, err := (deployAzureFileTemplate{path: testDeployTemplatePath(t)}).Load(context.Background()) - if err != nil { - t.Fatal(err) - } - if missing == "environment" { - plan.Environment = "" - } else { - var document map[string]any - if err := json.Unmarshal(template.Content, &document); err != nil { - t.Fatal(err) - } - delete(document["parameters"].(map[string]any), missing) - template.Content, err = json.Marshal(document) - if err != nil { - t.Fatal(err) - } - } - loaded, err := loadDeployAzureConfig(config) - if err != nil { - t.Fatal(err) - } - if err := deployAzureExecute(context.Background(), deps, loaded, template, plan, planPath, options); err == nil { - t.Fatal("legacy plan/template admitted") - } - if len(fake.calls) != 0 { - t.Fatalf("legacy plan/template caused Azure calls: %q", fake.calls) - } - }) - } -} - -func TestDeployAzureImageBindingsFailClosed(t *testing.T) { - fake, deps, configPath, plan, options := testAzureApplySetup(t) - if err := deployAzureApply(context.Background(), deps, configPath, testDeployTemplatePath(t), plan, options); err != nil { - t.Fatal(err) - } - content, err := os.ReadFile(options.ReceiptPath) - if err != nil { - t.Fatal(err) - } - var receipt deployAzureReceipt - if err := json.Unmarshal(content, &receipt); err != nil { - t.Fatal(err) - } - loaded, err := loadDeployAzureConfig(configPath) - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"missing", "duplicate", "digest", "reference", "target", "repository", "tag", "scan", "sbom", "provenance"} { - t.Run(name, func(t *testing.T) { - images := slices.Clone(receipt.Images) - switch name { - case "missing": - images = images[:2] - case "duplicate": - images[1] = images[0] - case "digest": - images[0].Digest = "invalid" - case "reference": - images[0].Reference = images[1].Reference - case "target": - images[0].Target = "unreviewed-target" - case "repository": - images[0].Repository = "other" - case "tag": - images[0].Tag = "latest" - case "scan": - images[0].Scan = "passed" - case "sbom": - images[0].SBOM = "" - case "provenance": - images[0].Provenance = "attested" - } - if _, err := deployAzureImageParameters(loaded.config, images, true, true); err == nil { - t.Fatal("invalid image/assurance binding admitted") - } - }) - } - fake.invalidDigest = true - options.ReceiptPath = filepath.Join(t.TempDir(), "failed-receipt.json") - fake.calls = nil - if err := deployAzureApply(context.Background(), deps, configPath, testDeployTemplatePath(t), plan, options); err == nil { - t.Fatal("invalid ACR digest admitted") - } - for _, call := range fake.calls { - if azcli.CommandName(call) == "deployment group create" && !strings.HasSuffix(call[slices.Index(call, "--name")+1], "-registry") { - t.Fatal("invalid digest reached full template deployment") - } - } -} - -func TestDeployAzureImageReadBackBlocksJobsOnMismatch(t *testing.T) { - for _, name := range []string{"fa-synthetic-test", "fa-synthetic-test-ingest", "fa-synthetic-test-db-provision"} { - for _, output := range []string{`null`, `{}`, `{"containers":[{"image":"other@sha256:wrong"}]}`} { - t.Run(name+"/"+output, func(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - fake.imageReadBack = map[string]string{name: output} - if err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options); err == nil { - t.Fatal("invalid/missing configured digest admitted") - } - for _, call := range fake.calls { - if azcli.CommandName(call) == "containerapp job start" { - t.Fatal("started job after image mismatch") - } - } - if _, err := os.Stat(options.ReceiptPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("image mismatch wrote receipt: %v", err) - } - }) - } - } -} - -func TestDeployAzureImageBindingsCoverSnapshotAndOptionalProvisioning(t *testing.T) { - fake, _, configPath, _, options := testAzureApplySetup(t) - loaded, err := loadDeployAzureConfig(configPath) - if err != nil { - t.Fatal(err) - } - build := deployAzureBuildContext{Directory: options.RepoDir, Revision: strings.Repeat("a", 40)} - for _, provision := range []bool{false, true} { - for _, empty := range []bool{false, true} { - options.EmptyEvidence = empty - images, err := deployAzureBuildImages(context.Background(), fake, loaded.config, build, options, provision) - if err != nil { - t.Fatal(err) - } - parameters, err := deployAzureImageParameters(loaded.config, images, empty, provision) - if err != nil { - t.Fatal(err) - } - if (parameters["provisioningImageDigest"] != nil) != provision || - parameters["functionImageDigest"] != testAzureImageDigest("cao-functions") || - parameters["ingestionImageDigest"] != testAzureImageDigest("cao-ingest") { - t.Fatalf("incorrect per-target bindings: %+v", parameters) - } - if _, err := deployAzureImageParameters(loaded.config, images, !empty, provision); err == nil { - t.Fatal("snapshot/empty target substitution was admitted") - } - } - } -} - -func TestDeployAzureDevelopmentContextProvenanceIncludesDirtyBytesAndModes(t *testing.T) { - root := t.TempDir() - for _, name := range []string{"server/Dockerfile", "server/go.mod", "dashboard/site/package.json", "dashboard/site/manifest.webmanifest", "server/azure/provision.sh"} { - writeTestFile(t, filepath.Join(root, name), "synthetic build input\n") - } - git := func(args ...string) { - t.Helper() - command := exec.Command("git", append([]string{"-C", root}, args...)...) - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("git %q: %v: %s", args, err, output) - } - } - git("init", "--quiet") - git("add", ".") - git("-c", "user.name=Synthetic Test", "-c", "user.email=test@example.test", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "synthetic source") - _, _, _, _, options := testAzureApplySetup(t) - build := func() deployAzureBuildContext { - t.Helper() - workspace, err := newDeployAzureWorkspace() - if err != nil { - t.Fatal(err) - } - defer func() { _ = workspace.Close() }() - result, err := prepareDeployAzureBuild(context.Background(), deployAzureDevSource{directory: root}, workspace, options) - if err != nil { - t.Fatal(err) - } - return result - } - clean := build() - writeTestFile(t, filepath.Join(root, "server", "untracked.go"), "untracked input is not uploaded\n") - untracked := build() - if untracked.Worktree != "dirty" || untracked.Digest != clean.Digest { - t.Fatal("untracked source must affect observed status but not enter the build context") - } - writeTestFile(t, filepath.Join(root, "server", "Dockerfile"), "different dirty input\n") - dirty := build() - if clean.Worktree != "clean" || dirty.Worktree != "dirty" || dirty.Revision != clean.Revision || dirty.Digest == clean.Digest { - t.Fatalf("worktree bytes falsely identified as the base commit: clean=%+v dirty=%+v", clean, dirty) - } - if err := os.Chmod(filepath.Join(root, "server", "azure", "provision.sh"), 0o755); err != nil { - t.Fatal(err) - } - executable := build() - if executable.Digest == dirty.Digest { - t.Fatal("context digest must bind executable mode as well as file contents") - } -} diff --git a/internal/cli/deploy_azure_config.go b/internal/cli/deploy_azure_config.go deleted file mode 100644 index 80a7b6b424..0000000000 --- a/internal/cli/deploy_azure_config.go +++ /dev/null @@ -1,416 +0,0 @@ -package cli - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/url" - "os" - "reflect" - "regexp" - "strings" -) - -const defaultDeployAzureConfigPath = ".github/cao/deploy-azure.json" - -// deployAzureConfig is the reviewed, secret-free adopter configuration for -// `gh cao deploy azure`. Field names map to server/azure/main.bicep parameters. -type deployAzureConfig struct { - SchemaVersion int `json:"schemaVersion"` - Environment string `json:"environment,omitempty"` - Subscription string `json:"subscription"` - ResourceGroup string `json:"resourceGroup"` - Location string `json:"location"` - Resources deployAzureResources `json:"resources"` - Image deployAzureImage `json:"image"` - AllowedHosts []string `json:"allowedHosts"` - Access deployAzureAccess `json:"access"` - RetentionDays int `json:"retentionDays"` - OAuth deployAzureOAuth `json:"oauth"` - Secrets deployAzureSecretSources `json:"secrets"` - KeyVault deployAzureKeyVault `json:"keyVault,omitempty"` - Telemetry deployAzureTelemetry `json:"telemetry,omitempty"` -} - -type deployAzureTelemetry struct { - OTLPEndpoint string `json:"otlpEndpoint,omitempty"` - LogsEnabled bool `json:"logsEnabled,omitempty"` -} - -type deployAzureKeyVault struct { - PurgeProtection *bool `json:"purgeProtection,omitempty"` -} - -type deployAzureResources struct { - FunctionApp string `json:"functionApp"` - StorageAccount string `json:"storageAccount"` - ContainerRegistry string `json:"containerRegistry"` - KeyVault string `json:"keyVault"` - PostgresServer string `json:"postgresServer,omitempty"` - PostgresDatabase string `json:"postgresDatabase,omitempty"` - HostingPlan string `json:"hostingPlan,omitempty"` - ApplicationInsights string `json:"applicationInsights,omitempty"` - LogAnalyticsWorkspace string `json:"logAnalyticsWorkspace,omitempty"` -} - -type deployAzureImage struct { - Repository string `json:"repository,omitempty"` - Tag string `json:"tag"` -} - -type deployAzureAccess struct { - Organizations []string `json:"organizations"` - Teams []string `json:"teams"` - Users []string `json:"users"` -} - -type deployAzureOAuth struct { - ClientID string `json:"clientId"` -} - -type deployAzureSecretSources struct { - PostgresAdministratorPassword *deployAzureSecretRef `json:"postgresAdministratorPassword"` - PostgresOwnerPassword *deployAzureSecretRef `json:"postgresOwnerPassword,omitempty"` - PostgresRuntimePassword *deployAzureSecretRef `json:"postgresRuntimePassword,omitempty"` - GitHubClientSecret *deployAzureSecretRef `json:"githubClientSecret"` - SessionSecret *deployAzureSecretRef `json:"sessionSecret"` - OTLPHeaders *deployAzureSecretRef `json:"otlpHeaders,omitempty"` -} - -// deployAzureSecretRef names where a secure value lives; it never holds the value. -type deployAzureSecretRef struct { - Env string `json:"env,omitempty"` - KeyVault *deployAzureKeyVaultRef `json:"keyVault,omitempty"` -} - -type deployAzureKeyVaultRef struct { - ID string `json:"id"` - SecretName string `json:"secretName"` -} - -func (r *deployAzureSecretRef) UnmarshalJSON(data []byte) error { - trimmed := bytes.TrimSpace(data) - if len(trimmed) > 0 && trimmed[0] != '{' { - return errors.New("secret values must not be embedded; reference an env var or Key Vault secret") - } - type plain deployAzureSecretRef - decoder := json.NewDecoder(bytes.NewReader(trimmed)) - decoder.DisallowUnknownFields() - var value plain - if err := decoder.Decode(&value); err != nil { - return err - } - *r = deployAzureSecretRef(value) - return nil -} - -// describe returns a secret-free, reviewable identifier for the reference. -func (r deployAzureSecretRef) describe() string { - if r.KeyVault != nil { - return "keyVault:" + r.KeyVault.ID + "/secrets/" + r.KeyVault.SecretName - } - return "env:" + r.Env -} - -type deployAzureSecureParameter struct { - name string - field string - minLength int - ref *deployAzureSecretRef -} - -func (c *deployAzureConfig) secureParameters() []deployAzureSecureParameter { - parameters := []deployAzureSecureParameter{ - {"postgresAdministratorPassword", "secrets.postgresAdministratorPassword", 16, c.Secrets.PostgresAdministratorPassword}, - {"githubClientSecret", "secrets.githubClientSecret", 1, c.Secrets.GitHubClientSecret}, - {"sessionSecret", "secrets.sessionSecret", 32, c.Secrets.SessionSecret}, - } - if c.Secrets.PostgresOwnerPassword != nil { - parameters = append(parameters, deployAzureSecureParameter{"postgresOwnerPassword", "secrets.postgresOwnerPassword", 32, c.Secrets.PostgresOwnerPassword}) - } - if c.Secrets.PostgresRuntimePassword != nil { - parameters = append(parameters, deployAzureSecureParameter{"postgresRuntimePassword", "secrets.postgresRuntimePassword", 32, c.Secrets.PostgresRuntimePassword}) - } - if c.Secrets.OTLPHeaders != nil { - parameters = append(parameters, deployAzureSecureParameter{"otlpHeaders", "secrets.otlpHeaders", 1, c.Secrets.OTLPHeaders}) - } - return parameters -} - -// parameters returns non-secure main.bicep parameters derived from the config. -func (c *deployAzureConfig) parameters() map[string]any { - parameters := map[string]any{ - "location": c.Location, - "functionAppName": c.Resources.FunctionApp, - "storageAccountName": c.Resources.StorageAccount, - "containerRegistryName": c.Resources.ContainerRegistry, - "keyVaultName": c.Resources.KeyVault, - "functionImageTag": deployAzureImageTag(*c), - "allowedHosts": nonNil(c.AllowedHosts), - "githubAllowedOrganizations": nonNil(c.Access.Organizations), - "githubAllowedTeams": nonNil(c.Access.Teams), - "githubAllowedUsers": nonNil(c.Access.Users), - "githubClientId": c.OAuth.ClientID, - "postgresRunRetentionDays": c.RetentionDays, - } - optional := map[string]string{ - "postgresServerName": c.Resources.PostgresServer, - "postgresDatabaseName": c.Resources.PostgresDatabase, - "hostingPlanName": c.Resources.HostingPlan, - "applicationInsightsName": c.Resources.ApplicationInsights, - "logAnalyticsWorkspaceName": c.Resources.LogAnalyticsWorkspace, - "functionImageRepository": c.Image.Repository, - } - for name, value := range optional { - if value != "" { - parameters[name] = value - } - } - if c.KeyVault.PurgeProtection != nil { - parameters["keyVaultPurgeProtection"] = *c.KeyVault.PurgeProtection - } - if c.Telemetry.OTLPEndpoint != "" { - parameters["otlpEndpoint"] = c.Telemetry.OTLPEndpoint - parameters["otlpLogsEnabled"] = c.Telemetry.LogsEnabled - } - return parameters -} - -// postgresServerName mirrors the template default for postgresServerName. -func (c *deployAzureConfig) postgresServerName() string { - if c.Resources.PostgresServer != "" { - return c.Resources.PostgresServer - } - return c.Resources.FunctionApp + "-pg" -} - -// globalResourceNames lists configured names that must not already exist. -func (c *deployAzureConfig) globalResourceNames() []string { - return []string{c.Resources.FunctionApp, c.Resources.StorageAccount, c.Resources.ContainerRegistry, c.Resources.KeyVault, c.postgresServerName()} -} - -func nonNil(values []string) []string { - if values == nil { - return []string{} - } - return values -} - -type deployAzureLoadedConfig struct { - config deployAzureConfig - path string - digest string -} - -func loadDeployAzureConfig(path string) (deployAzureLoadedConfig, error) { - content, err := os.ReadFile(path) - if err != nil { - return deployAzureLoadedConfig{}, fmt.Errorf("read deploy config %s: %w", path, err) - } - config, err := parseDeployAzureConfig(content) - if err != nil { - return deployAzureLoadedConfig{}, fmt.Errorf("invalid deploy config %s: %w", path, err) - } - sum := sha256.Sum256(content) - return deployAzureLoadedConfig{config: config, path: path, digest: hex.EncodeToString(sum[:])}, nil -} - -func parseDeployAzureConfig(content []byte) (deployAzureConfig, error) { - var config deployAzureConfig - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&config); err != nil { - return deployAzureConfig{}, err - } - if _, err := decoder.Token(); !errors.Is(err, io.EOF) { - return deployAzureConfig{}, errors.New("unexpected content after the configuration object") - } - if err := scanDeployAzureStrings(reflect.ValueOf(config), ""); err != nil { - return deployAzureConfig{}, err - } - if err := config.validate(); err != nil { - return deployAzureConfig{}, err - } - return config, nil -} - -var ( - deployPlaceholderPatterns = []*regexp.Regexp{ - regexp.MustCompile(`<[^>]*>`), - regexp.MustCompile(`\$\{[^}]*\}`), - regexp.MustCompile(`\{\{[^}]*\}\}`), - regexp.MustCompile(`(?i)\b(TODO|TBD|FIXME|CHANGEME|CHANGE_ME|REPLACE_ME|REPLACEME|PLACEHOLDER)\b`), - regexp.MustCompile(`(?i)^x{3,}$`), - } - deployEmbeddedSecretPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}`), - regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{20,}`), - regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----`), - regexp.MustCompile(`(?i)(AccountKey|SharedAccessKey|password|pwd|secret)\s*=`), - regexp.MustCompile(`(?i)postgres(ql)?://[^/\s:@]+:[^@\s]+@`), - } -) - -func scanDeployAzureStrings(value reflect.Value, path string) error { - switch value.Kind() { - case reflect.Pointer: - if value.IsNil() { - return nil - } - return scanDeployAzureStrings(value.Elem(), path) - case reflect.Struct: - for index := 0; index < value.NumField(); index++ { - name := strings.Split(value.Type().Field(index).Tag.Get("json"), ",")[0] - child := name - if path != "" { - child = path + "." + name - } - if err := scanDeployAzureStrings(value.Field(index), child); err != nil { - return err - } - } - case reflect.Slice: - for index := 0; index < value.Len(); index++ { - if err := scanDeployAzureStrings(value.Index(index), fmt.Sprintf("%s[%d]", path, index)); err != nil { - return err - } - } - case reflect.String: - text := value.String() - for _, pattern := range deployEmbeddedSecretPatterns { - if pattern.MatchString(text) { - return fmt.Errorf("%s appears to embed a secret value; reference secrets by env var or Key Vault name only", path) - } - } - for _, pattern := range deployPlaceholderPatterns { - if pattern.MatchString(text) { - return fmt.Errorf("%s contains an unresolved placeholder", path) - } - } - } - return nil -} - -var ( - deploySubscriptionPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) - deployResourceGroupPattern = regexp.MustCompile(`^[-\w.()]{0,89}[-\w()]$`) - deployLocationPattern = regexp.MustCompile(`^[a-z0-9]{2,40}$`) - deployFunctionAppPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,17}[a-z0-9]$`) - deployStoragePattern = regexp.MustCompile(`^[a-z0-9]{3,24}$`) - deployRegistryPattern = regexp.MustCompile(`^[a-zA-Z0-9]{5,50}$`) - deployKeyVaultPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$`) - deployGenericNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`) - deployImagePattern = regexp.MustCompile(`^[a-z0-9]+(?:[._/-][a-z0-9]+)*$`) - deployTagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`) - deployHostPattern = regexp.MustCompile(`^(?i)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`) - deployOwnerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$`) - deployTeamPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[a-z0-9][a-z0-9_-]{0,99}$`) - deployClientIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`) - deployEnvNamePattern = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,127}$`) - deployVaultIDPattern = regexp.MustCompile(`^/subscriptions/[0-9a-fA-F-]{36}/resourceGroups/[-\w.()]{1,90}/providers/Microsoft\.KeyVault/vaults/[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$`) - deploySecretNamePattern = regexp.MustCompile(`^[0-9a-zA-Z-]{1,127}$`) -) - -func (c deployAzureConfig) validate() error { - var problems []string - add := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } - if c.Environment != "" && c.Environment != "development" && c.Environment != "production" { - add("environment must be development or production") - } - require := func(field, value string, pattern *regexp.Regexp, rule string) { - switch { - case value == "": - add("%s is required", field) - case !pattern.MatchString(value): - add("%s must be %s", field, rule) - } - } - optional := func(field, value string, pattern *regexp.Regexp, rule string) { - if value != "" && !pattern.MatchString(value) { - add("%s must be %s", field, rule) - } - } - if c.SchemaVersion != 1 { - add("schemaVersion must be 1") - } - require("subscription", c.Subscription, deploySubscriptionPattern, "a subscription GUID") - require("resourceGroup", c.ResourceGroup, deployResourceGroupPattern, "a valid resource group name") - require("location", c.Location, deployLocationPattern, "a lowercase Azure region name such as westus3") - require("resources.functionApp", c.Resources.FunctionApp, deployFunctionAppPattern, "2-19 lowercase letters, digits, or hyphens, starting with a letter (derived Container Apps job names must fit 32 characters)") - if strings.Contains(c.Resources.FunctionApp, "--") { - add("resources.functionApp must not contain consecutive hyphens; it is also the Container Apps job prefix") - } - require("resources.storageAccount", c.Resources.StorageAccount, deployStoragePattern, "3-24 lowercase letters or digits") - require("resources.containerRegistry", c.Resources.ContainerRegistry, deployRegistryPattern, "5-50 letters or digits") - require("resources.keyVault", c.Resources.KeyVault, deployKeyVaultPattern, "3-24 letters, digits, or hyphens starting with a letter") - optional("resources.postgresServer", c.Resources.PostgresServer, deployGenericNamePattern, "3-63 letters, digits, or hyphens") - optional("resources.postgresDatabase", c.Resources.PostgresDatabase, deployGenericNamePattern, "1-63 letters, digits, or hyphens") - optional("resources.hostingPlan", c.Resources.HostingPlan, deployGenericNamePattern, "letters, digits, or hyphens") - optional("resources.applicationInsights", c.Resources.ApplicationInsights, deployGenericNamePattern, "letters, digits, or hyphens") - optional("resources.logAnalyticsWorkspace", c.Resources.LogAnalyticsWorkspace, deployGenericNamePattern, "letters, digits, or hyphens") - optional("image.repository", c.Image.Repository, deployImagePattern, "a lowercase container repository path") - require("image.tag", c.Image.Tag, deployTagPattern, "a valid container image tag") - if c.Image.Tag == "latest" { - add("image.tag must be immutable or reviewed, not latest") - } - if len(c.AllowedHosts) == 0 { - add("allowedHosts requires at least one public host name") - } - for index, host := range c.AllowedHosts { - optional(fmt.Sprintf("allowedHosts[%d]", index), host, deployHostPattern, "a DNS host name") - } - if len(c.Access.Organizations)+len(c.Access.Teams)+len(c.Access.Users) == 0 { - add("access requires at least one organization, team, or user") - } - for index, value := range c.Access.Organizations { - require(fmt.Sprintf("access.organizations[%d]", index), value, deployOwnerPattern, "a GitHub organization login") - } - for index, value := range c.Access.Teams { - require(fmt.Sprintf("access.teams[%d]", index), value, deployTeamPattern, "ORG/team-slug") - } - for index, value := range c.Access.Users { - require(fmt.Sprintf("access.users[%d]", index), value, deployOwnerPattern, "a GitHub user login") - } - if c.RetentionDays == 0 { - add("retentionDays is required and must be explicit") - } else if c.RetentionDays < 7 || c.RetentionDays > 3650 { - add("retentionDays must be between 7 and 3650") - } - require("oauth.clientId", c.OAuth.ClientID, deployClientIDPattern, "a GitHub OAuth App client ID") - if c.Telemetry.OTLPEndpoint == "" { - if c.Telemetry.LogsEnabled || c.Secrets.OTLPHeaders != nil { - add("telemetry.otlpEndpoint is required when logs or OTLP authentication are configured") - } - } else { - endpoint, err := url.Parse(c.Telemetry.OTLPEndpoint) - if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() == "" || - endpoint.User != nil || endpoint.RawQuery != "" || endpoint.ForceQuery || - endpoint.Fragment != "" || strings.Contains(c.Telemetry.OTLPEndpoint, "#") { - add("telemetry.otlpEndpoint must be an HTTPS collector base URL without credentials, query or fragment") - } - } - for _, secure := range c.secureParameters() { - if secure.ref == nil { - add("%s is required", secure.field) - continue - } - hasEnv, hasVault := secure.ref.Env != "", secure.ref.KeyVault != nil - switch { - case hasEnv == hasVault: - add("%s must set exactly one of env or keyVault", secure.field) - case hasEnv: - require(secure.field+".env", secure.ref.Env, deployEnvNamePattern, "an uppercase environment variable name") - default: - require(secure.field+".keyVault.id", secure.ref.KeyVault.ID, deployVaultIDPattern, "a Key Vault resource ID") - require(secure.field+".keyVault.secretName", secure.ref.KeyVault.SecretName, deploySecretNamePattern, "a Key Vault secret name") - } - } - if len(problems) > 0 { - return errors.New(strings.Join(problems, "; ")) - } - return nil -} diff --git a/internal/cli/deploy_azure_images.go b/internal/cli/deploy_azure_images.go deleted file mode 100644 index 01212e29e6..0000000000 --- a/internal/cli/deploy_azure_images.go +++ /dev/null @@ -1,452 +0,0 @@ -package cli - -import ( - "context" - "crypto/sha256" - _ "embed" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -// A release bundle source can implement this without changing apply ordering. -// Sources supply product files only; operator policy and evidence are separate. -type deployAzureBuildSource interface { - Materialize(context.Context, string) (deployAzureSourceIdentity, error) -} - -type deployAzureDevSource struct{ directory string } - -type deployAzureSourceIdentity struct { - Revision, Worktree string -} - -//go:embed deploy_azure_assets.json -var deployAzureAssetAllowlistJSON []byte - -var deployAzureAssetAllowlist = func() []string { - var entries []string - if err := json.Unmarshal(deployAzureAssetAllowlistJSON, &entries); err != nil { - panic("invalid embedded Azure asset allowlist: " + err.Error()) - } - return entries -}() - -var deployAzureBuildInputExcluded = regexp.MustCompile(`(^|/)(testdata|__tests__|node_modules|\.git)/|^server/internal/testutil/|_test\.go$|\.(test|spec)\.[cm]?[jt]sx?$|(?i:\.md$|\.(pem|key|p12|pfx|crt|cer|der)$)|(^|/)(\.env(\.|$)|id_(rsa|dsa|ecdsa|ed25519)|\.npmrc$|cao(\.[a-z0-9-]+)?\.json$)`) - -type deployAzureBuildContext struct { - Directory, Revision, Digest string - Worktree string -} - -type deployAzureBuiltImage struct { - Target string `json:"target"` - Reference string `json:"reference"` - Digest string `json:"digest"` - Repository string `json:"repository"` - Tag string `json:"tag"` - Scan string `json:"scan"` - SBOM string `json:"sbom"` - Provenance string `json:"provenance"` - ConfiguredReference string `json:"configuredReference"` -} - -func deployAzureImageTag(config deployAzureConfig) string { - content, _ := json.Marshal(config) - sum := sha256.Sum256(content) - // The reviewed version plus configuration identity is never a mutable - // shared `latest` tag. The freshly created registry is locked after build. - version := config.Image.Tag - if len(version) > 110 { - version = version[:110] - } - return version + "-" + hex.EncodeToString(sum[:6]) -} - -func (s deployAzureDevSource) Materialize(ctx context.Context, destination string) (deployAzureSourceIdentity, error) { - identity := deployAzureSourceIdentity{} - root, err := filepath.Abs(s.directory) - if err != nil { - return identity, err - } - root, err = filepath.EvalSymlinks(root) - if err != nil { - return identity, err - } - git := func(args ...string) (string, error) { - command := exec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...) - output, err := command.Output() - if err != nil { - return "", fmt.Errorf("inspect development build source: %w", err) - } - return string(output), nil - } - revision, err := git("rev-parse", "HEAD") - if err != nil { - return identity, err - } - revision = strings.TrimSpace(revision) - if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(revision) { - return identity, errors.New("development source must have a full Git commit SHA") - } - identity.Revision = revision - status, err := git("status", "--porcelain=v1", "-z", "--untracked-files=normal") - if err != nil { - return identity, err - } - identity.Worktree = "clean" - if status != "" { - identity.Worktree = "dirty" - } - files, err := git("ls-files", "-z") - if err != nil { - return identity, err - } - for _, name := range strings.Split(files, "\x00") { - if !deployAzureBuildFileAllowed(name) { - continue - } - if err := copyDeployAzureFile(filepath.Join(root, filepath.FromSlash(name)), filepath.Join(destination, filepath.FromSlash(name))); err != nil { - return identity, fmt.Errorf("copy build source %s: %w", name, err) - } - } - for _, required := range []string{"server/Dockerfile", "server/go.mod", "dashboard/site/package.json", "dashboard/site/manifest.webmanifest", "server/azure/provision.sh"} { - if _, err := os.Stat(filepath.Join(destination, required)); err != nil { - return identity, fmt.Errorf("development source is not a gh-cao checkout: missing %s", required) - } - } - // A worktree is not an attested commit checkout. Detect changes during - // copying; the context digest below remains the identity of actual inputs. - after, err := git("status", "--porcelain=v1", "-z", "--untracked-files=normal") - if err != nil { - return identity, err - } - if status != after { - return identity, errors.New("development source changed while materializing; retry from a stable worktree") - } - currentRevision, err := git("rev-parse", "HEAD") - if err != nil { - return identity, err - } - if strings.TrimSpace(currentRevision) != identity.Revision { - return identity, errors.New("development source revision changed while materializing; retry from a stable worktree") - } - return identity, nil -} - -func deployAzureBuildFileAllowed(name string) bool { - if name == "" || !filepath.IsLocal(name) || strings.Contains(name, "\\") { - return false - } - if deployAzureBuildInputExcluded.MatchString(name) { - return false - } - for _, entry := range deployAzureAssetAllowlist { - if name == entry || strings.HasSuffix(entry, "/") && strings.HasPrefix(name, entry) { - return true - } - } - return false -} - -func copyDeployAzureFile(source, destination string) error { - // Reject symbolic links in every path component, not only the leaf. - for path := source; ; path = filepath.Dir(path) { - info, err := os.Lstat(path) - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("symbolic links are not allowed in build inputs: %s", source) - } - if filepath.Dir(path) == path { - break - } - } - input, err := os.Open(source) - if err != nil { - return err - } - defer func() { _ = input.Close() }() - info, err := input.Stat() - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return fmt.Errorf("build input is not a regular file: %s", source) - } - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { - return err - } - output, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, deployAzureContextFileMode(info.Mode())) - if err != nil { - return err - } - _, copyErr := io.Copy(output, input) - closeErr := output.Close() - return errors.Join(copyErr, closeErr) -} - -func deployAzureContextFileMode(source os.FileMode) os.FileMode { - if source&0o111 != 0 { - return 0o755 - } - return 0o644 -} - -func prepareDeployAzureBuild(ctx context.Context, source deployAzureBuildSource, workspace *deployAzureWorkspace, options deployAzureApplyOptions) (deployAzureBuildContext, error) { - build := deployAzureBuildContext{Directory: filepath.Join(workspace.directory, "context")} - if err := os.Mkdir(build.Directory, 0o700); err != nil { - return build, err - } - identity, err := source.Materialize(ctx, build.Directory) - if err != nil { - return build, err - } - build.Revision, build.Worktree = identity.Revision, identity.Worktree - if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(build.Revision) { - return build, errors.New("build source must provide its full Git revision") - } - if build.Worktree != "clean" && build.Worktree != "dirty" { - return build, errors.New("build source must identify its clean or dirty worktree state") - } - repoDir, err := filepath.EvalSymlinks(options.RepoDir) - if err != nil { - return build, fmt.Errorf("resolve operator repository: %w", err) - } - for _, name := range []string{"cao.json", "cao.azure.json"} { - path := filepath.Join(repoDir, ".github", "workflows", name) - content, err := os.ReadFile(path) - if err != nil { - return build, fmt.Errorf("read adopter-owned %s: %w", name, err) - } - value, err := nativeDecodeJSON(content) - policy, ok := value.(map[string]any) - if err != nil || !ok || policy == nil { - return build, fmt.Errorf("adopter-owned %s must be a JSON object", name) - } - if name == "cao.json" { - if err := nativeValidatePolicy(policy); err != nil { - return build, fmt.Errorf("adopter-owned %s is invalid: %w", name, err) - } - } - for _, pattern := range deployEmbeddedSecretPatterns { - if pattern.Match(content) { - return build, fmt.Errorf("adopter-owned %s appears to embed a secret", name) - } - } - if err := copyDeployAzureFile(path, filepath.Join(build.Directory, ".github", "workflows", name)); err != nil { - return build, err - } - } - if options.SnapshotDir != "" { - if err := copyDeployAzureSnapshot(options.SnapshotDir, filepath.Join(build.Directory, "cao-deploy-evidence")); err != nil { - return build, err - } - } - hash := sha256.New() - err = filepath.WalkDir(build.Directory, func(path string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - info, err := entry.Info() - if err != nil { - return err - } - // Remote builds preserve context modes, and images run as a non-root - // user; the private parent workspace still protects the context on disk. - if entry.IsDir() { - return os.Chmod(path, 0o755) - } - if err := os.Chmod(path, deployAzureContextFileMode(info.Mode())); err != nil { - return err - } - name, err := filepath.Rel(build.Directory, path) - if err != nil { - return err - } - content, err := os.ReadFile(path) - if err != nil { - return err - } - fileSum := sha256.Sum256(content) - _, err = fmt.Fprintf(hash, "%s\x00%04o\x00%x\n", filepath.ToSlash(name), deployAzureContextFileMode(info.Mode()), fileSum) - return err - }) - build.Digest = hex.EncodeToString(hash.Sum(nil)) - return build, err -} - -func deployAzureBuildVersion(tag, revision string) string { - if nativeExactVersion.MatchString("v" + strings.TrimPrefix(tag, "v")) { - return tag - } - return "0.0.0-main." + revision -} - -func deployAzureBuildImages(ctx context.Context, az azcli.Executor, config deployAzureConfig, build deployAzureBuildContext, options deployAzureApplyOptions, provision bool) ([]deployAzureBuiltImage, error) { - repository := config.Image.Repository - if repository == "" { - repository = "cao-functions" - } - target := "azure-ingest-runtime" - if options.EmptyEvidence { - target = "azure-empty-ingest-runtime" - } - var images []deployAzureBuiltImage - targets := []struct{ repository, target string }{{repository, "azure-functions-runtime"}, {"cao-ingest", target}} - if provision { - targets = append(targets, struct{ repository, target string }{"cao-db-provision", "azure-db-provision"}) - } - for _, image := range targets { - tag := deployAzureImageTag(config) - args := []string{"acr", "build", "--subscription", config.Subscription, "--registry", config.Resources.ContainerRegistry, - "--image", image.repository + ":" + tag, "--file", "server/Dockerfile", "--target", image.target, - "--platform", "linux/amd64", "--build-arg", "REVISION=" + build.Revision, - "--build-arg", "VERSION=" + deployAzureBuildVersion(config.Image.Tag, build.Revision), "--build-arg", "CAO_PROFILE=cao.azure.json", - "--no-logs", "--output", "json"} - if options.EmptyEvidence { - args = append(args, "--build-arg", "CAO_INGEST_SOURCE=server/azure/empty-data") - } else { - args = append(args, "--build-arg", "CAO_INGEST_SOURCE=cao-deploy-evidence") - } - if _, err := az.Run(ctx, append(args, build.Directory)...); err != nil { - return nil, deployAzureBuildFailure(ctx, az, config, image.target, err) - } - result, err := az.Run(ctx, "acr", "repository", "show", "--subscription", config.Subscription, "--name", config.Resources.ContainerRegistry, "--image", image.repository+":"+tag, "--output", "json") - if err != nil { - return nil, fmt.Errorf("resolve built image digest: %w", err) - } - var metadata struct { - Digest string `json:"digest"` - } - if err := json.Unmarshal([]byte(result.Stdout), &metadata); err != nil { - return nil, fmt.Errorf("parse built image digest: %w", err) - } - if !regexp.MustCompile(`^sha256:[0-9a-f]{64}$`).MatchString(metadata.Digest) { - return nil, errors.New("ACR did not return a valid image digest") - } - if _, err := az.Run(ctx, "acr", "repository", "update", "--subscription", config.Subscription, "--name", config.Resources.ContainerRegistry, - "--image", image.repository+":"+tag, "--write-enabled", "false", "--delete-enabled", "false", "--output", "none"); err != nil { - return nil, fmt.Errorf("lock immutable image tag: %w", err) - } - images = append(images, deployAzureBuiltImage{ - Target: image.target, Reference: config.Resources.ContainerRegistry + ".azurecr.io/" + image.repository + "@" + metadata.Digest, - Digest: metadata.Digest, Repository: image.repository, Tag: tag, - Scan: "not-performed", SBOM: "not-generated", Provenance: "local-context-not-attested", - }) - } - return images, nil -} - -func deployAzureImageParameters(config deployAzureConfig, images []deployAzureBuiltImage, emptyEvidence, provision bool) (map[string]any, error) { - if config.Environment != "development" { - return nil, errors.New("development image bindings cannot authorize production apply") - } - repository := config.Image.Repository - if repository == "" { - repository = "cao-functions" - } - ingestTarget := "azure-ingest-runtime" - if emptyEvidence { - ingestTarget = "azure-empty-ingest-runtime" - } - expected := map[string]struct{ repository, parameter string }{ - "azure-functions-runtime": {repository, "functionImageDigest"}, - ingestTarget: {"cao-ingest", "ingestionImageDigest"}, - } - if provision { - expected["azure-db-provision"] = struct{ repository, parameter string }{"cao-db-provision", "provisioningImageDigest"} - } - parameters := config.parameters() - for _, image := range images { - binding, ok := expected[image.Target] - if !ok || image.Repository != binding.repository || image.Tag != deployAzureImageTag(config) || - !regexp.MustCompile(`^sha256:[0-9a-f]{64}$`).MatchString(image.Digest) || - image.Reference != config.Resources.ContainerRegistry+".azurecr.io/"+binding.repository+"@"+image.Digest || - image.Scan != "not-performed" || image.SBOM != "not-generated" || image.Provenance != "local-context-not-attested" { - return nil, errors.New("invalid development image binding or assurance disposition; refusing full template apply") - } - parameters[binding.parameter] = image.Digest - delete(expected, image.Target) - } - if len(expected) != 0 { - return nil, errors.New("missing development image target; refusing full template apply") - } - return parameters, nil -} - -// Read back configured references, not a claim about the running platform's -// measured image or an independent scan/provenance attestation. -func deployAzureReadBackImages(ctx context.Context, az azcli.Executor, config deployAzureConfig, images []deployAzureBuiltImage) error { - for i := range images { - image := &images[i] - scope := []string{"--subscription", config.Subscription, "--resource-group", config.ResourceGroup} - var args []string - if image.Target == "azure-functions-runtime" { - args = append([]string{"functionapp", "config", "show"}, scope...) - args = append(args, "--name", config.Resources.FunctionApp, "--query", "linuxFxVersion") - } else { - suffix := "-ingest" - if image.Target == "azure-db-provision" { - suffix = "-db-provision" - } - args = append([]string{"containerapp", "job", "show"}, scope...) - args = append(args, "--name", config.Resources.FunctionApp+suffix, "--query", "properties.template") - } - result, err := az.Run(ctx, append(args, "--output", "json")...) - if err != nil { - return fmt.Errorf("read back configured image for %s: %w", image.Target, err) - } - reference := "" - if image.Target == "azure-functions-runtime" { - var fx string - if err := json.Unmarshal([]byte(result.Stdout), &fx); err != nil { - return fmt.Errorf("parse configured Functions image: %w", err) - } - reference = strings.TrimPrefix(fx, "DOCKER|") - if fx != "DOCKER|"+image.Reference { - return errors.New("configured Functions image does not match the built digest; refusing job execution") - } - } else { - var template struct { - Containers []struct{ Image string } `json:"containers"` - InitContainers []json.RawMessage `json:"initContainers"` - } - if err := json.Unmarshal([]byte(result.Stdout), &template); err != nil { - return fmt.Errorf("parse configured job image: %w", err) - } - if len(template.Containers) != 1 || len(template.InitContainers) != 0 || template.Containers[0].Image != image.Reference { - return fmt.Errorf("configured image for %s does not match the built digest; refusing job execution", image.Target) - } - reference = template.Containers[0].Image - } - image.ConfiguredReference = reference - } - return nil -} - -func deployAzureBuildFailure(ctx context.Context, az azcli.Executor, config deployAzureConfig, target string, failure error) error { - run := regexp.MustCompile(`run with ID '([a-z0-9]+)'`).FindStringSubmatch(failure.Error()) - if len(run) != 2 { - return fmt.Errorf("ACR build target %s failed; full template was not deployed: %w", target, failure) - } - logs, err := az.Run(ctx, "acr", "task", "logs", "--subscription", config.Subscription, "--registry", config.Resources.ContainerRegistry, "--run-id", run[1]) - if err != nil { - return fmt.Errorf("ACR build target %s failed; full template was not deployed: %w; build log read-back failed: %v", target, failure, err) - } - tail := strings.TrimSpace(logs.Stdout) - if len(tail) > 6000 { - tail = tail[len(tail)-6000:] - } - return fmt.Errorf("ACR build target %s failed; full template was not deployed: %w\nACR build %s log tail:\n%s", target, failure, run[1], tail) -} diff --git a/internal/cli/deploy_azure_jobs.go b/internal/cli/deploy_azure_jobs.go deleted file mode 100644 index e6ef1defdc..0000000000 --- a/internal/cli/deploy_azure_jobs.go +++ /dev/null @@ -1,153 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -func deployAzurePause(ctx context.Context, deps deployAzureDeps, duration time.Duration) error { - if deps.pause != nil { - return deps.pause(ctx, duration) - } - timer := time.NewTimer(duration) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func deployAzureRunJob(ctx context.Context, deps deployAzureDeps, az azcli.Executor, config deployAzureConfig, job string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 20*time.Minute) - defer cancel() - scope := []string{"--subscription", config.Subscription, "--resource-group", config.ResourceGroup, "--name", job} - run := func(verb []string, extra ...string) (azcli.Result, error) { - args := append(append(append([]string{}, verb...), scope...), extra...) - return az.Run(ctx, append(args, "--output", "json")...) - } - result, err := run([]string{"containerapp", "job", "execution", "list"}) - if err != nil { - return "", fmt.Errorf("check concurrent ingestion: %w", err) - } - var executions []struct { - Name string `json:"name"` - Properties struct { - Status string `json:"status"` - } `json:"properties"` - } - if err := json.Unmarshal([]byte(result.Stdout), &executions); err != nil { - return "", fmt.Errorf("parse ingestion executions: %w", err) - } - for _, execution := range executions { - switch execution.Properties.Status { - case "Succeeded", "Failed", "Stopped": - default: - return "", fmt.Errorf("concurrent ingestion execution %s is %s; do not start overlapping database bootstrap or ingestion", execution.Name, execution.Properties.Status) - } - } - result, err = run([]string{"containerapp", "job", "start"}) - if err != nil { - return "", fmt.Errorf("start ingestion: %w", err) - } - var started struct { - Name string `json:"name"` - } - if err := json.Unmarshal([]byte(result.Stdout), &started); err != nil { - return "", fmt.Errorf("parse started ingestion execution: %w", err) - } - if started.Name == "" || !strings.HasPrefix(started.Name, job+"-") || strings.ContainsAny(started.Name, "/\\ \t\n") { - return "", errors.New("ingestion start did not return the expected exact execution name") - } - for attempt := 0; attempt < 120; attempt++ { - result, err = run([]string{"containerapp", "job", "execution", "show"}, "--job-execution-name", started.Name) - if err != nil { - return started.Name, fmt.Errorf("read ingestion execution %s: %w", started.Name, err) - } - var execution struct { - Name string `json:"name"` - Properties struct { - Status string `json:"status"` - } `json:"properties"` - } - if err := json.Unmarshal([]byte(result.Stdout), &execution); err != nil { - return started.Name, fmt.Errorf("parse ingestion execution: %w", err) - } - if execution.Name != started.Name { - return started.Name, errors.New("azure returned a different ingestion execution; refusing to wait on it") - } - switch execution.Properties.Status { - case "Succeeded": - return started.Name, nil - case "Failed", "Stopped": - return started.Name, deployAzureJobFailure(ctx, run, config, job, started.Name, execution.Properties.Status) - case "Running", "Processing", "Pending", "Unknown": - default: - return started.Name, fmt.Errorf("ingestion execution %s has unexpected status %q", started.Name, execution.Properties.Status) - } - if err := deployAzurePause(ctx, deps, 10*time.Second); err != nil { - return started.Name, fmt.Errorf("wait for ingestion execution %s timed out or was cancelled: %w", started.Name, err) - } - } - return started.Name, fmt.Errorf("ingestion execution %s did not finish within the bounded wait", started.Name) -} - -func deployAzureJobFailure(ctx context.Context, run func([]string, ...string) (azcli.Result, error), config deployAzureConfig, job, execution, status string) error { - result, err := run([]string{"containerapp", "job", "replica", "list"}, "--execution", execution) - diagnostic := "replica diagnostics unavailable" - var containers []string - if err == nil { - var replicas []struct { - Name string `json:"name"` - Properties struct { - InitContainers []struct { - Name string `json:"name"` - RunningState string `json:"runningState"` - RunningStateInfo string `json:"runningStateDetails"` - } `json:"initContainers"` - Containers []struct { - Name string `json:"name"` - RunningState string `json:"runningState"` - RunningStateInfo string `json:"runningStateDetails"` - } `json:"containers"` - } `json:"properties"` - } - if parseErr := json.Unmarshal([]byte(result.Stdout), &replicas); parseErr != nil { - diagnostic = "could not parse replica diagnostics" - } else { - var failures []string - for _, replica := range replicas { - for _, container := range replica.Properties.InitContainers { - failures = append(failures, fmt.Sprintf("init/bootstrap container %s: %s (%s)", container.Name, container.RunningState, container.RunningStateInfo)) - containers = append(containers, container.Name) - } - for _, container := range replica.Properties.Containers { - failures = append(failures, fmt.Sprintf("container %s: %s (%s)", container.Name, container.RunningState, container.RunningStateInfo)) - containers = append(containers, container.Name) - } - } - if len(failures) > 0 { - diagnostic = strings.Join(failures, "; ") - } - } - } else { - diagnostic += ": " + err.Error() - } - inspect := "inspect the execution's console logs in the environment's Log Analytics workspace" - if len(containers) > 0 { - inspect = fmt.Sprintf("inspect az containerapp job logs show --subscription %s --resource-group %s --name %s --execution %s --container %s", - config.Subscription, config.ResourceGroup, job, execution, strings.Join(containers, " (and --container ")) - if len(containers) > 1 { - inspect += strings.Repeat(")", len(containers)-1) - } - } - return fmt.Errorf("job execution %s %s: %s; %s; check Key Vault/ACR role propagation, PostgreSQL network reachability and database bootstrap permissions; verification was not attempted", - execution, status, diagnostic, inspect) -} diff --git a/internal/cli/deploy_azure_permissions.go b/internal/cli/deploy_azure_permissions.go deleted file mode 100644 index d5f119b5a4..0000000000 --- a/internal/cli/deploy_azure_permissions.go +++ /dev/null @@ -1,76 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "regexp" - "slices" - "strings" -) - -type deployAzurePermission struct { - Actions []string `json:"actions"` - NotActions []string `json:"notActions"` -} - -func deployAzureActionMatches(pattern, action string) bool { - expression := regexp.QuoteMeta(strings.ToLower(pattern)) - expression = strings.ReplaceAll(expression, `\*`, ".*") - matched, _ := regexp.MatchString("^"+expression+"$", strings.ToLower(action)) - return matched -} - -func deployAzureActionAllowed(permissions []deployAzurePermission, action string) bool { - for _, permission := range permissions { - allowed := slices.ContainsFunc(permission.Actions, func(pattern string) bool { return deployAzureActionMatches(pattern, action) }) - excluded := slices.ContainsFunc(permission.NotActions, func(pattern string) bool { return deployAzureActionMatches(pattern, action) }) - if allowed && !excluded { - return true - } - } - return false -} - -func (p *deployAzurePreflight) checkPermissions(ctx context.Context) deployAzureCheck { - const id = "permissions" - if check := p.skipped(id); check != nil { - return *check - } - scope := p.targetScope() - remediation := "Grant the deploying identity resource creation, ACR build and job execution permissions plus Microsoft.Authorization/roleAssignments/write at " + scope + "; activate any required PIM assignment, then rerun preflight." - var response struct { - Value []deployAzurePermission `json:"value"` - NextLink string `json:"nextLink"` - } - if err := p.runJSON(ctx, &response, "rest", "--method", "get", "--url", scope+"/providers/Microsoft.Authorization/permissions?api-version=2022-04-01", "--output", "json"); err != nil { - return deployAzureCheckResult(id, deployAzureCheckUnknown, "could not read ARM effective permissions: "+azError(err), remediation) - } - if response.Value == nil || response.NextLink != "" { - return deployAzureCheckResult(id, deployAzureCheckUnknown, "ARM effective permissions were missing or incomplete (pagination); authority could not be verified", remediation) - } - required := []string{ - "Microsoft.Resources/deployments/write", - "Microsoft.Authorization/roleAssignments/write", - "Microsoft.ContainerRegistry/registries/scheduleRun/action", - "Microsoft.ContainerRegistry/registries/listBuildSourceUploadUrl/action", - "Microsoft.App/jobs/start/action", - } - if !p.groupExists { - required = append(required, "Microsoft.Resources/subscriptions/resourceGroups/write") - } - for _, resourceType := range p.template.types { - required = append(required, resourceType+"/write") - } - var missing []string - for _, action := range required { - if !deployAzureActionAllowed(response.Value, action) { - missing = append(missing, action) - } - } - if len(missing) != 0 { - slices.Sort(missing) - missing = slices.Compact(missing) - return deployAzureCheckResult(id, deployAzureCheckFail, fmt.Sprintf("ARM effective permissions at %s do not grant: %s", scope, strings.Join(missing, ", ")), remediation) - } - return deployAzureCheckResult(id, deployAzureCheckPass, "can create resources and role assignments, build images and execute jobs at "+scope+" (ARM effective permissions; no Microsoft Graph lookup)", "") -} diff --git a/internal/cli/deploy_azure_preflight.go b/internal/cli/deploy_azure_preflight.go deleted file mode 100644 index 5197c24e77..0000000000 --- a/internal/cli/deploy_azure_preflight.go +++ /dev/null @@ -1,962 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "regexp" - "sort" - "strconv" - "strings" - "text/tabwriter" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -const ( - deployAzurePreflightKind = "cao.deploy.azure.preflight" - deployAzurePreflightVersion = 1 - // deployAzureMinimumAzVersion is the oldest Azure CLI exercised with the - // read-only checks and what-if options used here. - deployAzureMinimumAzVersion = "2.60.0" - - deployAzureCheckPass = "pass" - deployAzureCheckFail = "fail" - deployAzureCheckUnknown = "unknown" - deployAzureCheckInfo = "info" -) - -// deployAzureRequiredProviders are always checked; namespaces discovered in -// the template are added to this set. -var deployAzureRequiredProviders = []string{ - "Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.DBforPostgreSQL", "Microsoft.Insights", "Microsoft.KeyVault", - "Microsoft.Network", "Microsoft.OperationalInsights", "Microsoft.Storage", "Microsoft.Web", -} - -// Namespaces that are registered in every subscription and cannot be unregistered. -var deployAzureImplicitProviders = map[string]bool{"microsoft.resources": true, "microsoft.authorization": true} - -// deployAzureCheck is one structured preflight result. -type deployAzureCheck struct { - ID string `json:"id"` - Status string `json:"status"` - Message string `json:"message"` - Remediation string `json:"remediation,omitempty"` -} - -type deployAzurePreflightReport struct { - Kind string `json:"kind"` - SchemaVersion int `json:"schemaVersion"` - Status string `json:"status"` - Checks []deployAzureCheck `json:"checks"` -} - -type deployAzureResource struct { - ID string `json:"id"` - Type string `json:"type"` -} - -type deployAzureProvider struct { - Namespace string `json:"namespace"` - RegistrationState string `json:"registrationState"` - ResourceTypes []struct { - ResourceType string `json:"resourceType"` - Locations []string `json:"locations"` - } `json:"resourceTypes"` -} - -// deployAzurePreflight runs read-only checks and retains facts that plan mode reuses. -type deployAzurePreflight struct { - az azcli.Executor - config deployAzureConfig - template deployAzureTemplateFacts - - checks []deployAzureCheck - azAvailable bool - loggedIn bool - account deployAzureAccountInfo - groupKnown bool - groupExists bool - groupItems []deployAzureResource - existing []deployAzureResource - siteID string - providers []deployAzureProvider - providerErr error - timedOut bool -} - -func runDeployAzurePreflight(ctx context.Context, az azcli.Executor, config deployAzureConfig, template deployAzureTemplate) *deployAzurePreflight { - p := &deployAzurePreflight{az: az, config: config, template: deployAzureTemplateFactsFrom(template.Content, config.parameters())} - p.az = azcli.ExecutorFunc(func(ctx context.Context, args ...string) (azcli.Result, error) { - bounded, cancel := context.WithTimeout(ctx, time.Minute) - defer cancel() - result, err := az.Run(bounded, args...) - if errors.Is(err, context.DeadlineExceeded) || errors.Is(bounded.Err(), context.DeadlineExceeded) { - p.timedOut = true - return result, fmt.Errorf("preflight subprocess az %s timed out after at most one minute: %w", azcli.CommandName(args), context.DeadlineExceeded) - } - return result, err - }) - for _, check := range []func(context.Context) deployAzureCheck{ - p.checkTools, - p.checkLogin, - p.checkExisting, - p.checkPermissions, - p.checkProviders, - p.checkLocation, - p.checkStorageName, - p.checkRegistryName, - p.checkKeyVaultName, - p.checkFunctionAppName, - p.checkPostgresName, - p.checkIngress, - p.checkRetention, - } { - p.timedOut = false - result := check(ctx) - if p.timedOut { - result.Status = deployAzureCheckUnknown - } - p.checks = append(p.checks, result) - } - return p -} - -func (p *deployAzurePreflight) report() deployAzurePreflightReport { - status := deployAzureCheckPass - if p.failed() { - status = deployAzureCheckFail - } - return deployAzurePreflightReport{Kind: deployAzurePreflightKind, SchemaVersion: deployAzurePreflightVersion, Status: status, Checks: p.checks} -} - -func (p *deployAzurePreflight) failed() bool { - for _, check := range p.checks { - if check.Status == deployAzureCheckFail || check.Status == deployAzureCheckUnknown { - return true - } - } - return false -} - -func (p *deployAzurePreflight) err() error { - var failures []string - for _, check := range p.checks { - if check.Status == deployAzureCheckFail || check.Status == deployAzureCheckUnknown { - failures = append(failures, check.ID+": "+check.Message) - } - } - if len(failures) == 0 { - return nil - } - return fmt.Errorf("preflight failed (%d check(s)): %s", len(failures), strings.Join(failures, "; ")) -} - -func writeDeployAzurePreflightTable(w io.Writer, checks []deployAzureCheck) error { - table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) - _, _ = fmt.Fprintln(table, "STATUS\tCHECK\tMESSAGE") - counts := map[string]int{} - for _, check := range checks { - counts[check.Status]++ - _, _ = fmt.Fprintf(table, "%s\t%s\t%s\n", strings.ToUpper(check.Status), check.ID, check.Message) - } - if err := table.Flush(); err != nil { - return err - } - var remediation []string - for _, check := range checks { - if check.Remediation != "" && check.Status != deployAzureCheckPass { - remediation = append(remediation, fmt.Sprintf(" %s: %s", check.ID, check.Remediation)) - } - } - if len(remediation) > 0 { - if _, err := fmt.Fprintf(w, "\nRemediation:\n%s\n", strings.Join(remediation, "\n")); err != nil { - return err - } - } - _, err := fmt.Fprintf(w, "\nPreflight: %d pass, %d fail, %d unknown, %d info\n", - counts[deployAzureCheckPass], counts[deployAzureCheckFail], counts[deployAzureCheckUnknown], counts[deployAzureCheckInfo]) - return err -} - -func deployAzureCheckResult(id, status, message, remediation string) deployAzureCheck { - return deployAzureCheck{ID: id, Status: status, Message: message, Remediation: remediation} -} - -// skipped returns a non-nil unknown result when a prerequisite check failed. -func (p *deployAzurePreflight) skipped(id string) *deployAzureCheck { - switch { - case !p.azAvailable: - check := deployAzureCheckResult(id, deployAzureCheckUnknown, "not checked: the Azure CLI is unavailable", "Resolve the tools check, then rerun preflight.") - return &check - case !p.loggedIn: - check := deployAzureCheckResult(id, deployAzureCheckUnknown, "not checked: the login check did not pass", "Resolve the login check, then rerun preflight.") - return &check - } - return nil -} - -// azError summarizes an Azure CLI failure on one line. -func azError(err error) string { - for _, line := range strings.Split(err.Error(), "\n") { - if line = strings.TrimSpace(line); line != "" { - if len(line) > 300 { - line = line[:300] + "…" - } - return line - } - } - return "unknown error" -} - -func (p *deployAzurePreflight) run(ctx context.Context, args ...string) (string, error) { - result, err := p.az.Run(ctx, args...) - return result.Stdout, err -} - -func (p *deployAzurePreflight) runJSON(ctx context.Context, target any, args ...string) error { - stdout, err := p.run(ctx, args...) - if err != nil { - return err - } - if err := json.Unmarshal([]byte(stdout), target); err != nil { - return fmt.Errorf("parse az %s output: %w", azcli.CommandName(args), err) - } - return nil -} - -func (p *deployAzurePreflight) checkTools(ctx context.Context) deployAzureCheck { - const id = "tools" - var version map[string]any - if err := p.runJSON(ctx, &version, "version", "--output", "json"); err != nil { - return deployAzureCheckResult(id, deployAzureCheckFail, "Azure CLI is unavailable: "+azError(err), - "Install the Azure CLI "+deployAzureMinimumAzVersion+" or later (https://aka.ms/azure-cli) and ensure az is on PATH.") - } - p.azAvailable = true - installed, _ := version["azure-cli"].(string) - bicep := "Bicep is not required: the compiled ARM template is used directly" - newer, ok := deployAzureVersionAtLeast(installed, deployAzureMinimumAzVersion) - switch { - case !ok: - return deployAzureCheckResult(id, deployAzureCheckUnknown, fmt.Sprintf("could not read the Azure CLI version from az version (got %q); %s", installed, bicep), - "Run az version and confirm azure-cli is "+deployAzureMinimumAzVersion+" or later.") - case !newer: - return deployAzureCheckResult(id, deployAzureCheckFail, fmt.Sprintf("Azure CLI %s is older than the required %s; %s", installed, deployAzureMinimumAzVersion, bicep), - "Run az upgrade (or reinstall the Azure CLI) to "+deployAzureMinimumAzVersion+" or later.") - } - return deployAzureCheckResult(id, deployAzureCheckPass, fmt.Sprintf("Azure CLI %s (minimum %s); %s", installed, deployAzureMinimumAzVersion, bicep), "") -} - -var deployAzureVersionPattern = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)`) - -func deployAzureVersionAtLeast(installed, minimum string) (bool, bool) { - have := deployAzureVersionPattern.FindStringSubmatch(installed) - want := deployAzureVersionPattern.FindStringSubmatch(minimum) - if have == nil || want == nil { - return false, false - } - for index := 1; index <= 3; index++ { - h, _ := strconv.Atoi(have[index]) - w, _ := strconv.Atoi(want[index]) - if h != w { - return h > w, true - } - } - return true, true -} - -func (p *deployAzurePreflight) checkLogin(ctx context.Context) deployAzureCheck { - const id = "login" - if !p.azAvailable { - return *p.skipped(id) - } - account, err := deployAzureAccount(ctx, p.az, p.config.Subscription) - p.account = account - if err != nil { - return deployAzureCheckResult(id, deployAzureCheckFail, azError(err), - fmt.Sprintf("Run az login with an identity that can access subscription %s, and confirm the subscription is Enabled and matches config.subscription.", p.config.Subscription)) - } - p.loggedIn = true - principal := account.User.Type - if principal == "" { - principal = "unknown principal type" - } - return deployAzureCheckResult(id, deployAzureCheckPass, - fmt.Sprintf("subscription %s is Enabled; tenant %s; signed in as %s", account.ID, account.TenantID, principal), "") -} - -func (p *deployAzurePreflight) checkExisting(ctx context.Context) deployAzureCheck { - const id = "existing-instance" - const freshOnly = "only fresh deployments are supported; upgrading an existing instance is not yet available" - const remediation = "Choose unused names and an empty or new resource group in the config; upgrades of existing instances are not yet supported." - if check := p.skipped(id); check != nil { - // Existing-instance detection is fail-closed: unknown is never a pass. - check.Status = deployAzureCheckFail - return *check - } - exists, err := deployAzureGroupExists(ctx, p.az, p.config) - if err != nil { - return deployAzureCheckResult(id, deployAzureCheckFail, "could not determine whether an instance exists: "+azError(err), remediation) - } - p.groupKnown, p.groupExists = true, exists - var findings []string - if exists { - resources, err := deployAzureResourceList(ctx, p.az, "--subscription", p.config.Subscription, "--resource-group", p.config.ResourceGroup) - if err != nil { - return deployAzureCheckResult(id, deployAzureCheckFail, "could not determine whether an instance exists: "+azError(err), remediation) - } - p.groupItems = resources - p.existing = append(p.existing, resources...) - if len(resources) > 0 { - findings = append(findings, fmt.Sprintf("resource group %s already contains %d resource(s) (for example %s)", p.config.ResourceGroup, len(resources), resources[0].ID)) - } - } - for _, name := range p.config.globalResourceNames() { - resources, err := deployAzureResourceList(ctx, p.az, "--subscription", p.config.Subscription, "--name", name) - if err != nil { - return deployAzureCheckResult(id, deployAzureCheckFail, "could not determine whether an instance exists: "+azError(err), remediation) - } - if len(resources) > 0 { - p.existing = append(p.existing, resources...) - findings = append(findings, fmt.Sprintf("resource %s already exists in subscription %s (%s)", name, p.config.Subscription, resources[0].ID)) - } - } - for _, resource := range p.existing { - if strings.EqualFold(resource.Type, "Microsoft.Web/sites") && strings.EqualFold(deployAzureResourceName(resource.ID), p.config.Resources.FunctionApp) { - p.siteID = resource.ID - break - } - } - if len(findings) > 0 { - return deployAzureCheckResult(id, deployAzureCheckFail, strings.Join(findings, "; ")+"; "+freshOnly, remediation) - } - return deployAzureCheckResult(id, deployAzureCheckPass, - fmt.Sprintf("no existing instance: resource group %s exists=%t and configured names are unused in the subscription", p.config.ResourceGroup, exists), "") -} - -func deployAzureResourceName(id string) string { - return id[strings.LastIndex(id, "/")+1:] -} - -func (p *deployAzurePreflight) targetScope() string { - scope := "/subscriptions/" + p.config.Subscription - if p.groupExists { - scope += "/resourceGroups/" + p.config.ResourceGroup - } - return scope -} - -func (p *deployAzurePreflight) loadProviders(ctx context.Context) error { - if p.providers != nil || p.providerErr != nil { - return p.providerErr - } - var providers []deployAzureProvider - p.providerErr = p.runJSON(ctx, &providers, "provider", "list", "--subscription", p.config.Subscription, - "--query", "[].{namespace:namespace,registrationState:registrationState,resourceTypes:resourceTypes[].{resourceType:resourceType,locations:locations}}", - "--output", "json") - p.providers = providers - return p.providerErr -} - -func (p *deployAzurePreflight) requiredProviders() []string { - set := map[string]string{} - for _, namespace := range deployAzureRequiredProviders { - set[strings.ToLower(namespace)] = namespace - } - for _, resourceType := range p.template.types { - namespace, _, _ := strings.Cut(resourceType, "/") - if !deployAzureImplicitProviders[strings.ToLower(namespace)] { - if _, ok := set[strings.ToLower(namespace)]; !ok { - set[strings.ToLower(namespace)] = namespace - } - } - } - namespaces := make([]string, 0, len(set)) - for _, namespace := range set { - namespaces = append(namespaces, namespace) - } - sort.Strings(namespaces) - return namespaces -} - -func (p *deployAzurePreflight) provider(namespace string) *deployAzureProvider { - for index := range p.providers { - if strings.EqualFold(p.providers[index].Namespace, namespace) { - return &p.providers[index] - } - } - return nil -} - -func (p *deployAzurePreflight) checkProviders(ctx context.Context) deployAzureCheck { - const id = "resource-providers" - if check := p.skipped(id); check != nil { - return *check - } - required := p.requiredProviders() - if err := p.loadProviders(ctx); err != nil { - return deployAzureCheckResult(id, deployAzureCheckUnknown, "could not list resource providers: "+azError(err), - "Confirm these providers are Registered in the subscription: "+strings.Join(required, ", ")+".") - } - var unregistered, commands []string - for _, namespace := range required { - state := "NotFound" - if provider := p.provider(namespace); provider != nil { - state = provider.RegistrationState - } - if state != "Registered" { - unregistered = append(unregistered, fmt.Sprintf("%s (%s)", namespace, state)) - commands = append(commands, fmt.Sprintf("az provider register --subscription %s --namespace %s", p.config.Subscription, namespace)) - } - } - if len(unregistered) > 0 { - return deployAzureCheckResult(id, deployAzureCheckFail, "resource providers are not registered: "+strings.Join(unregistered, ", "), - "A subscription owner must register them, then wait for Registered: "+strings.Join(commands, "; ")) - } - return deployAzureCheckResult(id, deployAzureCheckPass, fmt.Sprintf("%d required providers registered: %s", len(required), strings.Join(required, ", ")), "") -} - -func deployAzureNormalizeLocation(location string) string { - return strings.ToLower(strings.ReplaceAll(location, " ", "")) -} - -func (p *deployAzurePreflight) checkLocation(ctx context.Context) deployAzureCheck { - const id = "location" - if check := p.skipped(id); check != nil { - return *check - } - location := deployAzureNormalizeLocation(p.config.Location) - var unsupported, undetermined []string - if err := p.loadProviders(ctx); err != nil { - undetermined = append(undetermined, "resource type locations ("+azError(err)+")") - } else { - for _, resourceType := range p.template.types { - namespace, typeName, _ := strings.Cut(resourceType, "/") - if strings.Contains(typeName, "/") || deployAzureImplicitProviders[strings.ToLower(namespace)] { - continue - } - provider := p.provider(namespace) - if provider == nil { - undetermined = append(undetermined, resourceType) - continue - } - found := false - for _, candidate := range provider.ResourceTypes { - if !strings.EqualFold(candidate.ResourceType, typeName) { - continue - } - found = true - supported := len(candidate.Locations) == 0 - for _, available := range candidate.Locations { - normalized := deployAzureNormalizeLocation(available) - if normalized == location || normalized == "global" { - supported = true - } - } - if !supported { - unsupported = append(unsupported, resourceType) - } - } - if !found { - undetermined = append(undetermined, resourceType) - } - } - } - if sku := p.template.planSKU; sku == "" { - undetermined = append(undetermined, "App Service plan SKU (not found in template)") - } else { - var locations []struct { - Name string `json:"name"` - } - var locationErr error - if sku == "EP1" || sku == "EP2" || sku == "EP3" { - var regions struct { - Value []struct { - Name string `json:"name"` - } `json:"value"` - } - locationErr = p.runJSON(ctx, ®ions, "rest", "--method", "get", "--url", - "/subscriptions/"+p.config.Subscription+"/providers/Microsoft.Web/geoRegions?api-version=2024-11-01&sku="+sku+"&linuxWorkersEnabled=true", "--output", "json") - locations = regions.Value - } else { - locationErr = p.runJSON(ctx, &locations, "appservice", "list-locations", "--subscription", p.config.Subscription, "--sku", sku, "--linux-workers-enabled", "--output", "json") - } - if locationErr != nil { - undetermined = append(undetermined, "App Service plan "+sku+" ("+azError(locationErr)+")") - } else { - offered := false - for _, candidate := range locations { - offered = offered || deployAzureNormalizeLocation(candidate.Name) == location - } - if !offered { - unsupported = append(unsupported, "App Service plan "+sku+" on Linux") - } - } - } - if p.template.postgresSKU == "" || p.template.postgresTier == "" { - undetermined = append(undetermined, "PostgreSQL SKU (not found in template)") - } else { - var capabilities any - label := fmt.Sprintf("PostgreSQL %s %s", p.template.postgresTier, p.template.postgresSKU) - if err := p.runJSON(ctx, &capabilities, "postgres", "flexible-server", "list-skus", "--subscription", p.config.Subscription, "--location", p.config.Location, "--output", "json"); err != nil { - undetermined = append(undetermined, label+" ("+azError(err)+")") - } else { - switch deployAzureFindSKU(capabilities, p.template.postgresTier, p.template.postgresSKU) { - case deployAzureCheckPass: - case deployAzureCheckFail: - unsupported = append(unsupported, label) - default: - undetermined = append(undetermined, label+" (unrecognized capability output)") - } - } - } - switch { - case len(unsupported) > 0: - return deployAzureCheckResult(id, deployAzureCheckFail, fmt.Sprintf("location %s does not offer: %s", p.config.Location, strings.Join(unsupported, ", ")), - "Choose a location that offers every required resource type and SKU, or request quota/access for this region.") - case len(undetermined) > 0: - return deployAzureCheckResult(id, deployAzureCheckUnknown, fmt.Sprintf("could not confirm location %s supports: %s", p.config.Location, strings.Join(undetermined, ", ")), - "Confirm availability in the Azure portal or with az provider show / az postgres flexible-server list-skus before applying.") - } - return deployAzureCheckResult(id, deployAzureCheckPass, fmt.Sprintf("location %s offers the required resource types, App Service plan %s, and PostgreSQL %s %s", - p.config.Location, p.template.planSKU, p.template.postgresTier, p.template.postgresSKU), "") -} - -// deployAzureFindSKU searches capability output for an object named tier that -// contains an object named sku. It returns pass, fail (tier found without the -// SKU), or unknown (tier not found, so the output shape is unrecognized). -func deployAzureFindSKU(value any, tier, sku string) string { - result := deployAzureCheckUnknown - var visit func(any, bool) - visit = func(node any, inTier bool) { - switch typed := node.(type) { - case map[string]any: - name, _ := typed["name"].(string) - if inTier && strings.EqualFold(name, sku) { - result = deployAzureCheckPass - return - } - nowInTier := inTier - if !inTier && strings.EqualFold(name, tier) { - nowInTier = true - if result == deployAzureCheckUnknown { - result = deployAzureCheckFail - } - } - for _, child := range typed { - visit(child, nowInTier) - if result == deployAzureCheckPass { - return - } - } - case []any: - for _, child := range typed { - visit(child, inTier) - if result == deployAzureCheckPass { - return - } - } - } - } - visit(value, false) - return result -} - -type deployAzureNameResult struct { - NameAvailable *bool `json:"nameAvailable"` - Reason string `json:"reason"` - Message string `json:"message"` -} - -func (p *deployAzurePreflight) nameCheck(ctx context.Context, id, label, field, name string, args ...string) (deployAzureCheck, bool) { - var result deployAzureNameResult - if err := p.runJSON(ctx, &result, args...); err != nil { - return deployAzureCheckResult(id, deployAzureCheckUnknown, fmt.Sprintf("could not check %s name %s: %s", label, name, azError(err)), - fmt.Sprintf("Confirm %s %s is globally available before applying.", label, name)), false - } - if result.NameAvailable == nil { - return deployAzureCheckResult(id, deployAzureCheckUnknown, fmt.Sprintf("name availability response for %s %s did not include nameAvailable", label, name), - fmt.Sprintf("Confirm %s %s is globally available before applying.", label, name)), false - } - if !*result.NameAvailable { - detail := strings.TrimSpace(strings.Trim(result.Reason+": "+result.Message, ": ")) - if detail == "" { - detail = "no reason given" - } - return deployAzureCheckResult(id, deployAzureCheckFail, fmt.Sprintf("%s name %s is not available (%s)", label, name, detail), - fmt.Sprintf("Choose a different %s in the config; %s names are globally unique.", field, label)), false - } - return deployAzureCheckResult(id, deployAzureCheckPass, fmt.Sprintf("%s name %s is available", label, name), ""), true -} - -func (p *deployAzurePreflight) restNameArgs(provider, resourceType, name string) []string { - apiVersion := p.template.apiVersions[strings.ToLower(resourceType)] - if apiVersion == "" { - apiVersion = "2023-01-01" - } - body, _ := json.Marshal(map[string]string{"name": name, "type": resourceType}) - path := fmt.Sprintf("/subscriptions/%s/providers/%s", p.config.Subscription, provider) - if provider == "Microsoft.DBforPostgreSQL" { - path += "/locations/" + p.config.Location - } - return []string{"rest", "--method", "post", - "--url", fmt.Sprintf("%s/checkNameAvailability?api-version=%s", path, apiVersion), - "--body", string(body), "--output", "json"} -} - -func (p *deployAzurePreflight) checkStorageName(ctx context.Context) deployAzureCheck { - const id = "name-storage-account" - if check := p.skipped(id); check != nil { - return *check - } - name := p.config.Resources.StorageAccount - check, _ := p.nameCheck(ctx, id, "storage account", "resources.storageAccount", name, - "storage", "account", "check-name", "--subscription", p.config.Subscription, "--name", name, "--output", "json") - return check -} - -func (p *deployAzurePreflight) checkRegistryName(ctx context.Context) deployAzureCheck { - const id = "name-container-registry" - if check := p.skipped(id); check != nil { - return *check - } - name := p.config.Resources.ContainerRegistry - check, _ := p.nameCheck(ctx, id, "container registry", "resources.containerRegistry", name, - "acr", "check-name", "--subscription", p.config.Subscription, "--name", name, "--output", "json") - return check -} - -func (p *deployAzurePreflight) checkKeyVaultName(ctx context.Context) deployAzureCheck { - const id = "name-key-vault" - if check := p.skipped(id); check != nil { - return *check - } - name := p.config.Resources.KeyVault - check, available := p.nameCheck(ctx, id, "Key Vault", "resources.keyVault", name, p.restNameArgs("Microsoft.KeyVault", "Microsoft.KeyVault/vaults", name)...) - if check.Status == deployAzureCheckFail { - return check - } - var deleted []struct { - Name string `json:"name"` - Properties struct { - Location string `json:"location"` - ScheduledPurgeDate string `json:"scheduledPurgeDate"` - } `json:"properties"` - } - if err := p.runJSON(ctx, &deleted, "keyvault", "list-deleted", "--subscription", p.config.Subscription, "--resource-type", "vault", "--output", "json"); err != nil { - return deployAzureCheckResult(id, deployAzureCheckUnknown, fmt.Sprintf("could not list soft-deleted Key Vaults: %s", azError(err)), - fmt.Sprintf("Run az keyvault list-deleted and confirm %s is not soft-deleted.", name)) - } - for _, vault := range deleted { - if strings.EqualFold(vault.Name, name) { - return deployAzureCheckResult(id, deployAzureCheckFail, - fmt.Sprintf("Key Vault %s is soft-deleted in this subscription (location %s, scheduled purge %s)", name, vault.Properties.Location, vault.Properties.ScheduledPurgeDate), - fmt.Sprintf("Choose a different resources.keyVault, or have an authorized operator recover (az keyvault recover --name %s) or purge (az keyvault purge --name %s) it after review.", name, name)) - } - } - if !available { - return check - } - check.Message += " and not soft-deleted in this subscription" - return check -} - -func (p *deployAzurePreflight) checkFunctionAppName(ctx context.Context) deployAzureCheck { - const id = "name-function-app" - if check := p.skipped(id); check != nil { - return *check - } - name := p.config.Resources.FunctionApp - check, _ := p.nameCheck(ctx, id, "Function App", "resources.functionApp", name, p.restNameArgs("Microsoft.Web", "Microsoft.Web/sites", name)...) - return check -} - -func (p *deployAzurePreflight) checkPostgresName(ctx context.Context) deployAzureCheck { - const id = "name-postgres-server" - if check := p.skipped(id); check != nil { - return *check - } - name := p.config.postgresServerName() - check, _ := p.nameCheck(ctx, id, "PostgreSQL server", "resources.postgresServer", name, - p.restNameArgs("Microsoft.DBforPostgreSQL", "Microsoft.DBforPostgreSQL/flexibleServers", name)...) - return check -} - -type deployAzureIngress struct { - PublicNetworkAccess string `json:"publicNetworkAccess"` - IPSecurityRestrictionsDefaultAction string `json:"ipSecurityRestrictionsDefaultAction"` - IPSecurityRestrictions []json.RawMessage `json:"ipSecurityRestrictions"` - SCMIPSecurityRestrictionsDefaultAction string `json:"scmIpSecurityRestrictionsDefaultAction"` - SCMIPSecurityRestrictionsUseMain *bool `json:"scmIpSecurityRestrictionsUseMain"` -} - -func (p *deployAzurePreflight) checkIngress(ctx context.Context) deployAzureCheck { - const id = "ingress-drift" - const remediation = "Report only. Before any future upgrade, carry approved ingress forward with functionPublicNetworkAccess, functionAccessRestrictionsDefaultAction, functionAccessRestrictions, functionScmAccessRestrictions, and functionScmAccessRestrictionsUseMain." - if p.siteID == "" { - return deployAzureCheckResult(id, deployAzureCheckInfo, "no existing Function App found; nothing to compare", "") - } - apiVersion := p.template.apiVersions["microsoft.web/sites"] - if apiVersion == "" { - apiVersion = "2023-12-01" - } - var site, config deployAzureIngress - if err := p.runJSON(ctx, &site, "resource", "show", "--ids", p.siteID, "--api-version", apiVersion, - "--query", "{publicNetworkAccess:properties.publicNetworkAccess}", "--output", "json"); err != nil { - return deployAzureCheckResult(id, deployAzureCheckInfo, "could not read existing Function App ingress: "+azError(err), remediation) - } - if err := p.runJSON(ctx, &config, "resource", "show", "--ids", p.siteID+"/config/web", "--api-version", apiVersion, - "--query", "{ipSecurityRestrictionsDefaultAction:properties.ipSecurityRestrictionsDefaultAction,ipSecurityRestrictions:properties.ipSecurityRestrictions,scmIpSecurityRestrictionsDefaultAction:properties.scmIpSecurityRestrictionsDefaultAction,scmIpSecurityRestrictionsUseMain:properties.scmIpSecurityRestrictionsUseMain}", - "--output", "json"); err != nil { - return deployAzureCheckResult(id, deployAzureCheckInfo, "could not read existing Function App access restrictions: "+azError(err), remediation) - } - defaults := p.template.ingress - useMain := "unset" - if config.SCMIPSecurityRestrictionsUseMain != nil { - useMain = strconv.FormatBool(*config.SCMIPSecurityRestrictionsUseMain) - } - rules := deployAzureCountExplicitRules(config.IPSecurityRestrictions) - fields := []struct{ name, live, template string }{ - {"publicNetworkAccess", site.PublicNetworkAccess, defaults.publicNetworkAccess}, - {"main defaultAction", config.IPSecurityRestrictionsDefaultAction, defaults.mainDefaultAction}, - {"main rules", strconv.Itoa(rules), strconv.Itoa(defaults.mainRules)}, - {"scm defaultAction", config.SCMIPSecurityRestrictionsDefaultAction, defaults.scmDefaultAction}, - {"scmIpSecurityRestrictionsUseMain", useMain, defaults.scmUseMain}, - } - var parts, drift []string - for _, field := range fields { - parts = append(parts, fmt.Sprintf("%s=%s (template %s)", field.name, valueOr(field.live, "unset"), valueOr(field.template, "unknown"))) - if !strings.EqualFold(field.live, field.template) { - drift = append(drift, field.name) - } - } - summary := "matches template defaults" - if len(drift) > 0 { - summary = "differs from template defaults in " + strings.Join(drift, ", ") - } - return deployAzureCheckResult(id, deployAzureCheckInfo, fmt.Sprintf("existing Function App %s %s: %s", p.config.Resources.FunctionApp, summary, strings.Join(parts, "; ")), remediation) -} - -// deployAzureCountExplicitRules ignores the implicit "Allow all" rule that -// App Service reports when no explicit rules exist. -func deployAzureCountExplicitRules(rules []json.RawMessage) int { - count := 0 - for _, raw := range rules { - var rule struct { - Name string `json:"name"` - IPAddress string `json:"ipAddress"` - } - if json.Unmarshal(raw, &rule) == nil && strings.EqualFold(rule.IPAddress, "Any") && strings.HasSuffix(strings.ToLower(rule.Name), "all") { - continue - } - count++ - } - return count -} - -func valueOr(value, fallback string) string { - if value == "" { - return fallback - } - return value -} - -var deployAzureRetentionSettings = []string{"CAO_POSTGRES_RUN_RETENTION_DAYS", "CAO_POSTGRES_LINKED_RETENTION_DAYS"} - -func (p *deployAzurePreflight) checkRetention(ctx context.Context) deployAzureCheck { - const id = "retention-readback" - const remediation = "Report only. Before any future upgrade, set retentionDays to at least the greatest existing effective retention across all roles." - type setting struct { - Name string `json:"name"` - Value string `json:"value"` - } - var apps []deployAzureResource - for _, resource := range p.existing { - if strings.EqualFold(resource.Type, "Microsoft.App/containerApps") || strings.EqualFold(resource.Type, "Microsoft.App/jobs") { - apps = append(apps, resource) - } - } - if p.siteID == "" && len(apps) == 0 { - return deployAzureCheckResult(id, deployAzureCheckInfo, "no existing instance found; nothing to read back", "") - } - filter := fmt.Sprintf("[?name=='%s' || name=='%s'].{name:name,value:value}", deployAzureRetentionSettings[0], deployAzureRetentionSettings[1]) - greatest := 0 - var roles []string - record := func(role string, values []setting) { - if len(values) == 0 { - roles = append(roles, role+": not set") - return - } - var parts []string - for _, value := range values { - parts = append(parts, fmt.Sprintf("%s=%s", strings.TrimPrefix(strings.TrimSuffix(value.Name, "_RETENTION_DAYS"), "CAO_POSTGRES_"), value.Value)) - if days, err := strconv.Atoi(value.Value); err == nil && days > greatest { - greatest = days - } - } - sort.Strings(parts) - roles = append(roles, role+": "+strings.Join(parts, " ")) - } - if p.siteID != "" { - var values []setting - group := deployAzureResourceGroupOf(p.siteID) - if err := p.runJSON(ctx, &values, "functionapp", "config", "appsettings", "list", "--subscription", p.config.Subscription, - "--resource-group", group, "--name", p.config.Resources.FunctionApp, "--query", filter, "--output", "json"); err != nil { - roles = append(roles, "function app: unreadable ("+azError(err)+")") - } else { - record("function app", values) - } - } - for _, resource := range apps { - var values []setting - name := deployAzureResourceName(resource.ID) - if err := p.runJSON(ctx, &values, "resource", "show", "--ids", resource.ID, - "--query", "properties.template.containers[].env[] | "+filter, "--output", "json"); err != nil { - roles = append(roles, name+": unreadable ("+azError(err)+")") - } else { - record(name, values) - } - } - message := fmt.Sprintf("existing retention %s; config retentionDays=%d", strings.Join(roles, "; "), p.config.RetentionDays) - if greatest > p.config.RetentionDays { - message += fmt.Sprintf(" is lower than the greatest existing value %d", greatest) - } - return deployAzureCheckResult(id, deployAzureCheckInfo, message, remediation) -} - -func deployAzureResourceGroupOf(id string) string { - parts := strings.Split(id, "/") - for index := 0; index+1 < len(parts); index++ { - if strings.EqualFold(parts[index], "resourceGroups") { - return parts[index+1] - } - } - return "" -} - -// deployAzureTemplateFacts are values preflight derives from the compiled template. -type deployAzureTemplateFacts struct { - types []string - apiVersions map[string]string - planSKU string - postgresSKU string - postgresTier string - ingress struct { - publicNetworkAccess string - mainDefaultAction string - mainRules int - scmDefaultAction string - scmUseMain string - } -} - -var deployAzureParameterExpression = regexp.MustCompile(`^\[parameters\('([^']+)'\)\]$`) - -func deployAzureTemplateFactsFrom(content []byte, supplied map[string]any) deployAzureTemplateFacts { - facts := deployAzureTemplateFacts{apiVersions: map[string]string{}} - var root map[string]any - if json.Unmarshal(content, &root) != nil { - return facts - } - types := map[string]bool{} - var walk func(map[string]any, map[string]any) - walk = func(document map[string]any, supplied map[string]any) { - parameters, _ := document["parameters"].(map[string]any) - resolve := func(value any) any { - if text, ok := value.(string); ok { - if match := deployAzureParameterExpression.FindStringSubmatch(text); match != nil { - if value, ok := supplied[match[1]]; ok { - return value - } - if parameter, ok := parameters[match[1]].(map[string]any); ok { - return parameter["defaultValue"] - } - return nil - } - } - return value - } - text := func(value any) string { - switch typed := resolve(value).(type) { - case string: - if strings.HasPrefix(typed, "[") { - return "" - } - return typed - case bool: - return strconv.FormatBool(typed) - } - return "" - } - if facts.postgresSKU == "" { - facts.postgresSKU = text("[parameters('postgresSkuName')]") - facts.postgresTier = text("[parameters('postgresSkuTier')]") - } - var list []any - switch typed := document["resources"].(type) { - case []any: - list = typed - case map[string]any: - for _, value := range typed { - list = append(list, value) - } - } - for _, item := range list { - resource, ok := item.(map[string]any) - if !ok { - continue - } - // Only omit resources proven disabled; unknown ARM expressions - // retain their prerequisites rather than weakening preflight. - if enabled, ok := resolve(resource["condition"]).(bool); ok && !enabled { - continue - } - resourceType, _ := resource["type"].(string) - if resourceType == "" { - continue - } - types[resourceType] = true - if apiVersion, ok := resource["apiVersion"].(string); ok && facts.apiVersions[strings.ToLower(resourceType)] == "" { - facts.apiVersions[strings.ToLower(resourceType)] = apiVersion - } - properties, _ := resource["properties"].(map[string]any) - switch strings.ToLower(resourceType) { - case "microsoft.web/serverfarms": - if sku, ok := resource["sku"].(map[string]any); ok && facts.planSKU == "" { - facts.planSKU = text(sku["name"]) - } - case "microsoft.web/sites": - facts.ingress.publicNetworkAccess = text(properties["publicNetworkAccess"]) - if siteConfig, ok := properties["siteConfig"].(map[string]any); ok { - facts.ingress.mainDefaultAction = text(siteConfig["ipSecurityRestrictionsDefaultAction"]) - if rules, ok := resolve(siteConfig["ipSecurityRestrictions"]).([]any); ok { - facts.ingress.mainRules = len(rules) - } - facts.ingress.scmDefaultAction = text(siteConfig["scmIpSecurityRestrictionsDefaultAction"]) - facts.ingress.scmUseMain = text(siteConfig["scmIpSecurityRestrictionsUseMain"]) - } - case "microsoft.resources/deployments": - if nested, ok := properties["template"].(map[string]any); ok { - values := map[string]any{} - if parameters, ok := properties["parameters"].(map[string]any); ok { - for name, entry := range parameters { - if parameter, ok := entry.(map[string]any); ok { - values[name] = resolve(parameter["value"]) - } - } - } - walk(nested, values) - } - } - } - } - walk(root, supplied) - for resourceType := range types { - facts.types = append(facts.types, resourceType) - } - sort.Strings(facts.types) - return facts -} diff --git a/internal/cli/deploy_azure_preflight_test.go b/internal/cli/deploy_azure_preflight_test.go deleted file mode 100644 index f036852bcd..0000000000 --- a/internal/cli/deploy_azure_preflight_test.go +++ /dev/null @@ -1,421 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - "testing" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -const testDeploySiteID = "/subscriptions/" + testDeploySubscription + "/resourceGroups/rg-synthetic-test/providers/Microsoft.Web/sites/fa-synthetic-test" - -// testDeployProviders builds az provider list output covering the required -// providers and every template resource type in one location. -func testDeployProviders(t *testing.T, state, location string) string { - t.Helper() - content, err := os.ReadFile(testDeployTemplatePath(t)) - if err != nil { - t.Fatal(err) - } - facts := deployAzureTemplateFactsFrom(content, nil) - type resourceType struct { - ResourceType string `json:"resourceType"` - Locations []string `json:"locations"` - } - type provider struct { - Namespace string `json:"namespace"` - RegistrationState string `json:"registrationState"` - ResourceTypes []resourceType `json:"resourceTypes"` - } - byNamespace := map[string]*provider{} - var order []string - add := func(namespace string) *provider { - if existing, ok := byNamespace[strings.ToLower(namespace)]; ok { - return existing - } - created := &provider{Namespace: namespace, RegistrationState: state} - byNamespace[strings.ToLower(namespace)] = created - order = append(order, strings.ToLower(namespace)) - return created - } - for _, namespace := range deployAzureRequiredProviders { - add(namespace) - } - for _, full := range facts.types { - namespace, typeName, _ := strings.Cut(full, "/") - entry := add(namespace) - entry.ResourceTypes = append(entry.ResourceTypes, resourceType{ResourceType: typeName, Locations: []string{location}}) - } - var providers []provider - for _, key := range order { - providers = append(providers, *byNamespace[key]) - } - return string(mustJSON(t, providers)) -} - -func runDeployPreflight(t *testing.T, fake *fakeAz, config any, extra ...string) (deployAzurePreflightReport, string, error) { - t.Helper() - t.Chdir(t.TempDir()) - configPath := writeDeployConfig(t, config) - root, stdout, stderr := testDeployRoot(fake) - root.SetArgs(append([]string{"deploy", "azure", "--preflight", "--json", "--config", configPath, "--template", testDeployTemplatePath(t)}, extra...)) - err := root.ExecuteContext(context.Background()) - var report deployAzurePreflightReport - if decodeErr := json.Unmarshal(stdout.Bytes(), &report); decodeErr != nil { - t.Fatalf("preflight JSON: %v\nstdout:\n%s\nstderr:\n%s\nerr: %v", decodeErr, stdout, stderr, err) - } - if entries, readErr := os.ReadDir("."); readErr != nil || len(entries) != 0 { - t.Fatalf("--preflight wrote files to the working directory: %v %v", entries, readErr) - } - return report, stdout.String() + stderr.String(), err -} - -func findDeployCheck(t *testing.T, report deployAzurePreflightReport, id string) deployAzureCheck { - t.Helper() - for _, check := range report.Checks { - if check.ID == id { - return check - } - } - t.Fatalf("check %s missing from %+v", id, report.Checks) - return deployAzureCheck{} -} - -var testDeployCheckIDs = []string{"tools", "login", "existing-instance", "permissions", "resource-providers", "location", - "name-storage-account", "name-container-registry", "name-key-vault", "name-function-app", "name-postgres-server", - "ingress-drift", "retention-readback"} - -var testDeployRestPath = regexp.MustCompile(`^/subscriptions/` + testDeploySubscription + `/providers/(Microsoft\.KeyVault|Microsoft\.Web|Microsoft\.DBforPostgreSQL)(/locations/[a-z0-9]+)?/checkNameAvailability\?api-version=\d{4}-\d{2}-\d{2}(-preview)?$`) - -func assertDeployCallsReadOnly(t *testing.T, calls [][]string) { - t.Helper() - for _, call := range calls { - if !azcli.IsReadOnly(call) { - t.Fatalf("non-read-only call %q", call) - } - if azcli.CommandName(call) != "rest" { - continue - } - method, url := call[slices.Index(call, "--method")+1], call[slices.Index(call, "--url")+1] - if strings.EqualFold(method, "get") && strings.HasSuffix(url, "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01") { - continue - } - if strings.EqualFold(method, "get") && strings.Contains(url, "/providers/Microsoft.Web/geoRegions?") && azcli.IsReadOnly(call) { - continue - } - if !strings.EqualFold(method, "post") || !testDeployRestPath.MatchString(url) { - t.Fatalf("az rest outside the checkNameAvailability allowlist: %q", call) - } - } -} - -func TestDeployAzurePreflightHealthy(t *testing.T) { - fake := &fakeAz{t: t} - report, _, err := runDeployPreflight(t, fake, testDeployConfig()) - if err != nil { - t.Fatal(err) - } - if report.Kind != deployAzurePreflightKind || report.SchemaVersion != 1 || report.Status != deployAzureCheckPass { - t.Fatalf("report header = %+v", report) - } - var ids []string - for _, check := range report.Checks { - ids = append(ids, check.ID) - want := deployAzureCheckPass - if check.ID == "ingress-drift" || check.ID == "retention-readback" { - want = deployAzureCheckInfo - } - if check.Status != want || check.Message == "" { - t.Fatalf("%s = %+v, want %s", check.ID, check, want) - } - } - if !slices.Equal(ids, testDeployCheckIDs) { - t.Fatalf("check ids = %q", ids) - } - if tools := findDeployCheck(t, report, "tools"); !strings.Contains(tools.Message, "Bicep is not required") { - t.Fatalf("tools message = %q", tools.Message) - } - if login := findDeployCheck(t, report, "login"); !strings.Contains(login.Message, testDeployTenant) { - t.Fatalf("login does not report tenant: %q", login.Message) - } - providers := findDeployCheck(t, report, "resource-providers") - for _, namespace := range []string{"Microsoft.Web", "Microsoft.App", "Microsoft.DBforPostgreSQL", "Microsoft.KeyVault", "Microsoft.ContainerRegistry", - "Microsoft.Storage", "Microsoft.Network", "Microsoft.OperationalInsights", "Microsoft.Insights", "Microsoft.ManagedIdentity"} { - if !strings.Contains(providers.Message, namespace) { - t.Fatalf("providers message missing %s: %q", namespace, providers.Message) - } - } - assertDeployCallsReadOnly(t, fake.calls) - var namespaces []string - for _, call := range fake.calls { - if azcli.CommandName(call) == "rest" { - namespaces = append(namespaces, strings.TrimPrefix(fakeAzKey(call), "rest ")) - } - } - if !slices.Equal(namespaces, []string{"Microsoft.Authorization", "Microsoft.Web geoRegions", "Microsoft.KeyVault", "Microsoft.Web", "Microsoft.DBforPostgreSQL"}) { - t.Fatalf("checkNameAvailability namespaces = %q", namespaces) - } -} - -func TestDeployAzurePreflightTable(t *testing.T) { - t.Chdir(t.TempDir()) - fake := &fakeAz{t: t, overrides: map[string]fakeAzResponse{"acr check-name": {stdout: `{"nameAvailable":false,"reason":"AlreadyExists","message":"taken"}`}}} - root, stdout, _ := testDeployRoot(fake) - root.SetArgs([]string{"deploy", "azure", "--preflight", "--config", writeDeployConfig(t, testDeployConfig())}) - err := root.Execute() - if err == nil || !strings.Contains(err.Error(), "name-container-registry") { - t.Fatalf("expected failing preflight error naming the check, got %v", err) - } - for _, want := range []string{"STATUS", "PASS", "FAIL", "INFO", "name-container-registry", "Remediation", "resources.containerRegistry"} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("table missing %q:\n%s", want, stdout) - } - } - if entries, _ := os.ReadDir("."); len(entries) != 0 { - t.Fatalf("--preflight wrote files: %v", entries) - } -} - -func TestDeployAzurePreflightChecks(t *testing.T) { - failed := errors.New("ERROR: synthetic az failure") - err := func(stderr string) fakeAzResponse { - return fakeAzResponse{stderr: stderr, err: &azcli.ExitError{Code: 1, Stderr: stderr}} - } - ok := func(stdout string) fakeAzResponse { return fakeAzResponse{stdout: stdout} } - unavailable := `{"nameAvailable":false,"reason":"AlreadyExists","message":"The name is already in use."}` - for _, tt := range []struct { - name string - overrides map[string]fakeAzResponse - fake func(*fakeAz) - config func(map[string]any) - id string - status string - message string - }{ - {name: "tools old az", overrides: map[string]fakeAzResponse{"version": ok(`{"azure-cli":"2.50.0"}`)}, id: "tools", status: "fail", message: "older than the required"}, - {name: "tools missing az", overrides: map[string]fakeAzResponse{"version": {err: failed}}, id: "tools", status: "fail", message: "Azure CLI is unavailable"}, - {name: "tools unreadable version", overrides: map[string]fakeAzResponse{"version": ok(`{}`)}, id: "tools", status: "unknown", message: "could not read the Azure CLI version"}, - {name: "tools failure skips later checks", overrides: map[string]fakeAzResponse{"version": {err: failed}}, id: "permissions", status: "unknown", message: "Azure CLI is unavailable"}, - - {name: "login not signed in", overrides: map[string]fakeAzResponse{"account show": err("Please run 'az login' to setup account.")}, id: "login", status: "fail", message: "az login"}, - {name: "login wrong subscription", overrides: map[string]fakeAzResponse{"account show": ok(`{"id":"00000000-0000-4000-8000-0000000000ff","state":"Enabled","tenantId":"` + testDeployTenant + `"}`)}, id: "login", status: "fail", message: "00000000-0000-4000-8000-0000000000ff"}, - {name: "login disabled subscription", overrides: map[string]fakeAzResponse{"account show": ok(`{"id":"` + testDeploySubscription + `","state":"Disabled","tenantId":"` + testDeployTenant + `"}`)}, id: "login", status: "fail", message: "Disabled"}, - {name: "login failure skips names", overrides: map[string]fakeAzResponse{"account show": {err: failed}}, id: "name-key-vault", status: "unknown", message: "login check did not pass"}, - - {name: "existing unknown fails closed", overrides: map[string]fakeAzResponse{"account show": {err: failed}}, id: "existing-instance", status: "fail", message: "login check did not pass"}, - {name: "existing list error fails closed", overrides: map[string]fakeAzResponse{"resource list": {err: failed}}, id: "existing-instance", status: "fail", message: "could not determine whether an instance exists"}, - {name: "existing named resource", fake: func(f *fakeAz) { - f.namedResources = map[string]string{"stsynthetictest": `[{"id":"/subscriptions/x/resourceGroups/rg-other/providers/Microsoft.Storage/storageAccounts/stsynthetictest","type":"Microsoft.Storage/storageAccounts"}]`} - }, id: "existing-instance", status: "fail", message: "only fresh deployments are supported"}, - - {name: "permissions reader only", overrides: map[string]fakeAzResponse{"rest Microsoft.Authorization": ok(`{"value":[{"actions":["*/read"],"notActions":[]}]}`)}, id: "permissions", status: "fail", message: "do not grant"}, - {name: "permissions contributor lacks assign", overrides: map[string]fakeAzResponse{"rest Microsoft.Authorization": ok(`{"value":[{"actions":["*"],"notActions":["Microsoft.Authorization/*/write"]}]}`)}, id: "permissions", status: "fail", message: "Microsoft.Authorization/roleAssignments/write"}, - {name: "permissions union restores assign", overrides: map[string]fakeAzResponse{"rest Microsoft.Authorization": ok(`{"value":[{"actions":["*"],"notActions":["Microsoft.Authorization/*/write"]},{"actions":["Microsoft.Authorization/roleAssignments/write"],"notActions":[]}]}`)}, id: "permissions", status: "pass", message: "can create resources and role assignments"}, - {name: "permissions paginated unknown", overrides: map[string]fakeAzResponse{"rest Microsoft.Authorization": ok(`{"value":[{"actions":["*"]}],"nextLink":"https://management.azure.com/next"}`)}, id: "permissions", status: "unknown", message: "missing or incomplete"}, - {name: "permissions malformed unknown", overrides: map[string]fakeAzResponse{"rest Microsoft.Authorization": ok(`{}`)}, id: "permissions", status: "unknown", message: "missing or incomplete"}, - {name: "permissions unavailable", overrides: map[string]fakeAzResponse{"rest Microsoft.Authorization": err("AuthorizationFailed")}, id: "permissions", status: "unknown", message: "could not read ARM effective permissions"}, - {name: "permissions timeout", overrides: map[string]fakeAzResponse{"rest Microsoft.Authorization": {err: context.DeadlineExceeded}}, id: "permissions", status: "unknown", message: "timed out after at most one minute"}, - {name: "permissions service principal", overrides: map[string]fakeAzResponse{"account show": ok(`{"id":"` + testDeploySubscription + `","state":"Enabled","tenantId":"` + testDeployTenant + `","user":{"name":"00000000-0000-4000-8000-000000000005","type":"servicePrincipal"}}`)}, id: "permissions", status: "pass", message: "can create resources"}, - {name: "permissions managed identity", overrides: map[string]fakeAzResponse{"account show": ok(`{"id":"` + testDeploySubscription + `","state":"Enabled","tenantId":"` + testDeployTenant + `","user":{"name":"x","type":"managedIdentity"}}`)}, id: "permissions", status: "pass", message: "no Microsoft Graph lookup"}, - - {name: "providers unregistered", fake: func(f *fakeAz) { - f.overrides = map[string]fakeAzResponse{"provider list": ok(testDeployProviders(f.t, "NotRegistered", "West US 3"))} - }, id: "resource-providers", status: "fail", message: "Microsoft.App (NotRegistered)"}, - {name: "providers missing namespace", overrides: map[string]fakeAzResponse{"provider list": ok(`[]`)}, id: "resource-providers", status: "fail", message: "Microsoft.Web (NotFound)"}, - {name: "providers list error", overrides: map[string]fakeAzResponse{"provider list": {err: failed}}, id: "resource-providers", status: "unknown", message: "could not list resource providers"}, - - {name: "location unsupported type", fake: func(f *fakeAz) { - f.overrides = map[string]fakeAzResponse{"provider list": ok(testDeployProviders(f.t, "Registered", "East US"))} - }, id: "location", status: "fail", message: "does not offer"}, - {name: "location plan sku unavailable", overrides: map[string]fakeAzResponse{"rest Microsoft.Web geoRegions": ok(`{"value":[{"name":"East US"}]}`)}, id: "location", status: "fail", message: "App Service plan EP1 on Linux"}, - {name: "location postgres sku missing", overrides: map[string]fakeAzResponse{"postgres flexible-server list-skus": ok(`[{"supportedServerEditions":[{"name":"Burstable","supportedServerSkus":[{"name":"Standard_B1ms"}]}]}]`)}, id: "location", status: "fail", message: "PostgreSQL Burstable Standard_B2s"}, - {name: "location postgres unrecognized", overrides: map[string]fakeAzResponse{"postgres flexible-server list-skus": ok(`[{"unexpected":true}]`)}, id: "location", status: "unknown", message: "unrecognized capability output"}, - {name: "location list error", overrides: map[string]fakeAzResponse{"rest Microsoft.Web geoRegions": {err: failed}}, id: "location", status: "unknown", message: "could not confirm location"}, - - {name: "storage taken", overrides: map[string]fakeAzResponse{"storage account check-name": ok(unavailable)}, id: "name-storage-account", status: "fail", message: "not available (AlreadyExists"}, - {name: "storage error", overrides: map[string]fakeAzResponse{"storage account check-name": {err: failed}}, id: "name-storage-account", status: "unknown", message: "could not check storage account name"}, - {name: "registry taken", overrides: map[string]fakeAzResponse{"acr check-name": ok(unavailable)}, id: "name-container-registry", status: "fail", message: "container registry name acrsynthetictest is not available"}, - {name: "registry malformed", overrides: map[string]fakeAzResponse{"acr check-name": ok(`{}`)}, id: "name-container-registry", status: "unknown", message: "did not include nameAvailable"}, - {name: "key vault taken", overrides: map[string]fakeAzResponse{"rest Microsoft.KeyVault": ok(unavailable)}, id: "name-key-vault", status: "fail", message: "Key Vault name kv-synthetic-test is not available"}, - {name: "key vault soft-deleted", overrides: map[string]fakeAzResponse{"keyvault list-deleted": ok(`[{"name":"kv-synthetic-test","properties":{"location":"westus3","scheduledPurgeDate":"2026-02-01T00:00:00Z"}}]`)}, id: "name-key-vault", status: "fail", message: "soft-deleted"}, - {name: "key vault deleted list error", overrides: map[string]fakeAzResponse{"keyvault list-deleted": {err: failed}}, id: "name-key-vault", status: "unknown", message: "could not list soft-deleted Key Vaults"}, - {name: "key vault check error", overrides: map[string]fakeAzResponse{"rest Microsoft.KeyVault": {err: failed}}, id: "name-key-vault", status: "unknown", message: "could not check Key Vault name"}, - {name: "function app taken", overrides: map[string]fakeAzResponse{"rest Microsoft.Web": ok(unavailable)}, id: "name-function-app", status: "fail", message: "Function App name fa-synthetic-test is not available"}, - {name: "function app error", overrides: map[string]fakeAzResponse{"rest Microsoft.Web": {err: failed}}, id: "name-function-app", status: "unknown", message: "could not check Function App name"}, - {name: "postgres taken", overrides: map[string]fakeAzResponse{"rest Microsoft.DBforPostgreSQL": ok(unavailable)}, id: "name-postgres-server", status: "fail", message: "PostgreSQL server name fa-synthetic-test-pg is not available"}, - {name: "postgres configured name", config: func(c map[string]any) { c["resources"].(map[string]any)["postgresServer"] = "pg-synthetic-test" }, - overrides: map[string]fakeAzResponse{"rest Microsoft.DBforPostgreSQL": ok(unavailable)}, id: "name-postgres-server", status: "fail", message: "pg-synthetic-test is not available"}, - {name: "postgres error", overrides: map[string]fakeAzResponse{"rest Microsoft.DBforPostgreSQL": {err: failed}}, id: "name-postgres-server", status: "unknown", message: "could not check PostgreSQL server name"}, - } { - t.Run(tt.name, func(t *testing.T) { - fake := &fakeAz{t: t, overrides: tt.overrides} - if tt.fake != nil { - tt.fake(fake) - } - config := testDeployConfig() - if tt.config != nil { - tt.config(config) - } - report, _, runErr := runDeployPreflight(t, fake, config) - check := findDeployCheck(t, report, tt.id) - if check.Status != tt.status || !strings.Contains(check.Message, tt.message) { - t.Fatalf("%s = %+v, want %s containing %q", tt.id, check, tt.status, tt.message) - } - if check.Status != deployAzureCheckPass && strings.TrimSpace(check.Remediation) == "" { - t.Fatalf("%s %s has no remediation", tt.id, check.Status) - } - anyFailed := slices.ContainsFunc(report.Checks, func(c deployAzureCheck) bool { - return c.Status == deployAzureCheckFail || c.Status == deployAzureCheckUnknown - }) - if anyFailed != (runErr != nil) || anyFailed != (report.Status == deployAzureCheckFail) { - t.Fatalf("failed=%t but err=%v status=%s", anyFailed, runErr, report.Status) - } - if len(report.Checks) != len(testDeployCheckIDs) { - t.Fatalf("preflight stopped early: %d checks", len(report.Checks)) - } - assertDeployCallsReadOnly(t, fake.calls) - }) - } -} - -func TestDeployAzurePreflightExistingInstanceReports(t *testing.T) { - site := `[{"id":"` + testDeploySiteID + `","type":"Microsoft.Web/sites"}]` - job := "/subscriptions/" + testDeploySubscription + "/resourceGroups/rg-synthetic-test/providers/Microsoft.App/jobs/fa-synthetic-test-ingest" - for _, tt := range []struct { - name string - overrides map[string]fakeAzResponse - ingress []string - retention []string - }{ - {name: "matches defaults", ingress: []string{"matches template defaults", "publicNetworkAccess=Enabled (template Enabled)", "scmIpSecurityRestrictionsUseMain=false (template false)"}, - retention: []string{"function app: LINKED=45 RUN=45", "fa-synthetic-test-ingest: LINKED=60 RUN=60", "retentionDays=45 is lower than the greatest existing value 60"}}, - {name: "drifted", overrides: map[string]fakeAzResponse{ - "resource show Microsoft.Web/sites": {stdout: `{"publicNetworkAccess":"Disabled"}`}, - "resource show config": {stdout: `{"ipSecurityRestrictionsDefaultAction":"Deny","ipSecurityRestrictions":[{"name":"office","ipAddress":"203.0.113.0/24","action":"Allow"}],"scmIpSecurityRestrictionsDefaultAction":"Allow","scmIpSecurityRestrictionsUseMain":true}`}, - }, ingress: []string{"differs from template defaults in publicNetworkAccess, main defaultAction, main rules, scm defaultAction, scmIpSecurityRestrictionsUseMain", "main rules=1 (template 0)"}}, - {name: "unreadable", overrides: map[string]fakeAzResponse{ - "resource show Microsoft.Web/sites": {err: errors.New("ERROR: AuthorizationFailed")}, - "functionapp config appsettings list": {err: errors.New("ERROR: AuthorizationFailed")}, - "resource show Microsoft.App/jobs": {stdout: `[]`}, - }, ingress: []string{"could not read existing Function App ingress"}, retention: []string{"function app: unreadable", "fa-synthetic-test-ingest: not set"}}, - } { - t.Run(tt.name, func(t *testing.T) { - fake := &fakeAz{t: t, groupExists: true, overrides: tt.overrides, - groupResources: `[{"id":"` + testDeploySiteID + `","type":"Microsoft.Web/sites"},{"id":"` + job + `","type":"Microsoft.App/jobs"}]`, - namedResources: map[string]string{"fa-synthetic-test": site}} - report, _, err := runDeployPreflight(t, fake, testDeployConfig()) - if err == nil || !strings.Contains(err.Error(), "only fresh deployments") { - t.Fatalf("existing instance must still fail closed, got %v", err) - } - for id, wants := range map[string][]string{"ingress-drift": tt.ingress, "retention-readback": tt.retention} { - check := findDeployCheck(t, report, id) - if check.Status != deployAzureCheckInfo || check.Remediation == "" { - t.Fatalf("%s must be report-only info with guidance: %+v", id, check) - } - for _, want := range wants { - if !strings.Contains(check.Message, want) { - t.Fatalf("%s message %q missing %q", id, check.Message, want) - } - } - } - assertDeployCallsReadOnly(t, fake.calls) - for _, call := range fake.calls { - if azcli.CommandName(call) == "functionapp config appsettings list" && !slices.Contains(call, "--query") { - t.Fatalf("app settings read without a retention-only query: %q", call) - } - } - }) - } -} - -func TestDeployAzurePreflightNeverExposesSecrets(t *testing.T) { - leak := "echo " + strings.Join(testDeploySecrets(), " ") - fake := &fakeAz{t: t, overrides: map[string]fakeAzResponse{ - "acr check-name": {stderr: leak, err: &azcli.ExitError{Code: 1, Stderr: leak}}, - "rest Microsoft.KeyVault": {stdout: `{"nameAvailable":false,"reason":"AlreadyExists","message":"` + testDeployPassword + `"}`}, - "keyvault list-deleted": {err: errors.New(leak)}, - "appservice list-locations": {err: errors.New(leak)}, - }} - report, output, err := runDeployPreflight(t, fake, testDeployConfig()) - observed := output + string(mustJSON(t, report)) - if err != nil { - observed += err.Error() - } - for _, call := range fake.calls { - observed += strings.Join(call, " ") - } - for _, secret := range testDeploySecrets() { - if strings.Contains(observed, secret) { - t.Fatalf("secret value exposed in preflight argv, output, or error:\n%s", observed) - } - } - if check := findDeployCheck(t, report, "name-container-registry"); !strings.Contains(check.Message, "***") { - t.Fatalf("expected redacted az error, got %q", check.Message) - } -} - -func TestDeployAzurePlanRunsPreflightFirst(t *testing.T) { - fake := &fakeAz{t: t, overrides: map[string]fakeAzResponse{"rest Microsoft.Web": {stdout: `{"nameAvailable":false,"reason":"AlreadyExists"}`}}} - stdout, _, out, err := runDeployPlan(t, fake, testDeployConfig()) - if err == nil || !strings.Contains(err.Error(), "name-function-app") { - t.Fatalf("expected preflight failure, got %v", err) - } - if !strings.Contains(stdout, "FAIL") || !strings.Contains(stdout, "name-function-app") { - t.Fatalf("plan did not print preflight table:\n%s", stdout) - } - for _, call := range fake.calls { - if slices.Contains(call, "what-if") { - t.Fatalf("what-if ran after preflight failed: %q", call) - } - } - if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) { - t.Fatal("failed preflight wrote a plan file") - } - if !slices.Equal(fake.calls[0], []string{"version", "--output", "json"}) { - t.Fatalf("first call = %q, want az version", fake.calls[0]) - } -} - -func TestDeployAzurePreflightWorksWithoutSecrets(t *testing.T) { - t.Chdir(t.TempDir()) - fake := &fakeAz{t: t} - root, stdout, _ := testDeployRootWith(fake, func(string) (string, bool) { return "", false }) - root.SetArgs([]string{"deploy", "azure", "--preflight", "--config", writeDeployConfig(t, testDeployConfig())}) - if err := root.Execute(); err != nil { - t.Fatalf("preflight should not require secrets: %v\n%s", err, stdout) - } - if entries, _ := os.ReadDir("."); len(entries) != 0 { - t.Fatalf("--preflight wrote files: %v", entries) - } -} - -func TestDeployAzurePreflightModeFlags(t *testing.T) { - configPath := writeDeployConfig(t, testDeployConfig()) - for _, tt := range []struct { - args []string - want string - }{ - {[]string{"--preflight", "--plan"}, "none of the others can be"}, - {[]string{"--preflight", "--apply"}, "none of the others can be"}, - {[]string{"--preflight", "--out", filepath.Join(t.TempDir(), "p.json")}, "--out is not valid with --preflight"}, - {[]string{"--preflight", "--plan-file", "p.json"}, "--plan-file is only valid with --apply"}, - {[]string{"--json"}, "--json is only valid with --preflight"}, - } { - fake := &fakeAz{t: t} - root, _, _ := testDeployRoot(fake) - root.SetArgs(append([]string{"deploy", "azure", "--config", configPath}, tt.args...)) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("%q: expected %q, got %v", tt.args, tt.want, err) - } - if len(fake.calls) != 0 { - t.Fatalf("%q: unexpected calls %q", tt.args, fake.calls) - } - } -} diff --git a/internal/cli/deploy_azure_restart.go b/internal/cli/deploy_azure_restart.go deleted file mode 100644 index 29c3f4c325..0000000000 --- a/internal/cli/deploy_azure_restart.go +++ /dev/null @@ -1,92 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "slices" - "strings" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -const ( - deployAzureReferenceTimeout = 10 * time.Minute - deployAzureReferencePause = 10 * time.Second -) - -// deployAzureRestartAndAwaitReferences restarts the Function App once after -// its managed-identity grants and database provisioning exist, because App -// Service resolves Key Vault references at site start and keeps a denied -// result until restart. It then waits until every Key Vault reference reports -// Resolved, reporting only setting names and statuses. -func deployAzureRestartAndAwaitReferences(ctx context.Context, deps deployAzureDeps, az azcli.Executor, config deployAzureConfig) error { - scope := []string{"--subscription", config.Subscription, "--resource-group", config.ResourceGroup, "--name", config.Resources.FunctionApp} - if _, err := az.Run(ctx, append([]string{"functionapp", "restart"}, scope...)...); err != nil { - return fmt.Errorf("restart Function App to re-resolve Key Vault references: %s", azError(err)) - } - ctx, cancel := context.WithTimeout(ctx, deployAzureReferenceTimeout) - defer cancel() - url := "/subscriptions/" + config.Subscription + "/resourceGroups/" + config.ResourceGroup + - "/providers/Microsoft.Web/sites/" + config.Resources.FunctionApp + "/config/configreferences/appsettings?api-version=2023-12-01" - var last string - for { - result, err := az.Run(ctx, "rest", "--method", "get", "--url", url, "--output", "json") - switch { - case err != nil: - last = "could not read Key Vault reference status: " + azError(err) - default: - pending, parseErr := deployAzureUnresolvedReferences(result.Stdout) - if parseErr != nil { - last = parseErr.Error() - } else if len(pending) == 0 { - return nil - } else { - last = "unresolved Key Vault references: " + strings.Join(pending, ", ") - } - } - if err := deployAzurePause(ctx, deps, deployAzureReferencePause); err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("the Function App's Key Vault references did not resolve within %s after restart (%s); verify the Function App identity's Key Vault Secrets User grant", deployAzureReferenceTimeout, last) - } - return fmt.Errorf("waiting for Function App Key Vault references: %w", err) - } - } -} - -// deployAzureUnresolvedReferences returns "NAME=status" for every reference -// that is not Resolved. Reference targets and values are never returned. -func deployAzureUnresolvedReferences(body string) ([]string, error) { - var response struct { - Value []struct { - Name string `json:"name"` - Properties struct { - Status string `json:"status"` - } `json:"properties"` - } `json:"value"` - } - if err := json.Unmarshal([]byte(body), &response); err != nil { - return nil, errors.New("could not parse Key Vault reference status") - } - if len(response.Value) == 0 { - return nil, errors.New("no Key Vault references reported yet") - } - var pending []string - for _, reference := range response.Value { - name := reference.Name - if i := strings.LastIndex(name, "/"); i >= 0 { - name = name[i+1:] - } - if reference.Properties.Status != "Resolved" { - status := reference.Properties.Status - if status == "" { - status = "Unknown" - } - pending = append(pending, name+"="+status) - } - } - slices.Sort(pending) - return pending, nil -} diff --git a/internal/cli/deploy_azure_restart_test.go b/internal/cli/deploy_azure_restart_test.go deleted file mode 100644 index 77ebdd9101..0000000000 --- a/internal/cli/deploy_azure_restart_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package cli - -import ( - "context" - "strings" - "testing" - "time" -) - -func TestDeployAzureRestartDefaultPauseHonorsCancellation(t *testing.T) { - loaded, err := loadDeployAzureConfig(writeDeployConfig(t, testDeployConfig())) - if err != nil { - t.Fatal(err) - } - az := &fakeAzureApply{t: t, referencesStuck: true} - deps := defaultDeployAzureDeps() - ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) - defer cancel() - err = deployAzureRestartAndAwaitReferences(ctx, deps, az, loaded.config) - if err == nil || ctx.Err() != context.DeadlineExceeded || !strings.Contains(err.Error(), "did not resolve") { - t.Fatalf("expected cancellable default pause, got %v", err) - } - if !az.restarted || az.referencePolls != 1 { - t.Fatal("restart did not poll unresolved references before waiting") - } -} diff --git a/internal/cli/deploy_azure_retry.go b/internal/cli/deploy_azure_retry.go deleted file mode 100644 index 667cc05bba..0000000000 --- a/internal/cli/deploy_azure_retry.go +++ /dev/null @@ -1,165 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -type deployAzureARMError struct { - Code string `json:"code"` - Message string `json:"message"` - Target string `json:"target"` - Details []deployAzureARMError `json:"details"` -} - -func deployAzureSecretPropagationFailure(stderr string, config deployAzureConfig, deployment string) bool { - start := strings.IndexByte(stderr, '{') - if start < 0 { - return false - } - var envelope struct { - Status string `json:"status"` - Error *deployAzureARMError `json:"error"` - } - if _, err := nativeDecodeJSON([]byte(stderr[start:])); err != nil { - return false - } - decoder := json.NewDecoder(strings.NewReader(stderr[start:])) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&envelope); err != nil || envelope.Error == nil || - envelope.Error.Code != "DeploymentFailed" || len(envelope.Error.Details) == 0 || - (envelope.Status != "" && envelope.Status != "Failed") { - return false - } - scope := strings.ToLower("/subscriptions/" + config.Subscription + "/resourceGroups/" + config.ResourceGroup + "/providers/") - deployments := scope + "microsoft.resources/deployments/" - jobs := scope + "microsoft.app/jobs/" + config.Resources.FunctionApp - var visit func(deployAzureARMError, string, int) bool - visit = func(failure deployAzureARMError, module string, depth int) bool { - if depth > 32 { - return false - } - switch strings.ToLower(failure.Target) { - case "": - case deployments + deployment: - if module != "" { - return false - } - case deployments + "snapshot-ingestion": - if module != "" && module != "snapshot-ingestion" { - return false - } - module = "snapshot-ingestion" - case deployments + "database-provisioning": - if module != "" && module != "database-provisioning" { - return false - } - module = "database-provisioning" - case jobs + "-ingest": - if module != "snapshot-ingestion" { - return false - } - case jobs + "-db-provision": - if module != "database-provisioning" { - return false - } - default: - return false - } - if len(failure.Details) > 0 { - if failure.Code != "DeploymentFailed" && failure.Code != "ResourceDeploymentFailure" { - return false - } - for _, child := range failure.Details { - if !visit(child, module, depth+1) { - return false - } - } - return true - } - message := strings.ToLower(failure.Message) - for _, code := range []string{"authorizationfailed", "requestdisallowedbypolicy", "forbiddenbypolicy", "deniedbypolicy"} { - if strings.Contains(message, code) { - return false - } - } - suffix := "-ingest-identity" - if module == "database-provisioning" { - suffix = "-db-provision-identity" - } - identity := scope + "microsoft.managedidentity/userassignedidentities/" + config.Resources.FunctionApp + suffix - return module != "" && failure.Code == "InvalidParameterValueInContainerTemplate" && - strings.Contains(message, "configuration.secrets") && - strings.Contains(message, "unable to get value using managed identity") && - strings.Contains(message, "unable to fetch secret") && strings.Contains(message, identity) - } - return visit(*envelope.Error, "", 0) -} - -func deployAzureCreateTemplate(ctx context.Context, deps deployAzureDeps, az azcli.Executor, config deployAzureConfig, deployment, template, parameters string) (azcli.Result, error) { - if err := ctx.Err(); err != nil { - return azcli.Result{}, err - } - request := []string{"deployment", "group", "create", "--subscription", config.Subscription, - "--resource-group", config.ResourceGroup, "--name", deployment, "--template-file", template, - "--parameters", "@" + parameters, "--output", "json"} - runAttempt := func() (azcli.Result, error) { - attemptCtx, cancel := context.WithTimeout(ctx, 45*time.Minute) - defer cancel() - result, failure := az.Run(attemptCtx, request...) - if attemptCtx.Err() == nil && !errors.Is(failure, context.Canceled) && !errors.Is(failure, context.DeadlineExceeded) { - return result, failure - } - failure = errors.Join(failure, attemptCtx.Err()) - cancelCtx, cancelCleanup := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) - defer cancelCleanup() - _, cancelErr := az.Run(cancelCtx, "deployment", "group", "cancel", "--subscription", config.Subscription, - "--resource-group", config.ResourceGroup, "--name", deployment, "--output", "none") - if cancelErr != nil { - return result, errors.Join(failure, fmt.Errorf("explicit ARM cancellation failed; deployment may still be running: %w", cancelErr)) - } - if deps.diagnostics != nil { - if _, err := fmt.Fprintln(deps.diagnostics, "ARM deployment timed out or was cancelled; the explicit cancellation request for this exact deployment was accepted."); err != nil { - return result, errors.Join(failure, fmt.Errorf("write ARM cancellation diagnostic: %w", err)) - } - } - return result, failure - } - result, failure := runAttempt() - if failure == nil { - return result, nil - } - for retry := 1; retry <= 3; retry++ { - if errors.Is(failure, context.Canceled) || errors.Is(failure, context.DeadlineExceeded) || - !deployAzureSecretPropagationFailure(result.Stderr, config, deployment) { - return result, failure - } - if err := ctx.Err(); err != nil { - return result, errors.Join(failure, err) - } - delay := time.Duration(retry) * 30 * time.Second - if deps.diagnostics != nil { - if _, err := fmt.Fprintf(deps.diagnostics, "Managed-identity Key Vault job secret resolution is propagating; retry %d/3 in %s using the same reviewed deployment.\n", retry, delay); err != nil { - return result, errors.Join(failure, fmt.Errorf("write deployment retry diagnostic: %w", err)) - } - } - if err := deployAzurePause(ctx, deps, delay); err != nil { - return result, errors.Join(failure, fmt.Errorf("wait for job secret access: %w", err)) - } - previousFailure := failure - result, failure = runAttempt() - if failure == nil { - return result, nil - } - if errors.Is(failure, context.Canceled) || errors.Is(failure, context.DeadlineExceeded) { - return result, errors.Join(failure, fmt.Errorf("preceding managed-identity secret propagation failure: %w", previousFailure)) - } - } - return result, fmt.Errorf("deployment failed after three bounded managed-identity secret propagation retries: %w", failure) -} diff --git a/internal/cli/deploy_azure_retry_test.go b/internal/cli/deploy_azure_retry_test.go deleted file mode 100644 index 3b26b4710c..0000000000 --- a/internal/cli/deploy_azure_retry_test.go +++ /dev/null @@ -1,397 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -func testAzureRetryConfig() deployAzureConfig { - return deployAzureConfig{ - Subscription: testDeploySubscription, ResourceGroup: "rg-synthetic-test", - Resources: deployAzureResources{FunctionApp: "fa-synthetic-test"}, - } -} - -func testAzureRetryFailure(config deployAzureConfig, deployment string, modules ...string) deployAzureARMError { - scope := "/subscriptions/" + config.Subscription + "/resourceGroups/" + config.ResourceGroup + "/providers/" - root := deployAzureARMError{Code: "DeploymentFailed", Target: scope + "Microsoft.Resources/deployments/" + deployment} - for _, module := range modules { - suffix := "-ingest-identity" - if module == "database-provisioning" { - suffix = "-db-provision-identity" - } - identity := scope + "Microsoft.ManagedIdentity/userAssignedIdentities/" + config.Resources.FunctionApp + suffix - root.Details = append(root.Details, deployAzureARMError{ - Code: "ResourceDeploymentFailure", Target: scope + "Microsoft.Resources/deployments/" + module, - Details: []deployAzureARMError{{Code: "DeploymentFailed", Details: []deployAzureARMError{{ - Code: "InvalidParameterValueInContainerTemplate", - Message: fmt.Sprintf("Field 'configuration.secrets' is invalid: Unable to get value using Managed identity %s for secret cao-postgres-url. Error: unable to fetch secret 'cao-postgres-url' using Managed identity '%s'", identity, identity), - }}}}, - }) - } - return root -} - -func testAzureRetryJSON(t *testing.T, failure deployAzureARMError) string { - t.Helper() - content, err := json.Marshal(map[string]any{"status": "Failed", "error": failure}) - if err != nil { - t.Fatal(err) - } - return "ERROR: " + string(content) -} - -func TestDeployAzureSecretRetryFailsClosedOnEveryErrorBranch(t *testing.T) { - config := testAzureRetryConfig() - tests := []struct { - name string - change func(*deployAzureARMError) - }{ - {"missing details", func(f *deployAzureARMError) { f.Details = nil }}, - {"authorization wrapper", func(f *deployAzureARMError) { f.Code = "AuthorizationFailed" }}, - {"authorization leaf", func(f *deployAzureARMError) { f.Details[0].Details[0].Details[0].Code = "AuthorizationFailed" }}, - {"mixed error leaves", func(f *deployAzureARMError) { - f.Details = append(f.Details, deployAzureARMError{Code: "ContainerAppInvalidName", Message: "invalid job name"}) - }}, - {"unrelated module", func(f *deployAzureARMError) { f.Details[0].Target += "-unrelated" }}, - {"unrelated resource", func(f *deployAzureARMError) { - f.Details[0].Details[0].Details[0].Target = "/subscriptions/" + config.Subscription + "/resourceGroups/another-group/providers/Microsoft.App/jobs/other" - }}, - {"unrelated managed identity", func(f *deployAzureARMError) { - leaf := &f.Details[0].Details[0].Details[0] - leaf.Message = strings.ReplaceAll(leaf.Message, config.Resources.FunctionApp+"-ingest-identity", "unrelated-identity") - }}, - {"explicit policy failure", func(f *deployAzureARMError) { - f.Details[0].Details[0].Details[0].Message += " RequestDisallowedByPolicy" - }}, - {"unrecognized secret error", func(f *deployAzureARMError) { - f.Details[0].Details[0].Details[0].Message = "configuration.secrets is invalid" - }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - failure := testAzureRetryFailure(config, "cao-test", "snapshot-ingestion", "database-provisioning") - tt.change(&failure) - if deployAzureSecretPropagationFailure(testAzureRetryJSON(t, failure), config, "cao-test") { - t.Fatal("unrecognized or mixed failure was admitted for retry") - } - }) - } - valid := testAzureRetryJSON(t, testAzureRetryFailure(config, "cao-test", "snapshot-ingestion", "database-provisioning")) - if !deployAzureSecretPropagationFailure(valid, config, "cao-test") { - t.Fatal("both recognized job modules must be retryable") - } - for _, malformed := range []string{ - "", `ERROR: {"error":`, `{"error":{"code":"DeploymentFailed","details":"invalid"}}`, - valid + "\nnot JSON", - strings.Replace(valid, `"status":"Failed"`, `"status":"Failed","status":"Succeeded"`, 1), - strings.Replace(valid, `"status":"Failed"`, `"additionalInfo":[{"code":"AuthorizationFailed"}],"status":"Failed"`, 1), - } { - if deployAzureSecretPropagationFailure(malformed, config, "cao-test") { - t.Fatalf("malformed or unknown error fields were retried: %s", malformed) - } - } -} - -func TestDeployAzureSecretRetryReusesExactReviewedRequest(t *testing.T) { - config := testAzureRetryConfig() - directory := t.TempDir() - template, parameters := filepath.Join(directory, "template.json"), filepath.Join(directory, "parameters.json") - writeTestFile(t, template, `{"template":"reviewed"}`) - writeTestFile(t, parameters, `{"password":{"value":"`+testDeploySession+`"}}`) - transient := testAzureRetryJSON(t, testAzureRetryFailure(config, "cao-test", "snapshot-ingestion", "database-provisioning")) - var calls [][]string - az := azcli.ExecutorFunc(func(ctx context.Context, args ...string) (azcli.Result, error) { - deadline, ok := ctx.Deadline() - if !ok || time.Until(deadline) > 45*time.Minute { - t.Fatal("deployment subprocess is not bounded") - } - calls = append(calls, slices.Clone(args)) - for _, secret := range testDeploySecrets() { - if strings.Contains(strings.Join(args, " "), secret) { - t.Fatal("secret in deployment retry argv") - } - } - content, err := os.ReadFile(parameters) - if err != nil || string(content) != `{"password":{"value":"`+testDeploySession+`"}}` { - t.Fatal("resolved parameters changed between requests") - } - content, err = os.ReadFile(template) - if err != nil || string(content) != `{"template":"reviewed"}` { - t.Fatal("reviewed template changed between requests") - } - if len(calls) == 1 { - return azcli.Result{Stderr: transient}, errors.New("original secret propagation detail") - } - if time.Until(deadline) < 10*time.Minute { - t.Fatal("a simulated ten-minute ARM replay must fit within the same 45-minute attempt bound") - } - return azcli.Result{Stdout: `{"succeeded":true}`}, nil - }) - var diagnostics bytes.Buffer - pauses := 0 - deps := deployAzureDeps{ - diagnostics: &diagnostics, - pause: func(ctx context.Context, delay time.Duration) error { - pauses++ - if delay != 30*time.Second { - t.Fatalf("unexpected backoff: %s", delay) - } - return ctx.Err() - }, - } - result, err := deployAzureCreateTemplate(context.Background(), deps, az, config, "cao-test", template, parameters) - if err != nil || result.Stdout != `{"succeeded":true}` || len(calls) != 2 || pauses != 1 { - t.Fatalf("recognized failure did not recover: calls=%d pauses=%d result=%+v err=%v", len(calls), pauses, result, err) - } - if !slices.Equal(calls[0], calls[1]) { - t.Fatalf("retry changed the reviewed request: %q vs %q", calls[0], calls[1]) - } - if !strings.Contains(diagnostics.String(), "retry 1/3") || strings.Contains(diagnostics.String(), "original secret propagation detail") { - t.Fatalf("missing or unsanitized retry diagnostic: %s", &diagnostics) - } - for _, secret := range testDeploySecrets() { - if strings.Contains(diagnostics.String(), secret) { - t.Fatal("retry diagnostic contains a secret") - } - } -} - -func TestDeployAzureSecretRetryPreservesShorterCallerDeadline(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - deadline, _ := ctx.Deadline() - config := testAzureRetryConfig() - transient := testAzureRetryJSON(t, testAzureRetryFailure(config, "cao-test", "snapshot-ingestion")) - calls := 0 - deps := deployAzureDeps{pause: func(ctx context.Context, _ time.Duration) error { - if actual, ok := ctx.Deadline(); !ok || !actual.Equal(deadline) { - t.Fatal("retry backoff extended the caller deadline") - } - return ctx.Err() - }} - az := azcli.ExecutorFunc(func(ctx context.Context, _ ...string) (azcli.Result, error) { - calls++ - if actual, ok := ctx.Deadline(); !ok || !actual.Equal(deadline) { - t.Fatal("deployment subprocess extended the caller deadline") - } - if calls == 1 { - return azcli.Result{Stderr: transient}, errors.New("secret propagation") - } - return azcli.Result{Stdout: `{}`}, nil - }) - if _, err := deployAzureCreateTemplate(ctx, deps, az, config, "cao-test", "template.json", "parameters.json"); err != nil { - t.Fatal(err) - } - if calls != 2 { - t.Fatalf("deployment calls=%d, want 2", calls) - } -} - -func TestDeployAzureSecretRetryExhaustionAndCancellation(t *testing.T) { - config := testAzureRetryConfig() - transient := testAzureRetryJSON(t, testAzureRetryFailure(config, "cao-test", "snapshot-ingestion")) - for _, mode := range []string{"exhaustion", "cancel-backoff", "cancel-command", "cancel-retry", "unrecognized-error"} { - t.Run(mode, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - calls, pauses, cancellations := 0, 0, 0 - deps := deployAzureDeps{pause: func(ctx context.Context, _ time.Duration) error { - pauses++ - if mode == "cancel-backoff" { - cancel() - } - return ctx.Err() - }} - az := azcli.ExecutorFunc(func(ctx context.Context, args ...string) (azcli.Result, error) { - if azcli.CommandName(args) == "deployment group cancel" { - cancellations++ - if ctx.Err() != nil { - t.Fatal("ARM cancellation reused a cancelled context") - } - if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 2*time.Minute { - t.Fatal("ARM cancellation is not bounded") - } - return azcli.Result{}, nil - } - calls++ - stderr := transient - failure := errors.New("terminal detail must survive") - if mode == "cancel-command" || (mode == "cancel-retry" && calls == 2) { - failure = context.DeadlineExceeded - } - if mode == "unrecognized-error" { - stderr = `ERROR: {"error":{"code":"AuthorizationFailed"}}` - } - return azcli.Result{Stderr: stderr}, failure - }) - _, err := deployAzureCreateTemplate(ctx, deps, az, config, "cao-test", "template.json", "parameters.json") - if err == nil { - t.Fatal("terminal deployment failure was hidden") - } - wantCalls, wantPauses := 1, 0 - switch mode { - case "exhaustion": - wantCalls, wantPauses = 4, 3 - if !strings.Contains(err.Error(), "three bounded") || !strings.Contains(err.Error(), "terminal detail must survive") { - t.Fatalf("retry exhaustion lost terminal detail: %v", err) - } - case "cancel-backoff": - wantPauses = 1 - if !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "terminal detail must survive") { - t.Fatalf("cancellation lost context or terminal detail: %v", err) - } - case "cancel-command": - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("subprocess deadline was hidden: %v", err) - } - case "cancel-retry": - wantCalls, wantPauses = 2, 1 - if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "terminal detail must survive") { - t.Fatalf("retry deadline lost context or preceding failure detail: %v", err) - } - } - if calls != wantCalls || pauses != wantPauses { - t.Fatalf("calls=%d pauses=%d, want %d/%d", calls, pauses, wantCalls, wantPauses) - } - wantCancellations := 0 - if mode == "cancel-command" || mode == "cancel-retry" { - wantCancellations = 1 - } - if cancellations != wantCancellations { - t.Fatalf("ARM cancellations=%d, want %d", cancellations, wantCancellations) - } - }) - } -} - -func TestDeployAzureAttemptDeadlineExplicitlyCancelsExactARMDeployment(t *testing.T) { - for _, mode := range []string{"initial-timeout", "replay-timeout", "cancel-fails"} { - t.Run(mode, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - config := testAzureRetryConfig() - transient := testAzureRetryJSON(t, testAzureRetryFailure(config, "cao-test", "snapshot-ingestion")) - var calls []string - var diagnostics bytes.Buffer - deps := deployAzureDeps{ - diagnostics: &diagnostics, - pause: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, - } - az := azcli.ExecutorFunc(func(attemptCtx context.Context, args ...string) (azcli.Result, error) { - verb := azcli.CommandName(args) - calls = append(calls, verb) - if verb == "deployment group create" { - if mode == "replay-timeout" && len(calls) == 1 { - return azcli.Result{Stderr: transient}, errors.New("preceding propagation detail") - } - <-attemptCtx.Done() - return azcli.Result{}, attemptCtx.Err() - } - want := []string{"deployment", "group", "cancel", "--subscription", config.Subscription, - "--resource-group", config.ResourceGroup, "--name", "cao-test", "--output", "none"} - if !slices.Equal(args, want) { - t.Fatalf("cancellation changed deployment identity: %q", args) - } - if attemptCtx.Err() != nil || ctx.Err() == nil { - t.Fatal("cancellation must remain executable after the deployment deadline") - } - if deadline, ok := attemptCtx.Deadline(); !ok || time.Until(deadline) > 2*time.Minute { - t.Fatal("ARM cancellation has no bounded independent deadline") - } - if mode == "cancel-fails" { - return azcli.Result{}, errors.New("cancel permission denied") - } - return azcli.Result{}, nil - }) - _, err := deployAzureCreateTemplate(ctx, deps, az, config, "cao-test", "template.json", "parameters.json") - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("attempt timeout did not fail closed: %v", err) - } - want := []string{"deployment group create", "deployment group cancel"} - if mode == "replay-timeout" { - want = []string{"deployment group create", "deployment group create", "deployment group cancel"} - if !strings.Contains(err.Error(), "preceding propagation detail") { - t.Fatalf("interrupted replay lost preceding detail: %v", err) - } - } - if !slices.Equal(calls, want) { - t.Fatalf("unexpected requests after timeout: %q", calls) - } - if mode == "cancel-fails" { - if !strings.Contains(err.Error(), "explicit ARM cancellation failed") || !strings.Contains(err.Error(), "cancel permission denied") { - t.Fatalf("ARM cancellation failure was hidden: %v", err) - } - } else if !strings.Contains(diagnostics.String(), "explicit cancellation request") { - t.Fatal("accepted ARM cancellation was not reported") - } - }) - } -} - -func TestDeployAzureApplyRecoversSecretPropagationWithoutReAdmission(t *testing.T) { - fake, deps, config, plan, options := testAzureApplySetup(t) - fake.transientARMFailures = 1 - var diagnostics bytes.Buffer - deps.diagnostics = &diagnostics - if err := deployAzureApply(context.Background(), deps, config, testDeployTemplatePath(t), plan, options); err != nil { - t.Fatal(err) - } - var deployments [][]string - builds := 0 - for _, args := range fake.calls { - switch azcli.CommandName(args) { - case "acr build": - builds++ - case "deployment group create": - index := slices.Index(args, "--name") - if !strings.HasSuffix(args[index+1], "-registry") { - deployments = append(deployments, args) - } - case "group exists", "deployment group what-if", "deployment sub what-if": - if len(deployments) > 0 { - t.Fatal("fresh-only admission ran again after starting the deployment") - } - } - } - if builds != 3 || len(deployments) != 2 || !slices.Equal(deployments[0], deployments[1]) || fake.httpCalls != 6 { - t.Fatalf("retry changed build/deploy/verify ordering: builds=%d deployments=%q http=%d", builds, deployments, fake.httpCalls) - } - if !strings.Contains(diagnostics.String(), "retry 1/3") { - t.Fatal("transient recovery did not surface its bounded retry") - } -} - -func TestDeployAzureJobPrefixLengthAndGrammarBeforeAzureCalls(t *testing.T) { - for _, name := range []string{strings.Repeat("a", 20), "a--b", "Uppercase", "1starts-with-digit"} { - t.Run(name, func(t *testing.T) { - config := testDeployConfig() - config["resources"].(map[string]any)["functionApp"] = name - fake := &fakeAz{t: t} - root, _, _ := testDeployRoot(fake) - root.SetArgs([]string{"deploy", "azure", "--plan", "--config", writeDeployConfig(t, config), "--template", testDeployTemplatePath(t), "--out", filepath.Join(t.TempDir(), "plan.json")}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "resources.functionApp") { - t.Fatalf("invalid derived job name was not rejected: %v", err) - } - if len(fake.calls) != 0 { - t.Fatalf("invalid job prefix reached Azure: %q", fake.calls) - } - }) - } - config := testDeployConfig() - config["resources"].(map[string]any)["functionApp"] = strings.Repeat("a", 19) - if _, err := loadDeployAzureConfig(writeDeployConfig(t, config)); err != nil { - t.Fatalf("19-character job prefix must fit the 32-character provisioning boundary: %v", err) - } -} diff --git a/internal/cli/deploy_azure_snapshot.go b/internal/cli/deploy_azure_snapshot.go deleted file mode 100644 index 6f6fa1fa6c..0000000000 --- a/internal/cli/deploy_azure_snapshot.go +++ /dev/null @@ -1,73 +0,0 @@ -package cli - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" -) - -func copyDeployAzureSnapshot(source, destination string) error { - root, err := filepath.EvalSymlinks(source) - if err != nil { - return fmt.Errorf("resolve evidence snapshot: %w", err) - } - content, err := os.ReadFile(filepath.Join(root, "payload-hashes.json")) - if err != nil { - return fmt.Errorf("snapshot requires payload-hashes.json: %w", err) - } - var manifest map[string]string - if err := json.Unmarshal(content, &manifest); err != nil { - return fmt.Errorf("parse snapshot manifest: %w", err) - } - if len(manifest) == 0 { - return errors.New("snapshot manifest is empty; use --empty-evidence for an explicitly empty deployment") - } - runShards := 0 - for name, expected := range manifest { - if !filepath.IsLocal(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || - !regexp.MustCompile(`^[0-9a-fA-F]{64}$`).MatchString(expected) { - return fmt.Errorf("unsafe snapshot manifest path or digest: %q", name) - } - if !strings.HasPrefix(name, "gh-aw-logs-runs/") && !strings.HasPrefix(name, "gh-aw-logs-records/") && name != "inventory-sources.json" { - return fmt.Errorf("snapshot manifest file is not allowlisted: %q", name) - } - if strings.HasPrefix(name, "gh-aw-logs-runs/") { - runShards++ - } - input := filepath.Join(root, filepath.FromSlash(name)) - output := filepath.Join(destination, filepath.FromSlash(name)) - if err := copyDeployAzureFile(input, output); err != nil { - return fmt.Errorf("copy snapshot payload %s: %w", name, err) - } - payload, err := os.ReadFile(output) - if err != nil { - return err - } - sum := sha256.Sum256(payload) - if !strings.EqualFold(hex.EncodeToString(sum[:]), expected) { - return fmt.Errorf("snapshot SHA-256 mismatch: %s", name) - } - } - if runShards == 0 { - return errors.New("snapshot has no compacted run-information shards") - } - inventory := filepath.Join(root, "inventory-sources.json") - raw, err := os.ReadFile(inventory) - if err != nil { - return fmt.Errorf("snapshot requires inventory-sources.json: %w", err) - } - var object map[string]any - if err := json.Unmarshal(raw, &object); err != nil || object == nil { - return errors.New("snapshot inventory-sources.json must be an object") - } - if err := copyDeployAzureFile(inventory, filepath.Join(destination, "inventory-sources.json")); err != nil { - return err - } - return copyDeployAzureFile(filepath.Join(root, "payload-hashes.json"), filepath.Join(destination, "payload-hashes.json")) -} diff --git a/internal/cli/deploy_azure_telemetry_test.go b/internal/cli/deploy_azure_telemetry_test.go deleted file mode 100644 index d8d5751d20..0000000000 --- a/internal/cli/deploy_azure_telemetry_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestDeployAzureTelemetryPlanKeepsOnlySecretReferences(t *testing.T) { - const header = "Authorization=Bearer%20synthetic-plan-otlp" - config := testDeployConfig() - config["telemetry"] = map[string]any{"otlpEndpoint": "https://collector.example.test", "logsEnabled": true} - config["secrets"].(map[string]any)["otlpHeaders"] = map[string]any{"env": "CAO_TEST_OTLP_HEADERS"} - fake := &fakeAz{t: t, groupExists: true, whatIfStderr: header} - root, stdout, stderr := testDeployRootWith(fake, func(name string) (string, bool) { - if name == "CAO_TEST_OTLP_HEADERS" { - return header, true - } - return testDeployEnv(name) - }) - planPath := filepath.Join(t.TempDir(), "plan.json") - root.SetArgs([]string{"deploy", "azure", "--config", writeDeployConfig(t, config), - "--template", testDeployTemplatePath(t), "--out", planPath}) - if err := root.ExecuteContext(t.Context()); err != nil { - t.Fatal(err) - } - content, err := os.ReadFile(planPath) - if err != nil { - t.Fatal(err) - } - var plan deployAzurePlan - if err := json.Unmarshal(content, &plan); err != nil { - t.Fatal(err) - } - if plan.SecureParameters["otlpHeaders"] != "env:CAO_TEST_OTLP_HEADERS" || - plan.Parameters["otlpEndpoint"] != "https://collector.example.test" || - plan.Parameters["otlpLogsEnabled"] != true { - t.Fatal("reviewed plan lost the explicit route or secret reference") - } - observed := string(content) + stdout.String() + stderr.String() - for _, call := range fake.calls { - observed += strings.Join(call, " ") - } - if strings.Contains(observed, header) { - t.Fatal("OTLP authentication escaped into a plan, output or command argument") - } - for _, file := range fake.parameterFiles { - if _, err := os.Stat(file); !os.IsNotExist(err) { - t.Fatal("temporary secure parameter file was not removed") - } - } -} - -func TestDeployAzureTelemetryParameters(t *testing.T) { - config := testDeployConfig() - config["telemetry"] = map[string]any{ - "otlpEndpoint": "https://collector.example.test/otlp", - "logsEnabled": true, - } - config["secrets"].(map[string]any)["otlpHeaders"] = map[string]any{"env": "CAO_TEST_OTLP_HEADERS"} - content, err := json.Marshal(config) - if err != nil { - t.Fatal(err) - } - parsed, err := parseDeployAzureConfig(content) - if err != nil { - t.Fatal(err) - } - parameters := parsed.parameters() - if parameters["otlpEndpoint"] != "https://collector.example.test/otlp" || parameters["otlpLogsEnabled"] != true { - t.Fatal("telemetry config did not reach template parameters") - } - template, err := (deployAzureFileTemplate{path: testDeployTemplatePath(t)}).Load(t.Context()) - if err != nil { - t.Fatal(err) - } - var names []string - for _, parameter := range parsed.secureParameters() { - names = append(names, parameter.name) - } - if _, err := checkDeployAzureTemplate(template, parameters, names); err != nil { - t.Fatal(err) - } - const syntheticHeader = "Authorization=Bearer%20synthetic-otlp" - secrets, err := resolveDeployAzureSecrets(parsed, func(name string) (string, bool) { - if name == "CAO_TEST_OTLP_HEADERS" { - return syntheticHeader, true - } - return testDeployEnv(name) - }) - if err != nil { - t.Fatal(err) - } - entry, found := secrets.entries["otlpHeaders"].(map[string]any) - if !found || entry["value"] != syntheticHeader { - t.Fatal("OTLP headers did not enter the secure parameter path") - } - if secrets.redact(syntheticHeader) != "***" { - t.Fatal("OTLP headers did not enter diagnostic redaction") - } - if _, exists := parameters["otlpHeaders"]; exists || strings.Contains(string(content), syntheticHeader) { - t.Fatal("OTLP credentials entered non-secure configuration") - } -} - -func TestDeployAzureTelemetryRejectsUnsafeConfiguration(t *testing.T) { - for _, endpoint := range []string{ - "http://collector.example.test", "https://user:password@collector.example.test", - "https://collector.example.test?token=value", "https://collector.example.test?", - "https://collector.example.test#fragment", "https://collector.example.test#", - "collector.example.test", "https:///missing-host", - } { - t.Run(endpoint, func(t *testing.T) { - config := testDeployConfig() - config["telemetry"] = map[string]any{"otlpEndpoint": endpoint} - content, err := json.Marshal(config) - if err != nil { - t.Fatal(err) - } - if _, err := parseDeployAzureConfig(content); err == nil || !strings.Contains(err.Error(), "HTTPS collector") { - t.Fatal("unsafe endpoint was not rejected with a bounded error") - } - }) - } - for _, field := range []string{"logs", "headers"} { - config := testDeployConfig() - if field == "logs" { - config["telemetry"] = map[string]any{"logsEnabled": true} - } else { - config["secrets"].(map[string]any)["otlpHeaders"] = map[string]any{"env": "CAO_TEST_OTLP_HEADERS"} - } - content, err := json.Marshal(config) - if err != nil { - t.Fatal(err) - } - if _, err := parseDeployAzureConfig(content); err == nil || !strings.Contains(err.Error(), "otlpEndpoint is required") { - t.Fatalf("%s without an endpoint was not rejected", field) - } - } -} diff --git a/internal/cli/deploy_azure_template.go b/internal/cli/deploy_azure_template.go deleted file mode 100644 index df60e4a64e..0000000000 --- a/internal/cli/deploy_azure_template.go +++ /dev/null @@ -1,278 +0,0 @@ -package cli - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/githubnext/gh-cao/internal/topology" -) - -// deployAzureTemplate is a compiled ARM template for the CAO Azure profile. -type deployAzureTemplate struct { - Content []byte - Digest string - Origin string -} - -// deployAzureTemplateSource supplies an embedded baseline or explicit ARM override. -type deployAzureTemplateSource interface { - Load(context.Context) (deployAzureTemplate, error) -} - -type deployAzureFileTemplate struct { - path string -} - -type deployAzureEmbeddedTemplate struct{} - -func (deployAzureEmbeddedTemplate) Load(ctx context.Context) (deployAzureTemplate, error) { - if err := ctx.Err(); err != nil { - return deployAzureTemplate{}, err - } - content, err := topology.AzureTemplate() - if err != nil { - return deployAzureTemplate{}, fmt.Errorf("read embedded Azure template: %w", err) - } - sum := sha256.Sum256(content) - return deployAzureTemplate{Content: content, Digest: hex.EncodeToString(sum[:]), Origin: "embedded"}, nil -} - -func (s deployAzureFileTemplate) Load(ctx context.Context) (deployAzureTemplate, error) { - if err := ctx.Err(); err != nil { - return deployAzureTemplate{}, err - } - content, err := os.ReadFile(s.path) - if err != nil { - return deployAzureTemplate{}, fmt.Errorf("read template %s: %w", s.path, err) - } - sum := sha256.Sum256(content) - return deployAzureTemplate{Content: content, Digest: hex.EncodeToString(sum[:]), Origin: "file"}, nil -} - -type deployAzureTemplateParameter struct { - Type string `json:"type"` - DefaultValue any `json:"defaultValue"` -} - -type deployAzureTemplateDocument struct { - Schema string `json:"$schema"` - Parameters map[string]deployAzureTemplateParameter `json:"parameters"` - Resources json.RawMessage `json:"resources"` -} - -// checkDeployAzureTemplate fails closed unless every supplied parameter is -// declared, secure values bind only to securestring parameters, and every -// required template parameter is supplied. -func checkDeployAzureTemplate(template deployAzureTemplate, parameters map[string]any, secure []string) (deployAzureTemplateDocument, error) { - var document deployAzureTemplateDocument - if err := json.Unmarshal(template.Content, &document); err != nil { - return document, fmt.Errorf("template is not compiled ARM JSON: %w", err) - } - if !strings.Contains(document.Schema, "deploymentTemplate.json") || len(document.Parameters) == 0 || len(document.Resources) == 0 { - return document, errors.New("template is not a resource group ARM deployment template") - } - supplied := map[string]bool{} - for name := range parameters { - declared, ok := document.Parameters[name] - if !ok { - return document, fmt.Errorf("template does not declare parameter %s", name) - } - if strings.EqualFold(declared.Type, "securestring") || strings.EqualFold(declared.Type, "secureobject") { - return document, fmt.Errorf("template parameter %s is secure but the config supplies it as plain text", name) - } - supplied[name] = true - } - for _, name := range secure { - declared, ok := document.Parameters[name] - if !ok { - return document, fmt.Errorf("template does not declare secure parameter %s", name) - } - if !strings.EqualFold(declared.Type, "securestring") { - return document, fmt.Errorf("template parameter %s must be securestring", name) - } - supplied[name] = true - } - var missing []string - for name, declared := range document.Parameters { - if declared.DefaultValue == nil && !supplied[name] { - missing = append(missing, name) - } - } - if len(missing) > 0 { - sort.Strings(missing) - return document, fmt.Errorf("template requires parameters the config does not supply: %s", strings.Join(missing, ", ")) - } - return document, nil -} - -// deployAzureWorkspace holds owner-only deployment inputs and removes them on close. -type deployAzureWorkspace struct { - directory string -} - -func newDeployAzureWorkspace() (*deployAzureWorkspace, error) { - directory, err := os.MkdirTemp("", "gh-cao-deploy-azure-") - if err != nil { - return nil, fmt.Errorf("create private deployment workspace: %w", err) - } - if err := os.Chmod(directory, 0o700); err != nil { - _ = os.RemoveAll(directory) - return nil, fmt.Errorf("restrict deployment workspace: %w", err) - } - return &deployAzureWorkspace{directory: directory}, nil -} - -func (w *deployAzureWorkspace) write(name string, value any) (string, error) { - content, err := json.MarshalIndent(value, "", " ") - if err != nil { - return "", err - } - path := filepath.Join(w.directory, name) - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return "", fmt.Errorf("create %s: %w", name, err) - } - if _, err := file.Write(content); err != nil { - _ = file.Close() - return "", fmt.Errorf("write %s: %w", name, err) - } - if err := file.Close(); err != nil { - return "", fmt.Errorf("write %s: %w", name, err) - } - return path, nil -} - -func (w *deployAzureWorkspace) Close() error { - return os.RemoveAll(w.directory) -} - -// deployAzureSecrets resolves secret references into parameter-file entries -// and remembers resolved values so diagnostics can be redacted. -type deployAzureSecrets struct { - entries map[string]any - values []string -} - -func resolveDeployAzureSecrets(config deployAzureConfig, getenv func(string) (string, bool)) (deployAzureSecrets, error) { - secrets := deployAzureSecrets{entries: map[string]any{}} - passwords := map[string]string{} - passwordRefs := map[string]string{} - for _, secure := range config.secureParameters() { - if strings.HasPrefix(secure.name, "postgres") { - ref := secure.ref.describe() - if previous := passwordRefs[ref]; previous != "" { - return deployAzureSecrets{}, fmt.Errorf("%s and %s must reference different database passwords", previous, secure.field) - } - passwordRefs[ref] = secure.field - } - if secure.ref.KeyVault != nil { - secrets.entries[secure.name] = map[string]any{"reference": map[string]any{ - "keyVault": map[string]string{"id": secure.ref.KeyVault.ID}, - "secretName": secure.ref.KeyVault.SecretName, - }} - continue - } - value, ok := getenv(secure.ref.Env) - if !ok || value == "" { - return deployAzureSecrets{}, fmt.Errorf("%s references environment variable %s, which is not set", secure.field, secure.ref.Env) - } - if len(value) < secure.minLength { - return deployAzureSecrets{}, fmt.Errorf("%s from %s must be at least %d characters", secure.field, secure.ref.Env, secure.minLength) - } - if strings.HasPrefix(secure.name, "postgres") { - if previous := passwords[value]; previous != "" { - return deployAzureSecrets{}, fmt.Errorf("%s and %s must have different database passwords", previous, secure.field) - } - passwords[value] = secure.field - } - secrets.entries[secure.name] = map[string]any{"value": value} - secrets.values = append(secrets.values, value) - } - return secrets, nil -} - -// deployAzureAvailableSecrets collects env-referenced secret values that are -// set, only so preflight diagnostics can redact them. -func deployAzureAvailableSecrets(config deployAzureConfig, getenv func(string) (string, bool)) deployAzureSecrets { - secrets := deployAzureSecrets{entries: map[string]any{}} - for _, secure := range config.secureParameters() { - if secure.ref.Env == "" { - continue - } - if value, ok := getenv(secure.ref.Env); ok && value != "" { - secrets.values = append(secrets.values, value) - } - } - return secrets -} - -func (s deployAzureSecrets) redact(text string) string { - for _, value := range s.values { - text = strings.ReplaceAll(text, value, "***") - } - return text -} - -func deployAzureParameterFile(parameters map[string]any, secrets deployAzureSecrets) map[string]any { - entries := map[string]any{} - for name, value := range parameters { - entries[name] = map[string]any{"value": value} - } - for name, entry := range secrets.entries { - entries[name] = entry - } - return map[string]any{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": entries, - } -} - -// deployAzureSubscriptionWrapper lets a subscription-scope what-if preview the -// resource group creation together with the resource-group-scoped template. -func deployAzureSubscriptionWrapper(template deployAzureTemplate, document deployAzureTemplateDocument, config deployAzureConfig, names []string) (map[string]any, error) { - var inner any - if err := json.Unmarshal(template.Content, &inner); err != nil { - return nil, err - } - declared := map[string]any{} - passed := map[string]any{} - for _, name := range names { - declared[name] = map[string]string{"type": document.Parameters[name].Type} - passed[name] = map[string]string{"value": "[parameters('" + name + "')]"} - } - return map[string]any{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": declared, - "resources": []any{ - map[string]any{ - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2022-09-01", - "name": config.ResourceGroup, - "location": config.Location, - }, - map[string]any{ - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "cao-dashboard", - "resourceGroup": config.ResourceGroup, - "dependsOn": []string{"[subscriptionResourceId('Microsoft.Resources/resourceGroups', '" + config.ResourceGroup + "')]"}, - "properties": map[string]any{ - "mode": "Incremental", - "expressionEvaluationOptions": map[string]string{"scope": "inner"}, - "template": inner, - "parameters": passed, - }, - }, - }, - }, nil -} diff --git a/internal/cli/deploy_azure_template_facts_test.go b/internal/cli/deploy_azure_template_facts_test.go deleted file mode 100644 index bebb13d9f9..0000000000 --- a/internal/cli/deploy_azure_template_facts_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package cli - -import ( - "os" - "slices" - "strings" - "testing" -) - -func TestDeployAzurePreflightDoesNotRequireKusto(t *testing.T) { - for _, kusto := range []string{ - "", - `,{"namespace":"Microsoft.Kusto","registrationState":"NotRegistered","resourceTypes":[{"resourceType":"clusters","locations":["East US"]}]}`, - } { - name := "missing" - if kusto != "" { - name = "unregistered and unavailable in region" - } - t.Run(name, func(t *testing.T) { - providers := testDeployProviders(t, "Registered", "West US 3") - providers = strings.TrimSuffix(providers, "]") + kusto + "]" - fake := &fakeAz{t: t, overrides: map[string]fakeAzResponse{ - "provider list": {stdout: providers}, - "rest Microsoft.Authorization": {stdout: `{"value":[{"actions":["*"],"notActions":["Microsoft.Kusto/*"]}]}`}, - }} - report, output, err := runDeployPreflight(t, fake, testDeployConfig()) - if err != nil { - t.Fatalf("disabled ADX must not block deployment: %v\n%s", err, output) - } - for _, id := range []string{"resource-providers", "permissions", "location"} { - check := findDeployCheck(t, report, id) - if check.Status != deployAzureCheckPass || strings.Contains(check.Message, "Microsoft.Kusto") { - t.Fatalf("%s includes disabled ADX prerequisites: %+v", id, check) - } - } - assertDeployCallsReadOnly(t, fake.calls) - }) - } -} - -func TestDeployAzureTemplateFactsADXOptIn(t *testing.T) { - content, err := os.ReadFile(testDeployTemplatePath(t)) - if err != nil { - t.Fatal(err) - } - for _, enabled := range []bool{false, true} { - facts := deployAzureTemplateFactsFrom(content, map[string]any{"adxEnabled": enabled}) - for _, resourceType := range []string{"Microsoft.Kusto/clusters", "Microsoft.Kusto/clusters/databases", "Microsoft.Network/privateEndpoints"} { - if slices.Contains(facts.types, resourceType) != enabled { - t.Fatalf("adxEnabled=%t: resource type %s in %q", enabled, resourceType, facts.types) - } - } - if got := slices.Contains((&deployAzurePreflight{template: facts}).requiredProviders(), "Microsoft.Kusto"); got != enabled { - t.Fatalf("adxEnabled=%t: Kusto provider required=%t", enabled, got) - } - } -} - -func TestDeployAzureTemplateFactsConditions(t *testing.T) { - for _, tt := range []struct { - name string - condition any - supplied map[string]any - want bool - }{ - {name: "unconditional", want: true}, - {name: "literal false", condition: false}, - {name: "literal true", condition: true, want: true}, - {name: "default false", condition: "[parameters('enabled')]"}, - {name: "supplied true", condition: "[parameters('enabled')]", supplied: map[string]any{"enabled": true}, want: true}, - {name: "unresolved parameter", condition: "[parameters('unknown')]", want: true}, - {name: "unsupported expression", condition: "[not(parameters('enabled'))]", want: true}, - {name: "string is not boolean", condition: "false", want: true}, - } { - t.Run(tt.name, func(t *testing.T) { - resource := map[string]any{"type": "Microsoft.Kusto/clusters", "condition": tt.condition} - document := map[string]any{ - "parameters": map[string]any{"enabled": map[string]any{"type": "bool", "defaultValue": false}}, - "resources": []any{resource}, - } - facts := deployAzureTemplateFactsFrom(mustJSON(t, document), tt.supplied) - if got := slices.Contains(facts.types, "Microsoft.Kusto/clusters"); got != tt.want { - t.Fatalf("Kusto included=%t, want %t", got, tt.want) - } - }) - } -} - -func TestDeployAzureTemplateFactsNestedParameters(t *testing.T) { - for _, tt := range []struct { - name string - passed map[string]any - want bool - }{ - {name: "inner default differs from outer"}, - {name: "outer value passed to inner", passed: map[string]any{"enabled": map[string]any{"value": "[parameters('enabled')]"}}, want: true}, - {name: "explicit false overrides outer true", passed: map[string]any{"enabled": map[string]any{"value": false}}}, - {name: "unresolved argument does not use inner default", passed: map[string]any{"enabled": map[string]any{"value": "[variables('unknown')]"}}, want: true}, - } { - t.Run(tt.name, func(t *testing.T) { - content := mustJSON(t, map[string]any{ - "parameters": map[string]any{"enabled": map[string]any{"defaultValue": true}}, - "resources": map[string]any{"nested": map[string]any{ - "type": "Microsoft.Resources/deployments", - "properties": map[string]any{ - "parameters": tt.passed, - "template": map[string]any{ - "parameters": map[string]any{"enabled": map[string]any{"defaultValue": false}}, - "resources": map[string]any{"cluster": map[string]any{ - "type": "Microsoft.Kusto/clusters", "condition": "[parameters('enabled')]", - }}, - }, - }, - }}, - }) - facts := deployAzureTemplateFactsFrom(content, nil) - if got := slices.Contains(facts.types, "Microsoft.Kusto/clusters"); got != tt.want { - t.Fatalf("Kusto included=%t, want %t", got, tt.want) - } - }) - } -} diff --git a/internal/cli/deploy_azure_test.go b/internal/cli/deploy_azure_test.go deleted file mode 100644 index 3ff320af3e..0000000000 --- a/internal/cli/deploy_azure_test.go +++ /dev/null @@ -1,716 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "slices" - "strings" - "testing" - "time" - - "github.com/spf13/cobra" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -// Synthetic values only; none identify a real deployment. -const ( - testDeploySubscription = "00000000-0000-4000-8000-000000000001" - testDeployTenant = "00000000-0000-4000-8000-000000000002" - testDeployPassword = "synthetic-postgres-password-0001" - testDeployOwnerPassword = "synthetic-owner-password-material-0004" - testDeployRuntimePassword = "synthetic-runtime-password-material-0005" - testDeployClientSecret = "synthetic-client-secret-value-0002" - testDeploySession = "synthetic-session-secret-material-0003-abcdef" - testDeployPrincipal = "00000000-0000-4000-8000-000000000004" -) - -func testDeployConfig() map[string]any { - return map[string]any{ - "schemaVersion": 1, - "environment": "development", - "subscription": testDeploySubscription, - "resourceGroup": "rg-synthetic-test", - "location": "westus3", - "resources": map[string]any{ - "functionApp": "fa-synthetic-test", - "storageAccount": "stsynthetictest", - "containerRegistry": "acrsynthetictest", - "keyVault": "kv-synthetic-test", - }, - "image": map[string]any{"tag": "v0.0.0-test"}, - "allowedHosts": []string{"dashboard.example.test"}, - "access": map[string]any{"organizations": []string{"example-org"}, "teams": []string{"example-org/example-team"}, "users": []string{}}, - "retentionDays": 45, - "oauth": map[string]any{"clientId": "Iv1.synthetic0001"}, - "secrets": map[string]any{ - "postgresAdministratorPassword": map[string]any{"env": "CAO_TEST_POSTGRES_PASSWORD"}, - "postgresOwnerPassword": map[string]any{"env": "CAO_TEST_OWNER_PASSWORD"}, - "postgresRuntimePassword": map[string]any{"env": "CAO_TEST_RUNTIME_PASSWORD"}, - "githubClientSecret": map[string]any{"env": "CAO_TEST_GITHUB_CLIENT_SECRET"}, - "sessionSecret": map[string]any{"env": "CAO_TEST_SESSION_SECRET"}, - }, - } -} - -func testDeployEnv(name string) (string, bool) { - value, ok := map[string]string{ - "CAO_TEST_POSTGRES_PASSWORD": testDeployPassword, - "CAO_TEST_OWNER_PASSWORD": testDeployOwnerPassword, - "CAO_TEST_RUNTIME_PASSWORD": testDeployRuntimePassword, - "CAO_TEST_GITHUB_CLIENT_SECRET": testDeployClientSecret, - "CAO_TEST_SESSION_SECRET": testDeploySession, - }[name] - return value, ok -} - -func testDeploySecrets() []string { - return []string{testDeployPassword, testDeployOwnerPassword, testDeployRuntimePassword, testDeployClientSecret, testDeploySession} -} - -func writeDeployConfig(t *testing.T, config any) string { - t.Helper() - content, err := json.MarshalIndent(config, "", " ") - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "deploy-azure.json") - if err := os.WriteFile(path, content, 0o644); err != nil { - t.Fatal(err) - } - return path -} - -// testDeployTemplateAbs is resolved at init so tests that t.Chdir still find it. -var testDeployTemplateAbs, testDeployTemplateErr = filepath.Abs(filepath.Join("..", "..", "server", "azure", "main.json")) - -func testDeployTemplatePath(t *testing.T) string { - t.Helper() - if testDeployTemplateErr != nil { - t.Fatal(testDeployTemplateErr) - } - return testDeployTemplateAbs -} - -// fakeAz records argv and answers the read-only plan commands. -type fakeAz struct { - t *testing.T - calls [][]string - groupExists bool - groupResources string - namedResources map[string]string - whatIf string - whatIfStderr string - whatIfErr error - parameterFiles []string - // overrides replace the default healthy response for a fakeAzKey. - overrides map[string]fakeAzResponse -} - -type fakeAzResponse struct { - stdout string - stderr string - err error -} - -var deployAzureAllowedCommands = [][]string{ - {"version"}, {"account", "show"}, {"group", "exists"}, {"resource", "list"}, {"resource", "show"}, - {"provider", "list"}, {"role", "assignment", "list"}, {"ad", "signed-in-user", "show"}, {"ad", "sp", "show"}, - {"storage", "account", "check-name"}, {"acr", "check-name"}, {"keyvault", "list-deleted"}, - {"appservice", "list-locations"}, {"postgres", "flexible-server", "list-skus"}, - {"functionapp", "config", "appsettings", "list"}, {"rest"}, - {"deployment", "group", "what-if"}, {"deployment", "sub", "what-if"}, -} - -// fakeAzKey names a call for overrides: the command path, plus the provider -// namespace for az rest checkNameAvailability and the target for resource show. -func fakeAzKey(args []string) string { - key := azcli.CommandName(args) - flag := func(name string) string { - index := slices.Index(args, name) - if index < 0 || index+1 >= len(args) { - return "" - } - return args[index+1] - } - switch key { - case "rest": - if strings.Contains(flag("--url"), "/providers/Microsoft.DBforPostgreSQL/locations/") { - return "rest Microsoft.DBforPostgreSQL" - } - parts := strings.Split(strings.SplitN(flag("--url"), "?", 2)[0], "/") - if parts[len(parts)-1] == "geoRegions" { - return "rest Microsoft.Web geoRegions" - } - return "rest " + parts[len(parts)-2] - case "resource show": - ids := flag("--ids") - if strings.HasSuffix(ids, "/config/web") { - return "resource show config" - } - return "resource show " + ids[strings.LastIndex(ids, "/providers/")+len("/providers/"):strings.LastIndex(ids, "/")] - } - return key -} - -var deployAzureForbiddenWords = []string{"create", "update", "delete", "set", "start", "stop", "restart", "import", "build", "push", "deploy", "assign", "purge", "login"} - -func (f *fakeAz) Run(ctx context.Context, args ...string) (azcli.Result, error) { - f.t.Helper() - if err := ctx.Err(); err != nil { - return azcli.Result{}, err - } - f.calls = append(f.calls, slices.Clone(args)) - path := azcli.CommandPath(args) - if !slices.ContainsFunc(deployAzureAllowedCommands, func(allowed []string) bool { return slices.Equal(allowed, path) }) || !azcli.IsReadOnly(args) { - f.t.Fatalf("plan executed non-allowlisted az command %q", strings.Join(args, " ")) - } - if response, ok := f.overrides[fakeAzKey(args)]; ok { - return azcli.Result{Stdout: response.stdout, Stderr: response.stderr}, response.err - } - for _, word := range path { - if slices.Contains(deployAzureForbiddenWords, word) && word != "deployment" { - f.t.Fatalf("plan executed mutating az verb %q", strings.Join(args, " ")) - } - } - flag := func(name string) string { - index := slices.Index(args, name) - if index < 0 || index+1 >= len(args) { - return "" - } - return args[index+1] - } - switch fakeAzKey(args) { - case "version": - return azcli.Result{Stdout: `{"azure-cli":"2.67.0","azure-cli-core":"2.67.0"}`}, nil - case "account show": - return azcli.Result{Stdout: fmt.Sprintf(`{"id":%q,"state":"Enabled","tenantId":%q,"user":{"name":"operator@example.test","type":"user"}}`, flag("--subscription"), testDeployTenant)}, nil - case "ad signed-in-user show": - return azcli.Result{Stdout: `"` + testDeployPrincipal + `"`}, nil - case "ad sp show": - return azcli.Result{Stdout: `"` + testDeployPrincipal + `"`}, nil - case "role assignment list": - return azcli.Result{Stdout: `[{"roleDefinitionName":"Owner","scope":"/subscriptions/` + testDeploySubscription + `"}]`}, nil - case "rest Microsoft.Authorization": - return azcli.Result{Stdout: `{"value":[{"actions":["*"],"notActions":[]}]}`}, nil - case "rest Microsoft.Web geoRegions": - return azcli.Result{Stdout: `{"value":[{"name":"West US 3"},{"name":"East US"}]}`}, nil - case "provider list": - return azcli.Result{Stdout: testDeployProviders(f.t, "Registered", "West US 3")}, nil - case "storage account check-name", "acr check-name", "rest Microsoft.KeyVault", "rest Microsoft.Web", "rest Microsoft.DBforPostgreSQL": - return azcli.Result{Stdout: `{"nameAvailable":true,"reason":null,"message":null}`}, nil - case "keyvault list-deleted": - return azcli.Result{Stdout: `[]`}, nil - case "appservice list-locations": - return azcli.Result{Stdout: `[{"name":"West US 3"},{"name":"East US"}]`}, nil - case "postgres flexible-server list-skus": - return azcli.Result{Stdout: `[{"supportedServerEditions":[{"name":"Burstable","supportedServerSkus":[{"name":"Standard_B1ms"},{"name":"Standard_B2s"}]}]}]`}, nil - case "resource show Microsoft.Web/sites": - return azcli.Result{Stdout: `{"publicNetworkAccess":"Enabled"}`}, nil - case "resource show config": - return azcli.Result{Stdout: `{"ipSecurityRestrictionsDefaultAction":"Allow","ipSecurityRestrictions":[{"name":"Allow all","ipAddress":"Any","action":"Allow"}],"scmIpSecurityRestrictionsDefaultAction":"Deny","scmIpSecurityRestrictionsUseMain":false}`}, nil - case "resource show Microsoft.App/containerApps", "resource show Microsoft.App/jobs": - return azcli.Result{Stdout: `[{"name":"CAO_POSTGRES_RUN_RETENTION_DAYS","value":"60"},{"name":"CAO_POSTGRES_LINKED_RETENTION_DAYS","value":"60"}]`}, nil - case "functionapp config appsettings list": - return azcli.Result{Stdout: `[{"name":"CAO_POSTGRES_RUN_RETENTION_DAYS","value":"45"},{"name":"CAO_POSTGRES_LINKED_RETENTION_DAYS","value":"45"}]`}, nil - case "group exists": - return azcli.Result{Stdout: fmt.Sprintf("%t\n", f.groupExists)}, nil - case "resource list": - if flag("--resource-group") != "" { - if f.groupResources != "" { - return azcli.Result{Stdout: f.groupResources}, nil - } - return azcli.Result{Stdout: "[]"}, nil - } - if found, ok := f.namedResources[flag("--name")]; ok { - return azcli.Result{Stdout: found}, nil - } - return azcli.Result{Stdout: "[]"}, nil - default: - parameters := strings.TrimPrefix(flag("--parameters"), "@") - info, err := os.Stat(parameters) - if err != nil { - f.t.Fatalf("what-if parameter file missing: %v", err) - } - if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { - f.t.Fatalf("parameter file mode = %v, want 0600", info.Mode().Perm()) - } - content, err := os.ReadFile(parameters) - if err != nil { - f.t.Fatal(err) - } - for _, secret := range testDeploySecrets() { - if !bytes.Contains(content, []byte(secret)) { - f.t.Fatalf("secure value was not delivered through the parameter file") - } - } - if _, err := os.Stat(flag("--template-file")); err != nil { - f.t.Fatalf("what-if template missing: %v", err) - } - f.parameterFiles = append(f.parameterFiles, parameters) - output := f.whatIf - if output == "" { - output = fmt.Sprintf(`{"status":"Succeeded","error":null,"changes":[{"resourceId":"/subscriptions/%s/resourceGroups/rg-synthetic-test/providers/Microsoft.Web/sites/fa-synthetic-test","changeType":"Create"},{"resourceId":"/subscriptions/%s/resourceGroups/rg-synthetic-test/providers/Microsoft.KeyVault/vaults/kv-synthetic-test","changeType":"Create"}]}`, testDeploySubscription, testDeploySubscription) - } - return azcli.Result{Stdout: output, Stderr: f.whatIfStderr}, f.whatIfErr - } -} - -func testDeployRoot(az azcli.Executor) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { - return testDeployRootWith(az, testDeployEnv) -} - -func testDeployRootWith(az azcli.Executor, getenv func(string) (string, bool)) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { - root := &cobra.Command{Use: "cao", SilenceErrors: true, SilenceUsage: true, - Annotations: map[string]string{cobra.CommandDisplayNameAnnotation: "gh cao"}} - root.AddCommand(deployCommand(deployAzureDeps{ - az: az, - getenv: getenv, - now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) }, - template: func(path string) deployAzureTemplateSource { return deployAzureFileTemplate{path: path} }, - })) - stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} - root.SetOut(stdout) - root.SetErr(stderr) - return root, stdout, stderr -} - -func runDeployPlan(t *testing.T, fake *fakeAz, config any) (string, string, string, error) { - t.Helper() - configPath := writeDeployConfig(t, config) - out := filepath.Join(t.TempDir(), "plans", "plan.json") - root, stdout, stderr := testDeployRoot(fake) - root.SetArgs([]string{"deploy", "azure", "--config", configPath, "--template", testDeployTemplatePath(t), "--out", out}) - err := root.ExecuteContext(context.Background()) - return stdout.String(), stderr.String(), out, err -} - -func TestDeployAzurePlanRunsOnlyReadOnlyCommands(t *testing.T) { - for _, tt := range []struct { - name string - groupExists bool - whatIf []string - scope string - }{ - {"new resource group", false, []string{"deployment", "sub", "what-if"}, "subscription"}, - {"empty resource group", true, []string{"deployment", "group", "what-if"}, "resourceGroup"}, - } { - t.Run(tt.name, func(t *testing.T) { - fake := &fakeAz{t: t, groupExists: tt.groupExists} - stdout, _, out, err := runDeployPlan(t, fake, testDeployConfig()) - if err != nil { - t.Fatal(err) - } - if len(fake.calls) == 0 || !slices.Equal(azcli.CommandPath(fake.calls[len(fake.calls)-1]), tt.whatIf) { - t.Fatalf("calls = %q, want final %q", fake.calls, tt.whatIf) - } - for _, call := range fake.calls { - if !azcli.IsReadOnly(call) { - t.Fatalf("non-read-only call %q", call) - } - } - for _, file := range fake.parameterFiles { - if _, err := os.Stat(file); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("parameter file %s was not removed: %v", file, err) - } - } - var plan deployAzurePlan - content, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(content, &plan); err != nil { - t.Fatal(err) - } - if plan.Kind != deployAzurePlanKind || plan.Scope != tt.scope || plan.CreatedAt != "2026-01-02T03:04:05Z" || - plan.TenantID != testDeployTenant || plan.WhatIf.Counts["Create"] != 2 || len(plan.Config.SHA256) != 64 || len(plan.Template.SHA256) != 64 { - t.Fatalf("unexpected plan: %s", content) - } - if plan.Parameters["functionAppName"] != "fa-synthetic-test" || plan.Parameters["postgresRunRetentionDays"] != float64(45) { - t.Fatalf("plan parameters do not map config: %v", plan.Parameters) - } - if plan.SecureParameters["sessionSecret"] != "env:CAO_TEST_SESSION_SECRET" { - t.Fatalf("secure parameter reference missing: %v", plan.SecureParameters) - } - if !strings.Contains(stdout, "Create=2") { - t.Fatalf("missing summary: %s", stdout) - } - }) - } -} - -func TestDeployAzurePlanNeverExposesSecrets(t *testing.T) { - leak := "echo " + strings.Join(testDeploySecrets(), " ") - for _, tt := range []struct { - name string - fake *fakeAz - }{ - {"success with noisy stderr", &fakeAz{whatIfStderr: leak}}, - {"what-if failure echoing secrets", &fakeAz{whatIfStderr: leak, whatIfErr: &azcli.ExitError{Code: 1, Stderr: leak}, whatIf: `{}`}}, - } { - t.Run(tt.name, func(t *testing.T) { - tt.fake.t = t - stdout, stderr, out, err := runDeployPlan(t, tt.fake, testDeployConfig()) - observed := stdout + stderr - if err != nil { - observed += err.Error() - } - if plan, readErr := os.ReadFile(out); readErr == nil { - observed += string(plan) - } - for _, call := range tt.fake.calls { - observed += strings.Join(call, " ") - } - for _, secret := range testDeploySecrets() { - if strings.Contains(observed, secret) { - t.Fatalf("secret value exposed in argv, output, error, or plan:\n%s", observed) - } - } - if tt.fake.whatIfErr != nil && (err == nil || !strings.Contains(err.Error(), "***")) { - t.Fatalf("expected redacted what-if error, got %v", err) - } - }) - } -} - -func TestDeployAzurePlanFailsClosedOnExistingInstance(t *testing.T) { - id := "/subscriptions/" + testDeploySubscription + "/resourceGroups/rg-other/providers/Microsoft.KeyVault/vaults/kv-synthetic-test" - for _, tt := range []struct { - name string - fake *fakeAz - want string - }{ - {"populated resource group", &fakeAz{groupExists: true, groupResources: `[{"id":"/subscriptions/x/resourceGroups/rg-synthetic-test/providers/Microsoft.Web/sites/other"}]`}, "already contains 1 resource"}, - {"named resource elsewhere", &fakeAz{namedResources: map[string]string{"kv-synthetic-test": `[{"id":"` + id + `"}]`}}, "kv-synthetic-test already exists"}, - {"what-if modifies", &fakeAz{whatIf: `{"status":"Succeeded","changes":[{"resourceId":"/x","changeType":"Modify"}]}`}, "reports Modify"}, - {"what-if failed", &fakeAz{whatIf: `{"status":"Failed","changes":[]}`}, "what-if status"}, - } { - t.Run(tt.name, func(t *testing.T) { - tt.fake.t = t - _, _, out, err := runDeployPlan(t, tt.fake, testDeployConfig()) - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("expected %q, got %v", tt.want, err) - } - if tt.want != "what-if status" && tt.want != "reports Modify" && !strings.Contains(err.Error(), "only fresh deployments") { - t.Fatalf("missing fresh-only guidance: %v", err) - } - if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) { - t.Fatal("failed plan wrote a plan file") - } - }) - } -} - -func TestDeployAzurePlanRequiresSecretsBeforeAzure(t *testing.T) { - fake := &fakeAz{t: t} - configPath := writeDeployConfig(t, testDeployConfig()) - root, _, _ := testDeployRootWith(fake, func(string) (string, bool) { return "", false }) - root.SetArgs([]string{"deploy", "azure", "--config", configPath, "--template", testDeployTemplatePath(t), "--out", filepath.Join(t.TempDir(), "p.json")}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "CAO_TEST_POSTGRES_PASSWORD") { - t.Fatalf("expected missing env error, got %v", err) - } - if len(fake.calls) != 0 { - t.Fatalf("Azure CLI called before secrets resolved: %q", fake.calls) - } -} - -func TestDeployAzurePlanUsesEmbeddedTopologyResources(t *testing.T) { - t.Chdir(t.TempDir()) - t.Setenv("PATH", "") - fake := &fakeAz{t: t} - root, _, _ := testDeployRoot(fake) - config := writeDeployConfig(t, testDeployConfig()) - out := filepath.Join(t.TempDir(), "plan.json") - root.SetArgs([]string{"deploy", "azure", "--config", config, "--out", out}) - if err := root.Execute(); err != nil { - t.Fatal(err) - } - plan, err := readDeployAzurePlan(out) - if err != nil { - t.Fatal(err) - } - if plan.Template.Origin != "embedded" || len(plan.Topology.Order) == 0 { - t.Fatalf("missing embedded template or resolved topology: %+v", plan) - } - before := len(fake.calls) - changed, err := loadDeployAzureConfig(config) - if err != nil { - t.Fatal(err) - } - changed.config.Telemetry.OTLPEndpoint = "https://collector.example.test" - plan.Topology, err = resolveDeployAzureTopology(context.Background(), changed.config) - if err != nil { - t.Fatal(err) - } - if err := writeDeployAzurePlan(out, plan); err != nil { - t.Fatal(err) - } - root, _, _ = testDeployRoot(fake) - root.SetArgs([]string{"deploy", "azure", "--apply", "--config", config, "--plan-file", out}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "topology") { - t.Fatalf("apply accepted changed topology: %v", err) - } - if len(fake.calls) != before { - t.Fatal("Azure was called before topology drift was rejected") - } -} - -func TestDeployAzureTemplateMustMatchConfig(t *testing.T) { - fake := &fakeAz{t: t} - template := filepath.Join(t.TempDir(), "main.json") - writeTestFile(t, template, `{"$schema":"https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#","parameters":{"location":{"type":"string"},"unexpectedRequired":{"type":"string"}},"resources":[]}`) - root, _, _ := testDeployRoot(fake) - root.SetArgs([]string{"deploy", "azure", "--config", writeDeployConfig(t, testDeployConfig()), "--template", template}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "template") { - t.Fatalf("expected template mismatch, got %v", err) - } - if len(fake.calls) != 0 { - t.Fatalf("unexpected calls: %q", fake.calls) - } -} - -func TestDeployAzureHelpIsOffline(t *testing.T) { - for _, args := range [][]string{{"deploy", "--help"}, {"deploy", "azure", "--help"}, {"deploy", "init", "--help"}, {"deploy", "generate", "--help"}, {"deploy", "check", "--help"}} { - failing := azcli.ExecutorFunc(func(context.Context, ...string) (azcli.Result, error) { - t.Fatal("help invoked the Azure CLI") - return azcli.Result{}, nil - }) - root, stdout, stderr := testDeployRoot(failing) - root.SetArgs(args) - if err := root.Execute(); err != nil { - t.Fatal(err) - } - if !strings.Contains(stdout.String(), "gh cao "+strings.Join(args[:len(args)-1], " ")) || stderr.Len() != 0 { - t.Fatalf("unexpected help output: %s %s", stdout, stderr) - } - } -} - -func TestDeployAzureStandaloneHelpWithEmptyPath(t *testing.T) { - repository, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - binary := filepath.Join(t.TempDir(), "gh-cao") - if runtime.GOOS == "windows" { - binary += ".exe" - } - build := exec.Command("go", "build", "-o", binary, "./cmd/gh-cao") - build.Dir = repository - if output, err := build.CombinedOutput(); err != nil { - t.Fatalf("build: %v\n%s", err, output) - } - working := t.TempDir() - for _, args := range [][]string{{"deploy", "--help"}, {"deploy", "azure", "--help"}, {"deploy", "generate", "--help"}} { - command := exec.Command(binary, args...) - command.Dir = working - command.Env = append(os.Environ(), "PATH=") - output, err := command.CombinedOutput() - if err != nil || !strings.Contains(string(output), "gh cao deploy") { - t.Fatalf("%q: %v\n%s", args, err, output) - } - } - command := exec.Command(binary, "deploy", "azure", "--config", "missing.json", "--template", "missing.json") - command.Dir = working - command.Env = append(os.Environ(), "PATH=") - if output, err := command.CombinedOutput(); err == nil || !strings.Contains(string(output), "missing.json") { - t.Fatalf("expected config error without Azure CLI: %v\n%s", err, output) - } - if files, _ := os.ReadDir(working); len(files) != 0 { - t.Fatal("deploy help or failed plan wrote to the working directory") - } -} - -func TestDeployAzureModeFlags(t *testing.T) { - for _, tt := range []struct { - args []string - want string - }{ - {[]string{"--apply"}, "--apply requires --plan-file"}, - {[]string{"--plan", "--apply", "--plan-file", "p.json"}, "none of the others can be"}, - {[]string{"--plan-file", "p.json"}, "--plan-file is only valid with --apply"}, - {[]string{"extra"}, "unknown command"}, - } { - fake := &fakeAz{t: t} - root, _, _ := testDeployRoot(fake) - root.SetArgs(append([]string{"deploy", "azure"}, tt.args...)) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("%q: expected %q, got %v", tt.args, tt.want, err) - } - if len(fake.calls) != 0 { - t.Fatalf("%q: unexpected calls %q", tt.args, fake.calls) - } - } -} - -func TestDeployAzureApplyValidatesPlanBeforeInputs(t *testing.T) { - fake := &fakeAz{t: t} - configPath := writeDeployConfig(t, testDeployConfig()) - planPath := filepath.Join(t.TempDir(), "plan.json") - root, _, _ := testDeployRoot(fake) - root.SetArgs([]string{"deploy", "azure", "--plan", "--config", configPath, "--template", testDeployTemplatePath(t), "--out", planPath}) - if err := root.Execute(); err != nil { - t.Fatal(err) - } - planCalls := len(fake.calls) - - apply := func(config, template, plan string) error { - root, stdout, _ := testDeployRoot(fake) - root.SetArgs([]string{"deploy", "azure", "--apply", "--config", config, "--template", template, "--plan-file", plan}) - err := root.Execute() - if stdout.Len() != 0 { - t.Fatalf("apply wrote output: %s", stdout) - } - return err - } - if err := apply(configPath, testDeployTemplatePath(t), planPath); err == nil || !strings.Contains(err.Error(), "--source-dir") { - t.Fatalf("expected explicit build-source failure, got %v", err) - } - - changed := testDeployConfig() - changed["retentionDays"] = 90 - if err := apply(writeDeployConfig(t, changed), testDeployTemplatePath(t), planPath); err == nil || !strings.Contains(err.Error(), "different config") { - t.Fatalf("expected config mismatch, got %v", err) - } - otherTemplate := filepath.Join(t.TempDir(), "main.json") - content, _ := os.ReadFile(testDeployTemplatePath(t)) - writeTestFile(t, otherTemplate, string(content)+"\n") - if err := apply(configPath, otherTemplate, planPath); err == nil || !strings.Contains(err.Error(), "different template") { - t.Fatalf("expected template mismatch, got %v", err) - } - tampered := filepath.Join(t.TempDir(), "tampered.json") - planContent, _ := os.ReadFile(planPath) - writeTestFile(t, tampered, strings.Replace(string(planContent), `"kind"`, `"unexpected": true, "kind"`, 1)) - if err := apply(configPath, testDeployTemplatePath(t), tampered); err == nil || !strings.Contains(err.Error(), "invalid plan file") { - t.Fatalf("expected invalid plan, got %v", err) - } - oldPlan, err := readDeployAzurePlan(planPath) - if err != nil { - t.Fatal(err) - } - oldPlan.SchemaVersion = 1 - if err := writeDeployAzurePlan(tampered, oldPlan); err != nil { - t.Fatal(err) - } - if err := apply(configPath, "", tampered); err == nil || !strings.Contains(err.Error(), "rerun --plan") { - t.Fatalf("expected old plan to require regeneration, got %v", err) - } - if len(fake.calls) != planCalls { - t.Fatalf("apply invoked the Azure CLI: %q", fake.calls[planCalls:]) - } -} - -func TestDeployAzureConfigValidation(t *testing.T) { - valid, err := json.Marshal(testDeployConfig()) - if err != nil { - t.Fatal(err) - } - if _, err := parseDeployAzureConfig(valid); err != nil { - t.Fatalf("valid config rejected: %v", err) - } - mutate := func(change func(map[string]any)) []byte { - config := testDeployConfig() - change(config) - content, err := json.Marshal(config) - if err != nil { - t.Fatal(err) - } - return content - } - resources := func(config map[string]any) map[string]any { return config["resources"].(map[string]any) } - secrets := func(config map[string]any) map[string]any { return config["secrets"].(map[string]any) } - for _, tt := range []struct { - name string - content []byte - want string - }{ - {"unknown top-level field", mutate(func(c map[string]any) { c["extra"] = true }), `unknown field "extra"`}, - {"unknown nested field", mutate(func(c map[string]any) { resources(c)["frontDoor"] = "fd-synthetic" }), `unknown field "frontDoor"`}, - {"missing subscription", mutate(func(c map[string]any) { delete(c, "subscription") }), "subscription is required"}, - {"missing key vault", mutate(func(c map[string]any) { delete(resources(c), "keyVault") }), "resources.keyVault is required"}, - {"missing retention", mutate(func(c map[string]any) { delete(c, "retentionDays") }), "retentionDays is required"}, - {"missing secret reference", mutate(func(c map[string]any) { delete(secrets(c), "sessionSecret") }), "secrets.sessionSecret is required"}, - {"angle placeholder", mutate(func(c map[string]any) { c["resourceGroup"] = "" }), "resourceGroup contains an unresolved placeholder"}, - {"template placeholder", mutate(func(c map[string]any) { c["oauth"] = map[string]any{"clientId": "${CLIENT_ID}"} }), "oauth.clientId contains an unresolved placeholder"}, - {"todo placeholder", mutate(func(c map[string]any) { c["allowedHosts"] = []string{"TODO.example.test"} }), "allowedHosts[0] contains an unresolved placeholder"}, - {"embedded secret string", mutate(func(c map[string]any) { secrets(c)["githubClientSecret"] = testDeployClientSecret }), "secret values must not be embedded"}, - {"embedded secret value field", mutate(func(c map[string]any) { - secrets(c)["githubClientSecret"] = map[string]any{"value": testDeployClientSecret} - }), `unknown field "value"`}, - {"embedded token", mutate(func(c map[string]any) { - c["oauth"] = map[string]any{"clientId": "ghp_" + strings.Repeat("A", 36)} - }), "appears to embed a secret value"}, - {"embedded connection string", mutate(func(c map[string]any) { - resources(c)["postgresDatabase"] = "postgres://admin:hunter2@db.example.test/cao" - }), "appears to embed a secret value"}, - {"ambiguous secret source", mutate(func(c map[string]any) { - secrets(c)["sessionSecret"] = map[string]any{"env": "CAO_TEST_SESSION_SECRET", "keyVault": map[string]any{"id": "/x", "secretName": "y"}} - }), "exactly one of env or keyVault"}, - {"retention out of range", mutate(func(c map[string]any) { c["retentionDays"] = 3 }), "between 7 and 3650"}, - {"mutable image tag", mutate(func(c map[string]any) { c["image"] = map[string]any{"tag": "latest"} }), "not latest"}, - {"no principals", mutate(func(c map[string]any) { c["access"] = map[string]any{} }), "at least one organization"}, - {"trailing content", append(append([]byte(nil), valid...), []byte(` {}`)...), "unexpected content"}, - } { - t.Run(tt.name, func(t *testing.T) { - _, err := parseDeployAzureConfig(tt.content) - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("expected %q, got %v", tt.want, err) - } - if strings.Contains(err.Error(), testDeployClientSecret) || strings.Contains(err.Error(), "hunter2") { - t.Fatalf("validation error echoed secret material: %v", err) - } - }) - } -} - -func TestDeployAzureKeyVaultReferenceStaysUnresolved(t *testing.T) { - config := testDeployConfig() - vault := "/subscriptions/" + testDeploySubscription + "/resourceGroups/rg-bootstrap-test/providers/Microsoft.KeyVault/vaults/kv-bootstrap-test" - config["secrets"].(map[string]any)["sessionSecret"] = map[string]any{"keyVault": map[string]any{"id": vault, "secretName": "session-secret"}} - parsed, err := parseDeployAzureConfig(mustJSON(t, config)) - if err != nil { - t.Fatal(err) - } - secrets, err := resolveDeployAzureSecrets(parsed, testDeployEnv) - if err != nil { - t.Fatal(err) - } - entry := mustJSON(t, secrets.entries["sessionSecret"]) - if !strings.Contains(string(entry), `"reference"`) || strings.Contains(string(entry), testDeploySession) { - t.Fatalf("Key Vault secret was resolved locally: %s", entry) - } -} - -func TestRootModuleDoesNotImportServer(t *testing.T) { - repository, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - list := exec.Command("go", "list", "-deps", "./...") - list.Dir = repository - output, err := list.Output() - if err != nil { - t.Fatalf("go list: %v", err) - } - for _, pkg := range strings.Fields(string(output)) { - if strings.HasPrefix(pkg, "github.com/githubnext/gh-cao/server") { - t.Fatalf("root module depends on server package %s", pkg) - } - } -} - -func mustJSON(t *testing.T, value any) []byte { - t.Helper() - content, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return content -} diff --git a/internal/cli/deploy_azure_verify.go b/internal/cli/deploy_azure_verify.go deleted file mode 100644 index d8abd8ce01..0000000000 --- a/internal/cli/deploy_azure_verify.go +++ /dev/null @@ -1,164 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/githubnext/gh-cao/internal/azcli" -) - -type deployAzureVerification struct { - Check string `json:"check"` - Status int `json:"status"` - Passed bool `json:"passed"` - CheckedAt string `json:"checkedAt"` -} - -func deployAzureVerify(ctx context.Context, deps deployAzureDeps, az azcli.Executor, config deployAzureConfig, host string) ([]deployAzureVerification, error) { - client := http.Client{Timeout: 15 * time.Second} - if deps.http != nil { - client = *deps.http - } - client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - var checks []deployAzureVerification - for _, check := range []struct { - name, host, path string - status int - }{ - {"health", host, "/api/health", 200}, - {"readiness", host, "/api/readiness", 200}, - {"signed-out-campaigns", host, "/api/v1/campaigns", 401}, - {"root-login", host, "/", 302}, - {"github-oauth", host, "/auth/login", 302}, - {"scm-denied", config.Resources.FunctionApp + ".scm.azurewebsites.net", "/", 403}, - } { - passed := false - var lastError error - for attempt := 0; attempt < 60; attempt++ { - requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, "https://"+check.host+check.path, nil) - if err != nil { - cancel() - return checks, err - } - response, err := client.Do(request) - if err != nil { - cancel() - // Do not expose transport errors that could contain redirect state. - lastError = fmt.Errorf("%s: HTTPS request failed", check.name) - } else { - body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - _ = response.Body.Close() - cancel() - if response.StatusCode == http.StatusForbidden && check.host == host { - return checks, deployAzureCheckIngress(ctx, az, config) - } - lastError = fmt.Errorf("%s returned HTTP %d, want %d", check.name, response.StatusCode, check.status) - if response.StatusCode != check.status && (check.name == "health" || check.name == "readiness") { - lastError = fmt.Errorf("%w (%s)", lastError, deployAzureHealthSignals(body)) - } - if response.StatusCode == check.status { - switch check.name { - case "root-login": - redirect, err := url.Parse(response.Header.Get("Location")) - passed = err == nil && redirect.Path == "/auth/login" && - (redirect.Host == "" || (redirect.Scheme == "https" && redirect.Host == host)) && redirect.User == nil - case "github-oauth": - redirect, err := url.Parse(response.Header.Get("Location")) - passed = err == nil && redirect.Scheme == "https" && redirect.Host == "github.com" && redirect.Path == "/login/oauth/authorize" && - redirect.User == nil && redirect.Query().Get("client_id") == config.OAuth.ClientID && - redirect.Query().Get("redirect_uri") == "https://"+host+"/auth/callback" - default: - passed = true - } - if !passed { - lastError = fmt.Errorf("%s returned an unexpected authentication redirect", check.name) - } - } - if passed { - checks = append(checks, deployAzureVerification{Check: check.name, Status: response.StatusCode, Passed: true, CheckedAt: deps.now().UTC().Format(time.RFC3339)}) - break - } - } - if err := deployAzurePause(ctx, deps, 5*time.Second); err != nil { - return checks, fmt.Errorf("verification cancelled after %v: %w", lastError, err) - } - } - if !passed { - return checks, fmt.Errorf("deployment verification failed after bounded retries: %w", lastError) - } - } - return checks, nil -} - -func deployAzureCheckIngress(ctx context.Context, az azcli.Executor, config deployAzureConfig) error { - scope := []string{"--subscription", config.Subscription, "--resource-group", config.ResourceGroup, "--name", config.Resources.FunctionApp} - app, err := az.Run(ctx, append([]string{"functionapp", "show"}, append(scope, "--query", "{publicNetworkAccess:publicNetworkAccess}", "--output", "json")...)...) - if err != nil { - return fmt.Errorf("main site returned 403; could not read ingress: %w", err) - } - var access struct { - PublicNetworkAccess string `json:"publicNetworkAccess"` - } - if err := json.Unmarshal([]byte(app.Stdout), &access); err != nil { - return fmt.Errorf("main site returned 403; parse ingress read-back: %w", err) - } - rules, err := az.Run(ctx, append([]string{"webapp", "config", "access-restriction", "show"}, append(scope, "--output", "json")...)...) - if err != nil { - return fmt.Errorf("main site returned 403; could not read access restrictions: %w", err) - } - var restrictions struct { - DefaultAction string `json:"ipSecurityRestrictionsDefaultAction"` - Rules []struct { - Action string `json:"action"` - IPAddress string `json:"ipAddress"` - Name string `json:"name"` - Priority int `json:"priority"` - } `json:"ipSecurityRestrictions"` - } - if err := json.Unmarshal([]byte(rules.Stdout), &restrictions); err != nil { - return fmt.Errorf("main site returned 403; parse access restrictions: %w", err) - } - custom := 0 - for _, rule := range restrictions.Rules { - if !strings.EqualFold(rule.Action, "Allow") || rule.IPAddress != "Any" || rule.Priority != 2147483647 { - custom++ - } - } - if !strings.EqualFold(access.PublicNetworkAccess, "Enabled") || restrictions.DefaultAction != "Allow" || custom != 0 { - return fmt.Errorf("ingress was changed outside this deployment (possible tenant policy/remediator): publicNetworkAccess=%s, main default action=%s, explicit rules=%d; no ingress changes were attempted", - access.PublicNetworkAccess, restrictions.DefaultAction, custom) - } - return errors.New("main site returned HTTP 403 although read-back is public Enabled/Allow with no custom rules; investigate Azure ingress; no ingress changes were attempted") -} - -// deployAzureHealthSignals summarizes only the boolean, signed-out health -// fields so a failed verification is actionable without echoing the body. -func deployAzureHealthSignals(body []byte) string { - var payload struct { - Redis *struct { - Connected bool `json:"connected"` - } `json:"redis"` - Data *struct { - Available bool `json:"available"` - } `json:"data"` - } - if err := json.Unmarshal(body, &payload); err != nil || payload.Redis == nil || payload.Data == nil { - return "response is not CAO health JSON; the Function App container may not have started, check its image pull and startup logs" - } - switch { - case !payload.Redis.Connected: - return "CAO reports its operational cache is disconnected" - case !payload.Data.Available: - return "CAO is running but reports dashboard data unavailable; check PostgreSQL runtime access and the ingestion job" - default: - return "CAO reports cache connected and data available" - } -} diff --git a/internal/cli/deploy_bundle.go b/internal/cli/deploy_bundle.go index f8f96bf3d8..d192673d91 100644 --- a/internal/cli/deploy_bundle.go +++ b/internal/cli/deploy_bundle.go @@ -23,7 +23,7 @@ func addDeployBundleCommands(command *cobra.Command) { }, } init.Flags().String("out", "requirements.yaml", "New YAML requirements file") - init.Flags().String("deployment", "azure-functions", "Example: single-vm, minimal-azure, azure-functions, azure-container-apps, or full-azure") + init.Flags().String("deployment", "azure-container-apps", "Example: single-vm, minimal-azure, azure-container-apps, or full-azure") generate := &cobra.Command{ Use: "generate", Short: "Resolve requirements and write a new deployment folder", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -32,6 +32,7 @@ func addDeployBundleCommands(command *cobra.Command) { if destination == "" { return fmt.Errorf("deploy generate requires --out") } + content, err := topology.ReadRequirements(source) if err != nil { return err @@ -66,6 +67,26 @@ func addDeployBundleCommands(command *cobra.Command) { command.AddCommand(init, generate, check) } +func deployCommand() *cobra.Command { + command := &cobra.Command{ + Use: "deploy", + Short: "Generate and check review-only CAO deployment bundles", + Long: `Resolve deployment dependencies from generated TOSCA requirements. + +init, generate and check prepare offline, review-only deployment bundles from +YAML feature choices and emit a TOSCA 1.3 deployment template. Templates, +examples and runbooks are embedded in Go; generation requires Microsoft's +official Bicep CLI on PATH. These commands never deploy resources or change +GitHub Apps.`, + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + return fmt.Errorf("deploy requires init, generate, or check") + }, + } + addDeployBundleCommands(command) + return command +} + func deployBundleReport(cmd *cobra.Command, verb, directory string, result topology.Topology) error { report, err := topology.ReadRuleReport(directory) if err != nil { diff --git a/internal/cli/deploy_bundle_test.go b/internal/cli/deploy_bundle_test.go index 8d55909611..2bb7194679 100644 --- a/internal/cli/deploy_bundle_test.go +++ b/internal/cli/deploy_bundle_test.go @@ -57,6 +57,7 @@ func TestDeployBundleCommandsOffline(t *testing.T) { } for _, args := range [][]string{ {"init", "--out", config}, {"generate", "--config", config, "--out", output}, {"generate", "--config", config}, {"check"}, {"generate", "--unknown"}, + {"azure", "--plan"}, {"init", "--deployment", "azure-functions", "--out", filepath.Join(directory, "retired.yaml")}, } { if _, err := run(args...); err == nil { t.Fatalf("expected failure: %v", args) diff --git a/internal/cli/evidence.go b/internal/cli/evidence.go index 5f52f61d87..bb1789967e 100644 --- a/internal/cli/evidence.go +++ b/internal/cli/evidence.go @@ -54,9 +54,9 @@ func defaultEvidenceDeps(version string) evidenceDeps { } type evidenceReleaseOptions struct { - Repository, Revision, Image, AzureReceipt, Output string - PackageRun, PromotionRun int64 - Verify bool + Repository, Revision, Image, Output string + PackageRun, PromotionRun int64 + Verify bool } func (o evidenceReleaseOptions) validate() error { @@ -88,7 +88,7 @@ configured image read-back is not running-image measurement, and artifact refere do not establish independent retention, governance or Type 2 effectiveness. Optional provenance verification uses gh attestation verify with the exact image, -source digest and reusable publisher. Azure receipts remain development-only.`, +source digest and reusable publisher.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if cmd.Flags().Changed("promotion-run") && !evidenceID(options.PromotionRun) { @@ -116,7 +116,6 @@ source digest and reusable publisher. Azure receipts remain development-only.`, flags.StringVar(&options.Image, "image", "", "Exact ghcr.io/OWNER/REPO/cao-server@sha256:DIGEST") flags.Int64Var(&options.PackageRun, "package-run", 0, "Existing CAO package workflow run ID") flags.Int64Var(&options.PromotionRun, "promotion-run", 0, "Optional existing prerelease-to-latest promotion run ID") - flags.StringVar(&options.AzureReceipt, "azure-receipt", "", "Optional existing development-only Azure receipt") flags.BoolVar(&options.Verify, "verify-provenance", false, "Verify exact image/source/publisher/run-attempt provenance with gh") flags.StringVar(&options.Output, "output", "", "New JSON file to write (mode 0600 where supported); never overwritten") group.AddCommand(release) @@ -149,28 +148,6 @@ func evidenceBinaryHash() (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } -func evidenceReadReceipt(path string) ([]byte, error) { - info, err := os.Lstat(path) - if err != nil || !info.Mode().IsRegular() { - return nil, errors.New("selected Azure receipt must be a readable regular file") - } - file, err := os.Open(path) - if err != nil { - return nil, errors.New("cannot read selected Azure receipt") - } - defer func() { _ = file.Close() }() - current, err := file.Stat() - if err != nil || !os.SameFile(info, current) { - return nil, errors.New("selected Azure receipt changed while opening") - } - const limit = 1024 * 1024 - content, err := io.ReadAll(io.LimitReader(file, limit+1)) - if err != nil || len(content) > limit { - return nil, errors.New("selected Azure receipt is unreadable or exceeds 1 MiB") - } - return content, nil -} - func writeReleaseEvidence(ctx context.Context, path string, report evidenceReleaseReport) error { if err := ctx.Err(); err != nil { return err diff --git a/internal/cli/evidence_azure.go b/internal/cli/evidence_azure.go deleted file mode 100644 index ad5430c0fd..0000000000 --- a/internal/cli/evidence_azure.go +++ /dev/null @@ -1,101 +0,0 @@ -package cli - -import ( - "encoding/json" - "errors" - "regexp" - "slices" - "strings" - "time" -) - -type evidenceAzureImage struct { - Target string `json:"target"` - Digest string `json:"digest"` - ReferenceSHA256 string `json:"referenceSHA256"` - ConfiguredReferenceMatches bool `json:"configuredReferenceMatches"` - Scan string `json:"scan"` - SBOM string `json:"sbom"` - Provenance string `json:"provenance"` -} - -type evidenceDeployment struct { - evidenceState - Attribution string `json:"attribution,omitempty"` - ReceiptSHA256 string `json:"receiptSHA256,omitempty"` - SourceRevision string `json:"sourceRevision,omitempty"` - SourceWorktree string `json:"sourceWorktree,omitempty"` - ContextSHA256 string `json:"contextSHA256,omitempty"` - PlanSHA256 string `json:"planSHA256,omitempty"` - Environment string `json:"environment,omitempty"` - ImageAssurance string `json:"imageAssurance,omitempty"` - StartedAt string `json:"startedAt,omitempty"` - CompletedAt string `json:"completedAt,omitempty"` - Images []evidenceAzureImage `json:"images,omitempty"` - Verification []deployAzureVerification `json:"verification,omitempty"` - VerificationComplete bool `json:"verificationComplete"` - RunningImageMeasurement string `json:"runningImageMeasurement,omitempty"` - IndependentVerification string `json:"independentVerification,omitempty"` -} - -func summarizeEvidenceAzure(content []byte, revision string) (evidenceDeployment, error) { - var receipt deployAzureReceipt - if json.Unmarshal(content, &receipt) != nil { - return evidenceDeployment{}, errors.New("selected Azure receipt is not valid JSON") - } - started, startErr := time.Parse(time.RFC3339Nano, receipt.StartedAt) - completed, completeErr := time.Parse(time.RFC3339Nano, receipt.CompletedAt) - if receipt.Kind != "cao.deploy.azure.receipt" || receipt.SchemaVersion != 1 || - receipt.SourceRevision != revision || !evidenceRevisionPattern.MatchString(receipt.SourceRevision) || - receipt.Environment != "development" || receipt.ImageAssurance != "not-evaluated-development-only" || - !slices.Contains([]string{"clean", "dirty"}, receipt.SourceWorktree) || - !evidenceHashPattern.MatchString(receipt.ContextSHA256) || !evidenceHashPattern.MatchString(receipt.PlanSHA256) || - startErr != nil || completeErr != nil || !evidenceTimestamp(receipt.StartedAt) || !evidenceTimestamp(receipt.CompletedAt) || - completed.Before(started) || len(receipt.Images) < 2 || len(receipt.Images) > 3 { - return evidenceDeployment{}, errors.New("invalid or mismatched Azure development receipt") - } - targets := map[string]bool{} - images := make([]evidenceAzureImage, 0, len(receipt.Images)) - referencePattern := regexp.MustCompile(`^[a-z0-9]+\.azurecr\.io/[a-z0-9][a-z0-9_.-]*@sha256:[0-9a-f]{64}$`) - for _, image := range receipt.Images { - if !slices.Contains([]string{"azure-functions-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"}, image.Target) || - targets[image.Target] || !evidenceDigestPattern.MatchString(image.Digest) || !referencePattern.MatchString(image.Reference) || - !strings.HasSuffix(image.Reference, "@"+image.Digest) || image.ConfiguredReference != image.Reference || - image.Scan != "not-performed" || image.SBOM != "not-generated" || image.Provenance != "local-context-not-attested" { - return evidenceDeployment{}, errors.New("invalid Azure image identity or assurance") - } - targets[image.Target] = true - images = append(images, evidenceAzureImage{ - Target: image.Target, Digest: image.Digest, ReferenceSHA256: evidenceSHA256([]byte(image.Reference)), - ConfiguredReferenceMatches: true, Scan: image.Scan, SBOM: image.SBOM, Provenance: image.Provenance, - }) - } - if !targets["azure-functions-runtime"] || targets["azure-ingest-runtime"] == targets["azure-empty-ingest-runtime"] { - return evidenceDeployment{}, errors.New("missing or conflicting Azure image target") - } - expected := map[string]int{ - "health": 200, "readiness": 200, "signed-out-campaigns": 401, - "root-login": 302, "github-oauth": 302, "scm-denied": 403, - } - if len(receipt.Verification) > len(expected) { - return evidenceDeployment{}, errors.New("invalid Azure verification inventory") - } - seen := map[string]bool{} - for _, check := range receipt.Verification { - status, ok := expected[check.Check] - if !ok || seen[check.Check] || check.Status < 100 || check.Status > 599 || - !evidenceTimestamp(check.CheckedAt) || check.Passed && check.Status != status { - return evidenceDeployment{}, errors.New("invalid Azure verification check") - } - seen[check.Check] = true - } - return evidenceDeployment{ - evidenceState: evidenceState{Status: "reported"}, Attribution: "owner-supplied-local-receipt", - ReceiptSHA256: evidenceSHA256(content), SourceRevision: revision, SourceWorktree: receipt.SourceWorktree, - ContextSHA256: receipt.ContextSHA256, PlanSHA256: receipt.PlanSHA256, - Environment: "development", ImageAssurance: receipt.ImageAssurance, - StartedAt: receipt.StartedAt, CompletedAt: receipt.CompletedAt, Images: images, - Verification: receipt.Verification, VerificationComplete: len(seen) == len(expected), - RunningImageMeasurement: "unknown", IndependentVerification: "unknown", - }, nil -} diff --git a/internal/cli/evidence_release.go b/internal/cli/evidence_release.go index 3590cf3b4f..93da22ad36 100644 --- a/internal/cli/evidence_release.go +++ b/internal/cli/evidence_release.go @@ -36,7 +36,7 @@ type evidenceReleaseReport struct { Package evidenceRun `json:"package"` Promotion evidencePromotion `json:"promotion"` Provenance evidenceProvenance `json:"provenance"` - Deployment evidenceDeployment `json:"deployment"` + Deployment evidenceState `json:"deployment"` ImageSourceBinding string `json:"imageSourceBinding"` DeployedImageDigest evidenceState `json:"deployedImageDigest"` IndependentRetention evidenceState `json:"independentRetention"` @@ -303,17 +303,7 @@ func collectReleaseEvidence(ctx context.Context, deps evidenceDeps, options evid if options.Verify { provenance = client.provenance(options, build.Reference) } - deployment := evidenceDeployment{evidenceState: evidenceUnknown("receipt-not-selected")} - if options.AzureReceipt != "" { - content, err := evidenceReadReceipt(options.AzureReceipt) - if err != nil { - return evidenceReleaseReport{}, err - } - deployment, err = summarizeEvidenceAzure(content, options.Revision) - if err != nil { - return evidenceReleaseReport{}, err - } - } + deployment := evidenceState{Status: "unknown", Reason: "deployment-evidence-not-collected"} if ctx.Err() != nil { return evidenceReleaseReport{}, ctx.Err() } diff --git a/internal/cli/evidence_test.go b/internal/cli/evidence_test.go index 6e082cc0be..156347686b 100644 --- a/internal/cli/evidence_test.go +++ b/internal/cli/evidence_test.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "reflect" - "regexp" "runtime" "strconv" "strings" @@ -35,7 +34,6 @@ type evidenceFixture struct { jobs []evidenceAPIJob artifacts []evidenceAPIArtifact approvals []evidenceAPIApproval - receipt deployAzureReceipt verification string verifyErr error calls [][]string @@ -78,20 +76,6 @@ func newEvidenceFixture(t *testing.T) *evidenceFixture { "invocationId":"https://github.com/octo/cao/actions/runs/42/attempts/2" }}} }} }]` - f.receipt = deployAzureReceipt{ - Kind: "cao.deploy.azure.receipt", SchemaVersion: 1, SourceRevision: testEvidenceRevision, SourceWorktree: "dirty", - Environment: "development", ImageAssurance: "not-evaluated-development-only", - ContextSHA256: strings.Repeat("c", 64), PlanSHA256: strings.Repeat("d", 64), - StartedAt: testEvidenceDate, CompletedAt: testEvidenceDate, DeploymentID: testEvidenceSecret, - Verification: []deployAzureVerification{{Check: "health", Status: 200, Passed: true, CheckedAt: testEvidenceDate}}, - } - for _, target := range []string{"azure-functions-runtime", "azure-empty-ingest-runtime"} { - ref := "private.azurecr.io/image@" + testEvidenceDigest - f.receipt.Images = append(f.receipt.Images, deployAzureBuiltImage{ - Target: target, Digest: testEvidenceDigest, Reference: ref, ConfiguredReference: ref, - Scan: "not-performed", SBOM: "not-generated", Provenance: "local-context-not-attested", Tag: testEvidenceSecret, - }) - } f.deps = evidenceDeps{ now: func() time.Time { parsed, _ := time.Parse(time.RFC3339, testEvidenceDate); return parsed }, version: "test", @@ -232,60 +216,6 @@ func TestEvidenceProvenance(t *testing.T) { } } -func TestEvidenceAzureReceipt(t *testing.T) { - f := newEvidenceFixture(t) - f.options.AzureReceipt = filepath.Join(t.TempDir(), "azure.json") - f.receipt.Verification[0].Passed = false - f.receipt.Verification[0].Status = 503 - content := evidenceTestJSON(t, f.receipt) - content = strings.TrimSuffix(content, "}") + `,"extra":"` + testEvidenceSecret + `"}` - if err := os.WriteFile(f.options.AzureReceipt, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - report := evidenceTestCollect(t, f) - azure := report.Deployment - if azure.Status != "reported" || azure.SourceWorktree != "dirty" || - azure.ImageAssurance != "not-evaluated-development-only" || len(azure.Images) != 2 || - azure.Images[0].Digest != testEvidenceDigest || !evidenceHashPattern.MatchString(azure.Images[0].ReferenceSHA256) || - azure.Verification[0].Passed || azure.Verification[0].Status != 503 || azure.VerificationComplete || - azure.RunningImageMeasurement != "unknown" || report.DeployedImageDigest.Status != "unknown" { - t.Fatal("development assurance or failed verification was incorrectly promoted") - } - if regexp.MustCompile(`private\.azurecr\.io`).MatchString(evidenceTestJSON(t, report)) { - t.Fatal("report exposed the private image reference") - } -} - -func TestEvidenceAzureRejectsMismatch(t *testing.T) { - for name, change := range map[string]func(*deployAzureReceipt){ - "source": func(r *deployAzureReceipt) { r.SourceRevision = strings.Repeat("f", 40) }, - "production": func(r *deployAzureReceipt) { r.Environment = "production" }, - "assurance": func(r *deployAzureReceipt) { r.ImageAssurance = "verified" }, - "digest": func(r *deployAzureReceipt) { r.Images[0].Digest = "sha256:" + strings.Repeat("f", 64) }, - "configured": func(r *deployAzureReceipt) { r.Images[0].ConfiguredReference = "another-image" }, - "duplicate target": func(r *deployAzureReceipt) { r.Images[1] = r.Images[0] }, - "conflicting ingestion": func(r *deployAzureReceipt) { - image := r.Images[1] - image.Target = "azure-ingest-runtime" - r.Images = append(r.Images, image) - }, - "credential URL": func(r *deployAzureReceipt) { - r.Images[0].Reference = "https://user:" + testEvidenceSecret + "@host/image" - }, - "duplicate check": func(r *deployAzureReceipt) { r.Verification = append(r.Verification, r.Verification[0]) }, - "false success": func(r *deployAzureReceipt) { r.Verification[0].Status = 503 }, - } { - t.Run(name, func(t *testing.T) { - f := newEvidenceFixture(t) - change(&f.receipt) - _, err := summarizeEvidenceAzure([]byte(evidenceTestJSON(t, f.receipt)), testEvidenceRevision) - if err == nil || strings.Contains(err.Error(), testEvidenceSecret) { - t.Fatalf("expected sanitized failure, got %v", err) - } - }) - } -} - func TestEvidenceOptionalFailuresAndRoles(t *testing.T) { f := newEvidenceFixture(t) for _, path := range []string{"actions/runs/42/approvals", "actions/runs/42/artifacts?per_page=100&page=1", "actions/runs/42/attempts/2/jobs?per_page=100&page=1"} { @@ -392,12 +322,8 @@ func TestEvidenceInvalidSelections(t *testing.T) { func TestEvidenceCommandPersistence(t *testing.T) { f := newEvidenceFixture(t) - f.options.AzureReceipt = filepath.Join(t.TempDir(), "azure.json") - if err := os.WriteFile(f.options.AzureReceipt, []byte(evidenceTestJSON(t, f.receipt)), 0o600); err != nil { - t.Fatal(err) - } args := []string{"release", "--repo", testEvidenceRepo, "--revision", testEvidenceRevision, - "--image", testEvidenceImage, "--package-run", "42", "--azure-receipt", f.options.AzureReceipt, "--output", f.options.Output} + "--image", testEvidenceImage, "--package-run", "42", "--output", f.options.Output} execute := func() error { cmd := evidenceCommand(f.deps) cmd.SetOut(&bytes.Buffer{}) @@ -417,7 +343,7 @@ func TestEvidenceCommandPersistence(t *testing.T) { t.Fatal("output must have private permissions") } var report evidenceReleaseReport - if json.Unmarshal(before, &report) != nil || report.Deployment.SourceRevision != testEvidenceRevision || report.Status != "incomplete" { + if json.Unmarshal(before, &report) != nil || report.Deployment.Status != "unknown" || report.Status != "incomplete" { t.Fatal("persisted report lost evidence shape") } if err := execute(); err == nil { @@ -427,14 +353,6 @@ func TestEvidenceCommandPersistence(t *testing.T) { if err != nil || !bytes.Equal(before, after) { t.Fatal("existing output changed") } - for _, content := range []string{testEvidenceSecret, strings.Repeat("x", 1024*1024+1)} { - if err := os.WriteFile(f.options.AzureReceipt, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - if err := execute(); err == nil || strings.Contains(err.Error(), testEvidenceSecret) { - t.Fatalf("selected invalid receipt did not fail safely: %v", err) - } - } ctx, cancel := context.WithCancel(context.Background()) cancel() output := filepath.Join(t.TempDir(), "cancelled.json") diff --git a/internal/cli/native_policy.go b/internal/cli/native_policy.go index 3f2cdbad8a..6a6b401441 100644 --- a/internal/cli/native_policy.go +++ b/internal/cli/native_policy.go @@ -522,7 +522,7 @@ func nativeValidateHost(value any, at string) error { "tls": nativePolicyObject(map[string]nativePolicyRule{"mode": nativeEnum("auto", "required", "disabled"), "server-name-env": env, "ca-certificate-env": env}), }, "module") target := nativePolicyObject(map[string]nativePolicyRule{ - "module": nativeEnum("generic", "container", "azure-functions"), "name": nativePolicyString(slugPattern), + "module": nativeEnum("generic", "container"), "name": nativePolicyString(slugPattern), "authentication": nativeEnum("github-oauth"), "listener": nativeEnum("process", "platform", "external"), "require-https": func(v any, p string) error { if v != true { diff --git a/internal/cli/root.go b/internal/cli/root.go index a9e7b48521..c442921169 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -65,7 +65,7 @@ Help, version, and catalog discovery work offline.`, root.AddCommand(admissionCommands(gh)) root.AddCommand(repositoryCommands(gh)) addOperatorCommands(root, runtime, gh) - root.AddCommand(deployCommand(defaultDeployAzureDeps())) + root.AddCommand(deployCommand()) root.AddCommand(evidenceCommand(defaultEvidenceDeps(version))) root.AddCommand(dashboardCommand(version, boundedDashboardGH, embeddedDashboardLogs)) root.AddCommand(dashboardActionCommand()) diff --git a/internal/topology/README.md b/internal/topology/README.md index 00fa2aef5a..99cc665711 100644 --- a/internal/topology/README.md +++ b/internal/topology/README.md @@ -1,9 +1,8 @@ # Topology solver This Go package resolves CAO deployment requirements offline and generates -review-only Azure deployment bundles. It also supplies the dependency engine -and embedded provider contract used by native Azure preflight, planning and -guarded development apply. +review-only Azure deployment bundles, including Container Apps and compact +hosted Go deployments. The solver derives dependencies from generated **TOSCA Simple Profile 1.3** node-template requirement assignments and uses a deterministic standard-library @@ -19,7 +18,7 @@ deployment boundaries. ## CLI usage ```sh -gh cao deploy init --deployment azure-functions --out /private/operator/requirements.yaml +gh cao deploy init --deployment azure-container-apps --out /private/operator/requirements.yaml # Edit the sample's identities, feature choices and Key Vault references. gh cao deploy generate --config /private/operator/requirements.yaml \ --out /private/operator/azure-plan @@ -45,11 +44,10 @@ Only the `azure` backend and requirements version `1` are implemented. | --- | --- | --- | | `single-vm` | Hosted Go and local PostgreSQL on one persistent VM | Public OAuth and Actions snapshots | | `minimal-azure` | Hosted Go VM and private Azure PostgreSQL | Public OAuth and Actions snapshots | -| `azure-functions` | Functions baseline and private Azure PostgreSQL | Actions snapshots or collector; public or private ingress; optional ADX, OTLP, session data lake and webhook gateway | -| `azure-container-apps` | Container Apps Consumption and private Azure PostgreSQL | Public OAuth and Actions snapshots; staged runtime activation | +| `azure-container-apps` | Container Apps Consumption and private Azure PostgreSQL | Public OAuth and Actions snapshots; staged runtime activation; optional separately staged session lake and webhook gateway hosts | -`full-azure` is an initialization sample selecting `azure-functions` with -additional features, not another `features.deployment` value. Unsupported +`full-azure` is an initialization sample selecting `azure-container-apps` with +session lake and webhook gateway hosts, not another `features.deployment` value. Unsupported combinations fail rather than silently adding services or changing profiles. ## Resolution pipeline and code map @@ -58,7 +56,7 @@ combinations fail rather than silently adding services or changing profiles. rejects unknown fields, invalid types, nulls, aliases, multiple YAML documents and input larger than 1 MiB. 2. **Validate the provider contract** (`solve.go`, `compact.go`, - `container_apps.go`, `azure.go`): `Solve` checks feature compatibility, + `container_apps.go`): `Solve` checks feature compatibility, parameters, OAuth allowlists, explicit retention and Key Vault references against the selected embedded ARM parameter definitions. 3. **Resolve dependencies** (`tosca.go`, `graph.go`): extract nodes and edges @@ -81,7 +79,6 @@ The main package entry points are: | `Generate` | Generate a checked bundle; requires a non-nil `BicepValidator` | | `Check` | Re-resolve requirements and verify bundle contents, hashes and dependency order; reject drift, orphaned outputs and symlinks | | `CheckTopology` | Validate a topology projection against its selected feature contract | -| `ResolveAzure`, `AzureTemplate` | Share dependency resolution and the embedded Functions ARM baseline with native deployment | Bundles include `requirements.yaml`, `parameters.json`, `topology.yaml`, `topology.json`, `manifest.json`, architecture diagrams, rule reports, Bicep @@ -100,9 +97,9 @@ prerequisites remain explicitly unverified: dependency order does not prove vault access, network connectivity, successful provisioning, runtime activation, backups or compliance. -YAML bundles are not apply plan files. Native `deploy azure` remains limited to -public Functions with Actions snapshots and optional OTLP; other generated -profiles do not widen its apply support or bypass its approval and drift gates. +YAML bundles are review artifacts, not apply plan files. Use a separately +approved Azure deployment process; generation never deploys or migrates existing +resources. ## Maintenance and validation @@ -113,14 +110,14 @@ go generate ./internal/topology go test ./internal/topology ``` -Regenerate assets after changing the authoritative Azure baseline under +Regenerate assets after changing the shared job modules under `server/azure/` or the compact/Container Apps template contracts. The generator -copies the baseline into `assets/azure-baseline.generated/` and compiles the +copies the shared jobs into `assets/azure-baseline.generated/` and compiles the compact and Container Apps ARM parameter contracts. Do not hand-edit these generated snapshots; regeneration requires the official Bicep CLI on `PATH`. Tests cover engine constraints, dependency contracts, fail-closed requirements, architecture rules, deterministic bundles, drift/symlink rejection, embedded -baseline parity and compiler behavior. `TestDeploymentSamples` compiles all five +shared-job parity and compiler behavior. `TestDeploymentSamples` compiles all four shipped samples with the actual Bicep compiler; missing Bicep is a failure, not passing evidence. diff --git a/internal/topology/assets.go b/internal/topology/assets.go index 6690ec2e8c..18d1fbd333 100644 --- a/internal/topology/assets.go +++ b/internal/topology/assets.go @@ -11,39 +11,12 @@ import ( //go:embed assets var assets embed.FS -// AzureTemplate returns the embedded, compiled Functions baseline. -func AzureTemplate() ([]byte, error) { - return assets.ReadFile("assets/azure-baseline.generated/main.json") -} - -func baselineFiles() (map[string][]byte, error) { - const directory = "assets/azure-baseline.generated" - entries, err := assets.ReadDir(directory) - if err != nil { - return nil, err - } - files := map[string][]byte{} - for _, entry := range entries { - if entry.IsDir() { - return nil, fmt.Errorf("unexpected directory in generated Azure baseline: %s", entry.Name()) - } - content, err := assets.ReadFile(directory + "/" + entry.Name()) - if err != nil { - return nil, err - } - files[entry.Name()] = content - } - return files, nil -} - func Example() ([]byte, error) { - return ExampleFor("azure-functions") + return ExampleFor("azure-container-apps") } func ExampleFor(deployment string) ([]byte, error) { switch deployment { - case "azure-functions": - return assets.ReadFile("assets/example.yaml") case "single-vm", "minimal-azure", "full-azure", "azure-container-apps": return assets.ReadFile("assets/examples/" + deployment + ".yaml") default: @@ -69,14 +42,6 @@ func deploymentFiles(features Features) (map[string][]byte, error) { } return files, nil } - if features.Deployment == "azure-functions" { - baseline, err := baselineFiles() - if err != nil { - return nil, err - } - delete(baseline, "main.json") - return baseline, nil - } files := map[string][]byte{} for name, source := range map[string]string{ "main.bicep": "assets/compact/main.bicep", diff --git a/internal/topology/assets/AGENT-MCP.md b/internal/topology/assets/AGENT-MCP.md index ee4438c52a..28c2b67844 100644 --- a/internal/topology/assets/AGENT-MCP.md +++ b/internal/topology/assets/AGENT-MCP.md @@ -89,13 +89,10 @@ required for this MCP path. approved runner/network reachability. Do not widen source CIDRs or expose a private service merely to make the sample connect. -The unchanged **Azure Functions baseline does NOT wire hosted MCP**: -its `azure-functions` entrypoint does not expose `--mcp-enabled` or populate -the MCP configuration. Setting the caller-repository environment variable -alone does not register `/mcp`. A reviewed hosting-composition change or a -compatible `serve-hosted` deployment is required; this generator makes neither -change automatically. The same limitation applies to the Functions front end -in the full Azure sample. +**Generated deployments do NOT enable hosted MCP**. Setting the +caller-repository environment variable alone does not register `/mcp`. +A reviewed `serve-hosted --mcp-enabled` hosting-composition change is required; +this generator does not make that change automatically. ## Access and authority boundaries diff --git a/internal/topology/assets/azure-baseline.generated/adx.bicep b/internal/topology/assets/azure-baseline.generated/adx.bicep deleted file mode 100644 index 5d38b88be8..0000000000 --- a/internal/topology/assets/azure-baseline.generated/adx.bicep +++ /dev/null @@ -1,101 +0,0 @@ -param location string -param clusterName string -param databaseName string -param virtualNetworkId string -param privateEndpointSubnetId string -param tags object = {} - -@description('Reviewed paid ADX SKU. The default is a development SKU without an SLA, not a free cluster.') -param skuName string = 'Dev(No SLA)_Standard_E2a_v4' - -@allowed([ - 'Basic' - 'Standard' -]) -param skuTier string = 'Basic' - -@minValue(1) -param capacity int = 1 - -resource cluster 'Microsoft.Kusto/clusters@2024-04-13' = { - name: clusterName - location: location - tags: tags - sku: { - name: skuName - tier: skuTier - capacity: capacity - } - properties: { - publicNetworkAccess: 'Disabled' - enableDiskEncryption: true - enableStreamingIngest: true - } -} - -resource database 'Microsoft.Kusto/clusters/databases@2024-04-13' = { - parent: cluster - name: databaseName - location: location - kind: 'ReadWrite' - properties: { - softDeletePeriod: 'P7D' - hotCachePeriod: 'P1D' - } -} - -resource dns 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: 'privatelink.${location}.kusto.windows.net' - location: 'global' -} - -resource dnsLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - parent: dns - name: '${clusterName}-adx' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: virtualNetworkId - } - } -} - -resource endpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { - name: '${clusterName}-adx' - location: location - tags: tags - properties: { - subnet: { - id: privateEndpointSubnetId - } - privateLinkServiceConnections: [ - { - name: 'adx' - properties: { - privateLinkServiceId: cluster.id - groupIds: ['cluster'] - } - } - ] - } -} - -resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { - parent: endpoint - name: 'adx' - properties: { - privateDnsZoneConfigs: [ - { - name: 'adx' - properties: { - privateDnsZoneId: dns.id - } - } - ] - } -} - -output clusterId string = cluster.id -output queryEndpoint string = cluster.properties.uri -output databaseName string = database.name diff --git a/internal/topology/assets/azure-baseline.generated/collector.bicep b/internal/topology/assets/azure-baseline.generated/collector.bicep deleted file mode 100644 index dcae0b31ad..0000000000 --- a/internal/topology/assets/azure-baseline.generated/collector.bicep +++ /dev/null @@ -1,333 +0,0 @@ -// Optional collection workers for the server ingestion profile. -// -// This module deploys nothing unless a collector image is supplied, so the -// default deployment stays exactly as it is: a Function App serving the -// dashboard from snapshots published by the Activity workflow. -// -// The workers run native Go acquisition in the `collect` role and share the -// durable PostgreSQL operational queue with the Function App. - -@description('Location for the collection workers.') -param location string - -@description('Resource tags.') -param tags object - -@minLength(2) -@maxLength(22) -@description('Name prefix for the collection resources.') -param namePrefix string - -@description('Container image running the collection role.') -param collectorImage string - -param environmentName string - -@description('Deployment registry containing the private collector image.') -param containerRegistryName string - -@minValue(7) -@maxValue(3650) -param postgresRunRetentionDays int = 45 - -@description('Key Vault URI holding the collection secrets.') -param keyVaultUri string - -@description('Key Vault resource name, used to grant the workers secret access.') -param keyVaultName string - -@description('Key Vault secret that stores the PostgreSQL URL.') -param postgresUrlSecretName string = 'cao-postgres-runtime-url' - -@minValue(1) -@description('Maximum bytes retained in the disposable operational cache.') -param operationalCacheMaxBytes int = 33554432 - -@minValue(1) -@description('Maximum size of one disposable operational cache value.') -param operationalCacheMaxValueBytes int = 4194304 - -@minValue(1) -@description('Maximum number of disposable operational cache entries.') -param operationalCacheMaxEntries int = 1024 - -@minValue(1) -@description('Maximum bytes retained in protected operational records.') -param operationalProtectedMaxBytes int = 134217728 - -@minValue(1) -@description('Maximum number of protected operational records.') -param operationalProtectedMaxEntries int = 200000 - -@description('Operational PostgreSQL namespace shared with the Function App.') -param operationalNamespace string - -@description('Control repository named by the reviewed inventory.') -param controlRepository string - -@minLength(64) -@maxLength(64) -@description('Exact provisioned lowercase SHA-256 digest of immutable reviewed inventory bytes. The workers fail startup if this digest is not in their shared PostgreSQL namespace.') -param nativeInventoryDigest string - -@description('GitHub App identifier whose installations define ingestion scope.') -param githubAppId string - -@minValue(1) -@description('Fixed number of PostgreSQL-backed collection workers.') -param workerReplicas int = 1 - -@description('Platform Application Insights setting; does not activate Go OTLP exporters.') -@secure() -param applicationInsightsConnectionString string - -@description('Shared Go OTLP/HTTP endpoint, protocol and explicit log opt-in from the parent deployment.') -param otlpEnvironment array = [] - -param otlpLogsEnabled bool = false - -@description('Whether the parent provisioned the cao-otel-headers Key Vault secret.') -param otlpHeadersEnabled bool = false - -@description('Bound on the collection task and dead-letter streams.') -@minValue(1000) -param queueMaxLength int = 200000 - -resource collectorIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: '${namePrefix}-collector-identity' - location: location - tags: tags -} - -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} - -resource environment 'Microsoft.App/managedEnvironments@2024-03-01' existing = { - name: environmentName -} - -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { - name: containerRegistryName -} - -module roles 'worker-roles.bicep' = { - name: 'collector-roles' - params: { - keyVaultName: keyVault.name - containerRegistryName: registry.name - principalId: collectorIdentity.properties.principalId - secretNames: concat([ - postgresUrlSecretName - 'cao-collect-private-key' - ], !otlpHeadersEnabled ? [] : ['cao-otel-headers']) - } -} - -var registries = [ - { - server: registry.properties.loginServer - identity: collectorIdentity.id - } -] - -var collectionBaseEnvironment = [ - { - name: 'CAO_POSTGRES_URL' - secretRef: 'cao-postgres-url' - } - { - name: 'CAO_POSTGRES_RUN_RETENTION_DAYS' - value: string(postgresRunRetentionDays) - } - { - name: 'CAO_POSTGRES_LINKED_RETENTION_DAYS' - value: string(postgresRunRetentionDays) - } - { - name: 'CAO_POLICY_PATH' - value: '/app/.github/workflows/cao.azure.json' - } - { - name: 'REDIS_NAMESPACE' - value: 'azure-dashboard' - } - { - name: 'CAO_OPERATIONAL_NAMESPACE' - value: operationalNamespace - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_BYTES' - value: string(operationalCacheMaxBytes) - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES' - value: string(operationalCacheMaxValueBytes) - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_ENTRIES' - value: string(operationalCacheMaxEntries) - } - { - name: 'CAO_OPERATIONAL_PROTECTED_MAX_BYTES' - value: string(operationalProtectedMaxBytes) - } - { - name: 'CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES' - value: string(operationalProtectedMaxEntries) - } - { - name: 'CAO_COLLECT_APP_ID' - value: githubAppId - } - { - name: 'CAO_COLLECT_NATIVE' - value: 'true' - } - { - name: 'CAO_COLLECT_NATIVE_INVENTORY_DIGEST' - value: nativeInventoryDigest - } - { - name: 'CAO_COLLECT_PRIVATE_KEY' - secretRef: 'cao-collect-private-key' - } - { - name: 'CAO_COLLECT_CONTROL_REPOSITORY' - value: controlRepository - } - { - name: 'CAO_COLLECT_QUEUE_MAX_LENGTH' - value: string(queueMaxLength) - } - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - secretRef: 'application-insights-connection-string' - } - { - name: 'OTEL_SERVICE_NAME' - value: '${namePrefix}-collector' - } - { - name: 'DEBUG' - value: otlpLogsEnabled ? 'cao:telemetry,cao:collect:recovery' : 'cao:collect:*' - } -] - -var collectionEnvironment = concat(collectionBaseEnvironment, otlpEnvironment, !otlpHeadersEnabled ? [] : [ - { - name: 'OTEL_EXPORTER_OTLP_HEADERS' - secretRef: 'cao-otel-headers' - } -]) - -var collectionSecrets = concat([ - { - name: 'cao-postgres-url' - keyVaultUrl: '${keyVaultUri}secrets/${postgresUrlSecretName}' - identity: collectorIdentity.id - } - { - name: 'cao-collect-private-key' - keyVaultUrl: '${keyVaultUri}secrets/cao-collect-private-key' - identity: collectorIdentity.id - } - { - name: 'application-insights-connection-string' - value: applicationInsightsConnectionString - } -], !otlpHeadersEnabled ? [] : [ - { - name: 'cao-otel-headers' - keyVaultUrl: '${keyVaultUri}secrets/cao-otel-headers' - identity: collectorIdentity.id - } -]) - -resource collectors 'Microsoft.App/containerApps@2024-03-01' = { - name: '${namePrefix}-collector' - location: location - tags: tags - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${collectorIdentity.id}': {} - } - } - properties: { - environmentId: environment.id - configuration: { - activeRevisionsMode: 'Single' - registries: registries - secrets: collectionSecrets - } - template: { - containers: [ - { - name: 'collector' - image: collectorImage - command: ['/app/cao-dashboard'] - args: ['collect', '--database-queries', '/app/queries/database.json'] - env: collectionEnvironment - resources: { - cpu: json('1.0') - memory: '2Gi' - } - } - ] - scale: { - minReplicas: workerReplicas - maxReplicas: workerReplicas - } - } - } - dependsOn: [roles] -} - -// Cold start runs as a job rather than as part of a worker, so repopulating a -// database is an explicit, observable operation that can be re-run safely. -resource backfill 'Microsoft.App/jobs@2024-03-01' = { - name: '${namePrefix}-backfill' - location: location - tags: tags - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${collectorIdentity.id}': {} - } - } - properties: { - environmentId: environment.id - configuration: { - triggerType: 'Manual' - registries: registries - replicaTimeout: 7200 - replicaRetryLimit: 1 - manualTriggerConfig: { - parallelism: 1 - replicaCompletionCount: 1 - } - secrets: collectionSecrets - } - template: { - containers: [ - { - name: 'backfill' - image: collectorImage - command: ['/app/cao-dashboard'] - args: ['backfill', '--database-queries', '/app/queries/database.json'] - env: collectionEnvironment - resources: { - cpu: json('1.0') - memory: '2Gi' - } - } - ] - } - } - dependsOn: [roles] -} - -output collectorIdentityPrincipalId string = collectorIdentity.properties.principalId -output collectorAppName string = collectors.name -output backfillJobName string = backfill.name diff --git a/internal/topology/assets/azure-baseline.generated/function-roles.bicep b/internal/topology/assets/azure-baseline.generated/function-roles.bicep deleted file mode 100644 index 6622d6e601..0000000000 --- a/internal/topology/assets/azure-baseline.generated/function-roles.bicep +++ /dev/null @@ -1,72 +0,0 @@ -param storageAccountName string -param keyVaultName string -param containerRegistryName string -param functionPrincipalId string -param secretNames array - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { - name: storageAccountName -} - -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} - -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { - name: containerRegistryName -} - -resource functionsStorageBlobOwner 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionPrincipalId, 'Storage Blob Data Owner') - scope: storage - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} - -resource functionsStorageQueueContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionPrincipalId, 'Storage Queue Data Contributor') - scope: storage - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} - -resource functionsStorageTableContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionPrincipalId, 'Storage Table Data Contributor') - scope: storage - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} - -resource secrets 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = [for secretName in secretNames: { - parent: keyVault - name: secretName -}] - -resource keyVaultSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for (secretName, i) in secretNames: { - name: guid(secrets[i].id, functionPrincipalId, 'Key Vault Secrets User') - scope: secrets[i] - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -}] - -resource functionsRegistryPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, functionPrincipalId, 'AcrPull') - scope: registry - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} diff --git a/internal/topology/assets/azure-baseline.generated/main.bicep b/internal/topology/assets/azure-baseline.generated/main.bicep deleted file mode 100644 index 202588c438..0000000000 --- a/internal/topology/assets/azure-baseline.generated/main.bicep +++ /dev/null @@ -1,812 +0,0 @@ -targetScope = 'resourceGroup' - -// Experimental Azure Functions deployment baseline for the Go dashboard server. -// Review and validate tenant-specific security, compliance, networking, -// monitoring, cost, rotation, and rollback requirements before live use. - -@description('Azure region for all dashboard resources.') -param location string = resourceGroup().location - -@minLength(2) -@maxLength(19) -@description('Globally unique lowercase Function App name and job prefix, starting with a letter and without consecutive hyphens. At most 19 characters keeps the derived -db-provision job within the Container Apps 32-character limit.') -param functionAppName string - -@description('Default endpoint access. Enabled (default) serves the dashboard over public HTTPS behind GitHub OAuth sign-in; no VPN, proxy, or corporate-network rule is required. Set Disabled only for a deliberately private deployment.') -@allowed([ - 'Disabled' - 'Enabled' -]) -param functionPublicNetworkAccess string = 'Enabled' - -@description('Default action for unmatched main-site requests. Allow (default) is the public-main model: GitHub OAuth remains the access control. Use Deny with explicit rules only for a deliberately restricted deployment. Never changes SCM restrictions.') -@allowed([ - 'Allow' - 'Deny' -]) -param functionAccessRestrictionsDefaultAction string = 'Allow' - -@description('Reviewed main-site access restriction rules. Explicitly carry forward existing approved rules during redeployment; omission uses an empty list. Unmatched requests use functionAccessRestrictionsDefaultAction.') -param functionAccessRestrictions array = [] - -@description('Reviewed SCM/Kudu access restriction rules, separate from main-site rules. Explicitly carry forward existing approved SCM rules; omission uses an empty list.') -param functionScmAccessRestrictions array = [] - -@description('Whether SCM reuses main-site rules. Carry forward the existing approved setting; false keeps SCM independently restricted by default.') -param functionScmAccessRestrictionsUseMain bool = false - -@description('Azure App Service plan name for the Function App.') -param hostingPlanName string = '${functionAppName}-plan' - -@description('Application Insights component name.') -param applicationInsightsName string = '${functionAppName}-ai' - -@description('Optional reviewed HTTPS OTLP/HTTP collector base URL, without credentials, query or fragment. Empty disables Go export; Application Insights settings alone only configure the Functions host.') -param otlpEndpoint string = '' - -@description('Explicit opt-in to export bounded operational Go logs to the OTLP collector. Independent audit delivery is unchanged.') -param otlpLogsEnabled bool = false - -@secure() -@description('Optional OTLP authentication headers from a secret manager. Stored only in Key Vault; never supply literal values in command-line arguments or checked-in parameters.') -param otlpHeaders string = '' - -var otlpEnvironment = [ - { - name: 'OTEL_EXPORTER_OTLP_ENDPOINT' - value: otlpEndpoint - } - { - name: 'OTEL_EXPORTER_OTLP_PROTOCOL' - value: 'http/protobuf' - } - { - name: 'CAO_OTEL_LOGS_ENABLED' - value: string(otlpLogsEnabled) - } -] - -var otlpHeadersEnabled = !empty(otlpEndpoint) && !empty(otlpHeaders) - -@description('Globally unique storage account name for Azure Functions runtime state.') -param storageAccountName string - -@description('Globally unique Azure Container Registry name containing the Function App image.') -param containerRegistryName string - -@description('Container repository containing cao-dashboard azure-functions. The existing cao-functions image repository remains supported.') -param functionImageRepository string = 'cao-functions' - -@description('Immutable or reviewed tag of the CAO Azure Functions runtime image.') -param functionImageTag string - -@description('Exact Functions image digest, including sha256:. When supplied, takes precedence over the tag. Does not establish scan or production assurance.') -param functionImageDigest string = '' - -@description('Key Vault name used for GitHub OAuth, session, PostgreSQL, and collection secrets.') -param keyVaultName string - -@description('Enable purge protection for production vaults. Disable only for explicitly disposable environments that must be purged after testing. Existing protected vaults cannot be downgraded.') -param keyVaultPurgeProtection bool = true - -@description('Globally unique name for a new private PostgreSQL Flexible Server.') -param postgresServerName string = '${functionAppName}-pg' - -param postgresAdministratorLogin string = 'caoadmin' - -@secure() -@minLength(16) -@description('PostgreSQL administrator password. Use secret-manager input; never commit it.') -param postgresAdministratorPassword string - -param postgresSkuName string = 'Standard_B2s' -param postgresSkuTier string = 'Burstable' - -@description('Optional existing network security group kept attached to every private subnet. Supply it when an approved NSG is already attached so redeployment does not detach it.') -param subnetNetworkSecurityGroupId string = '' - -@minLength(1) -@maxLength(63) -@description('PostgreSQL database that holds the canonical and operational stores. The default matches existing deployments. A new name creates a new, empty database; the previous database is retained, not deleted.') -param postgresDatabaseName string = 'cao' - -@minLength(1) -@maxLength(110) -@description('Runtime-only PostgreSQL URL secret. Use a new name and database when moving from the administrator-backed topology.') -param postgresUrlSecretName string = 'cao-postgres-runtime-url' - -@secure() -@minLength(16) -@description('Separately generated bootstrap-login password from the operator secret manager. Never reuse the administrator or runtime password.') -param postgresOwnerPassword string - -@secure() -@minLength(16) -@description('Separately generated runtime-login password from the operator secret manager.') -param postgresRuntimePassword string - -@description('ACR repository containing the azure-db-provision Dockerfile target.') -param provisioningImageRepository string = 'cao-db-provision' - -param provisioningImageTag string = functionImageTag - -@description('Exact database-provisioning image digest, including sha256:. When supplied, takes precedence over the tag.') -param provisioningImageDigest string = '' - -@description('Repository of an explicitly built snapshot or empty ingestion image in this deployment registry.') -param ingestionImageRepository string = 'cao-ingest' - -param ingestionImageTag string = functionImageTag - -@description('Exact ingestion image digest, including sha256:. When supplied, takes precedence over the tag.') -param ingestionImageDigest string = '' - -var functionImageSelector = empty(functionImageDigest) ? ':${functionImageTag}' : '@${functionImageDigest}' -var ingestionImageSelector = empty(ingestionImageDigest) ? ':${ingestionImageTag}' : '@${ingestionImageDigest}' -var provisioningImageSelector = empty(provisioningImageDigest) ? ':${provisioningImageTag}' : '@${provisioningImageDigest}' - -@minValue(1) -@description('Maximum bytes retained in the disposable operational cache.') -param operationalCacheMaxBytes int = 33554432 - -@minValue(1) -@description('Maximum size of one disposable operational cache value.') -param operationalCacheMaxValueBytes int = 4194304 - -@minValue(1) -@description('Maximum number of disposable operational cache entries.') -param operationalCacheMaxEntries int = 1024 - -@minValue(1) -@description('Maximum bytes retained in protected operational records.') -param operationalProtectedMaxBytes int = 134217728 - -@minValue(1) -@description('Maximum number of protected operational records.') -param operationalProtectedMaxEntries int = 200000 - -@minValue(7) -@maxValue(3650) -@description('Run and linked history retention in days. Before starting any role, explicitly preserve at least the greatest existing effective retention across all roles and the snapshot window. The 45-day default is not an upgrade recommendation.') -param postgresRunRetentionDays int = 45 - -@description('Public host names that Azure Front Door/App Service is allowed to forward to the Go dashboard handler.') -param allowedHosts array - -@description('GitHub organizations whose active members are authorized.') -param githubAllowedOrganizations array - -@description('GitHub teams whose active members are authorized, expressed as org/team-slug.') -param githubAllowedTeams array = [] - -@description('GitHub user logins authorized directly, without organization membership lookup.') -param githubAllowedUsers array = [] - -@description('GitHub OAuth App client ID. Do not use a PAT; the server only supports the OAuth authorization-code flow.') -param githubClientId string - -@secure() -@description('GitHub OAuth App client secret. It is written only to Key Vault and consumed by the Function App through a Key Vault reference.') -param githubClientSecret string - -@secure() -@minLength(32) -@description('At least 32 characters of random session secret material used to encrypt server-side OAuth sessions.') -param sessionSecret string - -@secure() -@description('Optional previous session secret retained during controlled key rotation until active sessions and pending revocations are drained.') -param previousSessionSecret string = '' - -@description('Single Log Analytics workspace shared by every role in this deployment.') -param logAnalyticsWorkspaceName string = '${functionAppName}-logs' - -// Optional server collection profile. -// -// Leaving collectorImage empty deploys the default profile unchanged: the -// dashboard serves snapshots published by the Activity workflow and the server -// collects nothing. Supplying an image selects the alternative profile, in -// which this deployment collects evidence itself. The two profiles are -// alternatives, never layers. - -@description('Optional container image running the collection role. Empty deploys no collection.') -param collectorImage string = '' - -@description('GitHub App identifier whose installations define ingestion scope. Required with collectorImage.') -param collectorGithubAppId string = '' - -@secure() -@description('GitHub App private key in PEM form. Required with collectorImage.') -param collectorPrivateKey string = '' - -@secure() -@description('Shared secret verifying GitHub webhook deliveries. Required with collectorImage.') -param githubWebhookSecret string = '' - -@description('GitHub logins permitted to run administrative operations.') -param githubAdminUsers array = [] - -@description('Control repository named by the reviewed inventory, in OWNER/REPOSITORY form.') -param collectorControlRepository string = '' - -@description('Exact lowercase SHA-256 digest of immutable reviewed inventory bytes already provisioned in the shared PostgreSQL namespace. Required when collectorImage is set; an absent or unprovisioned digest prevents the workers from starting.') -param collectorNativeInventoryDigest string = '' - -@minValue(1) -@description('Fixed number of PostgreSQL-backed collection workers.') -param collectorWorkerReplicas int = 1 - -@description('Provision an optional paid ADX destination only. Does not enable export, traces, or mining; Azure Functions is not a supported exporter host.') -param adxEnabled bool = false - -@minLength(4) -@maxLength(22) -@description('Globally unique ADX cluster name: lowercase letters and digits only.') -param adxClusterName string = 'adx${uniqueString(resourceGroup().id, functionAppName)}' - -@minLength(1) -@maxLength(100) -@description('ADX database identifier: ASCII letter followed by letters, digits, or underscores.') -param adxDatabaseName string = 'CAO' - -@description('Reviewed paid ADX SKU. The default is development-only without an SLA, not the free offering.') -param adxSkuName string = 'Dev(No SLA)_Standard_E2a_v4' - -@allowed([ - 'Basic' - 'Standard' -]) -param adxSkuTier string = 'Basic' - -@minValue(1) -@description('Reviewed ADX instance count compatible with the selected SKU and tier.') -param adxCapacity int = 1 - -var collectionEnabled = !empty(collectorImage) - -var tags = { - workload: 'gh-cao-dashboard' - hostingMode: 'azure-functions' -} -var githubRedirectUri = 'https://${functionAppName}.azurewebsites.net/auth/callback' - -module network 'network.bicep' = { - name: 'private-network-postgres' - params: { - location: location - namePrefix: functionAppName - postgresServerName: postgresServerName - postgresAdministratorLogin: postgresAdministratorLogin - postgresAdministratorPassword: postgresAdministratorPassword - postgresSkuName: postgresSkuName - postgresSkuTier: postgresSkuTier - subnetNetworkSecurityGroupId: subnetNetworkSecurityGroupId - postgresDatabaseName: postgresDatabaseName - adxEnabled: adxEnabled - } -} - -module adx 'adx.bicep' = if (adxEnabled) { - name: 'adx-destination' - params: { - location: location - clusterName: adxClusterName - databaseName: adxDatabaseName - skuName: adxSkuName - skuTier: adxSkuTier - capacity: adxCapacity - virtualNetworkId: network.outputs.virtualNetworkId - privateEndpointSubnetId: network.outputs.adxSubnetId - tags: tags - } -} - -resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { - name: logAnalyticsWorkspaceName - location: location - tags: tags - properties: { - sku: { - name: 'PerGB2018' - } - retentionInDays: 30 - } -} - -resource containerEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { - name: '${functionAppName}-jobs' - location: location - tags: tags - properties: { - appLogsConfiguration: { - destination: 'azure-monitor' - } - vnetConfiguration: { - infrastructureSubnetId: network.outputs.containerSubnetId - internal: true - } - zoneRedundant: false - } -} - -resource containerDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - name: 'container-logs' - scope: containerEnvironment - properties: { - workspaceId: workspace.id - logs: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - } -} - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: storageAccountName - location: location - tags: tags - sku: { - name: 'Standard_LRS' - } - kind: 'StorageV2' - properties: { - allowSharedKeyAccess: false - allowBlobPublicAccess: false - minimumTlsVersion: 'TLS1_2' - supportsHttpsTrafficOnly: true - } -} - -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { - name: containerRegistryName - location: location - tags: tags - sku: { - name: 'Basic' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - } -} - -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { - name: keyVaultName - location: location - tags: tags - properties: { - tenantId: subscription().tenantId - sku: { - family: 'A' - name: 'standard' - } - enableRbacAuthorization: true - enabledForTemplateDeployment: false - enableSoftDelete: true - enablePurgeProtection: keyVaultPurgeProtection ? true : null - softDeleteRetentionInDays: 90 - publicNetworkAccess: 'Enabled' - } -} - -resource githubClientSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: 'github-oauth-client-secret' - properties: { - value: githubClientSecret - contentType: 'GitHub OAuth client secret for CAO dashboard Azure Functions mode' - } -} - -resource sessionSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: 'cao-session-secret' - properties: { - value: sessionSecret - contentType: 'CAO dashboard session encryption secret' - } -} - -resource previousSessionSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(previousSessionSecret)) { - parent: keyVault - name: 'cao-session-secret-previous' - properties: { - value: previousSessionSecret - contentType: 'Previous CAO dashboard session encryption secret retained during rotation' - } -} - -resource postgresConnectionStringValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: postgresUrlSecretName - properties: { - value: 'postgresql://cao_runtime_login:${uriComponent(postgresRuntimePassword)}@${network.outputs.postgresHostName}:5432/${uriComponent(network.outputs.databaseName)}?sslmode=verify-full&options=-csearch_path%3Dcao' - contentType: 'CAO runtime-only PostgreSQL connection URL' - } -} - -resource postgresAdminUrl 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: '${postgresUrlSecretName}-admin' - properties: { - value: 'postgresql://${uriComponent(postgresAdministratorLogin)}:${uriComponent(postgresAdministratorPassword)}@${network.outputs.postgresHostName}:5432/${uriComponent(network.outputs.databaseName)}?sslmode=verify-full' - contentType: 'Short-lived CAO database provisioning only' - } -} - -resource postgresOwnerPasswordValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: '${postgresUrlSecretName}-owner-password' - properties: { - value: postgresOwnerPassword - } -} - -resource postgresRuntimePasswordValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: '${postgresUrlSecretName}-runtime-password' - properties: { - value: postgresRuntimePassword - } -} - -resource collectorPrivateKeyValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (collectionEnabled) { - parent: keyVault - name: 'cao-collect-private-key' - properties: { - value: collectorPrivateKey - contentType: 'GitHub App private key for the collection profile' - } -} - -resource githubWebhookSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (collectionEnabled) { - parent: keyVault - name: 'cao-github-webhook-secret' - properties: { - value: githubWebhookSecret - contentType: 'GitHub webhook shared secret' - } -} - -resource plan 'Microsoft.Web/serverfarms@2023-12-01' = { - name: hostingPlanName - location: location - tags: tags - kind: 'elastic' - sku: { - name: 'EP1' - tier: 'ElasticPremium' - } - properties: { - reserved: true - } -} - -resource insights 'Microsoft.Insights/components@2020-02-02' = { - name: applicationInsightsName - location: location - tags: tags - kind: 'web' - properties: { - Application_Type: 'web' - WorkspaceResourceId: workspace.id - } -} - -resource otlpHeadersValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (otlpHeadersEnabled) { - parent: keyVault - name: 'cao-otel-headers' - properties: { - value: otlpHeaders - contentType: 'OTLP collector authentication headers' - } -} - -resource functionApp 'Microsoft.Web/sites@2023-12-01' = { - name: functionAppName - location: location - tags: tags - kind: 'functionapp,linux,container' - identity: { - type: 'SystemAssigned' - } - properties: { - serverFarmId: plan.id - virtualNetworkSubnetId: network.outputs.functionSubnetId - httpsOnly: true - publicNetworkAccess: functionPublicNetworkAccess - clientAffinityEnabled: false - siteConfig: { - ipSecurityRestrictionsDefaultAction: functionAccessRestrictionsDefaultAction - ipSecurityRestrictions: functionAccessRestrictions - scmIpSecurityRestrictionsDefaultAction: 'Deny' - scmIpSecurityRestrictions: functionScmAccessRestrictions - scmIpSecurityRestrictionsUseMain: functionScmAccessRestrictionsUseMain - alwaysOn: true - minimumElasticInstanceCount: 1 - ftpsState: 'Disabled' - minTlsVersion: '1.2' - http20Enabled: true - linuxFxVersion: 'DOCKER|${registry.properties.loginServer}/${functionImageRepository}${functionImageSelector}' - appCommandLine: 'azure-functions' - acrUseManagedIdentityCreds: true - appSettings: concat([ - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'FUNCTIONS_WORKER_RUNTIME' - value: 'custom' - } - { - name: 'WEBSITES_PORT' - value: '8080' - } - { - name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' - value: 'false' - } - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: insights.properties.ConnectionString - } - { - name: 'AzureWebJobsStorage__accountName' - value: storage.name - } - { - name: 'AzureWebJobsStorage__credential' - value: 'managedidentity' - } - { - name: 'CAO_DASHBOARD_HOSTING' - value: 'azure-functions' - } - { - name: 'CAO_POLICY_PATH' - value: '.github/workflows/cao.azure.json' - } - { - name: 'CAO_DATABASE_QUERIES' - value: '/app/queries/database.json' - } - { - name: 'CAO_AZURE_ALLOWED_HOSTS' - value: join(allowedHosts, ',') - } - { - name: 'CAO_AZURE_REQUIRE_HTTPS' - value: 'true' - } - { - name: 'REDIS_NAMESPACE' - value: 'azure-dashboard' - } - { - name: 'CAO_OPERATIONAL_NAMESPACE' - value: 'azure-dashboard-operational' - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_BYTES' - value: string(operationalCacheMaxBytes) - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES' - value: string(operationalCacheMaxValueBytes) - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_ENTRIES' - value: string(operationalCacheMaxEntries) - } - { - name: 'CAO_OPERATIONAL_PROTECTED_MAX_BYTES' - value: string(operationalProtectedMaxBytes) - } - { - name: 'CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES' - value: string(operationalProtectedMaxEntries) - } - { - name: 'CAO_POSTGRES_URL' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/${postgresUrlSecretName})' - } - { - name: 'CAO_POSTGRES_RUN_RETENTION_DAYS' - value: string(postgresRunRetentionDays) - } - { - name: 'CAO_POSTGRES_LINKED_RETENTION_DAYS' - value: string(postgresRunRetentionDays) - } - { - name: 'CAO_GITHUB_CLIENT_ID' - value: githubClientId - } - { - name: 'CAO_GITHUB_CLIENT_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/github-oauth-client-secret)' - } - { - name: 'CAO_GITHUB_REDIRECT_URL' - value: githubRedirectUri - } - { - name: 'CAO_GITHUB_ALLOWED_ORGS' - value: join(githubAllowedOrganizations, ',') - } - { - name: 'CAO_GITHUB_ALLOWED_TEAMS' - value: join(githubAllowedTeams, ',') - } - { - name: 'CAO_GITHUB_ALLOWED_USERS' - value: join(githubAllowedUsers, ',') - } - { - name: 'CAO_SESSION_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-session-secret)' - } - ], otlpEnvironment, !otlpLogsEnabled ? [] : [ - { - name: 'DEBUG' - value: 'cao:server,cao:telemetry' - } - ], !otlpHeadersEnabled ? [] : [ - { - name: 'OTEL_EXPORTER_OTLP_HEADERS' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-otel-headers)' - } - ], empty(previousSessionSecret) ? [] : [ - { - name: 'CAO_SESSION_SECRET_PREVIOUS' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-session-secret-previous)' - } - ], !collectionEnabled ? [] : [ - // With collection configured the Function App admits webhook - // deliveries into the collection queue. It performs no collection of - // its own, so its per-request work stays constant. - { - name: 'CAO_GITHUB_WEBHOOK_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-github-webhook-secret)' - } - { - name: 'CAO_GITHUB_ADMIN_USERS' - value: join(githubAdminUsers, ',') - } - { - name: 'CAO_COLLECT_APP_ID' - value: collectorGithubAppId - } - { - name: 'CAO_COLLECT_NATIVE' - value: 'true' - } - { - // The Function App verifies deliveries and enqueues work. It never - // calls GitHub and never projects, so it is given no App private key - // or inventory bytes: the internet-facing front end holds no - // credential it cannot use. - name: 'CAO_COLLECT_ADMIT_ONLY' - value: 'true' - } - { - name: 'CAO_COLLECT_CONTROL_REPOSITORY' - value: collectorControlRepository - } - ]) - } - } - dependsOn: [ - githubClientSecretValue - postgresConnectionStringValue - sessionSecretValue - previousSessionSecretValue - otlpHeadersValue - ] -} - -module functionRoles 'function-roles.bicep' = { - name: 'function-managed-identity-roles' - params: { - storageAccountName: storage.name - keyVaultName: keyVault.name - containerRegistryName: registry.name - functionPrincipalId: functionApp.identity.principalId - secretNames: concat([ - postgresUrlSecretName - githubClientSecretValue.name - sessionSecretValue.name - ], empty(previousSessionSecret) ? [] : [previousSessionSecretValue.name], !collectionEnabled ? [] : [githubWebhookSecretValue.name], !otlpHeadersEnabled ? [] : ['cao-otel-headers']) - } -} - -module collection 'collector.bicep' = if (collectionEnabled) { - name: 'collection-workers' - params: { - location: location - tags: tags - namePrefix: functionAppName - environmentName: containerEnvironment.name - containerRegistryName: registry.name - collectorImage: collectorImage - postgresRunRetentionDays: postgresRunRetentionDays - keyVaultUri: keyVault.properties.vaultUri - keyVaultName: keyVault.name - postgresUrlSecretName: postgresUrlSecretName - operationalNamespace: 'azure-dashboard-operational' - operationalCacheMaxBytes: operationalCacheMaxBytes - operationalCacheMaxValueBytes: operationalCacheMaxValueBytes - operationalCacheMaxEntries: operationalCacheMaxEntries - operationalProtectedMaxBytes: operationalProtectedMaxBytes - operationalProtectedMaxEntries: operationalProtectedMaxEntries - controlRepository: collectorControlRepository - nativeInventoryDigest: collectorNativeInventoryDigest - githubAppId: collectorGithubAppId - workerReplicas: collectorWorkerReplicas - applicationInsightsConnectionString: insights.properties.ConnectionString - otlpEnvironment: otlpEnvironment - otlpLogsEnabled: otlpLogsEnabled - otlpHeadersEnabled: otlpHeadersEnabled - } - dependsOn: [ - collectorPrivateKeyValue - postgresConnectionStringValue - otlpHeadersValue - ] -} - -module ingestion 'ingestion.bicep' = { - name: 'snapshot-ingestion' - params: { - location: location - namePrefix: functionAppName - environmentName: containerEnvironment.name - containerRegistryName: registry.name - keyVaultName: keyVault.name - ingestionImage: '${registry.properties.loginServer}/${ingestionImageRepository}${ingestionImageSelector}' - postgresRunRetentionDays: postgresRunRetentionDays - postgresUrlSecretName: postgresUrlSecretName - canonicalNamespace: 'azure-dashboard' - } - dependsOn: [postgresConnectionStringValue] -} - -module provisioning 'provisioning.bicep' = { - name: 'database-provisioning' - params: { - location: location - namePrefix: functionAppName - environmentName: containerEnvironment.name - containerRegistryName: registry.name - keyVaultName: keyVault.name - provisioningImage: '${registry.properties.loginServer}/${provisioningImageRepository}${provisioningImageSelector}' - postgresUrlSecretName: postgresUrlSecretName - postgresHostName: network.outputs.postgresHostName - postgresDatabaseName: network.outputs.databaseName - postgresRunRetentionDays: postgresRunRetentionDays - operationalCacheMaxBytes: operationalCacheMaxBytes - operationalCacheMaxValueBytes: operationalCacheMaxValueBytes - operationalCacheMaxEntries: operationalCacheMaxEntries - operationalProtectedMaxBytes: operationalProtectedMaxBytes - operationalProtectedMaxEntries: operationalProtectedMaxEntries - } - dependsOn: [postgresAdminUrl, postgresOwnerPasswordValue, postgresRuntimePasswordValue] -} - -output collectionEnabled bool = collectionEnabled -output containerRegistryLoginServer string = registry.properties.loginServer -output functionContainerImage string = '${registry.properties.loginServer}/${functionImageRepository}${functionImageSelector}' -output functionHostName string = functionApp.properties.defaultHostName -output githubOAuthRedirectUri string = githubRedirectUri -output keyVaultUri string = keyVault.properties.vaultUri -output ingestionJobName string = ingestion.outputs.jobName -output provisioningJobName string = provisioning.outputs.jobName -output logAnalyticsWorkspaceId string = workspace.id -output applicationInsightsId string = insights.id -output postgresHostName string = network.outputs.postgresHostName -output adxClusterId string = adxEnabled ? adx!.outputs.clusterId : '' -output adxQueryEndpoint string = adxEnabled ? adx!.outputs.queryEndpoint : '' -output adxDatabaseName string = adxEnabled ? adx!.outputs.databaseName : '' diff --git a/internal/topology/assets/azure-baseline.generated/main.json b/internal/topology/assets/azure-baseline.generated/main.json deleted file mode 100644 index 8a4bc34768..0000000000 --- a/internal/topology/assets/azure-baseline.generated/main.json +++ /dev/null @@ -1,2598 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "17295245798372953389" - } - }, - "parameters": { - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for all dashboard resources." - } - }, - "functionAppName": { - "type": "string", - "minLength": 2, - "maxLength": 19, - "metadata": { - "description": "Globally unique lowercase Function App name and job prefix, starting with a letter and without consecutive hyphens. At most 19 characters keeps the derived -db-provision job within the Container Apps 32-character limit." - } - }, - "functionPublicNetworkAccess": { - "type": "string", - "defaultValue": "Enabled", - "allowedValues": [ - "Disabled", - "Enabled" - ], - "metadata": { - "description": "Default endpoint access. Enabled (default) serves the dashboard over public HTTPS behind GitHub OAuth sign-in; no VPN, proxy, or corporate-network rule is required. Set Disabled only for a deliberately private deployment." - } - }, - "functionAccessRestrictionsDefaultAction": { - "type": "string", - "defaultValue": "Allow", - "allowedValues": [ - "Allow", - "Deny" - ], - "metadata": { - "description": "Default action for unmatched main-site requests. Allow (default) is the public-main model: GitHub OAuth remains the access control. Use Deny with explicit rules only for a deliberately restricted deployment. Never changes SCM restrictions." - } - }, - "functionAccessRestrictions": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Reviewed main-site access restriction rules. Explicitly carry forward existing approved rules during redeployment; omission uses an empty list. Unmatched requests use functionAccessRestrictionsDefaultAction." - } - }, - "functionScmAccessRestrictions": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Reviewed SCM/Kudu access restriction rules, separate from main-site rules. Explicitly carry forward existing approved SCM rules; omission uses an empty list." - } - }, - "functionScmAccessRestrictionsUseMain": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Whether SCM reuses main-site rules. Carry forward the existing approved setting; false keeps SCM independently restricted by default." - } - }, - "hostingPlanName": { - "type": "string", - "defaultValue": "[format('{0}-plan', parameters('functionAppName'))]", - "metadata": { - "description": "Azure App Service plan name for the Function App." - } - }, - "applicationInsightsName": { - "type": "string", - "defaultValue": "[format('{0}-ai', parameters('functionAppName'))]", - "metadata": { - "description": "Application Insights component name." - } - }, - "otlpEndpoint": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional reviewed HTTPS OTLP/HTTP collector base URL, without credentials, query or fragment. Empty disables Go export; Application Insights settings alone only configure the Functions host." - } - }, - "otlpLogsEnabled": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Explicit opt-in to export bounded operational Go logs to the OTLP collector. Independent audit delivery is unchanged." - } - }, - "otlpHeaders": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional OTLP authentication headers from a secret manager. Stored only in Key Vault; never supply literal values in command-line arguments or checked-in parameters." - } - }, - "storageAccountName": { - "type": "string", - "metadata": { - "description": "Globally unique storage account name for Azure Functions runtime state." - } - }, - "containerRegistryName": { - "type": "string", - "metadata": { - "description": "Globally unique Azure Container Registry name containing the Function App image." - } - }, - "functionImageRepository": { - "type": "string", - "defaultValue": "cao-functions", - "metadata": { - "description": "Container repository containing cao-dashboard azure-functions. The existing cao-functions image repository remains supported." - } - }, - "functionImageTag": { - "type": "string", - "metadata": { - "description": "Immutable or reviewed tag of the CAO Azure Functions runtime image." - } - }, - "functionImageDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact Functions image digest, including sha256:. When supplied, takes precedence over the tag. Does not establish scan or production assurance." - } - }, - "keyVaultName": { - "type": "string", - "metadata": { - "description": "Key Vault name used for GitHub OAuth, session, PostgreSQL, and collection secrets." - } - }, - "keyVaultPurgeProtection": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Enable purge protection for production vaults. Disable only for explicitly disposable environments that must be purged after testing. Existing protected vaults cannot be downgraded." - } - }, - "postgresServerName": { - "type": "string", - "defaultValue": "[format('{0}-pg', parameters('functionAppName'))]", - "metadata": { - "description": "Globally unique name for a new private PostgreSQL Flexible Server." - } - }, - "postgresAdministratorLogin": { - "type": "string", - "defaultValue": "caoadmin" - }, - "postgresAdministratorPassword": { - "type": "securestring", - "minLength": 16, - "metadata": { - "description": "PostgreSQL administrator password. Use secret-manager input; never commit it." - } - }, - "postgresSkuName": { - "type": "string", - "defaultValue": "Standard_B2s" - }, - "postgresSkuTier": { - "type": "string", - "defaultValue": "Burstable" - }, - "subnetNetworkSecurityGroupId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing network security group kept attached to every private subnet. Supply it when an approved NSG is already attached so redeployment does not detach it." - } - }, - "postgresDatabaseName": { - "type": "string", - "defaultValue": "cao", - "minLength": 1, - "maxLength": 63, - "metadata": { - "description": "PostgreSQL database that holds the canonical and operational stores. The default matches existing deployments. A new name creates a new, empty database; the previous database is retained, not deleted." - } - }, - "postgresUrlSecretName": { - "type": "string", - "defaultValue": "cao-postgres-runtime-url", - "minLength": 1, - "maxLength": 110, - "metadata": { - "description": "Runtime-only PostgreSQL URL secret. Use a new name and database when moving from the administrator-backed topology." - } - }, - "postgresOwnerPassword": { - "type": "securestring", - "minLength": 16, - "metadata": { - "description": "Separately generated bootstrap-login password from the operator secret manager. Never reuse the administrator or runtime password." - } - }, - "postgresRuntimePassword": { - "type": "securestring", - "minLength": 16, - "metadata": { - "description": "Separately generated runtime-login password from the operator secret manager." - } - }, - "provisioningImageRepository": { - "type": "string", - "defaultValue": "cao-db-provision", - "metadata": { - "description": "ACR repository containing the azure-db-provision Dockerfile target." - } - }, - "provisioningImageTag": { - "type": "string", - "defaultValue": "[parameters('functionImageTag')]" - }, - "provisioningImageDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact database-provisioning image digest, including sha256:. When supplied, takes precedence over the tag." - } - }, - "ingestionImageRepository": { - "type": "string", - "defaultValue": "cao-ingest", - "metadata": { - "description": "Repository of an explicitly built snapshot or empty ingestion image in this deployment registry." - } - }, - "ingestionImageTag": { - "type": "string", - "defaultValue": "[parameters('functionImageTag')]" - }, - "ingestionImageDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact ingestion image digest, including sha256:. When supplied, takes precedence over the tag." - } - }, - "operationalCacheMaxBytes": { - "type": "int", - "defaultValue": 33554432, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in the disposable operational cache." - } - }, - "operationalCacheMaxValueBytes": { - "type": "int", - "defaultValue": 4194304, - "minValue": 1, - "metadata": { - "description": "Maximum size of one disposable operational cache value." - } - }, - "operationalCacheMaxEntries": { - "type": "int", - "defaultValue": 1024, - "minValue": 1, - "metadata": { - "description": "Maximum number of disposable operational cache entries." - } - }, - "operationalProtectedMaxBytes": { - "type": "int", - "defaultValue": 134217728, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in protected operational records." - } - }, - "operationalProtectedMaxEntries": { - "type": "int", - "defaultValue": 200000, - "minValue": 1, - "metadata": { - "description": "Maximum number of protected operational records." - } - }, - "postgresRunRetentionDays": { - "type": "int", - "defaultValue": 45, - "minValue": 7, - "maxValue": 3650, - "metadata": { - "description": "Run and linked history retention in days. Before starting any role, explicitly preserve at least the greatest existing effective retention across all roles and the snapshot window. The 45-day default is not an upgrade recommendation." - } - }, - "allowedHosts": { - "type": "array", - "metadata": { - "description": "Public host names that Azure Front Door/App Service is allowed to forward to the Go dashboard handler." - } - }, - "githubAllowedOrganizations": { - "type": "array", - "metadata": { - "description": "GitHub organizations whose active members are authorized." - } - }, - "githubAllowedTeams": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "GitHub teams whose active members are authorized, expressed as org/team-slug." - } - }, - "githubAllowedUsers": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "GitHub user logins authorized directly, without organization membership lookup." - } - }, - "githubClientId": { - "type": "string", - "metadata": { - "description": "GitHub OAuth App client ID. Do not use a PAT; the server only supports the OAuth authorization-code flow." - } - }, - "githubClientSecret": { - "type": "securestring", - "metadata": { - "description": "GitHub OAuth App client secret. It is written only to Key Vault and consumed by the Function App through a Key Vault reference." - } - }, - "sessionSecret": { - "type": "securestring", - "minLength": 32, - "metadata": { - "description": "At least 32 characters of random session secret material used to encrypt server-side OAuth sessions." - } - }, - "previousSessionSecret": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional previous session secret retained during controlled key rotation until active sessions and pending revocations are drained." - } - }, - "logAnalyticsWorkspaceName": { - "type": "string", - "defaultValue": "[format('{0}-logs', parameters('functionAppName'))]", - "metadata": { - "description": "Single Log Analytics workspace shared by every role in this deployment." - } - }, - "collectorImage": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional container image running the collection role. Empty deploys no collection." - } - }, - "collectorGithubAppId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "GitHub App identifier whose installations define ingestion scope. Required with collectorImage." - } - }, - "collectorPrivateKey": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "GitHub App private key in PEM form. Required with collectorImage." - } - }, - "githubWebhookSecret": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Shared secret verifying GitHub webhook deliveries. Required with collectorImage." - } - }, - "githubAdminUsers": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "GitHub logins permitted to run administrative operations." - } - }, - "collectorControlRepository": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Control repository named by the reviewed inventory, in OWNER/REPOSITORY form." - } - }, - "collectorNativeInventoryDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact lowercase SHA-256 digest of immutable reviewed inventory bytes already provisioned in the shared PostgreSQL namespace. Required when collectorImage is set; an absent or unprovisioned digest prevents the workers from starting." - } - }, - "collectorWorkerReplicas": { - "type": "int", - "defaultValue": 1, - "minValue": 1, - "metadata": { - "description": "Fixed number of PostgreSQL-backed collection workers." - } - }, - "adxEnabled": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Provision an optional paid ADX destination only. Does not enable export, traces, or mining; Azure Functions is not a supported exporter host." - } - }, - "adxClusterName": { - "type": "string", - "defaultValue": "[format('adx{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", - "minLength": 4, - "maxLength": 22, - "metadata": { - "description": "Globally unique ADX cluster name: lowercase letters and digits only." - } - }, - "adxDatabaseName": { - "type": "string", - "defaultValue": "CAO", - "minLength": 1, - "maxLength": 100, - "metadata": { - "description": "ADX database identifier: ASCII letter followed by letters, digits, or underscores." - } - }, - "adxSkuName": { - "type": "string", - "defaultValue": "Dev(No SLA)_Standard_E2a_v4", - "metadata": { - "description": "Reviewed paid ADX SKU. The default is development-only without an SLA, not the free offering." - } - }, - "adxSkuTier": { - "type": "string", - "defaultValue": "Basic", - "allowedValues": [ - "Basic", - "Standard" - ] - }, - "adxCapacity": { - "type": "int", - "defaultValue": 1, - "minValue": 1, - "metadata": { - "description": "Reviewed ADX instance count compatible with the selected SKU and tier." - } - } - }, - "variables": { - "otlpEnvironment": [ - { - "name": "OTEL_EXPORTER_OTLP_ENDPOINT", - "value": "[parameters('otlpEndpoint')]" - }, - { - "name": "OTEL_EXPORTER_OTLP_PROTOCOL", - "value": "http/protobuf" - }, - { - "name": "CAO_OTEL_LOGS_ENABLED", - "value": "[string(parameters('otlpLogsEnabled'))]" - } - ], - "otlpHeadersEnabled": "[and(not(empty(parameters('otlpEndpoint'))), not(empty(parameters('otlpHeaders'))))]", - "functionImageSelector": "[if(empty(parameters('functionImageDigest')), format(':{0}', parameters('functionImageTag')), format('@{0}', parameters('functionImageDigest')))]", - "ingestionImageSelector": "[if(empty(parameters('ingestionImageDigest')), format(':{0}', parameters('ingestionImageTag')), format('@{0}', parameters('ingestionImageDigest')))]", - "provisioningImageSelector": "[if(empty(parameters('provisioningImageDigest')), format(':{0}', parameters('provisioningImageTag')), format('@{0}', parameters('provisioningImageDigest')))]", - "collectionEnabled": "[not(empty(parameters('collectorImage')))]", - "tags": { - "workload": "gh-cao-dashboard", - "hostingMode": "azure-functions" - }, - "githubRedirectUri": "[format('https://{0}.azurewebsites.net/auth/callback', parameters('functionAppName'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2023-09-01", - "name": "[parameters('logAnalyticsWorkspaceName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "properties": { - "sku": { - "name": "PerGB2018" - }, - "retentionInDays": 30 - } - }, - { - "type": "Microsoft.App/managedEnvironments", - "apiVersion": "2024-03-01", - "name": "[format('{0}-jobs', parameters('functionAppName'))]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "properties": { - "appLogsConfiguration": { - "destination": "azure-monitor" - }, - "vnetConfiguration": { - "infrastructureSubnetId": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.containerSubnetId.value]", - "internal": true - }, - "zoneRedundant": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "name": "container-logs", - "properties": { - "workspaceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]", - "logs": [ - { - "categoryGroup": "allLogs", - "enabled": true - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - ] - }, - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2023-05-01", - "name": "[parameters('storageAccountName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "sku": { - "name": "Standard_LRS" - }, - "kind": "StorageV2", - "properties": { - "allowSharedKeyAccess": false, - "allowBlobPublicAccess": false, - "minimumTlsVersion": "TLS1_2", - "supportsHttpsTrafficOnly": true - } - }, - { - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('containerRegistryName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "sku": { - "name": "Basic" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled" - } - }, - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2023-07-01", - "name": "[parameters('keyVaultName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "properties": { - "tenantId": "[subscription().tenantId]", - "sku": { - "family": "A", - "name": "standard" - }, - "enableRbacAuthorization": true, - "enabledForTemplateDeployment": false, - "enableSoftDelete": true, - "enablePurgeProtection": "[if(parameters('keyVaultPurgeProtection'), true(), null())]", - "softDeleteRetentionInDays": 90, - "publicNetworkAccess": "Enabled" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'github-oauth-client-secret')]", - "properties": { - "value": "[parameters('githubClientSecret')]", - "contentType": "GitHub OAuth client secret for CAO dashboard Azure Functions mode" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-session-secret')]", - "properties": { - "value": "[parameters('sessionSecret')]", - "contentType": "CAO dashboard session encryption secret" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "condition": "[not(empty(parameters('previousSessionSecret')))]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-session-secret-previous')]", - "properties": { - "value": "[parameters('previousSessionSecret')]", - "contentType": "Previous CAO dashboard session encryption secret retained during rotation" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "properties": { - "value": "[format('postgresql://cao_runtime_login:{0}@{1}:5432/{2}?sslmode=verify-full&options=-csearch_path%3Dcao', uriComponent(parameters('postgresRuntimePassword')), reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value, uriComponent(reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.databaseName.value))]", - "contentType": "CAO runtime-only PostgreSQL connection URL" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), format('{0}-admin', parameters('postgresUrlSecretName')))]", - "properties": { - "value": "[format('postgresql://{0}:{1}@{2}:5432/{3}?sslmode=verify-full', uriComponent(parameters('postgresAdministratorLogin')), uriComponent(parameters('postgresAdministratorPassword')), reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value, uriComponent(reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.databaseName.value))]", - "contentType": "Short-lived CAO database provisioning only" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), format('{0}-owner-password', parameters('postgresUrlSecretName')))]", - "properties": { - "value": "[parameters('postgresOwnerPassword')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), format('{0}-runtime-password', parameters('postgresUrlSecretName')))]", - "properties": { - "value": "[parameters('postgresRuntimePassword')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "condition": "[variables('collectionEnabled')]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-collect-private-key')]", - "properties": { - "value": "[parameters('collectorPrivateKey')]", - "contentType": "GitHub App private key for the collection profile" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "condition": "[variables('collectionEnabled')]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-github-webhook-secret')]", - "properties": { - "value": "[parameters('githubWebhookSecret')]", - "contentType": "GitHub webhook shared secret" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2023-12-01", - "name": "[parameters('hostingPlanName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "elastic", - "sku": { - "name": "EP1", - "tier": "ElasticPremium" - }, - "properties": { - "reserved": true - } - }, - { - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[parameters('applicationInsightsName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "web", - "properties": { - "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - }, - "dependsOn": [ - "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - ] - }, - { - "condition": "[variables('otlpHeadersEnabled')]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-otel-headers')]", - "properties": { - "value": "[parameters('otlpHeaders')]", - "contentType": "OTLP collector authentication headers" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.Web/sites", - "apiVersion": "2023-12-01", - "name": "[parameters('functionAppName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "functionapp,linux,container", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('hostingPlanName'))]", - "virtualNetworkSubnetId": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.functionSubnetId.value]", - "httpsOnly": true, - "publicNetworkAccess": "[parameters('functionPublicNetworkAccess')]", - "clientAffinityEnabled": false, - "siteConfig": { - "ipSecurityRestrictionsDefaultAction": "[parameters('functionAccessRestrictionsDefaultAction')]", - "ipSecurityRestrictions": "[parameters('functionAccessRestrictions')]", - "scmIpSecurityRestrictionsDefaultAction": "Deny", - "scmIpSecurityRestrictions": "[parameters('functionScmAccessRestrictions')]", - "scmIpSecurityRestrictionsUseMain": "[parameters('functionScmAccessRestrictionsUseMain')]", - "alwaysOn": true, - "minimumElasticInstanceCount": 1, - "ftpsState": "Disabled", - "minTlsVersion": "1.2", - "http20Enabled": true, - "linuxFxVersion": "[format('DOCKER|{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('functionImageRepository'), variables('functionImageSelector'))]", - "appCommandLine": "azure-functions", - "acrUseManagedIdentityCreds": true, - "appSettings": "[concat(createArray(createObject('name', 'FUNCTIONS_EXTENSION_VERSION', 'value', '~4'), createObject('name', 'FUNCTIONS_WORKER_RUNTIME', 'value', 'custom'), createObject('name', 'WEBSITES_PORT', 'value', '8080'), createObject('name', 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', 'value', 'false'), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference(resourceId('Microsoft.Insights/components', parameters('applicationInsightsName')), '2020-02-02').ConnectionString), createObject('name', 'AzureWebJobsStorage__accountName', 'value', parameters('storageAccountName')), createObject('name', 'AzureWebJobsStorage__credential', 'value', 'managedidentity'), createObject('name', 'CAO_DASHBOARD_HOSTING', 'value', 'azure-functions'), createObject('name', 'CAO_POLICY_PATH', 'value', '.github/workflows/cao.azure.json'), createObject('name', 'CAO_DATABASE_QUERIES', 'value', '/app/queries/database.json'), createObject('name', 'CAO_AZURE_ALLOWED_HOSTS', 'value', join(parameters('allowedHosts'), ',')), createObject('name', 'CAO_AZURE_REQUIRE_HTTPS', 'value', 'true'), createObject('name', 'REDIS_NAMESPACE', 'value', 'azure-dashboard'), createObject('name', 'CAO_OPERATIONAL_NAMESPACE', 'value', 'azure-dashboard-operational'), createObject('name', 'CAO_OPERATIONAL_CACHE_MAX_BYTES', 'value', string(parameters('operationalCacheMaxBytes'))), createObject('name', 'CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES', 'value', string(parameters('operationalCacheMaxValueBytes'))), createObject('name', 'CAO_OPERATIONAL_CACHE_MAX_ENTRIES', 'value', string(parameters('operationalCacheMaxEntries'))), createObject('name', 'CAO_OPERATIONAL_PROTECTED_MAX_BYTES', 'value', string(parameters('operationalProtectedMaxBytes'))), createObject('name', 'CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES', 'value', string(parameters('operationalProtectedMaxEntries'))), createObject('name', 'CAO_POSTGRES_URL', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/{1})', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri, parameters('postgresUrlSecretName'))), createObject('name', 'CAO_POSTGRES_RUN_RETENTION_DAYS', 'value', string(parameters('postgresRunRetentionDays'))), createObject('name', 'CAO_POSTGRES_LINKED_RETENTION_DAYS', 'value', string(parameters('postgresRunRetentionDays'))), createObject('name', 'CAO_GITHUB_CLIENT_ID', 'value', parameters('githubClientId')), createObject('name', 'CAO_GITHUB_CLIENT_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/github-oauth-client-secret)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)), createObject('name', 'CAO_GITHUB_REDIRECT_URL', 'value', variables('githubRedirectUri')), createObject('name', 'CAO_GITHUB_ALLOWED_ORGS', 'value', join(parameters('githubAllowedOrganizations'), ',')), createObject('name', 'CAO_GITHUB_ALLOWED_TEAMS', 'value', join(parameters('githubAllowedTeams'), ',')), createObject('name', 'CAO_GITHUB_ALLOWED_USERS', 'value', join(parameters('githubAllowedUsers'), ',')), createObject('name', 'CAO_SESSION_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-session-secret)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri))), variables('otlpEnvironment'), if(not(parameters('otlpLogsEnabled')), createArray(), createArray(createObject('name', 'DEBUG', 'value', 'cao:server,cao:telemetry'))), if(not(variables('otlpHeadersEnabled')), createArray(), createArray(createObject('name', 'OTEL_EXPORTER_OTLP_HEADERS', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-otel-headers)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)))), if(empty(parameters('previousSessionSecret')), createArray(), createArray(createObject('name', 'CAO_SESSION_SECRET_PREVIOUS', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-session-secret-previous)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)))), if(not(variables('collectionEnabled')), createArray(), createArray(createObject('name', 'CAO_GITHUB_WEBHOOK_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-github-webhook-secret)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)), createObject('name', 'CAO_GITHUB_ADMIN_USERS', 'value', join(parameters('githubAdminUsers'), ',')), createObject('name', 'CAO_COLLECT_APP_ID', 'value', parameters('collectorGithubAppId')), createObject('name', 'CAO_COLLECT_NATIVE', 'value', 'true'), createObject('name', 'CAO_COLLECT_ADMIT_ONLY', 'value', 'true'), createObject('name', 'CAO_COLLECT_CONTROL_REPOSITORY', 'value', parameters('collectorControlRepository')))))]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'github-oauth-client-secret')]", - "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-otel-headers')]", - "[resourceId('Microsoft.Web/serverfarms', parameters('hostingPlanName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret-previous')]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret')]", - "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "private-network-postgres", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "postgresServerName": { - "value": "[parameters('postgresServerName')]" - }, - "postgresAdministratorLogin": { - "value": "[parameters('postgresAdministratorLogin')]" - }, - "postgresAdministratorPassword": { - "value": "[parameters('postgresAdministratorPassword')]" - }, - "postgresSkuName": { - "value": "[parameters('postgresSkuName')]" - }, - "postgresSkuTier": { - "value": "[parameters('postgresSkuTier')]" - }, - "subnetNetworkSecurityGroupId": { - "value": "[parameters('subnetNetworkSecurityGroupId')]" - }, - "postgresDatabaseName": { - "value": "[parameters('postgresDatabaseName')]" - }, - "adxEnabled": { - "value": "[parameters('adxEnabled')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "10800549044802487649" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "namePrefix": { - "type": "string" - }, - "postgresServerName": { - "type": "string" - }, - "postgresAdministratorLogin": { - "type": "string" - }, - "postgresAdministratorPassword": { - "type": "securestring" - }, - "postgresSkuName": { - "type": "string", - "defaultValue": "Standard_B2s" - }, - "postgresSkuTier": { - "type": "string", - "defaultValue": "Burstable" - }, - "subnetNetworkSecurityGroupId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing network security group to keep attached to every subnet. Empty leaves subnets without an NSG." - } - }, - "postgresDatabaseName": { - "type": "string", - "defaultValue": "cao", - "minLength": 1, - "maxLength": 63, - "metadata": { - "description": "PostgreSQL database that holds the canonical and operational stores. Changing it creates a new, empty database; the previous database is not deleted." - } - }, - "adxEnabled": { - "type": "bool", - "defaultValue": false - } - }, - "variables": { - "subnetNetworkSecurityGroup": "[if(empty(parameters('subnetNetworkSecurityGroupId')), createObject(), createObject('networkSecurityGroup', createObject('id', parameters('subnetNetworkSecurityGroupId'))))]" - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2024-05-01", - "name": "[format('{0}-vnet', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "properties": { - "addressSpace": { - "addressPrefixes": [ - "10.42.0.0/16" - ] - }, - "subnets": "[concat(createArray(createObject('name', 'functions', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.0.0/24', 'delegations', createArray(createObject('name', 'functions', 'properties', createObject('serviceName', 'Microsoft.Web/serverFarms')))))), createObject('name', 'postgres', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.1.0/24', 'delegations', createArray(createObject('name', 'postgres', 'properties', createObject('serviceName', 'Microsoft.DBforPostgreSQL/flexibleServers')))))), createObject('name', 'containers', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.2.0/23')))), if(parameters('adxEnabled'), createArray(createObject('name', 'adx', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.4.0/24', 'privateEndpointNetworkPolicies', 'Disabled')))), createArray()))]" - } - }, - { - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2024-06-01", - "name": "[format('{0}.private.postgres.database.azure.com', parameters('postgresServerName'))]", - "location": "global" - }, - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')), format('{0}-postgres', parameters('namePrefix')))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - ] - }, - { - "type": "Microsoft.DBforPostgreSQL/flexibleServers", - "apiVersion": "2024-08-01", - "name": "[parameters('postgresServerName')]", - "location": "[parameters('location')]", - "sku": { - "name": "[parameters('postgresSkuName')]", - "tier": "[parameters('postgresSkuTier')]" - }, - "properties": { - "version": "16", - "administratorLogin": "[parameters('postgresAdministratorLogin')]", - "administratorLoginPassword": "[parameters('postgresAdministratorPassword')]", - "network": { - "delegatedSubnetResourceId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'postgres')]", - "privateDnsZoneArmResourceId": "[resourceId('Microsoft.Network/privateDnsZones', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')))]", - "publicNetworkAccess": "Disabled" - }, - "storage": { - "storageSizeGB": 32, - "autoGrow": "Enabled" - }, - "backup": { - "backupRetentionDays": 7, - "geoRedundantBackup": "Disabled" - }, - "highAvailability": { - "mode": "Disabled" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')), format('{0}-postgres', parameters('namePrefix')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - ] - }, - { - "type": "Microsoft.DBforPostgreSQL/flexibleServers/databases", - "apiVersion": "2024-08-01", - "name": "[format('{0}/{1}', parameters('postgresServerName'), parameters('postgresDatabaseName'))]", - "properties": { - "charset": "UTF8", - "collation": "en_US.utf8" - }, - "dependsOn": [ - "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('postgresServerName'))]" - ] - } - ], - "outputs": { - "postgresHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('postgresServerName')), '2024-08-01').fullyQualifiedDomainName]" - }, - "databaseName": { - "type": "string", - "value": "[parameters('postgresDatabaseName')]" - }, - "functionSubnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'functions')]" - }, - "containerSubnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'containers')]" - }, - "virtualNetworkId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - }, - "adxSubnetId": { - "type": "string", - "value": "[if(parameters('adxEnabled'), resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'adx'), '')]" - } - } - } - } - }, - { - "condition": "[parameters('adxEnabled')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "adx-destination", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "clusterName": { - "value": "[parameters('adxClusterName')]" - }, - "databaseName": { - "value": "[parameters('adxDatabaseName')]" - }, - "skuName": { - "value": "[parameters('adxSkuName')]" - }, - "skuTier": { - "value": "[parameters('adxSkuTier')]" - }, - "capacity": { - "value": "[parameters('adxCapacity')]" - }, - "virtualNetworkId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.virtualNetworkId.value]" - }, - "privateEndpointSubnetId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.adxSubnetId.value]" - }, - "tags": { - "value": "[variables('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "15737866968243541603" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "clusterName": { - "type": "string" - }, - "databaseName": { - "type": "string" - }, - "virtualNetworkId": { - "type": "string" - }, - "privateEndpointSubnetId": { - "type": "string" - }, - "tags": { - "type": "object", - "defaultValue": {} - }, - "skuName": { - "type": "string", - "defaultValue": "Dev(No SLA)_Standard_E2a_v4", - "metadata": { - "description": "Reviewed paid ADX SKU. The default is a development SKU without an SLA, not a free cluster." - } - }, - "skuTier": { - "type": "string", - "defaultValue": "Basic", - "allowedValues": [ - "Basic", - "Standard" - ] - }, - "capacity": { - "type": "int", - "defaultValue": 1, - "minValue": 1 - } - }, - "resources": [ - { - "type": "Microsoft.Kusto/clusters", - "apiVersion": "2024-04-13", - "name": "[parameters('clusterName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "[parameters('skuName')]", - "tier": "[parameters('skuTier')]", - "capacity": "[parameters('capacity')]" - }, - "properties": { - "publicNetworkAccess": "Disabled", - "enableDiskEncryption": true, - "enableStreamingIngest": true - } - }, - { - "type": "Microsoft.Kusto/clusters/databases", - "apiVersion": "2024-04-13", - "name": "[format('{0}/{1}', parameters('clusterName'), parameters('databaseName'))]", - "location": "[parameters('location')]", - "kind": "ReadWrite", - "properties": { - "softDeletePeriod": "P7D", - "hotCachePeriod": "P1D" - }, - "dependsOn": [ - "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]" - ] - }, - { - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2024-06-01", - "name": "[format('privatelink.{0}.kusto.windows.net', parameters('location'))]", - "location": "global" - }, - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', format('privatelink.{0}.kusto.windows.net', parameters('location')), format('{0}-adx', parameters('clusterName')))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('virtualNetworkId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.kusto.windows.net', parameters('location')))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-adx', parameters('clusterName'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "properties": { - "subnet": { - "id": "[parameters('privateEndpointSubnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "adx", - "properties": { - "privateLinkServiceId": "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]", - "groupIds": [ - "cluster" - ] - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-adx', parameters('clusterName')), 'adx')]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "adx", - "properties": { - "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.kusto.windows.net', parameters('location')))]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.kusto.windows.net', parameters('location')))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-adx', parameters('clusterName')))]" - ] - } - ], - "outputs": { - "clusterId": { - "type": "string", - "value": "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]" - }, - "queryEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Kusto/clusters', parameters('clusterName')), '2024-04-13').uri]" - }, - "databaseName": { - "type": "string", - "value": "[parameters('databaseName')]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "function-managed-identity-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "storageAccountName": { - "value": "[parameters('storageAccountName')]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "functionPrincipalId": { - "value": "[reference(resourceId('Microsoft.Web/sites', parameters('functionAppName')), '2023-12-01', 'full').identity.principalId]" - }, - "secretNames": { - "value": "[concat(createArray(parameters('postgresUrlSecretName'), 'github-oauth-client-secret', 'cao-session-secret'), if(empty(parameters('previousSessionSecret')), createArray(), createArray('cao-session-secret-previous')), if(not(variables('collectionEnabled')), createArray(), createArray('cao-github-webhook-secret')), if(not(variables('otlpHeadersEnabled')), createArray(), createArray('cao-otel-headers')))]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "11163691175506270168" - } - }, - "parameters": { - "storageAccountName": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "functionPrincipalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('functionPrincipalId'), 'Storage Blob Data Owner')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('functionPrincipalId'), 'Storage Queue Data Contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('functionPrincipalId'), 'Storage Table Data Contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "copy": { - "name": "keyVaultSecretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('functionPrincipalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('functionPrincipalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', parameters('functionAppName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'github-oauth-client-secret')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-github-webhook-secret')]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret-previous')]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret')]", - "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" - ] - }, - { - "condition": "[variables('collectionEnabled')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "collection-workers", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "environmentName": { - "value": "[format('{0}-jobs', parameters('functionAppName'))]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "collectorImage": { - "value": "[parameters('collectorImage')]" - }, - "postgresRunRetentionDays": { - "value": "[parameters('postgresRunRetentionDays')]" - }, - "keyVaultUri": { - "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "postgresUrlSecretName": { - "value": "[parameters('postgresUrlSecretName')]" - }, - "operationalNamespace": { - "value": "azure-dashboard-operational" - }, - "operationalCacheMaxBytes": { - "value": "[parameters('operationalCacheMaxBytes')]" - }, - "operationalCacheMaxValueBytes": { - "value": "[parameters('operationalCacheMaxValueBytes')]" - }, - "operationalCacheMaxEntries": { - "value": "[parameters('operationalCacheMaxEntries')]" - }, - "operationalProtectedMaxBytes": { - "value": "[parameters('operationalProtectedMaxBytes')]" - }, - "operationalProtectedMaxEntries": { - "value": "[parameters('operationalProtectedMaxEntries')]" - }, - "controlRepository": { - "value": "[parameters('collectorControlRepository')]" - }, - "nativeInventoryDigest": { - "value": "[parameters('collectorNativeInventoryDigest')]" - }, - "githubAppId": { - "value": "[parameters('collectorGithubAppId')]" - }, - "workerReplicas": { - "value": "[parameters('collectorWorkerReplicas')]" - }, - "applicationInsightsConnectionString": { - "value": "[reference(resourceId('Microsoft.Insights/components', parameters('applicationInsightsName')), '2020-02-02').ConnectionString]" - }, - "otlpEnvironment": { - "value": "[variables('otlpEnvironment')]" - }, - "otlpLogsEnabled": { - "value": "[parameters('otlpLogsEnabled')]" - }, - "otlpHeadersEnabled": { - "value": "[variables('otlpHeadersEnabled')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "2327320549105080117" - } - }, - "parameters": { - "location": { - "type": "string", - "metadata": { - "description": "Location for the collection workers." - } - }, - "tags": { - "type": "object", - "metadata": { - "description": "Resource tags." - } - }, - "namePrefix": { - "type": "string", - "minLength": 2, - "maxLength": 22, - "metadata": { - "description": "Name prefix for the collection resources." - } - }, - "collectorImage": { - "type": "string", - "metadata": { - "description": "Container image running the collection role." - } - }, - "environmentName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string", - "metadata": { - "description": "Deployment registry containing the private collector image." - } - }, - "postgresRunRetentionDays": { - "type": "int", - "defaultValue": 45, - "minValue": 7, - "maxValue": 3650 - }, - "keyVaultUri": { - "type": "string", - "metadata": { - "description": "Key Vault URI holding the collection secrets." - } - }, - "keyVaultName": { - "type": "string", - "metadata": { - "description": "Key Vault resource name, used to grant the workers secret access." - } - }, - "postgresUrlSecretName": { - "type": "string", - "defaultValue": "cao-postgres-runtime-url", - "metadata": { - "description": "Key Vault secret that stores the PostgreSQL URL." - } - }, - "operationalCacheMaxBytes": { - "type": "int", - "defaultValue": 33554432, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in the disposable operational cache." - } - }, - "operationalCacheMaxValueBytes": { - "type": "int", - "defaultValue": 4194304, - "minValue": 1, - "metadata": { - "description": "Maximum size of one disposable operational cache value." - } - }, - "operationalCacheMaxEntries": { - "type": "int", - "defaultValue": 1024, - "minValue": 1, - "metadata": { - "description": "Maximum number of disposable operational cache entries." - } - }, - "operationalProtectedMaxBytes": { - "type": "int", - "defaultValue": 134217728, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in protected operational records." - } - }, - "operationalProtectedMaxEntries": { - "type": "int", - "defaultValue": 200000, - "minValue": 1, - "metadata": { - "description": "Maximum number of protected operational records." - } - }, - "operationalNamespace": { - "type": "string", - "metadata": { - "description": "Operational PostgreSQL namespace shared with the Function App." - } - }, - "controlRepository": { - "type": "string", - "metadata": { - "description": "Control repository named by the reviewed inventory." - } - }, - "nativeInventoryDigest": { - "type": "string", - "minLength": 64, - "maxLength": 64, - "metadata": { - "description": "Exact provisioned lowercase SHA-256 digest of immutable reviewed inventory bytes. The workers fail startup if this digest is not in their shared PostgreSQL namespace." - } - }, - "githubAppId": { - "type": "string", - "metadata": { - "description": "GitHub App identifier whose installations define ingestion scope." - } - }, - "workerReplicas": { - "type": "int", - "defaultValue": 1, - "minValue": 1, - "metadata": { - "description": "Fixed number of PostgreSQL-backed collection workers." - } - }, - "applicationInsightsConnectionString": { - "type": "securestring", - "metadata": { - "description": "Platform Application Insights setting; does not activate Go OTLP exporters." - } - }, - "otlpEnvironment": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Shared Go OTLP/HTTP endpoint, protocol and explicit log opt-in from the parent deployment." - } - }, - "otlpLogsEnabled": { - "type": "bool", - "defaultValue": false - }, - "otlpHeadersEnabled": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Whether the parent provisioned the cao-otel-headers Key Vault secret." - } - }, - "queueMaxLength": { - "type": "int", - "defaultValue": 200000, - "minValue": 1000, - "metadata": { - "description": "Bound on the collection task and dead-letter streams." - } - } - }, - "variables": { - "collectionBaseEnvironment": [ - { - "name": "CAO_POSTGRES_URL", - "secretRef": "cao-postgres-url" - }, - { - "name": "CAO_POSTGRES_RUN_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POSTGRES_LINKED_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POLICY_PATH", - "value": "/app/.github/workflows/cao.azure.json" - }, - { - "name": "REDIS_NAMESPACE", - "value": "azure-dashboard" - }, - { - "name": "CAO_OPERATIONAL_NAMESPACE", - "value": "[parameters('operationalNamespace')]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_BYTES", - "value": "[string(parameters('operationalCacheMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES", - "value": "[string(parameters('operationalCacheMaxValueBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_ENTRIES", - "value": "[string(parameters('operationalCacheMaxEntries'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_BYTES", - "value": "[string(parameters('operationalProtectedMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES", - "value": "[string(parameters('operationalProtectedMaxEntries'))]" - }, - { - "name": "CAO_COLLECT_APP_ID", - "value": "[parameters('githubAppId')]" - }, - { - "name": "CAO_COLLECT_NATIVE", - "value": "true" - }, - { - "name": "CAO_COLLECT_NATIVE_INVENTORY_DIGEST", - "value": "[parameters('nativeInventoryDigest')]" - }, - { - "name": "CAO_COLLECT_PRIVATE_KEY", - "secretRef": "cao-collect-private-key" - }, - { - "name": "CAO_COLLECT_CONTROL_REPOSITORY", - "value": "[parameters('controlRepository')]" - }, - { - "name": "CAO_COLLECT_QUEUE_MAX_LENGTH", - "value": "[string(parameters('queueMaxLength'))]" - }, - { - "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", - "secretRef": "application-insights-connection-string" - }, - { - "name": "OTEL_SERVICE_NAME", - "value": "[format('{0}-collector', parameters('namePrefix'))]" - }, - { - "name": "DEBUG", - "value": "[if(parameters('otlpLogsEnabled'), 'cao:telemetry,cao:collect:recovery', 'cao:collect:*')]" - } - ], - "collectionEnvironment": "[concat(variables('collectionBaseEnvironment'), parameters('otlpEnvironment'), if(not(parameters('otlpHeadersEnabled')), createArray(), createArray(createObject('name', 'OTEL_EXPORTER_OTLP_HEADERS', 'secretRef', 'cao-otel-headers'))))]", - "collectionSecrets": "[concat(createArray(createObject('name', 'cao-postgres-url', 'keyVaultUrl', format('{0}secrets/{1}', parameters('keyVaultUri'), parameters('postgresUrlSecretName')), 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))), createObject('name', 'cao-collect-private-key', 'keyVaultUrl', format('{0}secrets/cao-collect-private-key', parameters('keyVaultUri')), 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))), createObject('name', 'application-insights-connection-string', 'value', parameters('applicationInsightsConnectionString'))), if(not(parameters('otlpHeadersEnabled')), createArray(), createArray(createObject('name', 'cao-otel-headers', 'keyVaultUrl', format('{0}secrets/cao-otel-headers', parameters('keyVaultUri')), 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))))))]" - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[format('{0}-collector-identity', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.App/containerApps", - "apiVersion": "2024-03-01", - "name": "[format('{0}-collector', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "activeRevisionsMode": "Single", - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]" - } - ], - "secrets": "[variables('collectionSecrets')]" - }, - "template": { - "containers": [ - { - "name": "collector", - "image": "[parameters('collectorImage')]", - "command": [ - "/app/cao-dashboard" - ], - "args": [ - "collect", - "--database-queries", - "/app/queries/database.json" - ], - "env": "[variables('collectionEnvironment')]", - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ], - "scale": { - "minReplicas": "[parameters('workerReplicas')]", - "maxReplicas": "[parameters('workerReplicas')]" - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'collector-roles')]" - ] - }, - { - "type": "Microsoft.App/jobs", - "apiVersion": "2024-03-01", - "name": "[format('{0}-backfill', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "triggerType": "Manual", - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]" - } - ], - "replicaTimeout": 7200, - "replicaRetryLimit": 1, - "manualTriggerConfig": { - "parallelism": 1, - "replicaCompletionCount": 1 - }, - "secrets": "[variables('collectionSecrets')]" - }, - "template": { - "containers": [ - { - "name": "backfill", - "image": "[parameters('collectorImage')]", - "command": [ - "/app/cao-dashboard" - ], - "args": [ - "backfill", - "--database-queries", - "/app/queries/database.json" - ], - "env": "[variables('collectionEnvironment')]", - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'collector-roles')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "collector-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "secretNames": { - "value": "[concat(createArray(parameters('postgresUrlSecretName'), 'cao-collect-private-key'), if(not(parameters('otlpHeadersEnabled')), createArray(), createArray('cao-otel-headers')))]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "12213718997281109477" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "copy": { - "name": "secretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('principalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('principalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]" - ] - } - ], - "outputs": { - "collectorIdentityPrincipalId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "collectorAppName": { - "type": "string", - "value": "[format('{0}-collector', parameters('namePrefix'))]" - }, - "backfillJobName": { - "type": "string", - "value": "[format('{0}-backfill', parameters('namePrefix'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-collect-private-key')]", - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-otel-headers')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "snapshot-ingestion", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "environmentName": { - "value": "[format('{0}-jobs', parameters('functionAppName'))]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "ingestionImage": { - "value": "[format('{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('ingestionImageRepository'), variables('ingestionImageSelector'))]" - }, - "postgresRunRetentionDays": { - "value": "[parameters('postgresRunRetentionDays')]" - }, - "postgresUrlSecretName": { - "value": "[parameters('postgresUrlSecretName')]" - }, - "canonicalNamespace": { - "value": "azure-dashboard" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "9245532112067501862" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "namePrefix": { - "type": "string", - "minLength": 2, - "maxLength": 19 - }, - "environmentName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "ingestionImage": { - "type": "string" - }, - "postgresRunRetentionDays": { - "type": "int", - "defaultValue": 45, - "minValue": 7 - }, - "postgresUrlSecretName": { - "type": "string", - "defaultValue": "cao-postgres-runtime-url", - "metadata": { - "description": "Key Vault secret that stores the PostgreSQL URL." - } - }, - "canonicalNamespace": { - "type": "string", - "defaultValue": "azure-dashboard" - } - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[format('{0}-ingest-identity', parameters('namePrefix'))]", - "location": "[parameters('location')]" - }, - { - "type": "Microsoft.App/jobs", - "apiVersion": "2024-03-01", - "name": "[format('{0}-ingest', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "triggerType": "Manual", - "replicaTimeout": 7200, - "replicaRetryLimit": 0, - "manualTriggerConfig": { - "parallelism": 1, - "replicaCompletionCount": 1 - }, - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]" - } - ], - "secrets": [ - { - "name": "cao-postgres-url", - "keyVaultUrl": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri, parameters('postgresUrlSecretName'))]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]" - } - ] - }, - "template": { - "containers": [ - { - "name": "ingest", - "image": "[parameters('ingestionImage')]", - "command": [ - "/app/cao-dashboard" - ], - "args": [ - "ingest", - "--source", - "/data", - "--database-queries", - "/app/queries/database.json", - "--redis-namespace", - "[parameters('canonicalNamespace')]" - ], - "env": [ - { - "name": "CAO_POSTGRES_URL", - "secretRef": "cao-postgres-url" - }, - { - "name": "CAO_POSTGRES_RUN_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POSTGRES_LINKED_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - } - ], - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'ingestion-roles')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "ingestion-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "secretNames": { - "value": [ - "[parameters('postgresUrlSecretName')]" - ] - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "12213718997281109477" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "copy": { - "name": "secretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('principalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('principalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]" - ] - } - ], - "outputs": { - "jobName": { - "type": "string", - "value": "[format('{0}-ingest', parameters('namePrefix'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "database-provisioning", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "environmentName": { - "value": "[format('{0}-jobs', parameters('functionAppName'))]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "provisioningImage": { - "value": "[format('{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('provisioningImageRepository'), variables('provisioningImageSelector'))]" - }, - "postgresUrlSecretName": { - "value": "[parameters('postgresUrlSecretName')]" - }, - "postgresHostName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value]" - }, - "postgresDatabaseName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.databaseName.value]" - }, - "postgresRunRetentionDays": { - "value": "[parameters('postgresRunRetentionDays')]" - }, - "operationalCacheMaxBytes": { - "value": "[parameters('operationalCacheMaxBytes')]" - }, - "operationalCacheMaxValueBytes": { - "value": "[parameters('operationalCacheMaxValueBytes')]" - }, - "operationalCacheMaxEntries": { - "value": "[parameters('operationalCacheMaxEntries')]" - }, - "operationalProtectedMaxBytes": { - "value": "[parameters('operationalProtectedMaxBytes')]" - }, - "operationalProtectedMaxEntries": { - "value": "[parameters('operationalProtectedMaxEntries')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "6625495926426680658" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "namePrefix": { - "type": "string", - "minLength": 2, - "maxLength": 19 - }, - "environmentName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "provisioningImage": { - "type": "string" - }, - "postgresUrlSecretName": { - "type": "string" - }, - "postgresHostName": { - "type": "string" - }, - "postgresDatabaseName": { - "type": "string" - }, - "postgresRunRetentionDays": { - "type": "int", - "minValue": 7, - "maxValue": 3650 - }, - "operationalCacheMaxBytes": { - "type": "int" - }, - "operationalCacheMaxValueBytes": { - "type": "int" - }, - "operationalCacheMaxEntries": { - "type": "int" - }, - "operationalProtectedMaxBytes": { - "type": "int" - }, - "operationalProtectedMaxEntries": { - "type": "int" - } - }, - "variables": { - "secretNames": [ - "[format('{0}-admin', parameters('postgresUrlSecretName'))]", - "[format('{0}-owner-password', parameters('postgresUrlSecretName'))]", - "[format('{0}-runtime-password', parameters('postgresUrlSecretName'))]" - ] - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[format('{0}-db-provision-identity', parameters('namePrefix'))]", - "location": "[parameters('location')]" - }, - { - "type": "Microsoft.App/jobs", - "apiVersion": "2024-03-01", - "name": "[format('{0}-db-provision', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "copy": [ - { - "name": "secrets", - "count": "[length(variables('secretNames'))]", - "input": { - "name": "[variables('secretNames')[copyIndex('secrets')]]", - "keyVaultUrl": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri, variables('secretNames')[copyIndex('secrets')])]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]" - } - } - ], - "triggerType": "Manual", - "replicaTimeout": 7200, - "replicaRetryLimit": 0, - "manualTriggerConfig": { - "parallelism": 1, - "replicaCompletionCount": 1 - }, - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]" - } - ] - }, - "template": { - "containers": [ - { - "name": "db-provision", - "image": "[parameters('provisioningImage')]", - "env": [ - { - "name": "CAO_DATABASE_ADMIN_URL", - "secretRef": "[variables('secretNames')[0]]" - }, - { - "name": "CAO_POSTGRES_OWNER_PASSWORD", - "secretRef": "[variables('secretNames')[1]]" - }, - { - "name": "CAO_POSTGRES_RUNTIME_PASSWORD", - "secretRef": "[variables('secretNames')[2]]" - }, - { - "name": "PGHOST", - "value": "[parameters('postgresHostName')]" - }, - { - "name": "PGDATABASE", - "value": "[parameters('postgresDatabaseName')]" - }, - { - "name": "PGSSLMODE", - "value": "verify-full" - }, - { - "name": "PGSSLROOTCERT", - "value": "system" - }, - { - "name": "CAO_POSTGRES_RUN_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POSTGRES_LINKED_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_BYTES", - "value": "[string(parameters('operationalCacheMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES", - "value": "[string(parameters('operationalCacheMaxValueBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_ENTRIES", - "value": "[string(parameters('operationalCacheMaxEntries'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_BYTES", - "value": "[string(parameters('operationalProtectedMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES", - "value": "[string(parameters('operationalProtectedMaxEntries'))]" - } - ], - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'database-provisioning-roles')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "database-provisioning-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "secretNames": { - "value": "[variables('secretNames')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "12213718997281109477" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "copy": { - "name": "secretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('principalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('principalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]" - ] - } - ], - "outputs": { - "jobName": { - "type": "string", - "value": "[format('{0}-db-provision', parameters('namePrefix'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), format('{0}-admin', parameters('postgresUrlSecretName')))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), format('{0}-owner-password', parameters('postgresUrlSecretName')))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), format('{0}-runtime-password', parameters('postgresUrlSecretName')))]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]" - ] - } - ], - "outputs": { - "collectionEnabled": { - "type": "bool", - "value": "[variables('collectionEnabled')]" - }, - "containerRegistryLoginServer": { - "type": "string", - "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]" - }, - "functionContainerImage": { - "type": "string", - "value": "[format('{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('functionImageRepository'), variables('functionImageSelector'))]" - }, - "functionHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Web/sites', parameters('functionAppName')), '2023-12-01').defaultHostName]" - }, - "githubOAuthRedirectUri": { - "type": "string", - "value": "[variables('githubRedirectUri')]" - }, - "keyVaultUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri]" - }, - "ingestionJobName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'snapshot-ingestion'), '2025-04-01').outputs.jobName.value]" - }, - "provisioningJobName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'database-provisioning'), '2025-04-01').outputs.jobName.value]" - }, - "logAnalyticsWorkspaceId": { - "type": "string", - "value": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - }, - "applicationInsightsId": { - "type": "string", - "value": "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]" - }, - "postgresHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value]" - }, - "adxClusterId": { - "type": "string", - "value": "[if(parameters('adxEnabled'), reference(resourceId('Microsoft.Resources/deployments', 'adx-destination'), '2025-04-01').outputs.clusterId.value, '')]" - }, - "adxQueryEndpoint": { - "type": "string", - "value": "[if(parameters('adxEnabled'), reference(resourceId('Microsoft.Resources/deployments', 'adx-destination'), '2025-04-01').outputs.queryEndpoint.value, '')]" - }, - "adxDatabaseName": { - "type": "string", - "value": "[if(parameters('adxEnabled'), reference(resourceId('Microsoft.Resources/deployments', 'adx-destination'), '2025-04-01').outputs.databaseName.value, '')]" - } - } -} \ No newline at end of file diff --git a/internal/topology/assets/azure-baseline.generated/network.bicep b/internal/topology/assets/azure-baseline.generated/network.bicep deleted file mode 100644 index eeb8a9486c..0000000000 --- a/internal/topology/assets/azure-baseline.generated/network.bicep +++ /dev/null @@ -1,140 +0,0 @@ -param location string -param namePrefix string -param postgresServerName string -param postgresAdministratorLogin string -@secure() -param postgresAdministratorPassword string -param postgresSkuName string = 'Standard_B2s' -param postgresSkuTier string = 'Burstable' -@description('Optional existing network security group to keep attached to every subnet. Empty leaves subnets without an NSG.') -param subnetNetworkSecurityGroupId string = '' -@minLength(1) -@maxLength(63) -@description('PostgreSQL database that holds the canonical and operational stores. Changing it creates a new, empty database; the previous database is not deleted.') -param postgresDatabaseName string = 'cao' - -param adxEnabled bool = false - -var subnetNetworkSecurityGroup = empty(subnetNetworkSecurityGroupId) ? {} : { - networkSecurityGroup: { - id: subnetNetworkSecurityGroupId - } -} - -resource network 'Microsoft.Network/virtualNetworks@2024-05-01' = { - name: '${namePrefix}-vnet' - location: location - properties: { - addressSpace: { - addressPrefixes: ['10.42.0.0/16'] - } - subnets: concat([ - { - name: 'functions' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.0.0/24' - delegations: [ - { - name: 'functions' - properties: { - serviceName: 'Microsoft.Web/serverFarms' - } - } - ] - }) - } - { - name: 'postgres' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.1.0/24' - delegations: [ - { - name: 'postgres' - properties: { - serviceName: 'Microsoft.DBforPostgreSQL/flexibleServers' - } - } - ] - }) - } - { - name: 'containers' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.2.0/23' - }) - } - ], adxEnabled ? [ - { - name: 'adx' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.4.0/24' - privateEndpointNetworkPolicies: 'Disabled' - }) - } - ] : []) - } -} - -resource dns 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: '${postgresServerName}.private.postgres.database.azure.com' - location: 'global' -} - -resource dnsLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - parent: dns - name: '${namePrefix}-postgres' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: network.id - } - } -} - -resource postgres 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { - name: postgresServerName - location: location - sku: { - name: postgresSkuName - tier: postgresSkuTier - } - properties: { - version: '16' - administratorLogin: postgresAdministratorLogin - administratorLoginPassword: postgresAdministratorPassword - network: { - delegatedSubnetResourceId: resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'postgres') - privateDnsZoneArmResourceId: dns.id - publicNetworkAccess: 'Disabled' - } - storage: { - storageSizeGB: 32 - autoGrow: 'Enabled' - } - backup: { - backupRetentionDays: 7 - geoRedundantBackup: 'Disabled' - } - highAvailability: { - mode: 'Disabled' - } - } - dependsOn: [dnsLink] -} - -resource database 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { - parent: postgres - name: postgresDatabaseName - properties: { - charset: 'UTF8' - collation: 'en_US.utf8' - } -} - -output postgresHostName string = postgres.properties.fullyQualifiedDomainName -output databaseName string = database.name -output functionSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'functions') -output containerSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'containers') -output virtualNetworkId string = network.id -output adxSubnetId string = adxEnabled ? resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'adx') : '' diff --git a/internal/topology/assets/docs/OPTIONAL-FEATURES.md.tmpl b/internal/topology/assets/container-apps-docs/OPTIONAL-FEATURES.md.tmpl similarity index 97% rename from internal/topology/assets/docs/OPTIONAL-FEATURES.md.tmpl rename to internal/topology/assets/container-apps-docs/OPTIONAL-FEATURES.md.tmpl index a42123d3b2..f5c97c18ee 100644 --- a/internal/topology/assets/docs/OPTIONAL-FEATURES.md.tmpl +++ b/internal/topology/assets/container-apps-docs/OPTIONAL-FEATURES.md.tmpl @@ -1,13 +1,13 @@ # Optional infrastructure and activation -Deploy optional/hosts.bicep AFTER the unchanged main.bicep baseline. Review +Deploy optional/hosts.bicep AFTER the Container Apps main.bicep baseline. Review `bash scripts/what-if-optional.sh SUBSCRIPTION_ID RESOURCE_GROUP` separately. No generated script deploys infrastructure or starts a service automatically. All optional-role credentials and TLS keys must be referenced in the existing input Key Vault. runtime-secret-references.json names them without values. Missing required references fail generation rather than bypassing secret custody. -The dedicated 10.43.0.0/16 VNet peers to the baseline's 10.42.0.0/16. Verify +The dedicated 10.43.0.0/16 VNet peers to the baseline's 10.45.0.0/16. Verify no overlap with other connected networks. Dedicated subnets/default-deny NSGs isolate archive, receive and dispatch. Only reviewed CIDRs reach public HTTPS. SSH is NOT exposed; arrange approved private administration/Run Command. @@ -23,7 +23,7 @@ recovery; default VM and ADX SKUs are development choices, not production advice A SINGLE persistent archive VM has a detached-on-delete 128 GiB managed disk. A dedicated private Blob account/container and paid managed-identity ADX cluster/database provide Parquet generation storage and bounded external-table -queries. Functions runtime storage is NOT a lake. The host has container-scoped +queries. Dashboard PostgreSQL is NOT a lake. The host has container-scoped Blob Data Contributor; ADX has container-scoped Blob Data Reader. The ADX-managed private Blob endpoint needs independent approval. diff --git a/internal/topology/assets/container-apps-docs/README.md.tmpl b/internal/topology/assets/container-apps-docs/README.md.tmpl index ab24e5cd89..47e956f3af 100644 --- a/internal/topology/assets/container-apps-docs/README.md.tmpl +++ b/internal/topology/assets/container-apps-docs/README.md.tmpl @@ -6,9 +6,10 @@ dependencies; RULES.md records declared tradeoffs, not live acceptance. This profile uses one Azure Container Apps **Consumption** environment and a single Go `serve-hosted` replica behind managed HTTPS ingress. Dashboard assets, -OAuth and PostgreSQL query execution share that origin. There are **no Azure -Functions, Functions runtime Storage account, App Service/Premium plan, Redis, -VMs or Application Insights**. Private PostgreSQL Flexible Server, ACR, a target +OAuth and PostgreSQL query execution share that origin. The baseline has **no +App Service plan, runtime Storage account, Redis, VMs or Application Insights**. +Separately staged optional hosts may add VMs and session-lake storage. +Private PostgreSQL Flexible Server, ACR, a target Key Vault and Log Analytics remain paid resources. Consumption with one minimum replica is not scale-to-zero or a claim that the deployment is free. @@ -49,8 +50,8 @@ hosted trust policy. The platform terminates HTTPS; the private hop to port 4. Run `sh scripts/validate.sh` and `sh scripts/what-if.sh SUBSCRIPTION_ID RESOURCE_GROUP`. Review the exact parameters and template. Use a separately approved deployment process with - `runtimeEnabled: false` (default). `gh cao deploy azure --apply` remains - Functions-only and does NOT accept this topology bundle. + `runtimeEnabled: false` (default). Generation does not apply the bundle or + migrate any existing Azure resource. 5. Start ONLY the provisioningJobName output. Wait for that exact execution to succeed, including schema bootstrap and restricted role grants. Then start ingestionJobName and wait for its exact successful execution. ARM dependency @@ -77,8 +78,10 @@ Managed HTTP ingress has a platform request timeout; verify revision-event stream reconnection through that boundary rather than claiming unlimited stream lifetime. -Actions snapshots only: collector, private ingress, ADX, OTLP, session lake and -gateway combinations fail closed. Optional MCP is documented in AGENT-MCP.md, +Actions snapshots only: collector, private ingress, baseline ADX and OTLP +combinations fail closed. Session lake and gateway hosts are separately staged +only when explicitly selected; see OPTIONAL-FEATURES.md when present. +Optional MCP is documented in AGENT-MCP.md, but neither its flag nor caller-repository authority is enabled by this profile. Setting runtimeEnabled false does not remove an existing app in an incremental ARM deployment. Stopping/removing it needs a separate reviewed operation. diff --git a/internal/topology/assets/docs/GITHUB-APPS.md.tmpl b/internal/topology/assets/docs/GITHUB-APPS.md.tmpl deleted file mode 100644 index fa29c9058b..0000000000 --- a/internal/topology/assets/docs/GITHUB-APPS.md.tmpl +++ /dev/null @@ -1,52 +0,0 @@ -# GitHub application configuration - -## Browser sign-in: required - -Register a GitHub OAuth App OR a GitHub App using the user authorization-code -flow. Homepage: https://{{.Requirements.Azure.Parameters.functionAppName}}.azurewebsites.net/ -Authorization callback: https://{{.Requirements.Azure.Parameters.functionAppName}}.azurewebsites.net/auth/callback - -Set githubClientId and put its client secret in the referenced githubClientSecret -secret. No PAT or pasted user token is supported. -Sign-in GitHub Apps need expiring user tokens; organization/team checks require -Members: read and an approved installation in each allowed organization. -Approve classic OAuth App organization access where restrictions apply. -Ordinary classic login uses read:org read:user, not repo. Directly allowed -users do not require organization membership lookups. Test allowed AND -disallowed identities. App installation does not widen CAO policy. - -## Collection App: {{if eq .Topology.Features.Acquisition "collector"}}required, separate credential boundary{{else}}not selected{{end}} - -{{if eq .Topology.Features.Acquisition "collector"}} -Use App ID {{.Requirements.Azure.Parameters.collectorGithubAppId}}, NOT an -OAuth client ID. Configure HTTPS webhooks at -https://{{.Requirements.Azure.Parameters.functionAppName}}.azurewebsites.net/api/github/webhook -Store its RSA PEM in collectorPrivateKey and the same high-entropy HMAC secret -in GitHub and githubWebhookSecret. - -Enable Workflow run events and retain installation/installation-repositories -lifecycle deliveries. Review the exact ingestion evidence: Actions: read for -runs/artifacts, Contents: read for reviewed workflow/policy inputs and -Metadata: read for selected repositories. Other evidence needs separately -reviewed read permissions, never a speculative write/admin grant. - -Install only on reviewed repositories. Verify signature admission, completion, -installation scope and uninstall/removal behavior. Enrollment, reviewed -inventory and CAO policy remain separate authorities. -The public Function App receives only the webhook secret, never the App -private key. Collection workers hold installation-scoped credentials. -{{else}} -Actions snapshot ingestion needs no collection App here. Existing -control-repository workflow Apps remain under their existing reviewed policy; -this generator neither creates nor expands them. -{{end}} - -{{if .Optional}} -See OPTIONAL-FEATURES.md for additional archive sign-in and separate gateway -receiving/dispatch Apps. Never reuse their private keys in the Functions process. -{{end}} - -Hosted CLI and token-broker execution Apps are NOT enabled by the baseline. -Live safe-output writes require their own reviewed implementation and authority. -ADX needs separate Entra setup/runtime principals, not a GitHub App. -No App creation, installation, permission acceptance or live check is automated. diff --git a/internal/topology/assets/docs/README.md.tmpl b/internal/topology/assets/docs/README.md.tmpl deleted file mode 100644 index 1f49ce75f4..0000000000 --- a/internal/topology/assets/docs/README.md.tmpl +++ /dev/null @@ -1,70 +0,0 @@ -# CAO Azure topology - -Review-only output from **gh cao deploy generate**. The solver derives its -dependency graph from TOSCA Simple Profile 1.3 node-template requirements using -a standard-library topological sort; no TOSCA or graph Go library is needed. -It does not parse arbitrary TOSCA or cloud archives. No Node.js, cloud calls, -App creation, deployments or job starts are involved. -Generation compiles every emitted `.bicep` file with Microsoft's official -`bicep build --no-restore --stdout` before publishing this folder. Compiler -errors or a missing Bicep CLI fail generation; warnings are reported. -This is syntax/type/linter validation, not live Azure deployment acceptance. - -Start with [MANIFEST.md](MANIFEST.md) for Mermaid architecture and dependency -diagrams. [topology.yaml](topology.yaml) is the generated TOSCA service template; -topology.json is the deterministic requirement-graph projection, not a second -topology language. manifest.json binds the generated files by SHA-256. -AGENT-MCP.md and the architecture diagram show Actions agents' separate -bearer-token plus OIDC provenance path. It is not enabled by generation. - -## Review and use - -1. Complete [TODO.md](TODO.md) and [GITHUB-APPS.md](GITHUB-APPS.md). - Keep this folder private; it contains identities and secret references, - never secret values. -2. Follow the [Azure bootstrap guide](https://github.com/githubnext/gh-cao/blob/main/docs/deployment-azure.md#bootstrap-steps) - for registry bootstrap, approved policy build contexts, separately built - Functions/provisioning/ingestion images, reviewed digests and evidence. - Bicep creates ACR, but does not build images. -3. Run `bash scripts/validate.sh` and - `bash scripts/what-if.sh SUBSCRIPTION_ID RESOURCE_GROUP`. - Review the exact Bicep, parameters and what-if before your separately - approved deployment process. No generated script applies changes. -4. Wait for successful database provisioning before starting any database-owning - role. {{if eq .Topology.Features.Acquisition "collector"}}Pin reviewed inventory before starting collectors. Do NOT start snapshot ingestion.{{else}}Run verified snapshot or deliberately empty ingestion only after provisioning.{{end}} -5. Verify health, readiness, signed-out API denial, allowed/disallowed OAuth - sign-in, independent SCM denial, image digests and retention on every role. -{{if .Optional}} -6. Review [OPTIONAL-FEATURES.md](OPTIONAL-FEATURES.md). Optional hosts are a - separate deployment AFTER the baseline. Their runtimes remain staged until - policy, credentials and independent acceptance evidence are satisfied. -{{end}} - -The Azure baseline's Bicep files are byte-for-byte copies embedded in the -generating CLI. Its manual ingestion job is provisioned even in collector mode; -presence does not authorize running it. Generated TOSCA DependsOn relationships describe -configuration/bootstrap prerequisites, NOT replacement ARM scheduling. - -{{if eq .Topology.Features.Ingress "private"}}Private ingress disables public access. The baseline does NOT create a Functions -private endpoint, proxy, VPN, DNS or approved client connectivity. Arrange and -verify these externally, including OAuth callbacks and webhook delivery. -{{end}} -{{if eq .Topology.Features.Analytics "adx"}}The baseline's paid ADX destination does not enable export, create runtime -schema or grant an exporter. Azure Functions is not an exporter host. -{{end}} - -## Checking and reproducibility - -`gh cao deploy check /absolute/path/to/this-folder` re-solves requirements -against the generated TOSCA requirement graph, validates dependency closure/order/cycles -and feature/parameter/secret consistency, and detects file drift and orphaned output. -Keep the generating CLI release: newer embedded source revisions can intentionally -reject an older bundle. This is not a signed attestation or live readiness check. -Edit requirements.yaml and generate a NEW folder; do not hand-edit outputs. - -The Azure baseline is experimental, not production-assured or a certified -service. This bundle is not the native deployment CLI's guarded apply plan. -External prerequisites remain unverified, and no authority is inferred from -credentials, topology validation or provisioning. -Turning features off does not tear down resources, stop existing runtimes, -revoke Apps or erase evidence; removal requires a separate reviewed procedure. diff --git a/internal/topology/assets/docs/TODO.md.tmpl b/internal/topology/assets/docs/TODO.md.tmpl deleted file mode 100644 index 71e5b00c53..0000000000 --- a/internal/topology/assets/docs/TODO.md.tmpl +++ /dev/null @@ -1,55 +0,0 @@ -# Deployment TODOs - -- [ ] Review requirements.yaml, topology.yaml, MANIFEST.md and all parameters. -- [ ] Review subscription/resource group, global names, regional SKUs, quota, - provider registration, cost, networking, privacy, security, monitoring, - backup/restore, incident response and rollback. -- [ ] Preserve the greatest existing run/linked retention and snapshot window - across every role, including stopped jobs and active revisions. -- [ ] Configure the applications in GITHUB-APPS.md; verify installations and - authorized AND rejected identities independently. -- [ ] If agent MCP access is intended, complete AGENT-MCP.md: compatible host - opt-in, exact caller repository/default branch, both token headers, read - permissions and runner network path. The Functions entrypoint does not wire MCP. -- [ ] Provision the EXISTING input secret vault and referenced secrets through - an approved secret manager. Enable that vault's ARM template-deployment - access and the deployment principal's required deployment action, separately - from managed-identity access to the destination vault. -- [ ] Verify secret contents independently: distinct PostgreSQL passwords - (at least 16 characters for Bicep, at least 32 for native deploy), session - secret at least 32 characters, valid OAuth secret and optional PEM/HMAC. - The checker validates references, never reads secret values. -- [ ] Build approved policy and evidence into the correct image targets, push - to ACR, pin reviewed digests and retain provenance/scan/SBOM evidence. -- [ ] Run `gh cao deploy check`, Bicep validation and what-if; review the exact output. -- [ ] Obtain deployment approval separately. No generated script applies changes. -- [ ] Run database provisioning successfully before ingestion or collection. -{{if eq .Topology.Features.Acquisition "collector"}} -- [ ] Pin exact inventory bytes with Acquisition.PinInventoryConfiguration in - azure-dashboard BEFORE workers start; compare collectorNativeInventoryDigest. - This generator does not generate or provision inventory. -- [ ] Keep snapshot ingestion stopped; verify signed webhook delivery, - enrollment, worker and backfill health. Never run both acquisition profiles. -{{else}} -- [ ] Select verified snapshot OR deliberate empty evidence, run ingestion after - provisioning and separately arrange ongoing snapshot updates. -{{end}} -{{if eq .Topology.Features.Ingress "private"}} -- [ ] Provision and verify private Functions connectivity, DNS, approved client - access, OAuth redirects and GitHub webhook routing without widening policy. -{{end}} -{{if eq .Topology.Features.Telemetry "otlp"}} -- [ ] Provision the external HTTPS OTLP collector; verify TLS, authentication, - per-role backend delivery and controlled-outage alerts. -{{end}} -{{if eq .Topology.Features.Analytics "adx"}} -- [ ] Review paid ADX SKU/cost, private reachability, separate schema setup and - exporter authority. Do not activate export on Azure Functions. -{{end}} -{{if .Optional}} -- [ ] Complete OPTIONAL-FEATURES.md for additional hosts, Apps and runtime gates. -{{end}} -- [ ] Verify live health/readiness, sign-in, allowlist rejection and SCM denial. -- [ ] Retain independent evidence for every external prerequisite. A valid - TOSCA graph does not prove installation, reachability, policy approval, - secret availability, Azure destination conformance or runtime readiness. diff --git a/internal/topology/assets/example.yaml b/internal/topology/assets/example.yaml deleted file mode 100644 index 502b8c78f0..0000000000 --- a/internal/topology/assets/example.yaml +++ /dev/null @@ -1,31 +0,0 @@ -version: 1 -backend: azure -features: - acquisition: actions # actions | collector (mutually exclusive) - ingress: public # public (OAuth) | private (external connectivity required) - analytics: none # none | adx (paid destination only, no exporter) - telemetry: none # none | otlp (external HTTPS collector required) - sessionDataLake: false # dedicated persistent host + private Blob + paid ADX - webhookGateway: false # separate receive/dispatch hosts; reviewed policy required -azure: - # Example names must be replaced with your globally unique reviewed names. - parameters: - location: eastus - functionAppName: cao-example - storageAccountName: caoexamplesa - containerRegistryName: caoexampleacr - keyVaultName: cao-example-kv - functionImageTag: reviewed-image-tag - githubClientId: your-oauth-client-id - githubAllowedOrganizations: [your-organization] - # Explicitly preserve existing run/linked retention and snapshot window. - postgresRunRetentionDays: 45 - # EXISTING input vault, separate from the destination vault created by Bicep. - # Values are secret NAMES, never passwords, PEMs, tokens or client secrets. - secretVaultId: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/operator-secrets/providers/Microsoft.KeyVault/vaults/operator-secrets - secrets: - githubClientSecret: github-oauth-client-secret - sessionSecret: cao-session-secret - postgresAdministratorPassword: cao-postgres-admin-password - postgresOwnerPassword: cao-postgres-owner-password - postgresRuntimePassword: cao-postgres-runtime-password diff --git a/internal/topology/assets/examples/full-azure.yaml b/internal/topology/assets/examples/full-azure.yaml index 732bec4bd7..640c0a1b6f 100644 --- a/internal/topology/assets/examples/full-azure.yaml +++ b/internal/topology/assets/examples/full-azure.yaml @@ -1,33 +1,29 @@ -# Synthetic compile-only example. Neither inventory, Apps, vault entries nor -# OTLP destination are provisioned/approved by this file. +# Synthetic compile-only Container Apps example with separately staged optional hosts. version: 1 backend: azure features: - deployment: azure-functions - acquisition: collector + deployment: azure-container-apps + acquisition: actions ingress: public - analytics: adx - telemetry: otlp + analytics: none + telemetry: none sessionDataLake: true webhookGateway: true azure: parameters: location: eastus - functionAppName: example-full - storageAccountName: examplefullsa + deploymentName: example-full containerRegistryName: examplefullacr keyVaultName: example-full-kv - functionImageTag: reviewed-image-tag + dashboardImage: examplefullacr.azurecr.io/cao-dashboard@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + provisioningImage: examplefullacr.azurecr.io/cao-provision@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + ingestionImage: examplefullacr.azurecr.io/cao-ingest@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + runtimeEnabled: false + trustedProxyCidrs: [10.45.0.0/23] githubClientId: example-client-id githubAllowedOrganizations: [example-org] githubAdminUsers: [example-admin] postgresRunRetentionDays: 45 - collectorImage: examplefullacr.azurecr.io/cao-collector@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - collectorGithubAppId: "123456" - collectorControlRepository: example-org/control - collectorNativeInventoryDigest: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - collectorWorkerReplicas: 1 - otlpEndpoint: https://otel.example.com secretVaultId: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-secrets/providers/Microsoft.KeyVault/vaults/example-secrets secrets: githubClientSecret: github-client-secret @@ -35,9 +31,7 @@ azure: postgresAdministratorPassword: postgres-admin-password postgresOwnerPassword: postgres-owner-password postgresRuntimePassword: postgres-runtime-password - collectorPrivateKey: collection-private-key githubWebhookSecret: collection-webhook-secret - otlpHeaders: otlp-headers archiveOauthClientSecret: archive-oauth-secret archiveSessionSecret: archive-session-secret archiveEntraClientSecret: archive-entra-secret diff --git a/internal/topology/azure.go b/internal/topology/azure.go deleted file mode 100644 index 8b6b6649f8..0000000000 --- a/internal/topology/azure.go +++ /dev/null @@ -1,75 +0,0 @@ -package topology - -import ( - "context" - "encoding/json" - "fmt" - "slices" - "strings" -) - -func azureParameterDefinitions() (map[string]ParameterDefinition, error) { - content, err := AzureTemplate() - if err != nil { - return nil, err - } - var document struct { - Parameters map[string]ParameterDefinition `json:"parameters"` - } - if err := json.Unmarshal(content, &document); err != nil { - return nil, fmt.Errorf("read embedded Azure parameter contract: %w", err) - } - return document.Parameters, nil -} - -// ResolveAzure validates the native deployment profile against the embedded -// provider contract and resolves its dependencies. Secret names declare externally -// resolved inputs; no secret values or credential acquisition enter the engine. -func ResolveAzure(ctx context.Context, features Features, values map[string]any, secrets []string) (Topology, error) { - if err := features.validate(); err != nil { - return Topology{}, err - } - if features.Deployment != "azure-functions" || features.Acquisition != "actions" || features.Ingress != "public" || - features.Analytics != "none" || features.SessionDataLake || features.WebhookGateway { - return Topology{}, fmt.Errorf("native Azure deployment supports only public azure-functions with Actions snapshots and optional OTLP telemetry; use deploy generate for other profiles") - } - definitions, err := azureParameterDefinitions() - if err != nil { - return Topology{}, err - } - parameters := map[string]Parameter{} - for name, value := range values { - definition, exists := definitions[name] - if !exists || strings.HasPrefix(strings.ToLower(definition.Type), "secure") { - return Topology{}, fmt.Errorf("unknown or secret Azure parameter: %s", name) - } - parameters[name] = Parameter{Value: value} - } - _, result, err := resolveAzureParameters(ctx, features, parameters, definitions, secrets) - return result, err -} - -func resolveAzureParameters(ctx context.Context, features Features, parameters map[string]Parameter, definitions map[string]ParameterDefinition, secrets []string) ([]byte, Topology, error) { - for _, name := range secrets { - if !strings.EqualFold(definitions[name].Type, "securestring") { - return nil, Topology{}, fmt.Errorf("unknown or non-secret Azure secret: %s", name) - } - } - for name, definition := range definitions { - parameter, exists := parameters[name] - if definition.DefaultValue == nil && !exists && !slices.Contains(secrets, name) { - return nil, Topology{}, fmt.Errorf("azure dependency missing: %s", name) - } - if exists && parameter.Reference == nil { - if err := validateParameter(name, parameter.Value, definition); err != nil { - return nil, Topology{}, err - } - } - } - source, err := providerServiceTemplate(features, parameters, definitions) - if err != nil { - return nil, Topology{}, err - } - result, err := resolveTopology(ctx, source, features) - return source, result, err -} diff --git a/internal/topology/azure_test.go b/internal/topology/azure_test.go deleted file mode 100644 index 4f6dbd199b..0000000000 --- a/internal/topology/azure_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package topology - -import ( - "context" - "errors" - "reflect" - "strings" - "testing" -) - -func TestResolveAzureUsesBundleDependencyEngine(t *testing.T) { - input := exampleRequirements(t) - for _, telemetry := range []string{"none", "otlp"} { - t.Run(telemetry, func(t *testing.T) { - input.Features.Telemetry = telemetry - if telemetry == "otlp" { - input.Azure.Parameters["otlpEndpoint"] = "https://collector.example.test" - } - bundle, err := Solve(context.Background(), input) - if err != nil { - t.Fatal(err) - } - values := map[string]any{} - var secrets []string - for name, parameter := range bundle.Parameters.Parameters { - if parameter.Reference != nil { - secrets = append(secrets, name) - } else { - values[name] = parameter.Value - } - } - resolved, err := ResolveAzure(context.Background(), input.Features, values, secrets) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(resolved, bundle.Topology) { - t.Fatalf("Azure deploy and bundle dependencies differ: %+v != %+v", resolved, bundle.Topology) - } - values["postgresRunRetentionDays"] = -1 - if _, err := ResolveAzure(context.Background(), input.Features, values, secrets); err == nil { - t.Fatal("accepted a provider parameter constraint violation") - } - }) - } -} - -func TestResolveAzureRejectsInvalidInputs(t *testing.T) { - input := exampleRequirements(t) - for _, test := range []struct { - name string - values map[string]any - secure []string - want string - }{ - {"literal secret", map[string]any{"sessionSecret": "not-a-reference"}, nil, "secret Azure parameter"}, - {"unknown parameter", map[string]any{"notDeclared": true}, nil, "unknown"}, - {"non-secret reference", nil, []string{"functionAppName"}, "non-secret"}, - {"unknown reference", nil, []string{"notDeclared"}, "unknown"}, - {"missing required input", nil, nil, "dependency missing"}, - } { - t.Run(test.name, func(t *testing.T) { - _, err := ResolveAzure(context.Background(), input.Features, test.values, test.secure) - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("expected %s: %v", test.want, err) - } - }) - } - for _, profile := range []string{"single-vm", "minimal-azure", "azure-container-apps", "full-azure"} { - content, err := ExampleFor(profile) - if err != nil { - t.Fatal(err) - } - other, err := Parse(content) - if err != nil { - t.Fatal(err) - } - if _, err := ResolveAzure(context.Background(), other.Features, nil, nil); err == nil { - t.Fatalf("native deployment accepted a bundle-only profile: %s", profile) - } - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if _, err := Solve(ctx, input); !errors.Is(err, context.Canceled) { - t.Fatalf("lost cancellation: %v", err) - } -} diff --git a/internal/topology/bundle.go b/internal/topology/bundle.go index 55dc2ae2c1..97220cd87e 100644 --- a/internal/topology/bundle.go +++ b/internal/topology/bundle.go @@ -64,23 +64,19 @@ func filesFor(ctx context.Context, content []byte) (map[string][]byte, Topology, } files["RULES.md"] = []byte(report.Markdown()) files["MANIFEST.md"] = []byte(renderManifest(solution.Topology) + fmt.Sprintf("\n## Architecture rules\n\n%d strict errors; %d advisory warnings. See [RULES.md](RULES.md).\nStatic declared topology only; not a security certification or live assessment.\n", report.Errors, report.Warnings)) - documentDirectory := "assets/docs" + documentDirectory := "assets/compact-docs" if input.Features.Deployment == "azure-container-apps" { documentDirectory = "assets/container-apps-docs" - } else if input.Features.Deployment != "azure-functions" { - documentDirectory = "assets/compact-docs" - } - if input.Features.Deployment != "azure-functions" || len(additionalRuntimeSecrets(input.Features)) > 0 { - references := map[string]*SecretReference{} - for name, secret := range input.Azure.Secrets { - ref := &SecretReference{SecretName: secret} - ref.KeyVault.ID = input.Azure.SecretVaultID - references[name] = ref - } - files["runtime-secret-references.json"], err = jsonBytes(references) - if err != nil { - return nil, Topology{}, err - } + } + references := map[string]*SecretReference{} + for name, secret := range input.Azure.Secrets { + ref := &SecretReference{SecretName: secret} + ref.KeyVault.ID = input.Azure.SecretVaultID + references[name] = ref + } + files["runtime-secret-references.json"], err = jsonBytes(references) + if err != nil { + return nil, Topology{}, err } documents, err := fs.ReadDir(assets, documentDirectory) if err != nil { @@ -177,7 +173,7 @@ func writeExclusive(name string, content []byte) error { } func Init(destination string) error { - return InitExample(destination, "azure-functions") + return InitExample(destination, "azure-container-apps") } func InitExample(destination, deployment string) error { diff --git a/internal/topology/cmd/generate-assets/main.go b/internal/topology/cmd/generate-assets/main.go index 70591e7450..6d50d373f3 100644 --- a/internal/topology/cmd/generate-assets/main.go +++ b/internal/topology/cmd/generate-assets/main.go @@ -27,14 +27,11 @@ func generate() error { return err } directory := filepath.Join("..", "..", "server", "azure") - names, err := filepath.Glob(filepath.Join(directory, "*.bicep")) - if err != nil { - return err - } - if len(names) == 0 { - return fmt.Errorf("no canonical Azure Bicep sources found") + names := []string{ + filepath.Join(directory, "provisioning.bicep"), + filepath.Join(directory, "ingestion.bicep"), + filepath.Join(directory, "worker-roles.bicep"), } - names = append(names, filepath.Join(directory, "main.json")) destination := filepath.Join("assets", "azure-baseline.generated") if err := os.MkdirAll(destination, 0o755); err != nil { return err @@ -89,7 +86,7 @@ func compileContainerApps() error { } func compileContract(destination string, sources map[string]string) (err error) { - stage, err := os.MkdirTemp("", "cao-topology-contract-") + stage, err := os.MkdirTemp(".", ".cao-topology-contract-") if err != nil { return err } diff --git a/internal/topology/compact.go b/internal/topology/compact.go index ae563db2a4..4135c494b7 100644 --- a/internal/topology/compact.go +++ b/internal/topology/compact.go @@ -20,7 +20,7 @@ var compactSecretNames = []string{ func solveCompact(ctx context.Context, input Requirements) (Solution, error) { result := Solution{Requirements: input} if input.Azure.Hosts != nil { - return result, fmt.Errorf("azure.hosts is only supported by the azure-functions deployment") + return result, fmt.Errorf("azure.hosts is only supported by the Container Apps deployment") } definitions, parameters, defaults, err := deploymentParameters(input, "assets/compact/main.generated.json", compactSecretNames) if err != nil { diff --git a/internal/topology/container_apps.go b/internal/topology/container_apps.go index b74e65aafc..0739edbe60 100644 --- a/internal/topology/container_apps.go +++ b/internal/topology/container_apps.go @@ -15,9 +15,6 @@ var containerAppsSecretNames = []string{ func solveContainerApps(ctx context.Context, input Requirements) (Solution, error) { result := Solution{Requirements: input} - if input.Azure.Hosts != nil { - return result, fmt.Errorf("azure.hosts is only supported by the azure-functions deployment") - } definitions, parameters, defaults, err := deploymentParameters(input, "assets/container-apps/main.generated.json", containerAppsSecretNames) if err != nil { return result, err @@ -66,10 +63,14 @@ func solveContainerApps(ctx context.Context, input Requirements) (Solution, erro if err := validateHostedParameters(input, value); err != nil { return result, err } - if err := validateVaultReferences(input, containerAppsSecretNames); err != nil { + runtimeSecrets := additionalRuntimeSecrets(input.Features) + if err := validateVaultReferences(input, append(slices.Clone(containerAppsSecretNames), runtimeSecrets...)); err != nil { return result, err } for name, secret := range input.Azure.Secrets { + if slices.Contains(runtimeSecrets, name) { + continue + } if !slices.Contains(containerAppsSecretNames, name) { return result, fmt.Errorf("unknown Container Apps secret reference: %s", name) } @@ -85,6 +86,11 @@ func solveContainerApps(ctx context.Context, input Requirements) (Solution, erro } } result.Parameters = parameterDocument(parameters) + deploymentName, _ := value("deploymentName").(string) + result.Optional, err = solveHosts(input, parameters, deploymentName) + if err != nil { + return result, err + } result.TOSCA, err = providerServiceTemplate(input.Features, parameters, definitions) if err != nil { return result, err diff --git a/internal/topology/container_apps_test.go b/internal/topology/container_apps_test.go index 5f27819707..23eaca74f0 100644 --- a/internal/topology/container_apps_test.go +++ b/internal/topology/container_apps_test.go @@ -60,8 +60,8 @@ func TestContainerAppsValidation(t *testing.T) { {"private", func(r *Requirements) { r.Features.Ingress = "private" }, "Actions snapshots only"}, {"adx", func(r *Requirements) { r.Features.Analytics = "adx" }, "Actions snapshots only"}, {"telemetry", func(r *Requirements) { r.Features.Telemetry = "otlp" }, "Actions snapshots only"}, - {"lake", func(r *Requirements) { r.Features.SessionDataLake = true }, "Actions snapshots only"}, - {"gateway", func(r *Requirements) { r.Features.WebhookGateway = true }, "Actions snapshots only"}, + {"lake-without-secrets", func(r *Requirements) { r.Features.SessionDataLake = true }, "SEC-VAULT-001"}, + {"gateway-without-secrets", func(r *Requirements) { r.Features.WebhookGateway = true }, "SEC-VAULT-001"}, {"vm-hosts", func(r *Requirements) { r.Azure.Hosts = &Hosts{} }, "azure.hosts"}, {"functions-parameter", func(r *Requirements) { r.Azure.Parameters["functionAppName"] = "unexpected" }, "unknown or secret Azure parameter"}, } { diff --git a/internal/topology/diagrams.go b/internal/topology/diagrams.go index db9e5e44e0..ae95a16900 100644 --- a/internal/topology/diagrams.go +++ b/internal/topology/diagrams.go @@ -48,7 +48,7 @@ func renderManifest(t Topology) string { } } out.WriteString("```\n\n## Configuration and activation\n\nReview [README.md](README.md), [TODO.md](TODO.md) and [GITHUB-APPS.md](GITHUB-APPS.md).\n") - out.WriteString("\n[AGENT-MCP.md](AGENT-MCP.md) documents Actions agents using BOTH a bearer GITHUB_TOKEN\nand signed OIDC provenance in X-GitHub-OIDC-Token, with audience https://cao.githubnext.com.\nThis optional read-only path requires separate host activation and repository/default-branch\npermission checks; OIDC alone grants no access. The unchanged Functions baseline does NOT wire MCP.\n") + out.WriteString("\n[AGENT-MCP.md](AGENT-MCP.md) documents Actions agents using BOTH a bearer GITHUB_TOKEN\nand signed OIDC provenance in X-GitHub-OIDC-Token, with audience https://cao.githubnext.com.\nThis optional read-only path requires separate host activation and repository/default-branch\npermission checks; OIDC alone grants no access. Generated deployments do not enable MCP.\n") if t.Features.SessionDataLake || t.Features.WebhookGateway { out.WriteString("Additional host resources and activation gates: [OPTIONAL-FEATURES.md](OPTIONAL-FEATURES.md).\n") } @@ -60,50 +60,11 @@ func architecture(f Features) string { if f.Deployment == "azure-container-apps" { return containerAppsArchitecture(f) } - if f.Deployment != "azure-functions" { - return compactArchitecture(f) - } - lines := []string{ - "flowchart LR", ` browser["Authorized browser"]`, ` signin["GitHub browser sign-in App"]`, - ` subgraph baseline["Azure baseline"]`, - ` dashboard["Functions: dashboard + OAuth
one HTTP origin"]`, - ` postgres["Private PostgreSQL
canonical + isolated operational state"]`, - ` vault["Key Vault
secret-scoped managed identities"]`, - ` registry["ACR
reviewed runtime and job images"]`, - ` storage["Functions runtime storage
NOT the session lake"]`, - ` provision["Manual DB provisioning
separate privileged identity"]`, " end", - " signin <-->|OAuth authorization code| dashboard", - " dashboard -->|runtime login, verified TLS| postgres", - " dashboard -->|managed identity, secret references| vault", - " registry -->|managed identity pull| dashboard", " dashboard --> storage", - " provision -->|bootstrap only| postgres", - } - if f.Ingress == "private" { - lines = append(lines, ` privateIngress["Approved private connectivity
EXTERNAL prerequisite"]`, " browser --> privateIngress", " privateIngress -.->|HTTPS + OAuth| dashboard") - } else { - lines = append(lines, " browser -->|public HTTPS, OAuth required| dashboard") - } - if f.Acquisition == "collector" { - lines = append(lines, - ` collectionApp["Installation-scoped collection App"]`, - ` workers["Private collection workers + backfill
App key, reviewed inventory"]`, - " collectionApp -->|signed webhooks| dashboard", " dashboard -->|admit only, no App private key| postgres", - " postgres -->|durable collection queue| workers", " collectionApp -->|authorized evidence| workers", - " workers -->|canonical publication| postgres", " vault -->|secret-scoped access| workers") - } else { - lines = append(lines, ` snapshot["Verified Actions snapshot
OR explicitly empty evidence"]`, - ` ingestion["Manual ingestion job
runtime-only identity"]`, - " snapshot --> ingestion", " provision -.->|must succeed first| ingestion", " ingestion --> postgres") - } - if f.Analytics == "adx" { - lines = append(lines, ` analyticsAdx["Paid private ADX destination
NO exporter enabled"]`) - } - if f.Telemetry == "otlp" { - lines = append(lines, ` otlp["Operator-managed HTTPS OTLP collector
EXTERNAL prerequisite"]`, " dashboard -.->|opt-in telemetry| otlp") - if f.Acquisition == "collector" { - lines = append(lines, " workers -.->|opt-in telemetry| otlp") - } - } + return compactArchitecture(f) +} + +func optionalArchitecture(f Features) []string { + lines := []string{} if f.SessionDataLake || f.WebhookGateway { lines = append(lines, ` subgraph optional["Separate peered VNet: optional infrastructure, runtimes STAGED"]`) if f.SessionDataLake { @@ -138,7 +99,7 @@ func architecture(f Features) string { " gatewayQueue -.->|fenced claims, fresh authorization| dispatcher", " dispatcher -.->|only approved dispatch transport| workflows") } - return strings.Join(append(lines, agentMCPArchitecture(f)...), "\n") + return lines } func containerAppsArchitecture(f Features) string { @@ -173,6 +134,7 @@ func containerAppsArchitecture(f Features) string { " vault -->|runtime URL ONLY| ingestion", " environment --> monitoring", } + lines = append(lines, optionalArchitecture(f)...) return strings.Join(append(lines, agentMCPArchitecture(f)...), "\n") } @@ -208,9 +170,6 @@ func compactArchitecture(f Features) string { func agentMCPArchitecture(f Features) []string { endpoint := "Optional Go /mcp
separate serve-hosted activation" - if f.Deployment == "azure-functions" { - endpoint = "Go /mcp contract: NOT WIRED
unchanged Functions entrypoint" - } return []string{ ` actionsAgent["Agent in GitHub Actions job
default-branch caller only"]`, ` oidcIssuer["GitHub Actions OIDC issuer
short-lived signed provenance"]`, diff --git a/internal/topology/graph.go b/internal/topology/graph.go index 7df6ec4120..0bb4eeccb1 100644 --- a/internal/topology/graph.go +++ b/internal/topology/graph.go @@ -7,16 +7,13 @@ import ( ) var contracts = map[string][]string{ - "network": {}, "postgres": {"network"}, "registry": {}, "monitoring": {}, "runtime-storage": {}, + "network": {}, "postgres": {"network"}, "registry": {}, "monitoring": {}, "sign-in-app": {}, "secrets": {"sign-in-app"}, "job-environment": {"network", "monitoring"}, "database-provisioning": {"postgres", "secrets", "registry", "job-environment"}, - "dashboard": {"database-provisioning", "registry", "monitoring", "runtime-storage", "secrets"}, + "dashboard": {"database-provisioning", "registry", "monitoring", "secrets"}, "snapshot-ingestion": {"database-provisioning", "job-environment", "registry", "secrets"}, - "collection-app": {}, "reviewed-inventory": {"database-provisioning", "collection-app"}, - "collection": {"dashboard", "reviewed-inventory", "collection-app", "job-environment", "registry", "secrets"}, - "adx": {"network"}, "private-connectivity": {}, "otlp-collector": {}, - "telemetry-export": {"dashboard", "otlp-collector"}, "feature-network": {"network"}, - "archive-host": {"feature-network", "registry"}, "archive-blob": {"feature-network", "archive-host"}, + "feature-network": {"network"}, + "archive-host": {"feature-network", "registry"}, "archive-blob": {"feature-network", "archive-host"}, "archive-adx": {"feature-network", "archive-blob"}, "archive-approval": {}, "archive-acquisition": {}, "session-data-lake": {"archive-host", "archive-blob", "archive-adx", "archive-approval", "archive-acquisition", "sign-in-app"}, "gateway-apps": {}, "gateway-policy": {}, "gateway-receive-secrets": {}, "gateway-dispatch-secrets": {}, @@ -28,8 +25,7 @@ var contracts = map[string][]string{ } var external = map[string]bool{ - "sign-in-app": true, "collection-app": true, "reviewed-inventory": true, - "private-connectivity": true, "otlp-collector": true, "archive-approval": true, "archive-acquisition": true, + "sign-in-app": true, "archive-approval": true, "archive-acquisition": true, "gateway-apps": true, "gateway-policy": true, "gateway-receive-secrets": true, "gateway-dispatch-secrets": true, "runtime-secrets": true, "runtime-image": true, "runtime-activation": true, } @@ -39,7 +35,7 @@ func (f Features) validate() error { value string choices []string }{ - "deployment": {f.Deployment, []string{"single-vm", "minimal-azure", "azure-functions", "azure-container-apps"}}, + "deployment": {f.Deployment, []string{"single-vm", "minimal-azure", "azure-container-apps"}}, "acquisition": {f.Acquisition, []string{"actions", "collector"}}, "ingress": {f.Ingress, []string{"public", "private"}}, "analytics": {f.Analytics, []string{"none", "adx"}}, @@ -49,8 +45,11 @@ func (f Features) validate() error { return fmt.Errorf("unsupported features.%s", name) } } - if f.Deployment != "azure-functions" && (f.Acquisition != "actions" || f.Ingress != "public" || f.Analytics != "none" || f.Telemetry != "none" || f.SessionDataLake || f.WebhookGateway) { - return fmt.Errorf("compact and Container Apps deployments support public hosted OAuth with Actions snapshots only; select azure-functions for optional managed features") + if f.Acquisition != "actions" || f.Ingress != "public" || f.Analytics != "none" || f.Telemetry != "none" { + return fmt.Errorf("compact and Container Apps deployments support public hosted OAuth with Actions snapshots only") + } + if f.Deployment != "azure-container-apps" && (f.SessionDataLake || f.WebhookGateway) { + return fmt.Errorf("optional managed hosts require the Container Apps deployment") } return nil } @@ -64,7 +63,7 @@ func dependencies(id string, f Features) []string { return slices.Clone(contracts[id]) } } - if f.Deployment != "azure-functions" { + if f.Deployment != "azure-container-apps" { switch id { case "dashboard": return []string{"vm", "postgres", "sign-in-app", "runtime-secrets", "runtime-image", "runtime-activation", "snapshot-ingestion"} @@ -79,35 +78,11 @@ func dependencies(id string, f Features) []string { return []string{"vm", "database-provisioning", "runtime-image"} } } - result := append([]string{}, contracts[id]...) - if id == "dashboard" && f.Ingress == "private" { - result = append(result, "private-connectivity") - } - if id == "secrets" && f.Acquisition == "collector" { - result = append(result, "collection-app") - } - if id == "telemetry-export" && f.Acquisition == "collector" { - result = append(result, "collection") - } - return result + return slices.Clone(contracts[id]) } func componentIDs(f Features) []string { - selected := map[string]bool{"dashboard": true} - if f.Acquisition == "collector" { - selected["collection"] = true - } else { - selected["snapshot-ingestion"] = true - } - if f.Analytics == "adx" { - selected["adx"] = true - } - if f.Ingress == "private" { - selected["private-connectivity"] = true - } - if f.Telemetry == "otlp" { - selected["telemetry-export"] = true - } + selected := map[string]bool{"dashboard": true, "snapshot-ingestion": true} if f.SessionDataLake { selected["session-data-lake"] = true } diff --git a/internal/topology/mcp_documentation_test.go b/internal/topology/mcp_documentation_test.go index ef91130f33..80d3f9aedc 100644 --- a/internal/topology/mcp_documentation_test.go +++ b/internal/topology/mcp_documentation_test.go @@ -7,13 +7,13 @@ import ( ) func TestSamplesDocumentActionsOIDCMCPWithoutEnablingIt(t *testing.T) { - for _, profile := range []string{"single-vm", "minimal-azure", "azure-functions", "azure-container-apps", "full-azure"} { + for _, profile := range []string{"single-vm", "minimal-azure", "azure-container-apps", "full-azure"} { t.Run(profile, func(t *testing.T) { source, err := ExampleFor(profile) if err != nil { t.Fatal(err) } - files, graph, err := filesFor(context.Background(), source) + files, _, err := filesFor(context.Background(), source) if err != nil { t.Fatal(err) } @@ -29,7 +29,7 @@ func TestSamplesDocumentActionsOIDCMCPWithoutEnablingIt(t *testing.T) { "CAO_MCP_ACTIONS_REPOSITORY", "CURRENT", "default branch", "Pull-request, environment and non-default-branch", - "Azure Functions baseline does NOT wire hosted MCP", + "Generated deployments do NOT enable hosted MCP", "must NOT be added to `azure.secrets`", } { if !strings.Contains(document, required) { @@ -42,9 +42,8 @@ func TestSamplesDocumentActionsOIDCMCPWithoutEnablingIt(t *testing.T) { t.Fatalf("architecture omits %q", required) } } - unwired := strings.Contains(manifest, "Go /mcp contract: NOT WIRED") - if unwired != (graph.Features.Deployment == "azure-functions") { - t.Fatal("diagram misrepresents the host's MCP activation capability") + if strings.Contains(manifest, "NOT WIRED") { + t.Fatal("diagram retained the retired hosting profile") } if !strings.Contains(string(files["topology.yaml"]), "agent_mcp_activation: documented-not-enabled") { t.Fatal("TOSCA implies activation or omits the MCP contract reference") diff --git a/internal/topology/model.go b/internal/topology/model.go index 3278875394..bace166890 100644 --- a/internal/topology/model.go +++ b/internal/topology/model.go @@ -98,7 +98,7 @@ func parameterDocument(values map[string]Parameter) Parameters { func Parse(content []byte) (Requirements, error) { var result Requirements - result.Features = Features{Deployment: "azure-functions", Acquisition: "actions", Ingress: "public", Analytics: "none", Telemetry: "none"} + result.Features = Features{Deployment: "azure-container-apps", Acquisition: "actions", Ingress: "public", Analytics: "none", Telemetry: "none"} if len(content) > 1<<20 { return result, fmt.Errorf("requirements exceed 1 MiB") } diff --git a/internal/topology/rules.go b/internal/topology/rules.go index f6c55d24be..087fbe150c 100644 --- a/internal/topology/rules.go +++ b/internal/topology/rules.go @@ -129,15 +129,13 @@ func ReviewRules(solution Solution) RuleReport { required = slices.Clone(compactSecretNames) } else if input.Features.Deployment == "azure-container-apps" { required = slices.Clone(containerAppsSecretNames) - } else if input.Features.Acquisition == "collector" { - required = append(required, "collectorPrivateKey", "githubWebhookSecret") } required = append(required, additionalRuntimeSecrets(input.Features)...) nodes := map[string]bool{} for _, node := range solution.Topology.Nodes { nodes[node.ID] = true } - known := slices.Contains([]string{"single-vm", "minimal-azure", "azure-functions", "azure-container-apps"}, input.Features.Deployment) + known := slices.Contains([]string{"single-vm", "minimal-azure", "azure-container-apps"}, input.Features.Deployment) authorized := false for _, field := range []string{"githubAllowedOrganizations", "githubAllowedTeams", "githubAllowedUsers"} { if users, ok := list(input.Azure.Parameters[field]); ok && len(users) > 0 { diff --git a/internal/topology/samples_test.go b/internal/topology/samples_test.go index 660171a5ae..a6c4afd624 100644 --- a/internal/topology/samples_test.go +++ b/internal/topology/samples_test.go @@ -21,9 +21,8 @@ func TestDeploymentSamples(t *testing.T) { }{ {"single-vm", 1, 0, 0, 0, false}, {"minimal-azure", 1, 1, 0, 0, true}, - {"azure-functions", 0, 1, 1, 1, true}, {"azure-container-apps", 0, 1, 0, 1, true}, - {"full-azure", 3, 1, 1, 1, true}, + {"full-azure", 3, 1, 0, 1, true}, } { t.Run(sample.name, func(t *testing.T) { content, err := ExampleFor(sample.name) @@ -65,7 +64,7 @@ func TestDeploymentSamples(t *testing.T) { t.Fatalf("%s: got %d %s resources, want %d", sample.name, counts[kind], kind, expected) } } - if input.Features.Deployment != "azure-functions" { + if sample.name != "full-azure" { for _, absent := range []string{"Microsoft.Web/serverfarms", "Microsoft.Storage/storageAccounts", "Microsoft.Insights/components"} { if counts[absent] != 0 { t.Fatalf("profile accidentally carries %s", absent) diff --git a/internal/topology/solve.go b/internal/topology/solve.go index 8490f1c9ee..20e66115e7 100644 --- a/internal/topology/solve.go +++ b/internal/topology/solve.go @@ -2,11 +2,9 @@ package topology import ( "context" - "encoding/json" "fmt" "math" "net/netip" - "net/url" "reflect" "regexp" "slices" @@ -117,204 +115,7 @@ func Solve(ctx context.Context, input Requirements) (Solution, error) { if input.Features.Deployment == "azure-container-apps" { return solveContainerApps(ctx, input) } - if input.Features.Deployment != "azure-functions" { - return solveCompact(ctx, input) - } - definitions, err := azureParameterDefinitions() - if err != nil { - return result, err - } - defaults := map[string]any{} - for name, definition := range definitions { - if definition.DefaultValue != nil { - var value any - if err := json.Unmarshal(definition.DefaultValue, &value); err != nil { - return result, fmt.Errorf("invalid embedded Azure default for %s: %w", name, err) - } - defaults[name] = value - } - } - azure, features := input.Azure, input.Features - if !isVaultResourceID(azure.SecretVaultID) { - return result, fmt.Errorf("SEC-VAULT-001: azure.secretVaultId must identify an existing Azure Key Vault") - } - parameters := map[string]Parameter{} - for name, value := range azure.Parameters { - definition, exists := definitions[name] - if exists && strings.HasPrefix(strings.ToLower(definition.Type), "secure") { - return result, fmt.Errorf("SEC-VAULT-002: %s must use a vault reference, not a literal parameter", name) - } - if !exists { - return result, fmt.Errorf("unknown or secret Azure parameter: %s", name) - } - if err := validateParameter(name, value, definition); err != nil { - return result, err - } - parameters[name] = Parameter{Value: value} - } - value := func(name string) any { - if parameter, exists := parameters[name]; exists { - return parameter.Value - } - return defaults[name] - } - set := func(name string, wanted any) error { - if supplied, exists := azure.Parameters[name]; exists && !reflect.DeepEqual(supplied, wanted) { - // YAML and JSON array decoding can have different concrete slice types. - a, aErr := json.Marshal(supplied) - b, bErr := json.Marshal(wanted) - if aErr != nil || bErr != nil || string(a) != string(b) { - return fmt.Errorf("%s conflicts with selected features", name) - } - } - parameters[name] = Parameter{Value: wanted} - return nil - } - for name, wanted := range map[string]any{ - "adxEnabled": features.Analytics == "adx", - "functionPublicNetworkAccess": map[bool]string{true: "Disabled", false: "Enabled"}[features.Ingress == "private"], - } { - if err := set(name, wanted); err != nil { - return result, err - } - } - if features.Ingress == "public" { - if err := set("functionAccessRestrictionsDefaultAction", "Allow"); err != nil { - return result, err - } - if err := set("functionAccessRestrictions", []any{}); err != nil { - return result, err - } - } - collectionSecrets := []string{"collectorPrivateKey", "githubWebhookSecret"} - if features.Acquisition == "actions" { - if err := set("collectorImage", ""); err != nil { - return result, err - } - for _, name := range []string{"collectorGithubAppId", "collectorControlRepository", "collectorNativeInventoryDigest"} { - if nonempty(value(name)) { - return result, fmt.Errorf("%s requires collector acquisition", name) - } - } - for _, name := range collectionSecrets { - if _, exists := azure.Secrets[name]; exists { - return result, fmt.Errorf("%s requires collector acquisition", name) - } - } - } else { - for _, name := range []string{"collectorImage", "collectorGithubAppId", "collectorControlRepository", "collectorNativeInventoryDigest"} { - if !nonempty(value(name)) { - return result, fmt.Errorf("collector dependency missing: %s", name) - } - } - for name, pattern := range map[string]string{ - "collectorGithubAppId": `^\d+$`, "collectorControlRepository": `^[\w.-]+/[\w.-]+$`, "collectorNativeInventoryDigest": `^[a-f0-9]{64}$`, - } { - text, _ := value(name).(string) - if !match(pattern, text) { - return result, fmt.Errorf("invalid collector dependency: %s", name) - } - } - if users, ok := list(value("githubAdminUsers")); !ok || len(users) == 0 { - return result, fmt.Errorf("collector requires githubAdminUsers") - } - } - if features.Telemetry == "none" { - if err := set("otlpEndpoint", ""); err != nil { - return result, err - } - if err := set("otlpLogsEnabled", false); err != nil { - return result, err - } - if _, exists := azure.Secrets["otlpHeaders"]; exists { - return result, fmt.Errorf("otlpHeaders requires OTLP telemetry") - } - } else { - text, _ := value("otlpEndpoint").(string) - endpoint, err := url.Parse(text) - if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { - return result, fmt.Errorf("OTLP dependency requires HTTPS otlpEndpoint without credentials, query or fragment") - } - } - for name, pattern := range map[string]string{ - "functionAppName": `^[a-z][a-z0-9-]{0,17}[a-z0-9]$`, "storageAccountName": `^[a-z0-9]{3,24}$`, - "containerRegistryName": `^[a-zA-Z0-9]{5,50}$`, "keyVaultName": `^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$`, - } { - text, _ := value(name).(string) - if !match(pattern, text) || (name == "functionAppName" || name == "keyVaultName") && strings.Contains(text, "--") { - return result, fmt.Errorf("invalid Azure resource name: %s", name) - } - } - if !nonempty(value("githubClientId")) { - return result, fmt.Errorf("sign-in dependency missing: githubClientId") - } - appName, _ := value("functionAppName").(string) - host := appName + ".azurewebsites.net" - if _, exists := azure.Parameters["allowedHosts"]; !exists { - if err := set("allowedHosts", []string{host}); err != nil { - return result, err - } - } - if _, exists := azure.Parameters["githubAllowedOrganizations"]; !exists { - if err := set("githubAllowedOrganizations", []string{}); err != nil { - return result, err - } - } - authorized := false - for _, field := range []string{"allowedHosts", "githubAllowedOrganizations", "githubAllowedTeams", "githubAllowedUsers", "githubAdminUsers"} { - items, ok := list(value(field)) - if !ok || slices.ContainsFunc(items, func(item string) bool { return strings.TrimSpace(item) == "" }) { - return result, fmt.Errorf("%s must contain nonempty strings", field) - } - if field == "allowedHosts" && !slices.Contains(items, host) { - return result, fmt.Errorf("allowedHosts must include the Function App OAuth callback host") - } - if slices.Contains([]string{"githubAllowedOrganizations", "githubAllowedTeams", "githubAllowedUsers"}, field) && len(items) > 0 { - authorized = true - } - } - if !authorized { - return result, fmt.Errorf("sign-in requires at least one allowed GitHub organization, team or user") - } - if _, exists := azure.Parameters["postgresRunRetentionDays"]; !exists { - return result, fmt.Errorf("explicit postgresRunRetentionDays is required; preserve existing and snapshot retention") - } - requiredSecrets := []string{"githubClientSecret", "sessionSecret", "postgresAdministratorPassword", "postgresOwnerPassword", "postgresRuntimePassword"} - if features.Acquisition == "collector" { - requiredSecrets = append(requiredSecrets, collectionSecrets...) - } - runtimeSecrets := additionalRuntimeSecrets(features) - requiredSecrets = append(requiredSecrets, runtimeSecrets...) - if err := validateVaultReferences(input, requiredSecrets); err != nil { - return result, err - } - for _, name := range requiredSecrets { - if strings.TrimSpace(azure.Secrets[name]) == "" { - return result, fmt.Errorf("secret dependency missing: %s", name) - } - } - for name, secretName := range azure.Secrets { - runtimeOnly := slices.Contains(runtimeSecrets, name) - if !strings.HasPrefix(strings.ToLower(definitions[name].Type), "secure") && !runtimeOnly { - return result, fmt.Errorf("unknown or non-secret Azure secret: %s", name) - } - if !match(`^[a-zA-Z0-9-]{1,127}$`, secretName) { - return result, fmt.Errorf("invalid Key Vault secret name for %s", name) - } - if runtimeOnly { - continue - } - reference := &SecretReference{SecretName: secretName} - reference.KeyVault.ID = azure.SecretVaultID - parameters[name] = Parameter{Reference: reference} - } - result.Parameters = parameterDocument(parameters) - result.Optional, err = solveHosts(input, parameters, appName) - if err != nil { - return result, err - } - result.TOSCA, result.Topology, err = resolveAzureParameters(ctx, features, parameters, definitions, nil) - return result, err + return solveCompact(ctx, input) } func solveHosts(input Requirements, parameters map[string]Parameter, appName string) (*Parameters, error) { diff --git a/internal/topology/topology_test.go b/internal/topology/topology_test.go index 5675ff0539..0b80dd744b 100644 --- a/internal/topology/topology_test.go +++ b/internal/topology/topology_test.go @@ -66,7 +66,7 @@ func TestTopologyResolvedFromTOSCARequirements(t *testing.T) { if err := CheckTopology(topology); err != nil { t.Fatal(err) } - if !slices.Equal(topology.Order, []string{"monitoring", "network", "job-environment", "postgres", "registry", "runtime-storage", "sign-in-app", "secrets", "database-provisioning", "dashboard", "snapshot-ingestion"}) { + if !slices.Equal(topology.Order, []string{"monitoring", "network", "job-environment", "postgres", "registry", "runtime-activation", "sign-in-app", "secrets", "database-provisioning", "snapshot-ingestion", "dashboard"}) { t.Fatalf("unexpected topology order: %v", topology.Order) } var document map[string]any @@ -227,7 +227,8 @@ func TestRequirementsFailClosed(t *testing.T) { }}, {"missing-retention", func(r *Requirements) { delete(r.Azure.Parameters, "postgresRunRetentionDays") }}, {"invalid-retention", func(r *Requirements) { r.Azure.Parameters["postgresRunRetentionDays"] = 6 }}, - {"invalid-name", func(r *Requirements) { r.Azure.Parameters["functionAppName"] = "cao--bad" }}, + {"invalid-name", func(r *Requirements) { r.Azure.Parameters["deploymentName"] = "cao--bad" }}, + {"retired-deployment", func(r *Requirements) { r.Features.Deployment = "azure-functions" }}, {"missing-allowlist", func(r *Requirements) { r.Azure.Parameters["githubAllowedOrganizations"] = []string{} }}, {"feature-conflict", func(r *Requirements) { r.Azure.Parameters["adxEnabled"] = true }}, {"missing-collector", func(r *Requirements) { r.Features.Acquisition = "collector" }}, @@ -267,18 +268,11 @@ func TestTOSCABaseline(t *testing.T) { } func TestEmbeddedAzureParity(t *testing.T) { - files, err := baselineFiles() + files, err := deploymentFiles(Features{Deployment: "azure-container-apps"}) if err != nil { t.Fatal(err) } - names, err := filepath.Glob("../../server/azure/*.bicep") - if err != nil { - t.Fatal(err) - } - names = append(names, "../../server/azure/main.json") - if len(files) != len(names) { - t.Fatal("embedded Azure file set drift; run go generate ./internal/topology") - } + names := []string{"../../server/azure/provisioning.bicep", "../../server/azure/ingestion.bicep", "../../server/azure/worker-roles.bicep"} for _, name := range names { canonical, err := os.ReadFile(name) if err != nil { diff --git a/package.json b/package.json index 6f08319f39..469a71fa87 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "postinstall": "node dashboard/site/scripts/prepare-terminal.mjs", "install:gh-aw": "GH_AW_VERSION=$(node .github/workflows/shared/control.mjs compiler-version .github/workflows/cao.json) && curl -sL https://raw.githubusercontent.com/github/gh-aw/main/install-gh-aw.sh | bash -s -- \"$GH_AW_VERSION\"", "dashboard:local": "CAO_MARKETPLACE_POLICY_PATH=\"${CAO_MARKETPLACE_POLICY_PATH:-$PWD/.github/workflows/cao.json}\" go -C server run ./cmd/cao-dashboard serve --site ../dashboard/site/dist", - "dashboard:local:azure": "bash scripts/azure-local/azure-local.sh dev", "predashboard:local": "CAO_DASHBOARD_PROFILE=server npm --prefix dashboard/site run build -- dist ../../.github/workflows/cao.json", "dashboard:pages": "go -C server run ./cmd/cao-dashboard serve-pages", "dashboard:pages:build": "node scripts/simulate-dashboard-pages.mjs", @@ -35,9 +34,6 @@ "test:integration:actions-runtime": "node --test --test-concurrency=1 tests/integration/actions-runtime.test.mjs", "test:integration:bootstrap": "CAO_BOOTSTRAP_INTEGRATION=1 go -C server test -race ./internal/simulator -run '^(TestCAOBootstrapIntegration|TestBootstrap)' -count=1 -timeout=10m -v", "test:integration:setup": "CAO_SETUP_INTEGRATION=1 go -C server test -race ./internal/simulator -run '^(TestCAO(Setup|HostedCLISetup)Integration|TestSetup)' -count=1 -timeout=10m -v", - "test:integration:setup-azure": "CAO_SETUP_AZURE_INTEGRATION=1 GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off GOVCS='*:off' go -C server test -race ./internal/simulator -run '^(TestCAOSetupAzureIntegration|TestSetupAzure|TestLocalTransport)' -count=1 -timeout=10m -v", - "test:integration:azure-install-journey": "CAO_SETUP_AZURE_INTEGRATION=1 GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off GOVCS='*:off' go -C server test -race ./internal/simulator -run '^TestCAOAzureInstallJourneyIntegration$' -count=1 -timeout=10m -v", - "test:integration:azure-services": "bash scripts/azure-local/azure-local.sh run", "test:integration:actions-simulator-postgres": "CAO_ACTIONS_SIMULATOR_POSTGRES=1 go -C server test -race ./internal/admission ./internal/webhookgateway -run '^(TestLocalActionsPostgresIntegration|TestIssueServiceSimulatorGatewayPostgres|TestGitHubSimulator(Gateway|Discovery)Postgres)$' -count=1 -timeout=2m -v", "test:stress:dashboard-backfill": "CAO_BACKFILL_STRESS=1 bash scripts/test-dashboard-backfill.sh", "test:stress:monthly-budget": "CAO_MONTHLY_BUDGET_STRESS=1 go -C server test ./internal/postgresx -run '^TestMonthlyBudgetStress$' -count=1 -timeout=40m -v", diff --git a/scripts/azure-local/README.md b/scripts/azure-local/README.md deleted file mode 100644 index 8994fce3ad..0000000000 --- a/scripts/azure-local/README.md +++ /dev/null @@ -1,394 +0,0 @@ -# Local Azure service integration - -This Linux and macOS harness runs the native Go CAO Azure Functions hosting boundary against -Azurite and a real PostgreSQL container. It uses no Azure subscription, Azure login, -or Azure credentials. Its default integration profile uses only synthetic data and -credentials; an explicit GitHub.com development profile uses your own OAuth app. -It does not -emulate managed identity, Key Vault, deployed networking, Azure edge behavior, -or managed PostgreSQL TLS; those remain real-Azure smoke-test concerns. - -## Prerequisites - -- Linux or macOS on x64 or arm64, with Bash, Docker or Podman, Go, Node.js, Python 3, - curl, `ps`, and unzip. macOS's bundled Bash and Python 3.9 are supported; - GNU utilities and a separate Core Tools installation are not required. -- A running local Docker daemon (default, for example Docker Desktop or Colima on - macOS) or explicitly selected working Podman engine -- Cached Go dependencies for both the root Azure deployment CLI and Go server. - Populate these before entering the hermetic service tests: - - ```bash - go mod download && go -C server mod download - ``` - -Azure Functions Core Tools 4.15.1 is downloaded on first use from its official -GitHub release for the host OS and architecture and verified by a pinned SHA-256. -Each platform has a separate cache protected by an exclusive installation lock. -Only the custom-handler host is extracted: unused .NET in-process hosts and -Java, Node.js, PowerShell and Python workers are excluded from this Go-only harness. -The host runs natively, including on Apple Silicon; PostgreSQL 16 and Azurite 3.37.0 images -are pinned by tag or digest. - -Run the complete suite: - -```bash -npm run test:integration:azure-services -``` - -For Podman, use `CAO_CONTAINER_ENGINE=podman npm run test:integration:azure-services`. -Only `docker` and `podman` are accepted; no installed-engine fallback occurs. -This harness uses the engine directly and does not require Compose. PostgreSQL -uses the fully qualified `docker.io/library/postgres:16-alpine` image. -The selected engine is persisted in `runtime.env`; subsequent `wait`, `logs` and -`stop` restore that selection even if the caller selects another engine. -State created before engine selection was introduced belongs to Docker. -Keep the original engine's context/connection unchanged while a stack is running. - -The Podman integration workflow overlaps the independent hosted-CLI boundary, -Azure service suite and portable harness checks, and waits for all three before -reporting success or failure. Race-instrumented test executables and the dashboard -server are cached against the complete Go source, embedded resources, module -lockfiles and build recipe, with no stale-key restoration. On a miss, the build -also reuses Go's module and compilation cache. The checksum-verified Core Tools installation is cached -separately by platform and harness revision; runtime state, credentials and -container storage are never cached. Cold-cache runs still download tools and -compile the real adapters; cache hits do not replace test execution. -Service checks batch compatible Go selectors so packages share compilation and -race-instrumented test processes. Every selector and package must execute tests, -and any skipped service evidence still fails the suite. -CI explicitly supplies `AZURE_LOCAL_TEST_BINARIES` from this exact-source cache. -The harness copies that server and runs those test executables through Go's -cached `test2json`; a missing executable fails rather than falling back to another build. -Without that setting, local runs continue to build and test the current checkout. -The deployment-protocol suite runs while the real service stack starts, and the -independently namespaced PostgreSQL adapter suites run concurrently. Synthetic -PostgreSQL data lives on container tmpfs to avoid overlay-filesystem write costs; -this is still real PostgreSQL, but does not establish host-crash durability. -The explicit GitHub development profile retains its original storage behavior. -CI gives hosted-CLI workloads, real Azure services and CPU-heavy deployment -protocols separate parallel jobs to avoid engine-store and CPU contention. -The real service job uses `run-services`; all three jobs run on every invocation. -The ordinary `run` command still runs both suites and propagates either failure. - -## Local service engine setup - -The npm Redis, PostgreSQL and telemetry commands and backfill harness also use -`CAO_CONTAINER_ENGINE=docker|podman`, with Docker as the default. Missing or -invalid selections fail rather than switching engines. Docker supports -`docker compose` or legacy `docker-compose`; Podman requires `podman compose` -with an installed provider (`podman-compose` or `docker-compose`, optionally -selected using `PODMAN_COMPOSE_PROVIDER`). Use the same engine when bringing -a Compose stack down. - -The backfill harness specifically requires **Docker Compose v2** as Podman's -provider: its `up --wait`, `--wait-timeout` and `--pull never` behavior is required. -Set `PODMAN_COMPOSE_PROVIDER` to the Docker Compose v2 executable (for example -`docker-compose` or its CLI-plugin path). Backfill checks the provider version -and required flags before creating resources, rejecting `podman-compose` with -an actionable error. The simpler Redis/PostgreSQL service commands may still -use `podman-compose`. - -On macOS, run -`CAO_CONTAINER_ENGINE=podman npm run dashboard:server:setup:macos` -to install Go, Node.js, dashboard dependencies, Podman and `podman-compose`, -without Docker or Colima. Then: - -```sh -podman machine init # once, if no machine exists -podman machine start -export CAO_CONTAINER_ENGINE=podman -export PODMAN_COMPOSE_PROVIDER=podman-compose -npm run dashboard:server:postgres-up -``` - -Default macOS setup still installs Docker/Compose/Colima. Podman machines are -allowed for ordinary local services and the Azure harness, but not for -the stricter [Kusto acceptance](../../server/KUSTO-ACCEPTANCE.md). These settings -do not change hosted/container deployment authority. -On macOS, start your Docker runtime first (for example, `colima start`). -The same command builds the native Go server, bootstraps disposable databases, -starts the real Functions host, runs the service and HTTP integration cases, and -stops its exact processes and containers. Process ownership uses kernel start -times and executable paths on both operating systems, not process names or -Linux `/proc` emulation. Logs remain under `.tmp/azure-local/`. - -The separate Go setup and private-install journey suites also run natively. -They require an explicit disposable numeric-loopback PostgreSQL instance; set -`POSTGRES_URL` before running `npm run test:integration:setup-azure` or -`npm run test:integration:azure-install-journey`. They simulate Azure's deployment -APIs; they do not provision real Azure resources. - -## GitHub.com local development - -Use the same Functions/Azurite/PostgreSQL boundary with the **real built dashboard** -and ordinary GitHub.com OAuth and organization-membership authorization: - -```bash -export CAO_GITHUB_CLIENT_ID=YOUR_DEVELOPMENT_OAUTH_CLIENT_ID -export CAO_GITHUB_ALLOWED_ORGS=YOUR_ORGANIZATION -read -rsp 'Development OAuth client secret: ' CAO_GITHUB_CLIENT_SECRET; echo -export CAO_GITHUB_CLIENT_SECRET -npm run dashboard:local:azure -``` - -Install the existing dashboard npm dependencies first (`npm ci` in -`dashboard/site/`). Register a dedicated development OAuth app on GitHub.com with -homepage `http://localhost:7071/` and callback -`http://localhost:7071/auth/callback`. Authorize the app for the explicitly listed -organizations as required by their OAuth policies. The existing production OAuth -APIs, scopes, token exchange, membership checks, session encryption, secure -cookies, and CSRF checks are unchanged. This is **not** the separate -`CAO_GITHUB_LOCAL_USER` profile and does not bypass organization membership. -Use the printed **localhost** URL, not `127.0.0.1`: browsers permit the existing -Secure OAuth cookies on HTTP localhost but may reject them on numeric-loopback -HTTP origins. The actual listening socket remains bound to `127.0.0.1`. -Do not expose this listener through tunnels or proxies. - -`dev` binds Core Tools explicitly to `127.0.0.1`, defaults to stable port `7071`, -waits for readiness, prints the local URL and evidence selection, and runs in the -foreground until Ctrl+C. It then removes its exact host process group, containers -(including their anonymous volumes), generated settings, and runtime state. No -collector, GitHub webhook, dispatch worker, Azure credentials, or rollout change -is enabled. This profile exercises real GitHub.com authentication, not simulated -GitHub APIs; successful login still requires valid credentials and authorized -organization membership. - -Both local profiles explicitly serve files from the state's `function-app/site/` -directory, not the executable's embedded dashboard. Edit generated JavaScript or -CSS there and reload the browser without rebuilding Go. For source edits, rebuild -the dashboard into that directory; restart the server after query/catalog changes. -The directory is disposable and removed on stop, so retain changes in source files. - -| Environment variable | Development behavior | -| --- | --- | -| `CAO_CONTAINER_ENGINE` | Optional `docker` (default) or `podman`; no automatic fallback | -| `CAO_GITHUB_CLIENT_ID`, `CAO_GITHUB_CLIENT_SECRET` | Required real development OAuth credentials; never pass secrets as command arguments | -| `CAO_GITHUB_ALLOWED_ORGS` | Required comma-separated exact organization logins; no wildcard or implicit catalog scope | -| `AZURE_LOCAL_FUNCTIONS_PORT` | Optional stable unprivileged port, default `7071`; an unavailable port fails startup | -| `CAO_GITHUB_REDIRECT_URL` | Optional; if set, must exactly match `http://localhost:PORT/auth/callback` | -| `CAO_SESSION_SECRET` | Optional, at least 32 characters; otherwise generated cryptographically for each fresh stack | -| `AZURE_LOCAL_SOURCE_DIRECTORY` | Optional existing **absolute** deployed Activity snapshot directory, ingested with production hash verification | -| `AZURE_LOCAL_STATE_DIR` | Optional absolute isolated directory; default `.tmp/azure-local/github/` | - -Without a source directory, startup ingests `server/azure/empty-data`, the -production empty-evidence artifact. Empty views mean **no collected evidence**, -not a live fleet. The synthetic integration fixture is never generated or -implicitly loaded in this profile. Importing a snapshot does not collect or -refresh GitHub evidence. Restart with an explicitly updated snapshot to replace -it; local data is disposable and removed on stop. - -Both canonical data and operational state use **ephemeral PostgreSQL**, not Redis. -Restart persistence applies only while that container exists: stopping removes -sessions and any pending GitHub credential-revocation work. Deleting local state -does **not** prove remote tokens were revoked. Sign out of the dashboard before -stopping the stack. If the stack was interrupted, or remote cleanup is uncertain, -revoke the dedicated development app's authorization in your GitHub account's -Settings → Applications. The harness does not issue broad automatic revocation -calls or claim durable token cleanup after deleting the container. - -Other `CAO_*` environment configuration is rejected before startup rather than -silently inheriting a collector, alternative authentication policy, or remote -service. Core Tools receives a clean environment and private settings. State and -log directories are owner-only (`0700`), generated secret files are private -(`0600`), PostgreSQL has a random local password, and verbose host logging is -disabled. Development failures and `logs` print private log **paths**, not their -contents; retained logs may contain sensitive operational metadata. Never upload -the development state or logs as CI artifacts. Do not use shell tracing. - -For a background stack, select the profile explicitly: - -```bash -AZURE_LOCAL_PROFILE=github ./scripts/azure-local/azure-local.sh start -AZURE_LOCAL_PROFILE=github ./scripts/azure-local/azure-local.sh wait -AZURE_LOCAL_PROFILE=github ./scripts/azure-local/azure-local.sh logs -AZURE_LOCAL_PROFILE=github ./scripts/azure-local/azure-local.sh stop -``` - -`start` refuses existing GitHub stack state: stop it explicitly before changing -credentials, evidence, or port. Profiles cannot reuse or tear down one another's -state, including when given the same explicit directory. `test` and `run` remain -synthetic-only and must never be pointed at a development stack. -The non-secret `profile` marker remains with retained logs after stopping or -failed startup, so the synthetic profile cannot read or reuse old development -logs. Use a fresh directory to change profiles. Existing unmarked, nonempty -directories also fail closed; inspect their retained files and choose fresh -state rather than implicitly adopting them. - -With a development stack running and native Chromium installed, verify the real -browser cookie/callback boundary separately: - -```bash -PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium \ - node tests/integration/azure-local-dev-browser.test.mjs \ - .tmp/azure-local/github/runtime.json -``` - -This test intercepts the real local login redirect before following GitHub; it requires -no live GitHub login and never exchanges an authorization code. It verifies -Secure-cookie persistence, invalid-state rejection, valid-state consumption, -anonymous API denial, actual loopback binding, and absence of configured secrets -from host logs. It does not establish a successful GitHub token exchange or -organization membership. - -## Bicep service inventory and local equivalents - -The inventory includes all eight `server/azure/*.bicep` modules, existing -resources, and optional collector/ADX resources. The inventory guard compares -every resource type with the generated ARM template; a new service fails the -guard until its local coverage and limitations are reviewed. - -| Bicep resource type | Local equivalent and separate case | Not established locally | -| --- | --- | --- | -| `Microsoft.Web/sites` | [Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local); route forwarding, health/readiness, shared PostgreSQL rate limits and missing configuration | Azure edge forwarding, scale-in, platform timeouts | -| `Microsoft.Web/serverfarms` | Bicep/ARM hosting-plan contract assertions; no Azure plan emulator | Premium capacity, billing, always-on guarantees | -| `Microsoft.Storage/storageAccounts` | [Azurite](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite); separate signed Blob read/write/ETag/denial, Queue visibility/receipt/acknowledgement and Table entity/ETag cases | Entra/RBAC, managed storage durability; Table emulation is preview | -| `Microsoft.DBforPostgreSQL/flexibleServers` | Real PostgreSQL 16; production canonical adapter publication/reopen/contract-drift case | Flexible Server management, managed TLS, backups, HA | -| `Microsoft.DBforPostgreSQL/flexibleServers/databases` | Production PostgreSQL operational adapter conformance; sessions, CAS, admission, queue capacity and leases in isolated namespaces | Azure database provisioning and restore | -| `Microsoft.ContainerRegistry/registries` | Existing Azure CLI protocol fixture and production deploy adapter digest-binding/read-back cases; no ACR emulator | Registry push/pull, managed builds, AcrPull authorization | -| `Microsoft.App/managedEnvironments` | Bicep/ARM environment, subnet and logging contracts; no managed-environment emulator | Azure environment lifecycle and networking | -| `Microsoft.App/containerApps` | Collector image, identity, secret and deployment contract cases; ordinary local collection adapters are not a Container Apps emulator | Managed scheduling, replica coordination and autoscaling | -| `Microsoft.App/jobs` | Production deploy adapter job ordering, failure and cancellation cases through the Azure CLI protocol fixture; setup journey separately executes real provisioning/ingestion subprocesses | Azure scheduler, execution isolation and platform job cancellation | -| `Microsoft.KeyVault/vaults` | Local HTTP identity/secret fixture through the production `KeyVault.HTTPClient` seam; secret reads, rotation, denial, unavailability and redirect rejection | Azure vault RBAC, purge protection and App Service reference resolution | -| `Microsoft.KeyVault/vaults/secrets` | Same fixture; missing/empty secret failure, uncached rotation; separate deploy adapter unresolved-reference case | Actual Key Vault references and secret propagation | -| `Microsoft.ManagedIdentity/userAssignedIdentities` | Separate App Service and IMDS HTTP cases, audience/client selection, expired tokens and identity rejection | Entra token issuance, identity assignment and Azure authorization | -| `Microsoft.Authorization/roleAssignments` | Existing least-privilege Bicep/ARM role/scope assertions; no Azure RBAC emulator | Effective deployed permissions | -| `Microsoft.OperationalInsights/workspaces` | Workspace/shared-role logging deployment contracts; no Log Analytics emulator | Workspace retention, query execution and receipt of Azure platform logs | -| `Microsoft.Insights/components` | Template-driven local TLS OTLP receiver case decodes production trace/metric/log protobuf and checks failure isolation; **not** an Application Insights emulator | Azure ingestion, Functions host telemetry and managed alerts | -| `Microsoft.Insights/diagnosticSettings` | Diagnostic destination/category deployment contracts; no Azure diagnostic-settings emulator | Delivery of actual platform diagnostics | -| `Microsoft.Network/virtualNetworks` | Bicep/ARM subnet/delegation assertions; no Azure VNet emulator | Routing, NSG enforcement and isolation | -| `Microsoft.Network/privateDnsZones` | Private DNS naming and resource-link contracts | Azure private DNS resolution | -| `Microsoft.Network/privateDnsZones/virtualNetworkLinks` | Private DNS/VNet linkage contracts | Effective DNS registration and reachability | -| `Microsoft.Network/privateEndpoints` | ADX opt-in private endpoint/subnet/group contracts | Private Link connectivity and approval | -| `Microsoft.Network/privateEndpoints/privateDnsZoneGroups` | ADX private DNS zone association contracts | Actual endpoint DNS resolution | -| `Microsoft.Kusto/clusters` | Optional [official Kusto emulator](https://learn.microsoft.com/en-us/azure/data-explorer/kusto-emulator-overview); existing real-engine functional integration suite | Entra, streaming/managed ingestion, Azure cluster management and retention enforcement | -| `Microsoft.Kusto/clusters/databases` | Same optional real engine, isolated databases and generated KQL/mapping/query cases | Managed database durability, retention and Azure permissions | - -There is no supported Azure-wide local emulator. Protocol fixtures and -deployment-contract assertions above are deliberately not reported as managed -service integration. No production endpoint, HTTPS requirement, credentials or -rollout authority is weakened for tests. Every directly accessed Azure resource crosses an injectable interface. Existing -contracts are retained, and concrete Blob/ADX archive dependencies and the -Key Vault managed-identity dependency are replaced with owned service contracts: - -| Direct access | Consumer interface | Production adapter / local integration | -| --- | --- | --- | -| ARM resources, ACR, Functions settings/restart, Container Apps jobs, role/provider/name checks | `azcli.Executor` in deployment dependencies | `azcli.Process`; existing strict Azure CLI protocol simulator and deploy tests | -| PostgreSQL canonical data | `dashboardstore.Store` / `dashboardstore.Reader` | `postgresx.Store`; real PostgreSQL publication/reopen tests | -| PostgreSQL operational state | `operational.Store` and its domain service interfaces | `operational/postgres`; real multi-client conformance | -| Key Vault secret read | `tokenbroker.PrivateKeyReader` | `tokenbroker.KeyVault`; local HTTP fixture | -| App Service/IMDS managed identity | `tokenbroker.ManagedIdentityTokenSource` | `tokenbroker.ManagedIdentity`; separate local HTTP identity cases | -| ADX streaming export and trace verification | `adxingest.Sink` / `adxtrace.Sink` | `adxingest.Client`; existing protocol and optional real Kusto engine suites | -| Optional private archive Blob objects, leases and pointer CAS | `sessionarchiveblob.BlobStore` | Owned Blob HTTP adapter; new real Azurite integration through the interface, including verified retries after `BlobAlreadyExists` | -| Optional private archive ADX metadata/query access | `sessionarchiveblob.QueryService` | Owned bounded ADX HTTP adapter; injected HTTP fixture and separate Azure conformance | -| Archive publication and hosted read consumer | `sessionarchive.Publisher` / hosted reader/publisher interfaces | Legacy and native archive adapters; injected-service publication/restart/withdrawal cases | -| Go telemetry export | OpenTelemetry provider/exporter interfaces | Existing OTLP exporters and local TLS protobuf receiver | - -`NewWithServices` and `NewNativeWithServices` accept explicit archive services. -Both retain the existing configuration, scope and acceptance gates. Missing -services fail; unavailable adapters do not trigger another backend. Immutable -verification, lease ownership, conditional pointer publication, bounded complete -query results, cancellation and withdrawal/recovery semantics remain part of the -owned contracts. Service interfaces contain domain-owned results and errors, -not database handles, Azure SDK types or raw HTTP responses. - -Functions owns its platform Blob/Queue/Table accesses; CAO has no production -Queue/Table client to wrap. Bicep-only plan, network, diagnostics and role -resources are accessed through the deployment executor, not invented runtime -clients. No universal Azure service object or additional deployment resources -are introduced. - -The default service suite does **not** run or claim ADX engine acceptance. -After reviewing Microsoft's license, run it separately: - -```bash -ACCEPT_EULA=Y bash scripts/test-kusto-emulator.sh -``` - -The license prohibits benchmarks. The official emulator requires Linux/x86 -with the documented CPU features. An unavailable or unselected engine is -incomplete ADX evidence, not a passing alternative. - -For an already-running isolated local stack, service cases can run separately -from Functions forwarding: - -```bash -node tests/integration/azure-local-services.test.mjs PATH/TO/runtime.json -node tests/integration/azure-local-functions.test.mjs PATH/TO/runtime.json -``` - -The service runner requires explicit numeric-loopback endpoints, fresh -synthetic resources and namespaces, and cached Go dependencies. It disables -Go downloads, selects tests explicitly, rejects skipped or empty Go test -selections, and fails on missing/unavailable services rather than falling back. -Storage REST requests are test clients for the Functions platform dependency; -they are not an additional production storage adapter. - -The dispatcher also supports iterative use: - -```bash -./scripts/azure-local/azure-local.sh start -./scripts/azure-local/azure-local.sh wait -./scripts/azure-local/azure-local.sh test -./scripts/azure-local/azure-local.sh logs -./scripts/azure-local/azure-local.sh stop -``` - -`run` always stops its services, including after a test failure or interruption. -`stop` is idempotent. It removes generated settings and transient state while -logs remain under `.tmp/azure-local//logs/`. Set -`AZURE_LOCAL_STATE_DIR` to use an explicit isolated state directory or run -several stacks in parallel. Set `AZURE_LOCAL_WAIT_TIMEOUT` to change the -120-second readiness deadline. - -If original-engine removal fails, teardown requires successful exact-container-ID -inventory confirming absence. An unavailable engine or failed inventory is not -absence: recovery IDs, application and runtime state are retained, and replacing -the stack with another engine is blocked until cleanup can be confirmed. - -## Process model - -Each run owns a state directory, a uniquely named PostgreSQL container, a uniquely -named Azurite container, a Functions Core Tools process group, generated -Functions metadata, dynamically published loopback ports, and unique canonical -and operational namespaces. The state records exact process/container identities; teardown never -uses global process discovery. Fixed internal container ports are published to -dynamic host ports to avoid collisions. - -Readiness polling verifies PostgreSQL readiness, an Azurite Blob endpoint response, and -the CAO `/api/readiness` endpoint through Functions Core Tools. Timeouts print -all available service logs and fail. - -Build, canonical and operational database bootstrap, and fixture ingestion -failures print the failed stage, exit status, and captured output before teardown. -Their log files remain available alongside the service logs. Fixture ingestion -verifies the checked-in `server/testdata/deployed-subset/payload-hashes.json` -manifest; a mismatch fails startup rather than skipping or weakening integrity -checks. - -## Repository audit checklist - -Before changing this harness: - -- inspect `server/internal/server/azure.go` and the `cao-dashboard azure-functions` custom - handler entrypoint; -- inspect hosted environment validation and retain production PostgreSQL TLS - and HTTPS requirements; -- inspect the PostgreSQL operational namespace, rate-limit, ingestion, and integration tests; -- inspect `server/azure/main.bicep` and its contract tests without changing the - production deployment contract for local convenience; -- inspect existing dashboard build, server test fixtures, Docker, and GitHub - Actions conventions; -- confirm integration settings stay synthetic, development OAuth is explicitly - selected and private, and neither profile requires workflow secrets or Azure - credentials. - -## Functions end-to-end contract - -The integration test enters through the real Functions HTTP surface. It proves -that Core Tools registers and forwards the CAO routes, health/readiness reach -shared server logic and PostgreSQL, and PostgreSQL-backed pre-authentication -rate-limit state survives separate HTTP requests. Missing reviewed host policy -still makes the custom handler fail predictably. Separate service cases exercise -Azurite Blob, Queue and Table before entering the Functions surface. Each case -cleans up its own synthetic container, queue, table, database schema or namespace. diff --git a/scripts/azure-local/azure-local.sh b/scripts/azure-local/azure-local.sh deleted file mode 100755 index 6525c3a971..0000000000 --- a/scripts/azure-local/azure-local.sh +++ /dev/null @@ -1,678 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -umask 077 - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# shellcheck source=scripts/container-engine.sh -source "$ROOT/scripts/container-engine.sh" -PROFILE="${AZURE_LOCAL_PROFILE:-synthetic}" -if [[ "${1:-}" == dev ]]; then - [[ -z "${AZURE_LOCAL_PROFILE:-}" || "$PROFILE" == github ]] || { - printf 'azure-local: dev requires the github profile\n' >&2 - exit 1 - } - PROFILE=github -fi -[[ "$PROFILE" == synthetic || "$PROFILE" == github ]] || { - printf 'azure-local: profile must be synthetic or github\n' >&2 - exit 1 -} -DEFAULT_STATE=default -[[ "$PROFILE" != github ]] || DEFAULT_STATE=github -STATE_DIR="${AZURE_LOCAL_STATE_DIR:-"$ROOT/.tmp/azure-local/$DEFAULT_STATE"}" -[[ "$STATE_DIR" == /* ]] || { - printf 'azure-local: AZURE_LOCAL_STATE_DIR must be absolute\n' >&2 - exit 1 -} -LOG_DIR="$STATE_DIR/logs" -APP_DIR="$STATE_DIR/function-app" -RUNTIME_ENV="$STATE_DIR/runtime.env" -RUNTIME_JSON="$STATE_DIR/runtime.json" -HARNESS_VERSION=7 -CORE_TOOLS_VERSION=4.15.1 -PLATFORM_HELPER="$ROOT/scripts/azure-local/platform.py" -POSTGRES_IMAGE=docker.io/library/postgres:16-alpine -AZURITE_IMAGE=mcr.microsoft.com/azure-storage/azurite:3.37.0@sha256:830430c1da1a2d537e08f3e6764dd1f5ae00cf0346bcaf625b968ec3f0971fd5 -AZURITE_ACCOUNT=caoazurelocal -WAIT_TIMEOUT="${AZURE_LOCAL_WAIT_TIMEOUT:-120}" - -fail() { - printf 'azure-local: %s\n' "$*" >&2 - exit 1 -} - -select_platform() { - case "$(uname -s)/$(uname -m)" in - Linux/x86_64) - CORE_TOOLS_PLATFORM=linux-x64 - CORE_TOOLS_SHA256=9b982efb4047c717c9b1c26beb2269c2ac41b29bf8d21f4df1130c1586cc81cd ;; - Linux/aarch64|Linux/arm64) - CORE_TOOLS_PLATFORM=linux-arm64 - CORE_TOOLS_SHA256=d29b86958f42232ef4d7c03aa644a015c9dd3deccf70c34805670dc5155848a3 ;; - Darwin/arm64) - CORE_TOOLS_PLATFORM=osx-arm64 - CORE_TOOLS_SHA256=a1708a7b7415e79cbb2309c1eff9bf26d62e6982bff31ce06448f53686b71d49 ;; - Darwin/x86_64) - CORE_TOOLS_PLATFORM=osx-x64 - CORE_TOOLS_SHA256=0e3622b5efd91b86cfa5c51fa10e44375f956d794140a85d6bddee12a76a53c1 ;; - *) fail "supported platforms are Linux and macOS on x64 or arm64" ;; - esac -} - -sha256() { - python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' -} - -require_commands() { - local command - for command in curl go node python3 ps unzip; do - command -v "$command" >/dev/null 2>&1 || fail "required command not found: $command" - done - cao_container_engine_init - cao_container info >/dev/null 2>&1 || fail "$CAO_CONTAINER_ENGINE is not available" -} - -check_state_profile() { - local marker="$STATE_DIR/profile" - if [[ -e "$marker" ]]; then - [[ -f "$marker" && "$(cat "$marker")" == "$PROFILE" ]] || - fail "state belongs to a different or invalid profile; use its profile or a fresh state directory" - elif [[ -d "$STATE_DIR" && -n "$(find "$STATE_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then - fail "existing unmarked state cannot be reused; review retained files and choose a fresh state directory" - fi -} - -prepare_state() { - check_state_profile - mkdir -p "$STATE_DIR" - chmod 700 "$STATE_DIR" - if [[ ! -e "$STATE_DIR/profile" ]]; then - # Claim once; concurrent starts must never overwrite another profile's marker. - (set -o noclobber; printf '%s\n' "$PROFILE" >"$STATE_DIR/profile") 2>/dev/null || true - fi - check_state_profile - mkdir -p "$LOG_DIR" - chmod 700 "$LOG_DIR" -} - -load_runtime() { - check_state_profile - [[ -f "$RUNTIME_ENV" ]] || fail "no harness state found at $STATE_DIR; run start first" - # Older harness state belongs to Docker, regardless of the caller's selection. - CAO_CONTAINER_ENGINE=docker - # shellcheck disable=SC1090 - source "$RUNTIME_ENV" - [[ "${STATE_PROFILE:-synthetic}" == "$PROFILE" ]] || - fail "state belongs to a different profile; select its profile and stop it first" - cao_container_engine_init -} - -write_env_value() { - printf '%s=%q\n' "$1" "$2" >>"$RUNTIME_ENV" -} - -free_port() { - python3 - <<'PY' -import socket -with socket.socket() as listener: - listener.bind(("127.0.0.1", 0)) - print(listener.getsockname()[1]) -PY -} - -container_port() { - cao_container port "$1" "$2/tcp" | awk -F: 'END { print $NF }' -} - -install_core_tools_locked() { - local tools_root tools_dir archive temp_dir - tools_root="$ROOT/.tmp/azure-local/tools" - tools_dir="$tools_root/core-tools-$CORE_TOOLS_VERSION-$CORE_TOOLS_PLATFORM" - mkdir -p "$tools_root" - if [[ -x "$tools_dir/func" ]]; then - printf '%s\n' "$tools_dir/func" - return - fi - temp_dir="$(python3 -c 'import sys,tempfile; print(tempfile.mkdtemp(prefix=".install.", dir=sys.argv[1]))' "$tools_root")" - archive="$temp_dir/core-tools.zip" - curl -fsSL \ - "https://github.com/Azure/azure-functions-core-tools/releases/download/$CORE_TOOLS_VERSION/Azure.Functions.Cli.$CORE_TOOLS_PLATFORM.$CORE_TOOLS_VERSION.zip" \ - -o "$archive" || { - rm -rf "$temp_dir" - fail "Azure Functions Core Tools download failed" - } - python3 "$PLATFORM_HELPER" verify "$archive" "$CORE_TOOLS_SHA256" || - { - rm -rf "$temp_dir" - fail "Azure Functions Core Tools checksum verification failed" - } - unzip -q "$archive" -d "$temp_dir" -x \ - 'in-proc6/*' 'in-proc8/*' \ - 'workers/java/*' 'workers/node/*' 'workers/powershell/*' 'workers/python/*' - chmod +x "$temp_dir/func" - rm -f "$archive" - rm -rf "$tools_dir" - mv "$temp_dir" "$tools_dir" - printf '%s\n' "$tools_dir/func" -} - -install_core_tools() { - local tools_root="$ROOT/.tmp/azure-local/tools" - mkdir -p "$tools_root" - FUNC_BIN="$(python3 "$PLATFORM_HELPER" lock \ - "$tools_root/core-tools-$CORE_TOOLS_VERSION-$CORE_TOOLS_PLATFORM.lock" \ - bash "$ROOT/scripts/azure-local/azure-local.sh" install-core-tools)" -} - -capture_container_logs() { - local id name - id="$1" - name="$2" - if [[ -n "$id" ]] && cao_container inspect "$id" >/dev/null 2>&1; then - cao_container logs "$id" >"$LOG_DIR/$name.log" 2>&1 || true - fi -} - -process_matches() { - [[ -n "$1" && -n "$2" && -n "$3" ]] || return 1 - python3 "$PLATFORM_HELPER" matches "$1" "$2" "$3" -} - -process_start_matches() { - python3 "$PLATFORM_HELPER" matches "$1" "$2" -} - -wait_for_process_group() { - local pid expected_executable pgid - pid="$1" - expected_executable="$2" - for _ in {1..50}; do - pgid="$(ps -p "$pid" -o pgid= | tr -d ' ')" || true - if [[ "$pgid" == "$pid" ]]; then - if process_matches "$pid" "$FUNCTIONS_START_TIME" "$expected_executable"; then - FUNCTIONS_PGID="$pgid" - return 0 - fi - fi - sleep 0.1 - done - fail "Azure Functions host did not establish its process group" -} - -process_group_matches() { - python3 "$PLATFORM_HELPER" group "${FUNCTIONS_PGID:-}" "$APP_DIR/cao-dashboard" -} - -remove_owned_container() { - local id remaining - id="$1" - [[ -n "$id" ]] || return 0 - if cao_container rm -f "$id" --volumes >/dev/null 2>&1; then - return 0 - fi - # An inspect error is not absence: require successful exact-ID inventory. - if remaining="$(cao_container ps --all --quiet --no-trunc --filter "id=$id")" && - [[ -z "$remaining" ]]; then - return 0 - fi - printf 'azure-local: cannot confirm removal of %s from %s; recovery state retained at %s\n' \ - "$id" "$CAO_CONTAINER_ENGINE" "$STATE_DIR" >&2 - return 1 -} - -stop_internal() { - prepare_state - if [[ -f "$RUNTIME_ENV" ]]; then - load_runtime || return 1 - fi - if process_start_matches "${FUNCTIONS_PID:-}" "${FUNCTIONS_START_TIME:-}" || - process_group_matches; then - local quiet_checks=0 - for _ in {1..50}; do - local active=false - if process_start_matches "${FUNCTIONS_PID:-}" "${FUNCTIONS_START_TIME:-}"; then - kill -TERM "$FUNCTIONS_PID" 2>/dev/null || true - active=true - fi - if process_group_matches; then - kill -TERM -- "-$FUNCTIONS_PGID" 2>/dev/null || true - active=true - fi - if [[ "$active" == true ]]; then - quiet_checks=0 - else - quiet_checks=$((quiet_checks + 1)) - (( quiet_checks >= 5 )) && break - fi - sleep 0.1 - done - if process_start_matches "${FUNCTIONS_PID:-}" "${FUNCTIONS_START_TIME:-}"; then - kill -KILL "$FUNCTIONS_PID" 2>/dev/null || true - fi - if process_group_matches; then - kill -KILL -- "-$FUNCTIONS_PGID" 2>/dev/null || true - fi - fi - capture_container_logs "${AZURITE_CONTAINER_ID:-}" azurite - capture_container_logs "${POSTGRES_CONTAINER_ID:-}" postgres - local removal_failed=0 - remove_owned_container "${AZURITE_CONTAINER_ID:-}" || removal_failed=1 - remove_owned_container "${POSTGRES_CONTAINER_ID:-}" || removal_failed=1 - (( removal_failed == 0 )) || return 1 - rm -rf "$APP_DIR" - rm -f "$RUNTIME_ENV" "$RUNTIME_JSON" - unset POSTGRES_CONTAINER_ID AZURITE_CONTAINER_ID FUNCTIONS_PID FUNCTIONS_START_TIME FUNCTIONS_PGID -} - -poll() { - local label deadline - label="$1" - shift - deadline=$((SECONDS + WAIT_TIMEOUT)) - while (( SECONDS < deadline )); do - if "$@" >/dev/null 2>&1; then - return 0 - fi - sleep 1 - done - printf 'azure-local: timed out waiting for %s\n' "$label" >&2 - logs >&2 || true - return 1 -} - -postgres_ready() { - cao_container exec "$1" pg_isready -U postgres -d cao -} - -azurite_ready() { - local status - status="$(curl -sS -o /dev/null -w '%{http_code}' "$1")" || return 1 - [[ "$status" -lt 500 ]] -} - -functions_ready() { - curl -fsS "$1/api/readiness" -} - -run_logged() { - local label log_file status - label="$1" - log_file="$LOG_DIR/$2" - shift 2 - if "$@" >"$log_file" 2>&1; then - return 0 - else - status=$? - fi - printf 'azure-local: %s failed (exit %s); log: %s\n' "$label" "$status" "$log_file" >&2 - [[ "$PROFILE" == github ]] || cat "$log_file" >&2 - return "$status" -} - -prepare_application() { - mkdir -p "$APP_DIR/site" "$APP_DIR/.github/workflows" "$LOG_DIR" - if [[ "$PROFILE" == github ]]; then - run_logged "dashboard site build" site-build.log \ - npm --prefix "$ROOT/dashboard/site" run build -- "$APP_DIR/site" "$ROOT/.github/workflows/cao.json" - else - printf 'CAO local Azure simulation\n' >"$APP_DIR/site/index.html" - fi - cp "$ROOT/.github/workflows/cao.json" "$APP_DIR/.github/workflows/cao.json" - cp "$ROOT/.github/workflows/cao.azure.json" "$APP_DIR/.github/workflows/cao.azure.json" - if [[ -n "${AZURE_LOCAL_TEST_BINARIES:-}" ]]; then - [[ -x "$AZURE_LOCAL_TEST_BINARIES/cao-dashboard" ]] || - fail "explicit test binary is missing or not executable" - cp "$AZURE_LOCAL_TEST_BINARIES/cao-dashboard" "$APP_DIR/cao-dashboard" - else - run_logged "dashboard server build" server-build.log \ - go -C "$ROOT/server" build -o "$APP_DIR/cao-dashboard" ./cmd/cao-dashboard - fi - cat >"$APP_DIR/host.json" <<'JSON' -{ - "version": "2.0", - "customHandler": { - "description": { - "defaultExecutablePath": "cao-dashboard", - "arguments": ["azure-functions"] - }, - "enableForwardingHttpRequest": true - }, - "extensions": { - "http": { - "routePrefix": "" - } - } -} -JSON - mkdir -p "$APP_DIR/cao" - cat >"$APP_DIR/cao/function.json" <<'JSON' -{ - "bindings": [ - { - "authLevel": "anonymous", - "direction": "in", - "methods": ["delete", "get", "head", "options", "patch", "post", "put"], - "name": "request", - "route": "{*path}", - "type": "httpTrigger" - }, - { - "direction": "out", - "name": "$return", - "type": "http" - } - ] -} -JSON -} - -start() { - [[ "$PROFILE" == synthetic || -z "${AZURE_LOCAL_TEST_BINARIES:-}" ]] || - fail "prebuilt test executables require the synthetic profile" - check_state_profile - select_platform - if [[ "$PROFILE" == github ]]; then - node "$ROOT/scripts/azure-local/profile.mjs" validate - python3 - "${AZURE_LOCAL_FUNCTIONS_PORT:-7071}" <<'PY' -import socket, sys -try: - with socket.socket() as listener: - listener.bind(("127.0.0.1", int(sys.argv[1]))) -except OSError: - sys.exit("azure-local: requested Functions port is unavailable") -PY - fi - require_commands - local requested_engine="$CAO_CONTAINER_ENGINE" - prepare_state - if [[ -f "$RUNTIME_ENV" ]]; then - load_runtime || return 1 - [[ "$PROFILE" != github ]] || - fail "GitHub stack state already exists; stop it explicitly before restarting with new configuration" - if [[ "${STATE_HARNESS_VERSION:-}" == "$HARNESS_VERSION" ]] && - process_matches \ - "${FUNCTIONS_PID:-}" "${FUNCTIONS_START_TIME:-}" "${FUNC_BIN:-}"; then - printf 'azure-local: matching stack is already running at %s\n' "$STATE_DIR" - return - fi - stop_internal || return 1 - fi - CAO_CONTAINER_ENGINE="$requested_engine" - cao_container_engine_init - - : >"$RUNTIME_ENV" - chmod 600 "$RUNTIME_ENV" - write_env_value CAO_CONTAINER_ENGINE "$CAO_CONTAINER_ENGINE" - local previous_exit_trap - previous_exit_trap="$(trap -p EXIT || true)" - trap 'stop_internal' EXIT - trap 'exit 130' INT - trap 'exit 143' TERM - local run_id path_hash postgres_name azurite_name function_port postgres_port blob_port queue_port table_port namespace - local azurite_key - run_id="$(basename "$STATE_DIR" | tr -cd 'a-zA-Z0-9_.-' | cut -c1-40)" - [[ -n "$run_id" ]] || run_id="run-$$" - path_hash="$(printf '%s' "$STATE_DIR" | sha256 | cut -c1-12)" - run_id="$(printf '%s' "$run_id" | cut -c1-20)-$path_hash" - postgres_name="cao-azure-local-$run_id-postgres" - azurite_name="cao-azure-local-$run_id-azurite" - namespace="azure-local-$path_hash" - azurite_key="$(python3 -c 'import base64,hashlib; print(base64.b64encode(hashlib.sha256(b"cao-azure-local").digest()).decode())')" - - write_env_value STATE_HARNESS_VERSION "$HARNESS_VERSION" - write_env_value STATE_PROFILE "$PROFILE" - - local postgres_password="" - if [[ "$PROFILE" == github ]]; then - postgres_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" - POSTGRES_CONTAINER_ID="$(POSTGRES_PASSWORD="$postgres_password" cao_container run -d --name "$postgres_name" \ - -p 127.0.0.1::5432 -e POSTGRES_DB=cao -e POSTGRES_PASSWORD "$POSTGRES_IMAGE")" - else - POSTGRES_CONTAINER_ID="$(cao_container run -d --name "$postgres_name" -p 127.0.0.1::5432 \ - --tmpfs /var/lib/postgresql/data:rw \ - -e POSTGRES_DB=cao -e POSTGRES_HOST_AUTH_METHOD=trust "$POSTGRES_IMAGE")" - fi - write_env_value POSTGRES_CONTAINER_ID "$POSTGRES_CONTAINER_ID" - postgres_port="$(container_port "$POSTGRES_CONTAINER_ID" 5432)" - - AZURITE_CONTAINER_ID="$(cao_container run -d --name "$azurite_name" \ - -p 127.0.0.1::10000 -p 127.0.0.1::10001 -p 127.0.0.1::10002 \ - -e "AZURITE_ACCOUNTS=$AZURITE_ACCOUNT:$azurite_key" \ - "$AZURITE_IMAGE" azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 \ - --tableHost 0.0.0.0 --location /data --debug /data/debug.log)" - write_env_value AZURITE_CONTAINER_ID "$AZURITE_CONTAINER_ID" - blob_port="$(container_port "$AZURITE_CONTAINER_ID" 10000)" - queue_port="$(container_port "$AZURITE_CONTAINER_ID" 10001)" - table_port="$(container_port "$AZURITE_CONTAINER_ID" 10002)" - if [[ "$PROFILE" == github ]]; then - function_port="$((10#${AZURE_LOCAL_FUNCTIONS_PORT:-7071}))" - else - function_port="$(free_port)" - fi - - write_env_value POSTGRES_PORT "$postgres_port" - write_env_value AZURITE_BLOB_PORT "$blob_port" - write_env_value AZURITE_QUEUE_PORT "$queue_port" - write_env_value AZURITE_TABLE_PORT "$table_port" - write_env_value FUNCTIONS_PORT "$function_port" - write_env_value REDIS_NAMESPACE "$namespace" - - poll Postgres postgres_ready "$POSTGRES_CONTAINER_ID" - export CAO_POSTGRES_URL="postgres://postgres${postgres_password:+:$postgres_password}@127.0.0.1:$postgres_port/cao?sslmode=disable" - prepare_application - install_core_tools - write_env_value FUNC_BIN "$FUNC_BIN" - poll Postgres postgres_ready "$POSTGRES_CONTAINER_ID" - poll Azurite azurite_ready "http://127.0.0.1:$blob_port/$AZURITE_ACCOUNT?comp=list" - - CAO_DATABASE_ADMIN_URL="$CAO_POSTGRES_URL" run_logged "canonical database bootstrap" database-bootstrap.log \ - "$APP_DIR/cao-dashboard" database bootstrap --store canonical --namespace "$namespace" - - CAO_DATABASE_ADMIN_URL="$CAO_POSTGRES_URL" run_logged "operational database bootstrap" operational-database-bootstrap.log \ - "$APP_DIR/cao-dashboard" database bootstrap --store operational --namespace "${namespace}-operational" - - local source_directory ingestion_label - if [[ "$PROFILE" == github ]]; then - source_directory="${AZURE_LOCAL_SOURCE_DIRECTORY:-"$ROOT/server/azure/empty-data"}" - ingestion_label="development snapshot ingestion" - else - node "$ROOT/tests/e2e/dashboard-server-fixture.mjs" "$STATE_DIR/fixture" - source_directory="$STATE_DIR/fixture" - ingestion_label="fixture ingestion" - fi - run_logged "$ingestion_label" ingest.log "$APP_DIR/cao-dashboard" ingest \ - --source "$source_directory" \ - --redis-namespace "$namespace" \ - --database-queries "$ROOT/dashboard/site/src/data/queries/database.json" - - local storage_connection - storage_connection="DefaultEndpointsProtocol=http;AccountName=$AZURITE_ACCOUNT;AccountKey=$azurite_key;BlobEndpoint=http://127.0.0.1:$blob_port/$AZURITE_ACCOUNT;QueueEndpoint=http://127.0.0.1:$queue_port/$AZURITE_ACCOUNT;TableEndpoint=http://127.0.0.1:$table_port/$AZURITE_ACCOUNT;" - cat >"$APP_DIR/local.settings.json" <"$RUNTIME_JSON" <"$LOG_DIR/protocol-test.log" 2>&1 & - local protocol_pid=$! - trap 'trap - INT TERM; kill -TERM "$stack_pid" "$protocol_pid" 2>/dev/null || true; wait "$stack_pid" || true; wait "$protocol_pid" || true; exit 143' INT TERM - local status=0 protocol_status=0 - wait "$stack_pid" || status=$? - wait "$protocol_pid" || protocol_status=$? - if (( status == 0 )); then status="$protocol_status"; fi - trap - INT TERM - cat "$LOG_DIR/protocol-test.log" - return "$status" -} - -dev() { - start - trap 'status=$?; trap - EXIT INT TERM; stop_internal; exit "$status"' EXIT - trap 'exit 130' INT - trap 'exit 143' TERM - wait_for_stack - printf 'azure-local: GitHub.com dashboard: http://localhost:%s/ (Ctrl+C to stop)\n' "$FUNCTIONS_PORT" - if [[ -n "${AZURE_LOCAL_SOURCE_DIRECTORY:-}" ]]; then - printf 'azure-local: data source: explicit snapshot (no automatic collection)\n' - else - printf 'azure-local: data source: empty (no collected evidence)\n' - fi - while process_matches "$FUNCTIONS_PID" "$FUNCTIONS_START_TIME" "$FUNC_BIN"; do - sleep 1 - done - fail "Azure Functions host exited; inspect private logs at $LOG_DIR" -} - -case "${1:-}" in - install-core-tools) select_platform; install_core_tools_locked ;; - dev) dev ;; - start) start ;; - wait) wait_for_stack ;; - test) - [[ "$PROFILE" == synthetic ]] || fail "integration tests require the synthetic profile" - node "$ROOT/tests/integration/azure-local-protocols.test.mjs" - test_stack ;; - logs) logs ;; - stop) stop ;; - run) run_all ;; - run-services) run_all services ;; - *) fail "usage: $0 {dev|start|wait|test|logs|stop|run|run-services}" ;; -esac diff --git a/scripts/azure-local/platform.py b/scripts/azure-local/platform.py deleted file mode 100644 index 046d9355e6..0000000000 --- a/scripts/azure-local/platform.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -"""Portable process ownership and installation locking for the local Azure host.""" - -import ctypes -import fcntl -import hashlib -import os -from pathlib import Path -import subprocess -import sys - - -class BSDProcessInfo(ctypes.Structure): - _fields_ = [ - (name, ctypes.c_uint32) for name in ( - "flags", "status", "xstatus", "pid", "ppid", "uid", "gid", - "ruid", "rgid", "svuid", "svgid", "reserved", - ) - ] + [ - ("comm", ctypes.c_char * 16), - ("name", ctypes.c_char * 32), - ] + [ - (name, ctypes.c_uint32) for name in ( - "nfiles", "pgid", "pjobc", "tdev", "tpgid", - ) - ] + [ - ("nice", ctypes.c_int32), - ("start_seconds", ctypes.c_uint64), - ("start_microseconds", ctypes.c_uint64), - ] - - -def executable(pid): - if sys.platform == "darwin": - library = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True) - library.proc_pidpath.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32] - buffer = ctypes.create_string_buffer(4096) - if library.proc_pidpath(pid, buffer, len(buffer)) <= 0: - return "" - return os.path.realpath(os.fsdecode(buffer.value)) - try: - return os.path.realpath(os.readlink(f"/proc/{pid}/exe")) - except (FileNotFoundError, PermissionError, ProcessLookupError): - return "" - - -def identity(pid): - if sys.platform == "darwin": - library = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True) - library.proc_pidinfo.argtypes = [ - ctypes.c_int, ctypes.c_int, ctypes.c_uint64, - ctypes.c_void_p, ctypes.c_int, - ] - info = BSDProcessInfo() - # PROC_PIDTBSDINFO includes the kernel's microsecond start time, unlike ps. - if library.proc_pidinfo(pid, 3, 0, ctypes.byref(info), ctypes.sizeof(info)) != ctypes.sizeof(info): - return "" - return f"{info.start_seconds}:{info.start_microseconds}" - else: - try: - # comm can contain spaces and parentheses; fields after its final ')' - # start at field 3, making starttime (field 22) index 19. - return Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19] - except (FileNotFoundError, ProcessLookupError): - return "" - - -def main(): - command, *args = sys.argv[1:] - if command == "lock": - path, *child = args - with open(path, "a", encoding="utf-8") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - return subprocess.call(child) - if command == "launch": - directory, log, *child = args - with open(log, "ab", buffering=0) as output: - process = subprocess.Popen( - child, stdin=subprocess.DEVNULL, stdout=output, stderr=output, - cwd=directory, start_new_session=True, close_fds=True, - ) - print(process.pid) - return 0 - if command == "verify": - path, expected = args - digest = hashlib.sha256() - with open(path, "rb") as archive: - for block in iter(lambda: archive.read(1 << 20), b""): - digest.update(block) - return int(digest.hexdigest() != expected) - if command == "identity": - start = identity(int(args[0])) - if not start: - return 1 - print(start) - return 0 - if command == "matches": - pid, start, *expected = args - if not pid or not start or identity(int(pid)) != start: - return 1 - return int(bool(expected) and executable(int(pid)) != os.path.realpath(expected[0])) - if command == "group": - group, expected = args - if not group: - return 1 - processes = subprocess.check_output(["ps", "-axo", "pid=,pgid="], text=True) - for line in processes.splitlines(): - pid, pgid = map(int, line.split()) - if pgid == int(group) and executable(pid) == os.path.realpath(expected): - return 0 - return 1 - raise ValueError(f"unknown platform command: {command}") - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/azure-local/prepare-test-binaries.sh b/scripts/azure-local/prepare-test-binaries.sh deleted file mode 100644 index 3873fc8384..0000000000 --- a/scripts/azure-local/prepare-test-binaries.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -OUTPUT="$ROOT/.tmp/podman-bin" -mkdir -p "$OUTPUT/server" "$OUTPUT/root" -go -C "$ROOT" mod download -go -C "$ROOT/server" mod download -go -C "$ROOT/server" build -o "$OUTPUT/cao-dashboard" ./cmd/cao-dashboard -go -C "$ROOT/server" test -race -c -o "$OUTPUT/server/" \ - ./internal/hostedcli ./internal/operational ./internal/postgresx \ - ./internal/sessionarchiveblob ./internal/tokenbroker ./internal/telemetry -go -C "$ROOT" test -race -c -o "$OUTPUT/root/" ./internal/cli -cp "$(go -C "$ROOT" tool -n test2json)" "$OUTPUT/test2json" diff --git a/scripts/azure-local/profile.mjs b/scripts/azure-local/profile.mjs deleted file mode 100644 index 9623dc3a6c..0000000000 --- a/scripts/azure-local/profile.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import { dirname, isAbsolute, join } from "node:path"; -import { pathToFileURL } from "node:url"; - -export function githubProfile(env) { - for (const name of ["CAO_GITHUB_CLIENT_ID", "CAO_GITHUB_CLIENT_SECRET"]) { - if (!env[name]?.trim() || ["local-client", "local-client-secret"].includes(env[name])) { - throw new Error(`${name} requires real GitHub.com OAuth credentials`); - } - } - const organizations = (env.CAO_GITHUB_ALLOWED_ORGS ?? "").split(",").map((org) => org.trim()); - if (!organizations.length || organizations.some((org) => !/^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i.test(org))) { - throw new Error("CAO_GITHUB_ALLOWED_ORGS requires explicit GitHub organization logins"); - } - const port = env.AZURE_LOCAL_FUNCTIONS_PORT ?? "7071"; - if (!/^\d+$/.test(port) || Number(port) < 1024 || Number(port) > 65535) { - throw new Error("AZURE_LOCAL_FUNCTIONS_PORT must be an unprivileged TCP port (1024–65535)"); - } - // Browsers allow Secure cookies on HTTP localhost, not numeric loopback. - const redirect = `http://localhost:${Number(port)}/auth/callback`; - if (env.CAO_GITHUB_REDIRECT_URL && env.CAO_GITHUB_REDIRECT_URL !== redirect) { - throw new Error("CAO_GITHUB_REDIRECT_URL must exactly match the localhost callback and selected port"); - } - if (env.CAO_SESSION_SECRET !== undefined && (!env.CAO_SESSION_SECRET.trim() || env.CAO_SESSION_SECRET.length < 32)) { - throw new Error("CAO_SESSION_SECRET must contain at least 32 characters"); - } - const source = env.AZURE_LOCAL_SOURCE_DIRECTORY; - if (source !== undefined && (!isAbsolute(source) || !existsSync(source) || !statSync(source).isDirectory())) { - throw new Error("AZURE_LOCAL_SOURCE_DIRECTORY must be an existing absolute snapshot directory"); - } - return { - port: String(Number(port)), - redirect, - organizations: organizations.join(","), - sessionSecret: env.CAO_SESSION_SECRET ?? randomBytes(32).toString("hex"), - }; -} - -export function validateEnvironment(env) { - const allowed = new Set([ - "CAO_GITHUB_CLIENT_ID", "CAO_GITHUB_CLIENT_SECRET", "CAO_GITHUB_ALLOWED_ORGS", - "CAO_GITHUB_REDIRECT_URL", "CAO_SESSION_SECRET", "CAO_CONTAINER_ENGINE", - ]); - if (Object.keys(env).some((name) => name.startsWith("CAO_") && !allowed.has(name))) { - throw new Error("GitHub development accepts only the documented CAO OAuth variables; unset other CAO configuration"); - } - if (env.CAO_CONTAINER_ENGINE !== undefined && !["docker", "podman"].includes(env.CAO_CONTAINER_ENGINE)) { - throw new Error("CAO_CONTAINER_ENGINE must be docker or podman"); - } - if (env.GITHUB_SERVER_URL && env.GITHUB_SERVER_URL !== "https://github.com") { - throw new Error("GitHub development supports GitHub.com only"); - } - return githubProfile(env); -} - -export function writeSettings(path, profile, env) { - if (profile !== "github") return; - const config = githubProfile(env); - const settings = JSON.parse(readFileSync(path, "utf8")); - Object.assign(settings.Values, { - CAO_GITHUB_CLIENT_ID: env.CAO_GITHUB_CLIENT_ID, - CAO_GITHUB_CLIENT_SECRET: env.CAO_GITHUB_CLIENT_SECRET, - CAO_GITHUB_ALLOWED_ORGS: config.organizations, - CAO_GITHUB_REDIRECT_URL: config.redirect, - CAO_SESSION_SECRET: config.sessionSecret, - CAO_AZURE_DASHBOARD_QUERIES: join(dirname(path), "site/dashboard.json"), - }); - chmodSync(path, 0o600); - writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - if (process.argv[2] === "validate") validateEnvironment(process.env); - else if (process.argv[2] === "settings") writeSettings(process.argv[3], process.argv[4], process.env); - else throw new Error("unknown Azure local profile command"); - } catch (error) { - // Validation errors name the setting, never its secret value. - console.error(`azure-local: ${error.message}`); - process.exitCode = 1; - } -} diff --git a/scripts/build-cao-azure-assets.mjs b/scripts/build-cao-azure-assets.mjs index bfebe4ef01..a85c097a2d 100644 --- a/scripts/build-cao-azure-assets.mjs +++ b/scripts/build-cao-azure-assets.mjs @@ -4,11 +4,11 @@ import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { gzipSync } from "node:zlib"; -import azureAssetAllowlist from "../internal/cli/deploy_azure_assets.json" with { type: "json" }; +import azureAssetAllowlist from "./cao-azure-assets.json" with { type: "json" }; export const AZURE_ASSET_MANIFEST = "cao-azure.json"; -// Exactly the inputs that the azure-functions-runtime, azure-ingest-runtime, and +// Exactly the inputs that the azure-container-apps-runtime, azure-ingest-runtime, and // azure-empty-ingest-runtime and azure-db-provision targets read from the build // context (esbuild also reads dashboard/site/tsconfig.json), plus the compiled // ARM template. Entries ending in "/" select tracked files below that directory. @@ -161,8 +161,8 @@ export function buildCaoAzureAssets(sourceRoot, destination, revision) { schemaVersion: 1, revision, dockerfile: "server/Dockerfile", - template: "server/azure/main.json", - targets: ["azure-functions-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"], + template: "internal/topology/assets/container-apps/main.generated.json", + targets: ["azure-container-apps-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"], buildContexts: { "cao-policy": "required", "cao-ingest-source": "azure-ingest-runtime" }, files: Object.fromEntries(entries.map(({ path: file, content }) => [ file, createHash("sha256").update(content).digest("hex"), diff --git a/internal/cli/deploy_azure_assets.json b/scripts/cao-azure-assets.json similarity index 96% rename from internal/cli/deploy_azure_assets.json rename to scripts/cao-azure-assets.json index 888f7e273e..466a77943e 100644 --- a/internal/cli/deploy_azure_assets.json +++ b/scripts/cao-azure-assets.json @@ -23,9 +23,9 @@ "dashboard/site/src/", "dashboard/site/tsconfig.json", "internal/dashboardquery/", + "internal/topology/assets/container-apps/", "server/Dockerfile", "server/azure/empty-data/", - "server/azure/main.json", "server/azure/provision.sh", "server/azure/provision.sql", "server/cmd/cao-dashboard/", diff --git a/scripts/run-unit-area.mjs b/scripts/run-unit-area.mjs index 4aae500b11..edec43d7f9 100644 --- a/scripts/run-unit-area.mjs +++ b/scripts/run-unit-area.mjs @@ -15,7 +15,7 @@ export const unitTestAreas = { /^add-operational-value-skill\./, /^token-intervention-lifecycle\./, ], tooling: [ - /^cao-/, /^ops-publish/, /^optimization-/, /^dependabot-/, /^azure-local-/, /^container-engine\./, /^podman-ci\./, + /^cao-/, /^ops-publish/, /^optimization-/, /^dependabot-/, /^container-engine\./, /^podman-ci\./, /^eslint-rules-/, /^actions-context\./, /^actions-runtime\./, /^agent-plugin\./, /^install-script\./, /^installer-canary\./, /^repository-/, /^no-hardcoded-github-actions-url\./, /^token-optimization-data-contract\./, /^unit-area-selection\./, diff --git a/server/ADMISSION.md b/server/ADMISSION.md index bd7898d1a8..d12f4a21b7 100644 --- a/server/ADMISSION.md +++ b/server/ADMISSION.md @@ -165,8 +165,8 @@ workflow results. Use an existing HTTPS hosted deployment with the **PostgreSQL operational backend**. Redis and volatile operational backends cannot enable -this service. The same handler is used by the process, external-listener and -Azure Functions profiles; the existing trusted-host/proxy policy still applies. +this service. The same handler is used by the process and external-listener +profiles; the existing trusted-host/proxy policy still applies. The endpoint authenticates OIDC independently of dashboard OAuth and CSRF. It never accepts a dashboard session or local bearer capability as admission. diff --git a/server/ADX-TRACES.md b/server/ADX-TRACES.md index c7ab68cf8b..cc73ba4def 100644 --- a/server/ADX-TRACES.md +++ b/server/ADX-TRACES.md @@ -79,7 +79,7 @@ PostgreSQL, restart-durable operational services, explicit Entra credentials, exact canonical repository scope, and one confirmed exporter replica. Traces additionally require **co-resident collection workers**. Static/Pages, snapshot-only servers, admission-only, standalone collect/backfill and -per-request Azure Functions cannot capture traces. A narrower trace scope is +per-request hosts cannot capture traces. A narrower trace scope is allowed; it cannot exceed `CAO_ADX_REPOSITORY_IDS`. Print `cao-dashboard adx schema` and review/apply the generated tables, mappings, diff --git a/server/Dockerfile b/server/Dockerfile index ece0cf3bac..7c0b5b356b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -95,30 +95,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ ENTRYPOINT ["/app/cao-dashboard"] CMD ["serve-hosted", "--listen", "0.0.0.0:8080"] -FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 AS azure-functions-runtime - -# hadolint ignore=DL3018 -RUN apk upgrade --no-cache \ - && apk add --no-cache ca-certificates \ - && addgroup -S -g 65532 cao \ - && adduser -S -D -H -u 65532 -G cao cao \ - && mkdir -p /app/site /app/.github/workflows \ - && chown -R cao:cao /app - -COPY --from=server-build --chown=cao:cao /out/cao-dashboard /app/cao-dashboard -COPY --from=dashboard-build --chown=cao:cao /workspace/dashboard/site/dist/ /app/site/ -COPY --from=dashboard-build --chown=cao:cao /workspace/dashboard/site/src/data/queries/database.json /app/queries/database.json -COPY --from=dashboard-build --chown=cao:cao /workspace/.github/workflows/cao.json /app/.github/workflows/cao.json -COPY --from=dashboard-build --chown=cao:cao /workspace/.github/workflows/cao.azure.json /app/.github/workflows/cao.azure.json - -ENV FUNCTIONS_CUSTOMHANDLER_HOST=0.0.0.0 \ - FUNCTIONS_CUSTOMHANDLER_PORT=8080 \ - CAO_DATABASE_QUERIES=/app/queries/database.json -WORKDIR /app -USER 65532:65532 -EXPOSE 8080 -ENTRYPOINT ["/app/cao-dashboard"] -CMD ["azure-functions"] +FROM dashboard-runtime AS azure-container-apps-runtime FROM dashboard-runtime AS azure-ingest-runtime diff --git a/server/README.md b/server/README.md index fbff8ad623..ebee3d103d 100644 --- a/server/README.md +++ b/server/README.md @@ -207,62 +207,19 @@ exist on GHE.com; no public GHE.com scenario is claimed. The simulator does not issue or validate real PATs, approve Apps, or prove restricted Pages entitlement. See [setup combinations and live gaps](../docs/setup-compatibility.md). -## Local Azure setup integration - -Run `npm run test:integration:setup-azure` with Go 1.27.3 or newer on PATH, Git, GitHub CLI, the -policy-pinned gh-aw prerequisite, and Docker installed. The Go Extension CI -setup job runs this command alongside the GitHub-only setup suite on path-matched -pushes to `main`, not pull requests. It uses the -same digest-pinned official Azurite image as `scripts/azure-local/azure-local.sh`, -with a disposable account, loopback-only dynamic port, and automatic cleanup. -Prerequisites must already be local: run `go mod download`, -`go -C server mod download`, and pull the exact Azurite image declared in -`scripts/azure-local/azure-local.sh` before entering the simulation. CI does this -in a separate prerequisite step. The simulation disables module downloads and -implicit toolchain/image pulls; missing prerequisites fail instead of accessing a registry. -Without Docker, set `CAO_SETUP_AZURITE_BINARY` to an installed official -`azurite-blob` executable; its process and storage are also isolated and removed. -Set `POSTGRES_URL` to a disposable numeric-loopback PostgreSQL instance. The -successful deployment builds `cao-dashboard` from the exact simulated ACR -context, runs real database provisioning and empty-evidence ingestion in an -isolated schema, and starts `cao-dashboard azure-functions` when the Azure -simulator restarts the Function App. The deployment's HTTPS health, readiness, -signed-out API and OAuth checks pass through simulated Azure ingress to that -real process, followed by a verified graceful SIGTERM shutdown. GitHub setup -still uses the GitHub simulator; no live GitHub or Azure credentials are used. - -The test builds the current native extension and runs real `gh cao setup`, -then `gh cao deploy azure --preflight`, `--plan`, and `--apply`. Azure deployment -is a separate command, not a `setup azure` subcommand. The existing GitHub -simulator handles setup. A test-only `az` subprocess adapter supplies the -stateful ARM, ACR, Container Apps jobs, Key Vault reference status, and HTTPS -ingress behavior that Azurite does not emulate. Azurite receives actual signed -blob writes and reads for synthetic build manifests and deployment state. -Unknown commands and destinations fail closed; no Azure CLI installation, -cloud login, real credentials, GitHub writes, or Azure subscription is used. -The `az` adapter and an allowlist of local tools are the entire command PATH: -there is no fallback to a real Azure CLI. Git network protocols are disabled. -GitHub and Functions HTTPS names terminate at the loopback CONNECT proxy with -an ephemeral CA; the proxy never forwards to those hosts. Azure command and -storage clients use exact numeric-loopback transports, rejecting redirects, -environment proxies, metadata-service addresses, and nonlocal destinations. -The Azurite container uses a disposable internal-only Docker network and a local -Unix Docker socket; remote Docker contexts and TCP/SSH daemons are refused. -Docker does not publish ports on internal-only networks, so a host-loopback -relay carries blob traffic through `docker exec` to the container's loopback -listener without adding a network with external access or publishing a port. -Regression probes verify live GitHub, ARM, Entra, and metadata destinations are -rejected or handled only by local fixtures, and ambient GitHub/Azure/OIDC -credentials are not inherited. - -Assertions cover read-only preflight/planning, plan-drift rejection before -writes, locked per-target digest bindings, provisioning-before-ingestion, -provisioning failure without verification or a receipt, delayed Key Vault -readiness, six ingress/OAuth checks, secret-free persisted receipts, existing -instance/repeated-apply rejection, and unchanged GitHub policy and consumer files. -This is command/protocol coverage: image builds, job execution, PostgreSQL, -managed identities, and hosted OAuth are simulated, not deployed or attested. -The separate Azure-local harness exercises the real Functions host. +## Azure hosting validation + +Azure uses the existing hosted Go server on Container Apps. Generate and check +an offline review bundle with `gh cao deploy init --deployment +azure-container-apps`, `deploy generate` and `deploy check`; operators execute +reviewed templates separately. The former native Azure deployment command, +receipt, Functions runtime, Core Tools local harness and Azure installation +simulators are retired. + +Local Go/PostgreSQL and template tests do not establish live Azure, +managed-identity, private-network or OAuth acceptance. Follow the generated +runbook and retain independent tenant-specific deployment evidence. See the +[Azure guide](../docs/deployment-azure.md). ## Configurable company ingestion stress @@ -1284,11 +1241,11 @@ without disabling stderr logs. > [!IMPORTANT] > The default `serve` command remains local-only: it uses a local bearer > capability and intentionally rejects non-loopback listen addresses. Remote -> Hosted use requires the explicit `serve-hosted` or Azure Functions profile, which +> hosted use requires the explicit `serve-hosted` profile, which > replaces the local capability with GitHub OAuth, refresh-token-backed > server-side sessions, explicit GitHub organization/team authorization, and an -> Azure trusted-proxy policy. PATs are not supported. The Azure Functions -> profile is experimental: deploy it only after security review, staging +> trusted-proxy policy. PATs are not supported. The Azure Container Apps +> templates are experimental: deploy only after security review, staging > validation, and rollback planning for your Azure tenant. ## Local Actions and OIDC simulator @@ -1707,7 +1664,6 @@ flowchart LR | Redis operations | `internal/redisx/` | Supports caches, queues, and sessions. | | HTTP(S)/API server | `internal/server/` | Enforces loopback binding, optionally terminates operator-configured TLS, serves static dashboard assets, handles API requests, and publishes revision events. | | Externally hosted service | `hosting/` | Exposes a listener-independent application lifecycle and the complete hosted HTTP handler to other Go HTTP hosts. | -| Azure Functions profile | `internal/server/azure.go` | Builds the same HTTP handler without starting a listener, validates Azure app settings, requires `rediss://` Redis, and trusts forwarded host/protocol headers only for configured Azure hosts. | | GitHub OAuth sessions | `internal/server/oauth.go` | Implements the GitHub OAuth authorization-code flow, active organization/team authorization, refresh-token rotation, server-side encrypted sessions in Redis, logout revocation, and CSRF protection for mutating requests. | | Shared API model | `internal/model/` | Defines logical sources, diagnostics, and query metrics. | | Telemetry | `internal/telemetry/` | Configures OpenTelemetry trace, metric, and opt-in log providers, exposes the server's tracer, and writes W3C trace/span id response headers. | @@ -1794,7 +1750,7 @@ The configured `--site` directory must contain a readable, non-empty built `index.html`; hosted startup rejects an API-only or unbuilt deployment. The container supplies its bundled UI at `/app/site`. Source-built deployments must build `dashboard/site/` first and pass that build directory when using a -different location. This requirement also applies to Azure Functions and the +different location. This requirement also applies to containers and the `server/hosting` external-listener facade. The Go handler injects `dashboard-data-backend=server-http`, @@ -2052,7 +2008,7 @@ manager used by the `execute-cli` deployment, then securely remove the local copy. At runtime, expose the secret only to the executor, preferably as a secret-manager-mounted file configured with `CAO_HOSTED_CLI_PRIVATE_KEY_FILE`. Never put the key in checked-in configuration, command arguments, logs, or the -`serve-hosted` process. The Azure Function App deployment does not host +`serve-hosted` process. The Container Apps dashboard deployment does not host `execute-cli`; do not add this key to its settings. | Variable | Purpose | @@ -2497,10 +2453,7 @@ command; merging source does not update an already installed extension. Use the existing package workflow run ID and immutable published image digest, not a tag. Optional `--promotion-run RUN_ID` collects references from the existing prerelease-to-latest workflow; its workflow SHA is **not** the product source -SHA. Optional `--azure-receipt /secure/evidence/deploy-azure-receipt.json` consumes -the existing `cao deploy azure` receipt, requiring its source revision to match -the selected revision. It preserves development-only assurance, dirty-worktree -state, context/plan hashes, per-target digests and reported verification outcomes. +SHA. Private registry references are hashed; cloud resource identifiers, raw responses, actor names, comments, job/step names, secrets and credential-bearing URLs are not copied. No Azure API or deployment is invoked. @@ -2578,7 +2531,7 @@ Use `rediss://` whenever the Redis provider offers TLS. A deployment-managed Redis service may use plaintext only on the private service network when `control-plane.web.host.redis.allow-private-plaintext` is `true` and the endpoint uses a private service hostname or IP. This policy does not -affect Azure: Azure Functions continues to require `rediss://`. +relax the requirement for verified TLS on remote Redis connections. Deployment promotion and rollback belong to the application host. Review source changes, isolate source builders, and verify readiness before promotion. @@ -2790,75 +2743,25 @@ invalid, expired, and unauthenticated requests cannot bypass Redis enforcement. ### Hosted Azure architecture -> [!WARNING] -> The hosted Azure architecture is an experimental production-readiness profile, -> not a turnkey production certification. Treat the Bicep, OAuth policy, Redis -> topology, and Key Vault access model as a reviewed baseline that must be -> validated against your organization's Azure, GitHub, compliance, monitoring, -> incident-response, and data-retention requirements before live use. - -The Azure Functions profile keeps the dashboard browser isolated from Postgres, -Redis, GitHub tokens, refresh tokens, database credentials, and Key Vault secret values. -The Function App is the only public application boundary and the only component -that talks to GitHub APIs, Key Vault references, Postgres, and Azure Managed Redis. - -```mermaid -flowchart LR - Browser["Authorized user's browser"] - Edge["Azure HTTPS edge / App Service front end
sets forwarded host + proto"] - Function["Function App
Go dashboard HTTP handler
GitHub OAuth sessions + CSRF"] - GitHubOAuth["GitHub OAuth + API
login, refresh, org/team membership"] - KeyVault["Azure Key Vault
OAuth secret, session secret, Redis URL"] - Postgres["Postgres
current dashboard entities"] - Redis["Azure Managed Redis
TLS
operational state"] - Storage["Functions storage account
runtime state only"] - Insights["Application Insights
non-secret operational telemetry"] - OTel["Operator-managed OTel collector
TLS + authentication"] - Operators["Control-plane operators
deploy Bicep + rotate secrets"] - - Browser -->|"HTTPS static assets + API + best-effort SSE"| Edge - Edge -->|"trusted forwarded host/proto only when allow-listed"| Function - Function -->|"OAuth code, refresh, membership checks"| GitHubOAuth - Function -->|"Key Vault references resolved by managed identity"| KeyVault - Function -->|"rediss:// operational commands"| Redis - Function -->|"entity source reads/writes"| Postgres - Function -->|"runtime binding state"| Storage - Function -->|"opt-in OTLP/HTTP, bounded metadata only"| OTel - OTel -->|"Azure Monitor exporter"| Insights - Operators -->|"reviewed Bicep + secret rotation"| KeyVault - Operators -->|"deploy package + app settings"| Function - - classDef boundary fill:#eef6ff,stroke:#0969da,stroke-width:2px; - class Function,KeyVault,Postgres,Redis boundary; -``` - -Primary actors and responsibilities: - -- **Dashboard user**: authenticates through GitHub OAuth, must satisfy the - configured organization/team authorization policy, and receives only - same-origin dashboard HTML/API responses. -- **Azure platform**: terminates HTTPS, invokes the custom Functions handler, - resolves Key Vault references through managed identity, and may cold-start, - scale in, or terminate long-lived SSE requests. -- **GitHub OAuth/API**: issues expiring access/refresh tokens and confirms - organization/team membership; GitHub tokens never leave the server. -- **Postgres**: stores the current dashboard entity sources and diagnostics; - replacement is transactional, without generations or snapshots. -- **Redis**: stores operational queues, caches, and sessions, not - dashboard entities. -- **Control-plane operator**: reviews Bicep/app settings, keeps Key Vault - mandatory, rotates credentials, and validates compliance evidence. - -Trust boundaries: - -- Browser ↔ Function App: authenticated same-origin HTTPS with `Secure`, - `HttpOnly`, `SameSite=Lax` session cookies and CSRF headers for mutation. -- Function App ↔ GitHub: server-side OAuth/token refresh/membership calls; no - PATs and no GitHub tokens forwarded to the browser. -- Function App ↔ Key Vault: managed identity and RBAC only; no secret values in - Bicep outputs, logs, checked-in parameters, or browser-readable state. -- Function App ↔ Redis: TLS-only `rediss://` and a deployment - namespace for disposable derived data. +The Container Apps review bundle uses one hosted Go replica with managed HTTPS +ingress, private PostgreSQL canonical and operational storage, scoped Key Vault +references, managed-identity ACR pulls, separate provisioning/ingestion jobs and +Log Analytics. The base profile uses Actions snapshots and public OAuth ingress; +it does not provision Redis or Application Insights or support collector +acquisition, OTLP or base ADX analytics. + +Built dashboard assets, OAuth, query APIs and revision events stay on one origin. +GitHub remains the identity/membership provider; tokens and database credentials +never reach the browser. Configure independently verified private proxy peers. +Separate schema-owner and runtime credentials; infrastructure access grants no +CAO enrollment, dispatch or live authority. + +Web activation is default-off until independently verified database provisioning +and explicit snapshot/empty ingestion succeed. ARM dependencies do not execute +or verify jobs. Templates and offline checks are not deployed acceptance or +production image assurance. Optional lake/gateway infrastructure remains +separately selected and staged. See the +[Azure guide](../docs/deployment-azure.md) for operator-run deployment and gates. ## Data ingestion @@ -2906,7 +2809,7 @@ alternatives, not layers, because a canonical database has one writer. | Server configuration | `CAO_SOURCE_DIRECTORY` | `CAO_COLLECT_APP_ID` and the other `CAO_COLLECT_*` settings | | GitHub credentials | held by the explicit acquisition process | a GitHub App held by the deployment | | Event source | explicit snapshot acquisition | webhook deliveries | -| Deployment | Function App only | Function App plus Container Apps workers | +| Deployment | Hosted Go server; supported Container Apps review bundle | Separately configured hosted Go server and collection workers; not supported by the base Container Apps review bundle | The default profile is unchanged: with only `CAO_SOURCE_DIRECTORY` configured the server behaves exactly as before and performs no GitHub collection. @@ -3661,13 +3564,11 @@ There is no Azure-specific exporter linked into the binary. To ship telemetry to Application Insights, point `OTEL_EXPORTER_OTLP_ENDPOINT` at an OpenTelemetry Collector configured with the `azuremonitorexporter`; supply that collector's Application Insights connection string from its secret manager. The -`APPLICATIONINSIGHTS_CONNECTION_STRING` on the Function App does not activate -Go exports or configure an external collector. The Azure template supports an -explicit `otlpEndpoint`, `otlpLogsEnabled`, and secure `otlpHeaders` overlay for -the Function App and optional collection/backfill roles. See the -[Azure telemetry route and deployment receipts](../docs/deployment-azure.md#explicit-go-telemetry-route). -The Function App itself never imports an Azure Monitor SDK, and telemetry -delivery is not a request-readiness dependency. +`APPLICATIONINSIGHTS_CONNECTION_STRING` alone does not activate Go exports or +configure an external collector. The base Container Apps review bundle supports +Log Analytics container logs, not OTLP or Application Insights; generic +operator-managed telemetry is a separate deployment choice, not a feature +enabled by that bundle. Telemetry delivery is not a request-readiness dependency. ## Security boundaries @@ -3704,115 +3605,24 @@ The local capability profile is not suitable for remote or multi-user deployment. The capability authorizes its holder to read the full active dashboard data; it provides no user identity or per-source authorization. -The Azure Functions profile is the experimental remote profile. Run -`cao-dashboard azure-functions` to start its custom HTTP handler using -`FUNCTIONS_CUSTOMHANDLER_PORT` and the existing Azure environment settings. -It calls `NewAzureFunctionsHandlerFromEnv` in the shared server package; -`serve` does not enable this profile. Azure -mode fails closed unless all of the following are configured: - -- a readable `cao.json` with the `azure-functions` target and a Redis provider; -- the policy-selected Redis URL environment variable with a `rediss://` URL; -- the policy-selected Redis namespace environment variable; -- `CAO_AZURE_ALLOWED_HOSTS` and HTTPS forwarded-protocol enforcement; -- GitHub OAuth App or GitHub App client ID/secret and redirect URL; -- at least 32 characters of `CAO_SESSION_SECRET`; -- at least one explicit `CAO_GITHUB_ALLOWED_ORGS` or - `CAO_GITHUB_ALLOWED_TEAMS` value. - -For Linux-only local integration testing, the repository-owned harness starts -Azure Functions Core Tools, Azurite, and Redis and drives the Functions HTTP -surface without Azure credentials: - -```bash -./scripts/azure-local/azure-local.sh run -``` - -See [`scripts/azure-local/README.md`](../scripts/azure-local/README.md) for -prerequisites, individual lifecycle commands, logs, process isolation, and the -local-vs-cloud test boundary. The harness sets the explicit -`CAO_AZURE_LOCAL_SIMULATION=1` seam, which permits only loopback plaintext Redis -and local HTTP. Without that setting, Azure mode continues to require -`rediss://` Redis and HTTPS exactly as production does. - -Azure mode does not accept the local bearer capability and does not support -PATs. Login alone does not grant access: after exchanging the OAuth code, the -server verifies active organization or team membership. OAuth App clients -request `read:org read:user` for membership and disclosed 2FA-enabled evidence; -GitHub App clients (`Iv...`) request `offline_access` for -expiring user tokens and server-side refresh even if the App's global -expiration setting is disabled. For a GitHub App, configure organization -**Members: read** on the registration; scopes do not grant App permissions. -Install the App in each organization whose membership authorizes dashboard access. -This scope requires GitHub.com or GHES 3.23 or later. The browser sign-in App -need not be the collection or workflow App, and its credentials do not grant -campaign authority. Access and refresh tokens remain server-side, -encrypted before storage in Redis, and are never written to browser-readable -storage, URLs, API payloads, or Bicep outputs. Session cookies are `Secure`, -`HttpOnly`, and `SameSite=Lax`; mutating API requests must include the -session-bound CSRF token. - -`GET /api/v1/events` is available through Azure Functions only while the -platform keeps the invocation alive. Clients must treat SSE as best-effort and -fall back to `/api/v1/refresh` because Functions instances may cold-start, -scale in, or terminate long-running requests. Cold starts rebuild the Go app -from app settings and check Redis before serving requests. -Request cancellation propagates through `request.Context()` to Redis queries. - -The Bicep deployment in `server/azure/main.bicep` provisions a Function App, -Key Vault, Application Insights, storage, and module-free Azure Managed Redis. -Every secret-bearing app setting—including Functions runtime storage—uses a -versionless Key Vault reference so ordinary credential rotation -does not require rewriting application configuration. Session-key rotation uses -the optional secure `previousSessionSecret` deployment parameter: deploy the old -key as previous and the new key as current, wait for active sessions and queued -revocations (including expired-session cleanup) to drain, then remove the -previous key. Unknown-expiry GitHub tokens have no local time-based drain -deadline: retain their key until successful revocation or independently -confirmed provider expiry. New session records preserve cleanup ownership -after session authority expires, so outstanding debt consumes protected -capacity and can backpressure sign-in. Stop old replicas before rolling out -the new Redis session-envelope format, preserve the stable revocation prefix, -and reconcile pre-upgrade sessions/abandoned grants separately. Do not treat -deployment or the local session TTL as provider revocation. See -[OAuth cleanup and rotation](SECURITY.md#github-oauth-and-authorization) for -outage ownership and restart limitations. Encrypted records carry a -key identifier, and the server can read both keys during that window. The -template outputs only non-secret host names, redirect URI, Redis database name, -and Key Vault URI. Redis access keys -are an unavoidable path for Azure Managed Redis client authentication today; -store the `rediss://` URL in Key Vault, rotate the Redis key in Azure, publish a -new Key Vault secret version, and allow the platform to refresh the reference. - -### Azure secure-computing baseline - -Treat the Bicep file as the minimum secure baseline for remote dashboard -hosting: - -- Do not use PATs. Azure mode supports only the GitHub OAuth - authorization-code flow and configured organization/team authorization. -- Keep Key Vault mandatory for every secret-bearing value, including the GitHub - OAuth client secret, `CAO_SESSION_SECRET`, and `CAO_REDIS_URL`. Do not replace - Key Vault references with literal app settings, deployment outputs, or - checked-in parameter files. -- Use the Function App's system-assigned managed identity with Key Vault RBAC - to read secrets. Do not copy Key Vault secret values into logs, telemetry, - tickets, dashboard documents, or browser-readable configuration. -- Keep HTTPS-only Functions, TLS-only Redis, disabled FTPS, disabled Redis - public network access, storage HTTPS enforcement, Key Vault soft delete, and - non-secret Bicep outputs enabled for compliance review. -- Treat the Postgres entity store as rebuildable from authoritative inputs. Compliance evidence - comes from the checked-in Bicep, GitHub OAuth authorization policy, Key Vault - access controls, Azure activity logs, Application Insights without secrets, - and the CAO source artifacts that feed Redis. -- Rotate OAuth, session, storage, and Redis credentials through Key Vault and - Azure platform controls; then restart the Function App so current secret - versions are resolved. - -Do not remove the experimental designation until the hosted profile has passed -a deployment-specific security review, compliance review, load/cost validation, -incident-response exercise, backup/rollback exercise, and Azure network-access -review for the target tenant. +Remote hosting uses the explicit `serve-hosted` profile, not the local +capability profile. Azure Container Apps uses that same hosted process. +Keep GitHub OAuth allowlists, same-origin cookies/CSRF, private PostgreSQL, +verified proxy peers and secret-manager references intact. The browser sign-in +App is not rollout or collection authority. + +Platform restarts, ingress timeouts and proxies can interrupt revision streams; +verify reconnection and use `/api/v1/refresh` for revision checks. Do not assume +ephemeral files provide durable archive staging. Preserve current/previous +session keys until every active session and retained credential-cleanup debt +has drained or has provider-confirmed expiry; local session TTL and deployment +completion do not prove upstream revocation. See +[OAuth cleanup and rotation](SECURITY.md#github-oauth-and-authorization). + +The Container Apps templates and offline review bundle do not establish +production acceptance. Independently validate the target tenant's network, +OAuth, image assurance, secrets, backup/rollback, cost, incident response and +logging controls before live use. See [`SECURITY.md`](SECURITY.md) for the complete protection model, operational guidance, limitations, and private vulnerability-reporting process. @@ -3829,7 +3639,7 @@ npm run dashboard:server:setup:macos Installs Go, Node.js 24, npm packages and Docker/Compose/Colima. `CAO_CONTAINER_ENGINE=podman` selects Podman/Compose (no Colima); default Docker -never falls back. See [setup](../scripts/azure-local/README.md#local-service-engine-setup). +never falls back. See [`POSTGRES.md`](POSTGRES.md) for local database setup. PostgreSQL is required for local development; Redis remains optional. From the repository root, with CAO_POSTGRES_URL configured and storage provisioned: diff --git a/server/SECURITY.md b/server/SECURITY.md index 8fc8c67ac0..405bdd1a80 100644 --- a/server/SECURITY.md +++ b/server/SECURITY.md @@ -8,8 +8,6 @@ The Go dashboard server has three security profiles: - the default local profile used by `cao-dashboard serve`, for one trusted operator on loopback; and -- the explicit Azure Functions profile, for remote access through GitHub OAuth, - server-side sessions, Azure trusted-proxy headers, and Azure Managed Redis; and - the host-neutral `serve-hosted` profile, with the same GitHub identity boundary behind an explicitly trusted HTTPS proxy and any compatible managed Redis. @@ -67,7 +65,7 @@ The initial broker requires one explicitly acknowledged process replica. Job invalidation and graceful shutdown revoke tracked tokens, but a crash loses the process-local revocation registry. Installation tokens can remain valid until their actual expiry, bounded to one hour. No durable cleanup, -multi-replica operation, Azure Functions hosting, or integrated-deployment +multi-replica operation or integrated-deployment certification is claimed. #### Broker enrollment and setup @@ -121,7 +119,7 @@ rotation without persisting keys in CAO. Use a private endpoint/firewall where appropriate and audit secret reads without capturing values. Run through hosted HTTPS with the reviewed allowed host and one process-owned -listener. Azure Functions, externally owned listeners, and multiple replicas +listener. Externally owned listeners and multiple replicas are rejected for this initial profile. Do not autoscale, overlap deployments, or enable broker request/response-body capture in a reverse proxy or APM system. Only explicitly trusted push, schedule and manual-dispatch execution is @@ -522,39 +520,36 @@ and standalone collection roles are unsupported. Declaring one replica does not turn Upstash into a cross-process coordination service and must not be used to justify additional replicas. -## Azure Functions profile +## Hosted identity and Azure deployment > [!WARNING] -> Azure Functions mode is experimental. It defines a security baseline for -> review and staged evaluation, not a blanket production approval. Keep the -> profile disabled for live use until the target tenant has completed security, +> Azure Container Apps templates are experimental. They define a review +> baseline, not a blanket production approval. Keep runtime activation disabled +> until the target tenant has completed security, > compliance, privacy, network, monitoring, incident-response, and rollback > reviews. -Azure Functions mode is enabled by using -`NewAzureFunctionsHandlerFromEnv`, which selects the platform-listener, -GitHub-OAuth host capability profile. It does not start its own listener, does -not accept `--access-token`, and does not support PATs. Requests are handled by -the Azure Functions HTTP runtime and the same Go dashboard HTTP handler. +Azure uses the process-owned `serve-hosted` container profile with private +PostgreSQL canonical and operational storage. Built assets, OAuth, query APIs +and revision events remain on one origin. No PAT or local bearer capability +substitutes for hosted GitHub OAuth. Azure Functions runtime, deployment and +local harness support are retired. -Azure mode fails closed unless `cao.json` selects the `azure-functions` target -and configuration includes: +Generate and check an offline `azure-container-apps` review bundle; operators +execute reviewed templates separately. The base profile uses one replica, +public OAuth ingress, Actions snapshots, Key Vault, ACR and Log Analytics. +It does not provision Redis or Application Insights or support collector +acquisition, OTLP or base ADX analytics. Optional archive/gateway infrastructure +remains independently selected and staged. -- `rediss://` Redis transport and a Redis namespace; -- an explicit trusted host allow-list from `CAO_AZURE_ALLOWED_HOSTS`; -- HTTPS forwarded-protocol enforcement; -- GitHub OAuth App or GitHub App client ID, client secret, redirect URL, and a session secret - of at least 32 characters; and -- at least one allowed GitHub organization or `org/team-slug` value. - -The trusted-proxy policy is intentionally separate from local `Host` checks. -Local requests must still use loopback hosts. Azure requests may use forwarded -host/protocol headers only when the forwarded host exactly matches the -configured allow-list and the forwarded protocol is HTTPS. +Trust forwarded host/protocol headers only from independently verified private +proxy CIDRs, with an allowed public host and HTTPS protocol. Sample CIDRs are +not live proxy evidence. Private PostgreSQL connectivity and secret-scoped +managed identities do not grant rollout, acquisition or dispatch authority. ### GitHub OAuth and authorization -Azure mode implements the GitHub OAuth authorization-code flow. OAuth Apps +Hosted mode implements the GitHub OAuth authorization-code flow. OAuth Apps request `read:org read:user` for active organization or team membership checks and disclosure of the private `/user.two_factor_authentication` field. `read:org` alone cannot disclose that field. Existing grants require a new @@ -567,7 +562,7 @@ allowed organization. This scope neither adds repository permissions nor grants offline dashboard authority, and requires GitHub.com or GHES 3.23 or later. Successful GitHub authentication alone is insufficient: the server must verify an allowed organization or team membership before creating a -session. Do not add PAT handling to Azure mode; PATs bypass the required +session. Do not add PAT handling to hosted mode; PATs bypass the required browser login, refresh-token rotation, revocation, and explicit membership authorization controls. @@ -734,37 +729,35 @@ client must reauthenticate. Logout revokes both the current access token and the current refresh token when GitHub accepts revocation, deletes the Redis session, and clears cookies. -### Azure lifecycle and transport limits +### Hosted lifecycle and transport limits -Cold starts rebuild the app from environment configuration, validate Redis -connectivity before serving. Request cancellation is propagated through -`request.Context()` to Redis calls. +Startup validates the selected configuration and operational backend. +Request cancellation propagates through `request.Context()` to backend calls. Health checks remain at `GET /api/v1/health`. Unauthenticated health responses report operational connectivity and capabilities, recovery readiness, and data availability. Authenticated sessions also receive revision and count metadata. -Server-Sent Events at `GET /api/v1/events` are best-effort in Azure Functions. -The endpoint works while the platform keeps the invocation alive, but scale-in, -cold starts, idle timeouts, proxies, and plan limits can terminate long-lived +Server-Sent Events at `GET /api/v1/events` are subject to deployment transport +limits. Restarts, scale-in, idle timeouts and proxies can terminate long-lived connections. Clients must continue to use `/api/v1/refresh` as the reliable revision check. WebSockets are not part of this profile. ### Threat model -The Azure Functions profile assumes the following actors: +Hosted deployments assume the following actors: | Actor | Capabilities | Security expectation | | --- | --- | --- | | Authorized dashboard user | Opens the hosted dashboard and makes browser API requests. | Must authenticate through GitHub OAuth, satisfy explicit org/team authorization, send CSRF headers for mutation, and receive no Redis credentials or GitHub tokens. | -| Unauthenticated or unauthorized user | Can reach the public Function App URL. | Can read only minimal health status; all static dashboard access redirects to login and all data APIs fail closed. | +| Unauthenticated or unauthorized user | Can reach the public application URL. | Can read only minimal health status; all static dashboard access redirects to login and all data APIs fail closed. | | Browser attacker | Can attempt CSRF, stale session reuse, URL injection, or token exfiltration through browser storage. | Session authority is in `Secure`, `HttpOnly`, `SameSite=Lax` cookies; CSRF tokens are session-bound; GitHub tokens and Redis URLs are never browser-readable. | -| Network attacker | Can observe or interfere with traffic outside Azure/GitHub TLS channels. | HTTPS-only Function App, TLS Redis, verified Redis certificates, and no plaintext remote Redis are required. | +| Network attacker | Can observe or interfere with traffic outside Azure/GitHub TLS channels. | HTTPS ingress, trusted private proxy peers and verified backend TLS are required. | | Azure platform/operator | Can deploy Bicep, configure app settings, rotate keys, and view platform metadata. | Uses reviewed Bicep, managed identity, Key Vault RBAC, non-secret outputs, and secret rotation procedures; does not copy secret values into logs, tickets, or checked-in files. | | GitHub OAuth/API | Issues tokens and reports membership. | OAuth client secret remains in Key Vault, tokens remain server-side, refresh failures clear sessions, and membership is rechecked before session creation. | | Redis | Stores operational state, bounded caches, and encrypted OAuth session records. | Does not store dashboard entities or control dashboard-data readiness; access is TLS-only and namespace-scoped. | -| Telemetry/diagnostics reader | Can view Application Insights and operational logs. | Sees only structured, non-secret operational metadata; no tokens, cookies, Redis URLs, prompt contents, source records, or secret values are logged. | +| Telemetry/diagnostics reader | Can view configured operational logs. | Sees only structured, non-secret operational metadata; no tokens, cookies, database URLs, prompt contents, source records, or secret values are logged. | Protected assets: @@ -782,27 +775,27 @@ Primary threats and mitigations: - **Credential disclosure**: mitigated by mandatory Key Vault references, managed identity/RBAC, non-secret Bicep outputs, server-side token storage, - AES-GCM encryption before Redis persistence, and no secrets in telemetry or + AES-GCM encryption before session persistence, and no secrets in telemetry or browser-readable state. - **PAT or bearer bypass**: mitigated by rejecting local bearer capabilities in - Azure mode and documenting PATs as unsupported. Only GitHub OAuth plus + hosted mode and documenting PATs as unsupported. Only GitHub OAuth plus explicit org/team authorization can create a session. - **CSRF and session fixation**: mitigated by signed OAuth state, opaque session IDs, `Secure`/`HttpOnly`/`SameSite=Lax` cookies, per-session CSRF tokens, and session deletion on refresh failure/logout. -- **Untrusted proxy headers**: mitigated by an explicit - `CAO_AZURE_ALLOWED_HOSTS` allow-list and required HTTPS forwarded protocol. +- **Untrusted proxy headers**: mitigated by independently verified private + proxy CIDRs, an allowed public host and required HTTPS forwarded protocol. The local profile keeps separate loopback `Host` protections. - **Redis compromise or data confusion**: mitigated by treating Redis as disposable derived state, using TLS-only Redis, namespacing every key, disabling Redis public network access in Bicep, and rebuilding from trusted artifacts when needed. -- **Long-lived connection assumptions**: mitigated by treating SSE as - best-effort in Functions and requiring `/api/v1/refresh` polling as the +- **Long-lived connection assumptions**: mitigated by verifying SSE + reconnection and retaining `/api/v1/refresh` polling as the reliable revision check. - **Over-broad observability**: mitigated by logging only structured operational metadata and excluding tokens, cookies, Redis URLs, source - records, and prompt contents from Application Insights and diagnostics. + records, and prompt contents from logs and diagnostics. - **Stale or stolen administrator session**: mitigated by administrator step-up. Administrator authority expires within `CAO_ADMIN_REAUTH_MAX_AGE` of the last interactive authentication and is never extended by refresh. @@ -813,19 +806,20 @@ Primary threats and mitigations: [Hosted CLI actions](#hosted-cli-actions). - **Deployment drift**: mitigated by using checked-in Bicep as the reviewed contract, focused Bicep contract tests for Key Vault and platform security - controls, and compliance review of Azure activity logs and Function App + controls, and compliance review of Azure activity logs and Container App configuration changes. Open experimental risks that must be accepted or closed before live production use: -- Azure Functions cold starts, scale-in, and platform timeouts may interrupt +- Restarts, scale-in and platform timeouts may interrupt long-lived SSE even when query/refresh endpoints continue to work. - The Bicep template is a baseline and does not by itself prove tenant-specific network isolation, private endpoint reachability, cost limits, backup posture, data residency, or regulatory compliance. -- PostgreSQL stores the rebuildable dashboard projection; Redis stores only - operational state, bounded caches, and encrypted sessions. An organization +- PostgreSQL stores the rebuildable dashboard projection and separately owned + operational state; explicit Redis alternatives store only operational state, + bounded caches and encrypted sessions. An organization must validate whether its data classification permits those stores and retention models. - The profile has focused automated tests and Bicep contract checks, but it @@ -835,47 +829,29 @@ use: ### Azure secure-computing and compliance controls -Key Vault is mandatory for every secret-bearing Azure setting. The deployment -contract keeps the GitHub OAuth client secret, session secret, Redis -`rediss://` URL, and Functions runtime storage connection in Key Vault and wires -the Function App through versionless Key Vault references. Do not emit these -values from Bicep, commit them in parameter files, copy them into app settings -as literals, or log them during deployment. - -Use a system-assigned managed identity and Key Vault RBAC for secret reads. -Review Key Vault access policies/role assignments, Azure activity logs, and -Function App configuration changes as part of compliance evidence. Secret -rotation should happen through GitHub OAuth settings, Azure Redis/storage key -rotation, and new Key Vault secret versions. Versionless references allow the -platform to resolve current versions without changing application settings. - -Keep the Azure secure-computing baseline enabled: - -- HTTPS-only Function App, TLS 1.2 or newer, disabled FTPS, and no local bearer - capability in Azure mode; -- Redis encrypted client protocol, disabled Redis public network access, and no - Redis credentials in browser payloads; -- storage HTTPS enforcement and no public blob access for Functions runtime - state; -- Key Vault RBAC authorization, soft delete, and no secret values in Bicep - outputs; and -- Application Insights/telemetry with structured operational metadata only, - never GitHub tokens, Redis URLs, session secrets, cookies, authorization - headers, source records, or prompt contents. - -Application Insights app settings alone do not activate Go exporters. Use the -[reviewed HTTPS OTLP route](../docs/deployment-azure.md#explicit-go-telemetry-route) -for the Function App and collection/backfill roles, with secret-manager input -for collector authentication and secret-scoped Key Vault resolution. Go log -export requires explicit opt-in and uses bounded namespaces, not the workers' -broad debug selection. Collector/backend delivery and alert acknowledgements -still require deployment receipts; platform logs and independent audit delivery -remain separate. Exporter availability must not gate request readiness. - -The Azure Elastic Premium baseline keeps one always-on instance for the -process-lifetime revocation worker. Redis remains the durable queue across -process replacement; each startup resumes bounded retry batches before the -minute interval begins. +Key Vault is mandatory for secret-bearing Azure parameters. Keep OAuth, +session, webhook and database credentials in scoped secret references, never +literal parameters, Bicep outputs, logs or browser payloads. Use separate +provisioning and runtime identities; schema-owner credentials do not belong in +the dashboard or runtime-only ingestion job. + +Review managed-identity grants, vault governance, private PostgreSQL DNS and +TLS, trusted proxy peers, HTTPS ingress and configuration changes as independent +controls. Preserve OAuth cleanup-debt draining and current/previous key rotation +across process replacement. Persistent PostgreSQL state does not by itself prove +backup recovery or upstream credential revocation. + +Keep `runtimeEnabled: false` until privileged provisioning and explicit +snapshot/empty ingestion independently succeed. ARM dependencies do not run +or verify jobs. Log Analytics container logs do not establish Go exporter +delivery; the base profile does not support OTLP. Optional lake/gateway hosts +need separate authority, persistent ownership and real destination acceptance. + +Offline bundle checks are not cloud deployment acceptance, image scan/SBOM/ +provenance assurance or compliance certification. Operators retain and review +exact-image evidence, OAuth allow/deny tests, readiness, private-network checks, +secret rotation, backup/rollback and incident-response outcomes separately. +See the [Container Apps guide](../docs/deployment-azure.md). ## Redis transport and isolation @@ -884,7 +860,7 @@ minute interval begins. private IP or single-label service hostname only when `allow-private-plaintext: true` is set in the `cao.json` Redis module. - All other Redis connections require `rediss://` with normal certificate-chain - and hostname verification. Azure always follows this path. There is no + and hostname verification. There is no insecure TLS mode. - Redis usernames and passwords remain in the Go process and are never returned in HTML, browser configuration, API payloads, or query URLs. @@ -899,13 +875,12 @@ It does not replace Redis ACLs. Use a dedicated Redis instance or database and credentials restricted to the deployment namespace for separate trust boundaries. -For Azure, use encrypted Redis client protocol. `server/azure/main.bicep` sets -Redis public network access to disabled and expects the Redis URL to be -delivered through Key Vault-backed app settings. Redis client authentication -currently relies on access keys; do not output them. To rotate, regenerate the -Redis access key, update -the `cao-redis-url` Key Vault secret with the new `rediss://` URL, then restart -the Function App so Key Vault references resolve the latest secret version. +The base Azure Container Apps review bundle uses PostgreSQL operational state, +not Redis. For independently configured Redis deployments, review private +connectivity, verified TLS, narrow credentials and secret-manager delivery +separately. Rotate credentials through the provider and secret manager, then +verify that running processes use the intended secret version; never log keys +or connection URLs. ## Artifact ingestion protections diff --git a/server/SESSION-ARCHIVE.md b/server/SESSION-ARCHIVE.md index f357c3530d..ceb184cdfa 100644 --- a/server/SESSION-ARCHIVE.md +++ b/server/SESSION-ARCHIVE.md @@ -13,7 +13,7 @@ Approve exact collection scope, a versioned closed content/redaction policy, rights, full-content readers, retention/replay horizons, destination suitability, and resource budgets. Use a private persistent POSIX volume with verified lock, rename, and durability semantics. Do not use a public shard directory, ephemeral -container filesystem, per-request Functions host, or a shared multi-replica mount. +container filesystem, per-request host, or a shared multi-replica mount. Archive configuration never authorizes new GitHub acquisition. The supplied standard Alpine images do not imply DuckDB compatibility or diff --git a/server/SOC2.md b/server/SOC2.md index 34824a24d6..ad8d0e0617 100644 --- a/server/SOC2.md +++ b/server/SOC2.md @@ -1,9 +1,14 @@ # Go server SOC 2 readiness assessment > Historical evidence: the assessments below apply to their cited source SHAs, -> not the current PostgreSQL-only implementation. SQLite backends are removed; -> recorded SQLite results are not current validation or supported deployment -> guidance. See `ARCHITECTURE.md` and `server/POSTGRES.md` for current boundaries. +> not the current PostgreSQL-only implementation. SQLite backends and all Azure +> Functions runtime, deployment/receipt and local harness support are removed. +> Recorded SQLite or Functions results, file paths and proposal descriptions +> below are historical evidence only, not current validation or supported +> deployment guidance. Azure now uses an offline Container Apps review bundle +> and separate operator-run deployment; no new deployment acceptance is claimed. +> See `ARCHITECTURE.md`, `server/POSTGRES.md` and +> [`docs/deployment-azure.md`](../docs/deployment-azure.md) for current boundaries. > This note does not reassess controls or establish operating effectiveness. ## Current-source reassessment - 2026-10-22 UTC diff --git a/server/azure/collector.bicep b/server/azure/collector.bicep index dcae0b31ad..26817da8f4 100644 --- a/server/azure/collector.bicep +++ b/server/azure/collector.bicep @@ -1,11 +1,10 @@ // Optional collection workers for the server ingestion profile. // // This module deploys nothing unless a collector image is supplied, so the -// default deployment stays exactly as it is: a Function App serving the -// dashboard from snapshots published by the Activity workflow. +// hosted dashboard remains independent of these optional collection workers. // // The workers run native Go acquisition in the `collect` role and share the -// durable PostgreSQL operational queue with the Function App. +// durable PostgreSQL operational queue with the hosted server. @description('Location for the collection workers.') param location string @@ -59,7 +58,7 @@ param operationalProtectedMaxBytes int = 134217728 @description('Maximum number of protected operational records.') param operationalProtectedMaxEntries int = 200000 -@description('Operational PostgreSQL namespace shared with the Function App.') +@description('Operational PostgreSQL namespace shared with the hosted server.') param operationalNamespace string @description('Control repository named by the reviewed inventory.') diff --git a/server/azure/function-roles.bicep b/server/azure/function-roles.bicep deleted file mode 100644 index 6622d6e601..0000000000 --- a/server/azure/function-roles.bicep +++ /dev/null @@ -1,72 +0,0 @@ -param storageAccountName string -param keyVaultName string -param containerRegistryName string -param functionPrincipalId string -param secretNames array - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { - name: storageAccountName -} - -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} - -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { - name: containerRegistryName -} - -resource functionsStorageBlobOwner 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionPrincipalId, 'Storage Blob Data Owner') - scope: storage - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} - -resource functionsStorageQueueContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionPrincipalId, 'Storage Queue Data Contributor') - scope: storage - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} - -resource functionsStorageTableContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionPrincipalId, 'Storage Table Data Contributor') - scope: storage - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} - -resource secrets 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = [for secretName in secretNames: { - parent: keyVault - name: secretName -}] - -resource keyVaultSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for (secretName, i) in secretNames: { - name: guid(secrets[i].id, functionPrincipalId, 'Key Vault Secrets User') - scope: secrets[i] - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -}] - -resource functionsRegistryPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, functionPrincipalId, 'AcrPull') - scope: registry - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalId: functionPrincipalId - principalType: 'ServicePrincipal' - } -} diff --git a/server/azure/main.bicep b/server/azure/main.bicep deleted file mode 100644 index 202588c438..0000000000 --- a/server/azure/main.bicep +++ /dev/null @@ -1,812 +0,0 @@ -targetScope = 'resourceGroup' - -// Experimental Azure Functions deployment baseline for the Go dashboard server. -// Review and validate tenant-specific security, compliance, networking, -// monitoring, cost, rotation, and rollback requirements before live use. - -@description('Azure region for all dashboard resources.') -param location string = resourceGroup().location - -@minLength(2) -@maxLength(19) -@description('Globally unique lowercase Function App name and job prefix, starting with a letter and without consecutive hyphens. At most 19 characters keeps the derived -db-provision job within the Container Apps 32-character limit.') -param functionAppName string - -@description('Default endpoint access. Enabled (default) serves the dashboard over public HTTPS behind GitHub OAuth sign-in; no VPN, proxy, or corporate-network rule is required. Set Disabled only for a deliberately private deployment.') -@allowed([ - 'Disabled' - 'Enabled' -]) -param functionPublicNetworkAccess string = 'Enabled' - -@description('Default action for unmatched main-site requests. Allow (default) is the public-main model: GitHub OAuth remains the access control. Use Deny with explicit rules only for a deliberately restricted deployment. Never changes SCM restrictions.') -@allowed([ - 'Allow' - 'Deny' -]) -param functionAccessRestrictionsDefaultAction string = 'Allow' - -@description('Reviewed main-site access restriction rules. Explicitly carry forward existing approved rules during redeployment; omission uses an empty list. Unmatched requests use functionAccessRestrictionsDefaultAction.') -param functionAccessRestrictions array = [] - -@description('Reviewed SCM/Kudu access restriction rules, separate from main-site rules. Explicitly carry forward existing approved SCM rules; omission uses an empty list.') -param functionScmAccessRestrictions array = [] - -@description('Whether SCM reuses main-site rules. Carry forward the existing approved setting; false keeps SCM independently restricted by default.') -param functionScmAccessRestrictionsUseMain bool = false - -@description('Azure App Service plan name for the Function App.') -param hostingPlanName string = '${functionAppName}-plan' - -@description('Application Insights component name.') -param applicationInsightsName string = '${functionAppName}-ai' - -@description('Optional reviewed HTTPS OTLP/HTTP collector base URL, without credentials, query or fragment. Empty disables Go export; Application Insights settings alone only configure the Functions host.') -param otlpEndpoint string = '' - -@description('Explicit opt-in to export bounded operational Go logs to the OTLP collector. Independent audit delivery is unchanged.') -param otlpLogsEnabled bool = false - -@secure() -@description('Optional OTLP authentication headers from a secret manager. Stored only in Key Vault; never supply literal values in command-line arguments or checked-in parameters.') -param otlpHeaders string = '' - -var otlpEnvironment = [ - { - name: 'OTEL_EXPORTER_OTLP_ENDPOINT' - value: otlpEndpoint - } - { - name: 'OTEL_EXPORTER_OTLP_PROTOCOL' - value: 'http/protobuf' - } - { - name: 'CAO_OTEL_LOGS_ENABLED' - value: string(otlpLogsEnabled) - } -] - -var otlpHeadersEnabled = !empty(otlpEndpoint) && !empty(otlpHeaders) - -@description('Globally unique storage account name for Azure Functions runtime state.') -param storageAccountName string - -@description('Globally unique Azure Container Registry name containing the Function App image.') -param containerRegistryName string - -@description('Container repository containing cao-dashboard azure-functions. The existing cao-functions image repository remains supported.') -param functionImageRepository string = 'cao-functions' - -@description('Immutable or reviewed tag of the CAO Azure Functions runtime image.') -param functionImageTag string - -@description('Exact Functions image digest, including sha256:. When supplied, takes precedence over the tag. Does not establish scan or production assurance.') -param functionImageDigest string = '' - -@description('Key Vault name used for GitHub OAuth, session, PostgreSQL, and collection secrets.') -param keyVaultName string - -@description('Enable purge protection for production vaults. Disable only for explicitly disposable environments that must be purged after testing. Existing protected vaults cannot be downgraded.') -param keyVaultPurgeProtection bool = true - -@description('Globally unique name for a new private PostgreSQL Flexible Server.') -param postgresServerName string = '${functionAppName}-pg' - -param postgresAdministratorLogin string = 'caoadmin' - -@secure() -@minLength(16) -@description('PostgreSQL administrator password. Use secret-manager input; never commit it.') -param postgresAdministratorPassword string - -param postgresSkuName string = 'Standard_B2s' -param postgresSkuTier string = 'Burstable' - -@description('Optional existing network security group kept attached to every private subnet. Supply it when an approved NSG is already attached so redeployment does not detach it.') -param subnetNetworkSecurityGroupId string = '' - -@minLength(1) -@maxLength(63) -@description('PostgreSQL database that holds the canonical and operational stores. The default matches existing deployments. A new name creates a new, empty database; the previous database is retained, not deleted.') -param postgresDatabaseName string = 'cao' - -@minLength(1) -@maxLength(110) -@description('Runtime-only PostgreSQL URL secret. Use a new name and database when moving from the administrator-backed topology.') -param postgresUrlSecretName string = 'cao-postgres-runtime-url' - -@secure() -@minLength(16) -@description('Separately generated bootstrap-login password from the operator secret manager. Never reuse the administrator or runtime password.') -param postgresOwnerPassword string - -@secure() -@minLength(16) -@description('Separately generated runtime-login password from the operator secret manager.') -param postgresRuntimePassword string - -@description('ACR repository containing the azure-db-provision Dockerfile target.') -param provisioningImageRepository string = 'cao-db-provision' - -param provisioningImageTag string = functionImageTag - -@description('Exact database-provisioning image digest, including sha256:. When supplied, takes precedence over the tag.') -param provisioningImageDigest string = '' - -@description('Repository of an explicitly built snapshot or empty ingestion image in this deployment registry.') -param ingestionImageRepository string = 'cao-ingest' - -param ingestionImageTag string = functionImageTag - -@description('Exact ingestion image digest, including sha256:. When supplied, takes precedence over the tag.') -param ingestionImageDigest string = '' - -var functionImageSelector = empty(functionImageDigest) ? ':${functionImageTag}' : '@${functionImageDigest}' -var ingestionImageSelector = empty(ingestionImageDigest) ? ':${ingestionImageTag}' : '@${ingestionImageDigest}' -var provisioningImageSelector = empty(provisioningImageDigest) ? ':${provisioningImageTag}' : '@${provisioningImageDigest}' - -@minValue(1) -@description('Maximum bytes retained in the disposable operational cache.') -param operationalCacheMaxBytes int = 33554432 - -@minValue(1) -@description('Maximum size of one disposable operational cache value.') -param operationalCacheMaxValueBytes int = 4194304 - -@minValue(1) -@description('Maximum number of disposable operational cache entries.') -param operationalCacheMaxEntries int = 1024 - -@minValue(1) -@description('Maximum bytes retained in protected operational records.') -param operationalProtectedMaxBytes int = 134217728 - -@minValue(1) -@description('Maximum number of protected operational records.') -param operationalProtectedMaxEntries int = 200000 - -@minValue(7) -@maxValue(3650) -@description('Run and linked history retention in days. Before starting any role, explicitly preserve at least the greatest existing effective retention across all roles and the snapshot window. The 45-day default is not an upgrade recommendation.') -param postgresRunRetentionDays int = 45 - -@description('Public host names that Azure Front Door/App Service is allowed to forward to the Go dashboard handler.') -param allowedHosts array - -@description('GitHub organizations whose active members are authorized.') -param githubAllowedOrganizations array - -@description('GitHub teams whose active members are authorized, expressed as org/team-slug.') -param githubAllowedTeams array = [] - -@description('GitHub user logins authorized directly, without organization membership lookup.') -param githubAllowedUsers array = [] - -@description('GitHub OAuth App client ID. Do not use a PAT; the server only supports the OAuth authorization-code flow.') -param githubClientId string - -@secure() -@description('GitHub OAuth App client secret. It is written only to Key Vault and consumed by the Function App through a Key Vault reference.') -param githubClientSecret string - -@secure() -@minLength(32) -@description('At least 32 characters of random session secret material used to encrypt server-side OAuth sessions.') -param sessionSecret string - -@secure() -@description('Optional previous session secret retained during controlled key rotation until active sessions and pending revocations are drained.') -param previousSessionSecret string = '' - -@description('Single Log Analytics workspace shared by every role in this deployment.') -param logAnalyticsWorkspaceName string = '${functionAppName}-logs' - -// Optional server collection profile. -// -// Leaving collectorImage empty deploys the default profile unchanged: the -// dashboard serves snapshots published by the Activity workflow and the server -// collects nothing. Supplying an image selects the alternative profile, in -// which this deployment collects evidence itself. The two profiles are -// alternatives, never layers. - -@description('Optional container image running the collection role. Empty deploys no collection.') -param collectorImage string = '' - -@description('GitHub App identifier whose installations define ingestion scope. Required with collectorImage.') -param collectorGithubAppId string = '' - -@secure() -@description('GitHub App private key in PEM form. Required with collectorImage.') -param collectorPrivateKey string = '' - -@secure() -@description('Shared secret verifying GitHub webhook deliveries. Required with collectorImage.') -param githubWebhookSecret string = '' - -@description('GitHub logins permitted to run administrative operations.') -param githubAdminUsers array = [] - -@description('Control repository named by the reviewed inventory, in OWNER/REPOSITORY form.') -param collectorControlRepository string = '' - -@description('Exact lowercase SHA-256 digest of immutable reviewed inventory bytes already provisioned in the shared PostgreSQL namespace. Required when collectorImage is set; an absent or unprovisioned digest prevents the workers from starting.') -param collectorNativeInventoryDigest string = '' - -@minValue(1) -@description('Fixed number of PostgreSQL-backed collection workers.') -param collectorWorkerReplicas int = 1 - -@description('Provision an optional paid ADX destination only. Does not enable export, traces, or mining; Azure Functions is not a supported exporter host.') -param adxEnabled bool = false - -@minLength(4) -@maxLength(22) -@description('Globally unique ADX cluster name: lowercase letters and digits only.') -param adxClusterName string = 'adx${uniqueString(resourceGroup().id, functionAppName)}' - -@minLength(1) -@maxLength(100) -@description('ADX database identifier: ASCII letter followed by letters, digits, or underscores.') -param adxDatabaseName string = 'CAO' - -@description('Reviewed paid ADX SKU. The default is development-only without an SLA, not the free offering.') -param adxSkuName string = 'Dev(No SLA)_Standard_E2a_v4' - -@allowed([ - 'Basic' - 'Standard' -]) -param adxSkuTier string = 'Basic' - -@minValue(1) -@description('Reviewed ADX instance count compatible with the selected SKU and tier.') -param adxCapacity int = 1 - -var collectionEnabled = !empty(collectorImage) - -var tags = { - workload: 'gh-cao-dashboard' - hostingMode: 'azure-functions' -} -var githubRedirectUri = 'https://${functionAppName}.azurewebsites.net/auth/callback' - -module network 'network.bicep' = { - name: 'private-network-postgres' - params: { - location: location - namePrefix: functionAppName - postgresServerName: postgresServerName - postgresAdministratorLogin: postgresAdministratorLogin - postgresAdministratorPassword: postgresAdministratorPassword - postgresSkuName: postgresSkuName - postgresSkuTier: postgresSkuTier - subnetNetworkSecurityGroupId: subnetNetworkSecurityGroupId - postgresDatabaseName: postgresDatabaseName - adxEnabled: adxEnabled - } -} - -module adx 'adx.bicep' = if (adxEnabled) { - name: 'adx-destination' - params: { - location: location - clusterName: adxClusterName - databaseName: adxDatabaseName - skuName: adxSkuName - skuTier: adxSkuTier - capacity: adxCapacity - virtualNetworkId: network.outputs.virtualNetworkId - privateEndpointSubnetId: network.outputs.adxSubnetId - tags: tags - } -} - -resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { - name: logAnalyticsWorkspaceName - location: location - tags: tags - properties: { - sku: { - name: 'PerGB2018' - } - retentionInDays: 30 - } -} - -resource containerEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { - name: '${functionAppName}-jobs' - location: location - tags: tags - properties: { - appLogsConfiguration: { - destination: 'azure-monitor' - } - vnetConfiguration: { - infrastructureSubnetId: network.outputs.containerSubnetId - internal: true - } - zoneRedundant: false - } -} - -resource containerDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - name: 'container-logs' - scope: containerEnvironment - properties: { - workspaceId: workspace.id - logs: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - } -} - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: storageAccountName - location: location - tags: tags - sku: { - name: 'Standard_LRS' - } - kind: 'StorageV2' - properties: { - allowSharedKeyAccess: false - allowBlobPublicAccess: false - minimumTlsVersion: 'TLS1_2' - supportsHttpsTrafficOnly: true - } -} - -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { - name: containerRegistryName - location: location - tags: tags - sku: { - name: 'Basic' - } - properties: { - adminUserEnabled: false - publicNetworkAccess: 'Enabled' - } -} - -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { - name: keyVaultName - location: location - tags: tags - properties: { - tenantId: subscription().tenantId - sku: { - family: 'A' - name: 'standard' - } - enableRbacAuthorization: true - enabledForTemplateDeployment: false - enableSoftDelete: true - enablePurgeProtection: keyVaultPurgeProtection ? true : null - softDeleteRetentionInDays: 90 - publicNetworkAccess: 'Enabled' - } -} - -resource githubClientSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: 'github-oauth-client-secret' - properties: { - value: githubClientSecret - contentType: 'GitHub OAuth client secret for CAO dashboard Azure Functions mode' - } -} - -resource sessionSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: 'cao-session-secret' - properties: { - value: sessionSecret - contentType: 'CAO dashboard session encryption secret' - } -} - -resource previousSessionSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(previousSessionSecret)) { - parent: keyVault - name: 'cao-session-secret-previous' - properties: { - value: previousSessionSecret - contentType: 'Previous CAO dashboard session encryption secret retained during rotation' - } -} - -resource postgresConnectionStringValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: postgresUrlSecretName - properties: { - value: 'postgresql://cao_runtime_login:${uriComponent(postgresRuntimePassword)}@${network.outputs.postgresHostName}:5432/${uriComponent(network.outputs.databaseName)}?sslmode=verify-full&options=-csearch_path%3Dcao' - contentType: 'CAO runtime-only PostgreSQL connection URL' - } -} - -resource postgresAdminUrl 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: '${postgresUrlSecretName}-admin' - properties: { - value: 'postgresql://${uriComponent(postgresAdministratorLogin)}:${uriComponent(postgresAdministratorPassword)}@${network.outputs.postgresHostName}:5432/${uriComponent(network.outputs.databaseName)}?sslmode=verify-full' - contentType: 'Short-lived CAO database provisioning only' - } -} - -resource postgresOwnerPasswordValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: '${postgresUrlSecretName}-owner-password' - properties: { - value: postgresOwnerPassword - } -} - -resource postgresRuntimePasswordValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: '${postgresUrlSecretName}-runtime-password' - properties: { - value: postgresRuntimePassword - } -} - -resource collectorPrivateKeyValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (collectionEnabled) { - parent: keyVault - name: 'cao-collect-private-key' - properties: { - value: collectorPrivateKey - contentType: 'GitHub App private key for the collection profile' - } -} - -resource githubWebhookSecretValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (collectionEnabled) { - parent: keyVault - name: 'cao-github-webhook-secret' - properties: { - value: githubWebhookSecret - contentType: 'GitHub webhook shared secret' - } -} - -resource plan 'Microsoft.Web/serverfarms@2023-12-01' = { - name: hostingPlanName - location: location - tags: tags - kind: 'elastic' - sku: { - name: 'EP1' - tier: 'ElasticPremium' - } - properties: { - reserved: true - } -} - -resource insights 'Microsoft.Insights/components@2020-02-02' = { - name: applicationInsightsName - location: location - tags: tags - kind: 'web' - properties: { - Application_Type: 'web' - WorkspaceResourceId: workspace.id - } -} - -resource otlpHeadersValue 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (otlpHeadersEnabled) { - parent: keyVault - name: 'cao-otel-headers' - properties: { - value: otlpHeaders - contentType: 'OTLP collector authentication headers' - } -} - -resource functionApp 'Microsoft.Web/sites@2023-12-01' = { - name: functionAppName - location: location - tags: tags - kind: 'functionapp,linux,container' - identity: { - type: 'SystemAssigned' - } - properties: { - serverFarmId: plan.id - virtualNetworkSubnetId: network.outputs.functionSubnetId - httpsOnly: true - publicNetworkAccess: functionPublicNetworkAccess - clientAffinityEnabled: false - siteConfig: { - ipSecurityRestrictionsDefaultAction: functionAccessRestrictionsDefaultAction - ipSecurityRestrictions: functionAccessRestrictions - scmIpSecurityRestrictionsDefaultAction: 'Deny' - scmIpSecurityRestrictions: functionScmAccessRestrictions - scmIpSecurityRestrictionsUseMain: functionScmAccessRestrictionsUseMain - alwaysOn: true - minimumElasticInstanceCount: 1 - ftpsState: 'Disabled' - minTlsVersion: '1.2' - http20Enabled: true - linuxFxVersion: 'DOCKER|${registry.properties.loginServer}/${functionImageRepository}${functionImageSelector}' - appCommandLine: 'azure-functions' - acrUseManagedIdentityCreds: true - appSettings: concat([ - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'FUNCTIONS_WORKER_RUNTIME' - value: 'custom' - } - { - name: 'WEBSITES_PORT' - value: '8080' - } - { - name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' - value: 'false' - } - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: insights.properties.ConnectionString - } - { - name: 'AzureWebJobsStorage__accountName' - value: storage.name - } - { - name: 'AzureWebJobsStorage__credential' - value: 'managedidentity' - } - { - name: 'CAO_DASHBOARD_HOSTING' - value: 'azure-functions' - } - { - name: 'CAO_POLICY_PATH' - value: '.github/workflows/cao.azure.json' - } - { - name: 'CAO_DATABASE_QUERIES' - value: '/app/queries/database.json' - } - { - name: 'CAO_AZURE_ALLOWED_HOSTS' - value: join(allowedHosts, ',') - } - { - name: 'CAO_AZURE_REQUIRE_HTTPS' - value: 'true' - } - { - name: 'REDIS_NAMESPACE' - value: 'azure-dashboard' - } - { - name: 'CAO_OPERATIONAL_NAMESPACE' - value: 'azure-dashboard-operational' - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_BYTES' - value: string(operationalCacheMaxBytes) - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES' - value: string(operationalCacheMaxValueBytes) - } - { - name: 'CAO_OPERATIONAL_CACHE_MAX_ENTRIES' - value: string(operationalCacheMaxEntries) - } - { - name: 'CAO_OPERATIONAL_PROTECTED_MAX_BYTES' - value: string(operationalProtectedMaxBytes) - } - { - name: 'CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES' - value: string(operationalProtectedMaxEntries) - } - { - name: 'CAO_POSTGRES_URL' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/${postgresUrlSecretName})' - } - { - name: 'CAO_POSTGRES_RUN_RETENTION_DAYS' - value: string(postgresRunRetentionDays) - } - { - name: 'CAO_POSTGRES_LINKED_RETENTION_DAYS' - value: string(postgresRunRetentionDays) - } - { - name: 'CAO_GITHUB_CLIENT_ID' - value: githubClientId - } - { - name: 'CAO_GITHUB_CLIENT_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/github-oauth-client-secret)' - } - { - name: 'CAO_GITHUB_REDIRECT_URL' - value: githubRedirectUri - } - { - name: 'CAO_GITHUB_ALLOWED_ORGS' - value: join(githubAllowedOrganizations, ',') - } - { - name: 'CAO_GITHUB_ALLOWED_TEAMS' - value: join(githubAllowedTeams, ',') - } - { - name: 'CAO_GITHUB_ALLOWED_USERS' - value: join(githubAllowedUsers, ',') - } - { - name: 'CAO_SESSION_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-session-secret)' - } - ], otlpEnvironment, !otlpLogsEnabled ? [] : [ - { - name: 'DEBUG' - value: 'cao:server,cao:telemetry' - } - ], !otlpHeadersEnabled ? [] : [ - { - name: 'OTEL_EXPORTER_OTLP_HEADERS' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-otel-headers)' - } - ], empty(previousSessionSecret) ? [] : [ - { - name: 'CAO_SESSION_SECRET_PREVIOUS' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-session-secret-previous)' - } - ], !collectionEnabled ? [] : [ - // With collection configured the Function App admits webhook - // deliveries into the collection queue. It performs no collection of - // its own, so its per-request work stays constant. - { - name: 'CAO_GITHUB_WEBHOOK_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVault.properties.vaultUri}secrets/cao-github-webhook-secret)' - } - { - name: 'CAO_GITHUB_ADMIN_USERS' - value: join(githubAdminUsers, ',') - } - { - name: 'CAO_COLLECT_APP_ID' - value: collectorGithubAppId - } - { - name: 'CAO_COLLECT_NATIVE' - value: 'true' - } - { - // The Function App verifies deliveries and enqueues work. It never - // calls GitHub and never projects, so it is given no App private key - // or inventory bytes: the internet-facing front end holds no - // credential it cannot use. - name: 'CAO_COLLECT_ADMIT_ONLY' - value: 'true' - } - { - name: 'CAO_COLLECT_CONTROL_REPOSITORY' - value: collectorControlRepository - } - ]) - } - } - dependsOn: [ - githubClientSecretValue - postgresConnectionStringValue - sessionSecretValue - previousSessionSecretValue - otlpHeadersValue - ] -} - -module functionRoles 'function-roles.bicep' = { - name: 'function-managed-identity-roles' - params: { - storageAccountName: storage.name - keyVaultName: keyVault.name - containerRegistryName: registry.name - functionPrincipalId: functionApp.identity.principalId - secretNames: concat([ - postgresUrlSecretName - githubClientSecretValue.name - sessionSecretValue.name - ], empty(previousSessionSecret) ? [] : [previousSessionSecretValue.name], !collectionEnabled ? [] : [githubWebhookSecretValue.name], !otlpHeadersEnabled ? [] : ['cao-otel-headers']) - } -} - -module collection 'collector.bicep' = if (collectionEnabled) { - name: 'collection-workers' - params: { - location: location - tags: tags - namePrefix: functionAppName - environmentName: containerEnvironment.name - containerRegistryName: registry.name - collectorImage: collectorImage - postgresRunRetentionDays: postgresRunRetentionDays - keyVaultUri: keyVault.properties.vaultUri - keyVaultName: keyVault.name - postgresUrlSecretName: postgresUrlSecretName - operationalNamespace: 'azure-dashboard-operational' - operationalCacheMaxBytes: operationalCacheMaxBytes - operationalCacheMaxValueBytes: operationalCacheMaxValueBytes - operationalCacheMaxEntries: operationalCacheMaxEntries - operationalProtectedMaxBytes: operationalProtectedMaxBytes - operationalProtectedMaxEntries: operationalProtectedMaxEntries - controlRepository: collectorControlRepository - nativeInventoryDigest: collectorNativeInventoryDigest - githubAppId: collectorGithubAppId - workerReplicas: collectorWorkerReplicas - applicationInsightsConnectionString: insights.properties.ConnectionString - otlpEnvironment: otlpEnvironment - otlpLogsEnabled: otlpLogsEnabled - otlpHeadersEnabled: otlpHeadersEnabled - } - dependsOn: [ - collectorPrivateKeyValue - postgresConnectionStringValue - otlpHeadersValue - ] -} - -module ingestion 'ingestion.bicep' = { - name: 'snapshot-ingestion' - params: { - location: location - namePrefix: functionAppName - environmentName: containerEnvironment.name - containerRegistryName: registry.name - keyVaultName: keyVault.name - ingestionImage: '${registry.properties.loginServer}/${ingestionImageRepository}${ingestionImageSelector}' - postgresRunRetentionDays: postgresRunRetentionDays - postgresUrlSecretName: postgresUrlSecretName - canonicalNamespace: 'azure-dashboard' - } - dependsOn: [postgresConnectionStringValue] -} - -module provisioning 'provisioning.bicep' = { - name: 'database-provisioning' - params: { - location: location - namePrefix: functionAppName - environmentName: containerEnvironment.name - containerRegistryName: registry.name - keyVaultName: keyVault.name - provisioningImage: '${registry.properties.loginServer}/${provisioningImageRepository}${provisioningImageSelector}' - postgresUrlSecretName: postgresUrlSecretName - postgresHostName: network.outputs.postgresHostName - postgresDatabaseName: network.outputs.databaseName - postgresRunRetentionDays: postgresRunRetentionDays - operationalCacheMaxBytes: operationalCacheMaxBytes - operationalCacheMaxValueBytes: operationalCacheMaxValueBytes - operationalCacheMaxEntries: operationalCacheMaxEntries - operationalProtectedMaxBytes: operationalProtectedMaxBytes - operationalProtectedMaxEntries: operationalProtectedMaxEntries - } - dependsOn: [postgresAdminUrl, postgresOwnerPasswordValue, postgresRuntimePasswordValue] -} - -output collectionEnabled bool = collectionEnabled -output containerRegistryLoginServer string = registry.properties.loginServer -output functionContainerImage string = '${registry.properties.loginServer}/${functionImageRepository}${functionImageSelector}' -output functionHostName string = functionApp.properties.defaultHostName -output githubOAuthRedirectUri string = githubRedirectUri -output keyVaultUri string = keyVault.properties.vaultUri -output ingestionJobName string = ingestion.outputs.jobName -output provisioningJobName string = provisioning.outputs.jobName -output logAnalyticsWorkspaceId string = workspace.id -output applicationInsightsId string = insights.id -output postgresHostName string = network.outputs.postgresHostName -output adxClusterId string = adxEnabled ? adx!.outputs.clusterId : '' -output adxQueryEndpoint string = adxEnabled ? adx!.outputs.queryEndpoint : '' -output adxDatabaseName string = adxEnabled ? adx!.outputs.databaseName : '' diff --git a/server/azure/main.json b/server/azure/main.json deleted file mode 100644 index 8a4bc34768..0000000000 --- a/server/azure/main.json +++ /dev/null @@ -1,2598 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "17295245798372953389" - } - }, - "parameters": { - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for all dashboard resources." - } - }, - "functionAppName": { - "type": "string", - "minLength": 2, - "maxLength": 19, - "metadata": { - "description": "Globally unique lowercase Function App name and job prefix, starting with a letter and without consecutive hyphens. At most 19 characters keeps the derived -db-provision job within the Container Apps 32-character limit." - } - }, - "functionPublicNetworkAccess": { - "type": "string", - "defaultValue": "Enabled", - "allowedValues": [ - "Disabled", - "Enabled" - ], - "metadata": { - "description": "Default endpoint access. Enabled (default) serves the dashboard over public HTTPS behind GitHub OAuth sign-in; no VPN, proxy, or corporate-network rule is required. Set Disabled only for a deliberately private deployment." - } - }, - "functionAccessRestrictionsDefaultAction": { - "type": "string", - "defaultValue": "Allow", - "allowedValues": [ - "Allow", - "Deny" - ], - "metadata": { - "description": "Default action for unmatched main-site requests. Allow (default) is the public-main model: GitHub OAuth remains the access control. Use Deny with explicit rules only for a deliberately restricted deployment. Never changes SCM restrictions." - } - }, - "functionAccessRestrictions": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Reviewed main-site access restriction rules. Explicitly carry forward existing approved rules during redeployment; omission uses an empty list. Unmatched requests use functionAccessRestrictionsDefaultAction." - } - }, - "functionScmAccessRestrictions": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Reviewed SCM/Kudu access restriction rules, separate from main-site rules. Explicitly carry forward existing approved SCM rules; omission uses an empty list." - } - }, - "functionScmAccessRestrictionsUseMain": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Whether SCM reuses main-site rules. Carry forward the existing approved setting; false keeps SCM independently restricted by default." - } - }, - "hostingPlanName": { - "type": "string", - "defaultValue": "[format('{0}-plan', parameters('functionAppName'))]", - "metadata": { - "description": "Azure App Service plan name for the Function App." - } - }, - "applicationInsightsName": { - "type": "string", - "defaultValue": "[format('{0}-ai', parameters('functionAppName'))]", - "metadata": { - "description": "Application Insights component name." - } - }, - "otlpEndpoint": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional reviewed HTTPS OTLP/HTTP collector base URL, without credentials, query or fragment. Empty disables Go export; Application Insights settings alone only configure the Functions host." - } - }, - "otlpLogsEnabled": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Explicit opt-in to export bounded operational Go logs to the OTLP collector. Independent audit delivery is unchanged." - } - }, - "otlpHeaders": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional OTLP authentication headers from a secret manager. Stored only in Key Vault; never supply literal values in command-line arguments or checked-in parameters." - } - }, - "storageAccountName": { - "type": "string", - "metadata": { - "description": "Globally unique storage account name for Azure Functions runtime state." - } - }, - "containerRegistryName": { - "type": "string", - "metadata": { - "description": "Globally unique Azure Container Registry name containing the Function App image." - } - }, - "functionImageRepository": { - "type": "string", - "defaultValue": "cao-functions", - "metadata": { - "description": "Container repository containing cao-dashboard azure-functions. The existing cao-functions image repository remains supported." - } - }, - "functionImageTag": { - "type": "string", - "metadata": { - "description": "Immutable or reviewed tag of the CAO Azure Functions runtime image." - } - }, - "functionImageDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact Functions image digest, including sha256:. When supplied, takes precedence over the tag. Does not establish scan or production assurance." - } - }, - "keyVaultName": { - "type": "string", - "metadata": { - "description": "Key Vault name used for GitHub OAuth, session, PostgreSQL, and collection secrets." - } - }, - "keyVaultPurgeProtection": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Enable purge protection for production vaults. Disable only for explicitly disposable environments that must be purged after testing. Existing protected vaults cannot be downgraded." - } - }, - "postgresServerName": { - "type": "string", - "defaultValue": "[format('{0}-pg', parameters('functionAppName'))]", - "metadata": { - "description": "Globally unique name for a new private PostgreSQL Flexible Server." - } - }, - "postgresAdministratorLogin": { - "type": "string", - "defaultValue": "caoadmin" - }, - "postgresAdministratorPassword": { - "type": "securestring", - "minLength": 16, - "metadata": { - "description": "PostgreSQL administrator password. Use secret-manager input; never commit it." - } - }, - "postgresSkuName": { - "type": "string", - "defaultValue": "Standard_B2s" - }, - "postgresSkuTier": { - "type": "string", - "defaultValue": "Burstable" - }, - "subnetNetworkSecurityGroupId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing network security group kept attached to every private subnet. Supply it when an approved NSG is already attached so redeployment does not detach it." - } - }, - "postgresDatabaseName": { - "type": "string", - "defaultValue": "cao", - "minLength": 1, - "maxLength": 63, - "metadata": { - "description": "PostgreSQL database that holds the canonical and operational stores. The default matches existing deployments. A new name creates a new, empty database; the previous database is retained, not deleted." - } - }, - "postgresUrlSecretName": { - "type": "string", - "defaultValue": "cao-postgres-runtime-url", - "minLength": 1, - "maxLength": 110, - "metadata": { - "description": "Runtime-only PostgreSQL URL secret. Use a new name and database when moving from the administrator-backed topology." - } - }, - "postgresOwnerPassword": { - "type": "securestring", - "minLength": 16, - "metadata": { - "description": "Separately generated bootstrap-login password from the operator secret manager. Never reuse the administrator or runtime password." - } - }, - "postgresRuntimePassword": { - "type": "securestring", - "minLength": 16, - "metadata": { - "description": "Separately generated runtime-login password from the operator secret manager." - } - }, - "provisioningImageRepository": { - "type": "string", - "defaultValue": "cao-db-provision", - "metadata": { - "description": "ACR repository containing the azure-db-provision Dockerfile target." - } - }, - "provisioningImageTag": { - "type": "string", - "defaultValue": "[parameters('functionImageTag')]" - }, - "provisioningImageDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact database-provisioning image digest, including sha256:. When supplied, takes precedence over the tag." - } - }, - "ingestionImageRepository": { - "type": "string", - "defaultValue": "cao-ingest", - "metadata": { - "description": "Repository of an explicitly built snapshot or empty ingestion image in this deployment registry." - } - }, - "ingestionImageTag": { - "type": "string", - "defaultValue": "[parameters('functionImageTag')]" - }, - "ingestionImageDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact ingestion image digest, including sha256:. When supplied, takes precedence over the tag." - } - }, - "operationalCacheMaxBytes": { - "type": "int", - "defaultValue": 33554432, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in the disposable operational cache." - } - }, - "operationalCacheMaxValueBytes": { - "type": "int", - "defaultValue": 4194304, - "minValue": 1, - "metadata": { - "description": "Maximum size of one disposable operational cache value." - } - }, - "operationalCacheMaxEntries": { - "type": "int", - "defaultValue": 1024, - "minValue": 1, - "metadata": { - "description": "Maximum number of disposable operational cache entries." - } - }, - "operationalProtectedMaxBytes": { - "type": "int", - "defaultValue": 134217728, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in protected operational records." - } - }, - "operationalProtectedMaxEntries": { - "type": "int", - "defaultValue": 200000, - "minValue": 1, - "metadata": { - "description": "Maximum number of protected operational records." - } - }, - "postgresRunRetentionDays": { - "type": "int", - "defaultValue": 45, - "minValue": 7, - "maxValue": 3650, - "metadata": { - "description": "Run and linked history retention in days. Before starting any role, explicitly preserve at least the greatest existing effective retention across all roles and the snapshot window. The 45-day default is not an upgrade recommendation." - } - }, - "allowedHosts": { - "type": "array", - "metadata": { - "description": "Public host names that Azure Front Door/App Service is allowed to forward to the Go dashboard handler." - } - }, - "githubAllowedOrganizations": { - "type": "array", - "metadata": { - "description": "GitHub organizations whose active members are authorized." - } - }, - "githubAllowedTeams": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "GitHub teams whose active members are authorized, expressed as org/team-slug." - } - }, - "githubAllowedUsers": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "GitHub user logins authorized directly, without organization membership lookup." - } - }, - "githubClientId": { - "type": "string", - "metadata": { - "description": "GitHub OAuth App client ID. Do not use a PAT; the server only supports the OAuth authorization-code flow." - } - }, - "githubClientSecret": { - "type": "securestring", - "metadata": { - "description": "GitHub OAuth App client secret. It is written only to Key Vault and consumed by the Function App through a Key Vault reference." - } - }, - "sessionSecret": { - "type": "securestring", - "minLength": 32, - "metadata": { - "description": "At least 32 characters of random session secret material used to encrypt server-side OAuth sessions." - } - }, - "previousSessionSecret": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional previous session secret retained during controlled key rotation until active sessions and pending revocations are drained." - } - }, - "logAnalyticsWorkspaceName": { - "type": "string", - "defaultValue": "[format('{0}-logs', parameters('functionAppName'))]", - "metadata": { - "description": "Single Log Analytics workspace shared by every role in this deployment." - } - }, - "collectorImage": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional container image running the collection role. Empty deploys no collection." - } - }, - "collectorGithubAppId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "GitHub App identifier whose installations define ingestion scope. Required with collectorImage." - } - }, - "collectorPrivateKey": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "GitHub App private key in PEM form. Required with collectorImage." - } - }, - "githubWebhookSecret": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Shared secret verifying GitHub webhook deliveries. Required with collectorImage." - } - }, - "githubAdminUsers": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "GitHub logins permitted to run administrative operations." - } - }, - "collectorControlRepository": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Control repository named by the reviewed inventory, in OWNER/REPOSITORY form." - } - }, - "collectorNativeInventoryDigest": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Exact lowercase SHA-256 digest of immutable reviewed inventory bytes already provisioned in the shared PostgreSQL namespace. Required when collectorImage is set; an absent or unprovisioned digest prevents the workers from starting." - } - }, - "collectorWorkerReplicas": { - "type": "int", - "defaultValue": 1, - "minValue": 1, - "metadata": { - "description": "Fixed number of PostgreSQL-backed collection workers." - } - }, - "adxEnabled": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Provision an optional paid ADX destination only. Does not enable export, traces, or mining; Azure Functions is not a supported exporter host." - } - }, - "adxClusterName": { - "type": "string", - "defaultValue": "[format('adx{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", - "minLength": 4, - "maxLength": 22, - "metadata": { - "description": "Globally unique ADX cluster name: lowercase letters and digits only." - } - }, - "adxDatabaseName": { - "type": "string", - "defaultValue": "CAO", - "minLength": 1, - "maxLength": 100, - "metadata": { - "description": "ADX database identifier: ASCII letter followed by letters, digits, or underscores." - } - }, - "adxSkuName": { - "type": "string", - "defaultValue": "Dev(No SLA)_Standard_E2a_v4", - "metadata": { - "description": "Reviewed paid ADX SKU. The default is development-only without an SLA, not the free offering." - } - }, - "adxSkuTier": { - "type": "string", - "defaultValue": "Basic", - "allowedValues": [ - "Basic", - "Standard" - ] - }, - "adxCapacity": { - "type": "int", - "defaultValue": 1, - "minValue": 1, - "metadata": { - "description": "Reviewed ADX instance count compatible with the selected SKU and tier." - } - } - }, - "variables": { - "otlpEnvironment": [ - { - "name": "OTEL_EXPORTER_OTLP_ENDPOINT", - "value": "[parameters('otlpEndpoint')]" - }, - { - "name": "OTEL_EXPORTER_OTLP_PROTOCOL", - "value": "http/protobuf" - }, - { - "name": "CAO_OTEL_LOGS_ENABLED", - "value": "[string(parameters('otlpLogsEnabled'))]" - } - ], - "otlpHeadersEnabled": "[and(not(empty(parameters('otlpEndpoint'))), not(empty(parameters('otlpHeaders'))))]", - "functionImageSelector": "[if(empty(parameters('functionImageDigest')), format(':{0}', parameters('functionImageTag')), format('@{0}', parameters('functionImageDigest')))]", - "ingestionImageSelector": "[if(empty(parameters('ingestionImageDigest')), format(':{0}', parameters('ingestionImageTag')), format('@{0}', parameters('ingestionImageDigest')))]", - "provisioningImageSelector": "[if(empty(parameters('provisioningImageDigest')), format(':{0}', parameters('provisioningImageTag')), format('@{0}', parameters('provisioningImageDigest')))]", - "collectionEnabled": "[not(empty(parameters('collectorImage')))]", - "tags": { - "workload": "gh-cao-dashboard", - "hostingMode": "azure-functions" - }, - "githubRedirectUri": "[format('https://{0}.azurewebsites.net/auth/callback', parameters('functionAppName'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2023-09-01", - "name": "[parameters('logAnalyticsWorkspaceName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "properties": { - "sku": { - "name": "PerGB2018" - }, - "retentionInDays": 30 - } - }, - { - "type": "Microsoft.App/managedEnvironments", - "apiVersion": "2024-03-01", - "name": "[format('{0}-jobs', parameters('functionAppName'))]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "properties": { - "appLogsConfiguration": { - "destination": "azure-monitor" - }, - "vnetConfiguration": { - "infrastructureSubnetId": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.containerSubnetId.value]", - "internal": true - }, - "zoneRedundant": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "name": "container-logs", - "properties": { - "workspaceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]", - "logs": [ - { - "categoryGroup": "allLogs", - "enabled": true - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - ] - }, - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2023-05-01", - "name": "[parameters('storageAccountName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "sku": { - "name": "Standard_LRS" - }, - "kind": "StorageV2", - "properties": { - "allowSharedKeyAccess": false, - "allowBlobPublicAccess": false, - "minimumTlsVersion": "TLS1_2", - "supportsHttpsTrafficOnly": true - } - }, - { - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2023-07-01", - "name": "[parameters('containerRegistryName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "sku": { - "name": "Basic" - }, - "properties": { - "adminUserEnabled": false, - "publicNetworkAccess": "Enabled" - } - }, - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2023-07-01", - "name": "[parameters('keyVaultName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "properties": { - "tenantId": "[subscription().tenantId]", - "sku": { - "family": "A", - "name": "standard" - }, - "enableRbacAuthorization": true, - "enabledForTemplateDeployment": false, - "enableSoftDelete": true, - "enablePurgeProtection": "[if(parameters('keyVaultPurgeProtection'), true(), null())]", - "softDeleteRetentionInDays": 90, - "publicNetworkAccess": "Enabled" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'github-oauth-client-secret')]", - "properties": { - "value": "[parameters('githubClientSecret')]", - "contentType": "GitHub OAuth client secret for CAO dashboard Azure Functions mode" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-session-secret')]", - "properties": { - "value": "[parameters('sessionSecret')]", - "contentType": "CAO dashboard session encryption secret" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "condition": "[not(empty(parameters('previousSessionSecret')))]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-session-secret-previous')]", - "properties": { - "value": "[parameters('previousSessionSecret')]", - "contentType": "Previous CAO dashboard session encryption secret retained during rotation" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "properties": { - "value": "[format('postgresql://cao_runtime_login:{0}@{1}:5432/{2}?sslmode=verify-full&options=-csearch_path%3Dcao', uriComponent(parameters('postgresRuntimePassword')), reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value, uriComponent(reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.databaseName.value))]", - "contentType": "CAO runtime-only PostgreSQL connection URL" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), format('{0}-admin', parameters('postgresUrlSecretName')))]", - "properties": { - "value": "[format('postgresql://{0}:{1}@{2}:5432/{3}?sslmode=verify-full', uriComponent(parameters('postgresAdministratorLogin')), uriComponent(parameters('postgresAdministratorPassword')), reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value, uriComponent(reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.databaseName.value))]", - "contentType": "Short-lived CAO database provisioning only" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), format('{0}-owner-password', parameters('postgresUrlSecretName')))]", - "properties": { - "value": "[parameters('postgresOwnerPassword')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), format('{0}-runtime-password', parameters('postgresUrlSecretName')))]", - "properties": { - "value": "[parameters('postgresRuntimePassword')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "condition": "[variables('collectionEnabled')]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-collect-private-key')]", - "properties": { - "value": "[parameters('collectorPrivateKey')]", - "contentType": "GitHub App private key for the collection profile" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "condition": "[variables('collectionEnabled')]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-github-webhook-secret')]", - "properties": { - "value": "[parameters('githubWebhookSecret')]", - "contentType": "GitHub webhook shared secret" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2023-12-01", - "name": "[parameters('hostingPlanName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "elastic", - "sku": { - "name": "EP1", - "tier": "ElasticPremium" - }, - "properties": { - "reserved": true - } - }, - { - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[parameters('applicationInsightsName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "web", - "properties": { - "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - }, - "dependsOn": [ - "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - ] - }, - { - "condition": "[variables('otlpHeadersEnabled')]", - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'cao-otel-headers')]", - "properties": { - "value": "[parameters('otlpHeaders')]", - "contentType": "OTLP collector authentication headers" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.Web/sites", - "apiVersion": "2023-12-01", - "name": "[parameters('functionAppName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "functionapp,linux,container", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('hostingPlanName'))]", - "virtualNetworkSubnetId": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.functionSubnetId.value]", - "httpsOnly": true, - "publicNetworkAccess": "[parameters('functionPublicNetworkAccess')]", - "clientAffinityEnabled": false, - "siteConfig": { - "ipSecurityRestrictionsDefaultAction": "[parameters('functionAccessRestrictionsDefaultAction')]", - "ipSecurityRestrictions": "[parameters('functionAccessRestrictions')]", - "scmIpSecurityRestrictionsDefaultAction": "Deny", - "scmIpSecurityRestrictions": "[parameters('functionScmAccessRestrictions')]", - "scmIpSecurityRestrictionsUseMain": "[parameters('functionScmAccessRestrictionsUseMain')]", - "alwaysOn": true, - "minimumElasticInstanceCount": 1, - "ftpsState": "Disabled", - "minTlsVersion": "1.2", - "http20Enabled": true, - "linuxFxVersion": "[format('DOCKER|{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('functionImageRepository'), variables('functionImageSelector'))]", - "appCommandLine": "azure-functions", - "acrUseManagedIdentityCreds": true, - "appSettings": "[concat(createArray(createObject('name', 'FUNCTIONS_EXTENSION_VERSION', 'value', '~4'), createObject('name', 'FUNCTIONS_WORKER_RUNTIME', 'value', 'custom'), createObject('name', 'WEBSITES_PORT', 'value', '8080'), createObject('name', 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', 'value', 'false'), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference(resourceId('Microsoft.Insights/components', parameters('applicationInsightsName')), '2020-02-02').ConnectionString), createObject('name', 'AzureWebJobsStorage__accountName', 'value', parameters('storageAccountName')), createObject('name', 'AzureWebJobsStorage__credential', 'value', 'managedidentity'), createObject('name', 'CAO_DASHBOARD_HOSTING', 'value', 'azure-functions'), createObject('name', 'CAO_POLICY_PATH', 'value', '.github/workflows/cao.azure.json'), createObject('name', 'CAO_DATABASE_QUERIES', 'value', '/app/queries/database.json'), createObject('name', 'CAO_AZURE_ALLOWED_HOSTS', 'value', join(parameters('allowedHosts'), ',')), createObject('name', 'CAO_AZURE_REQUIRE_HTTPS', 'value', 'true'), createObject('name', 'REDIS_NAMESPACE', 'value', 'azure-dashboard'), createObject('name', 'CAO_OPERATIONAL_NAMESPACE', 'value', 'azure-dashboard-operational'), createObject('name', 'CAO_OPERATIONAL_CACHE_MAX_BYTES', 'value', string(parameters('operationalCacheMaxBytes'))), createObject('name', 'CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES', 'value', string(parameters('operationalCacheMaxValueBytes'))), createObject('name', 'CAO_OPERATIONAL_CACHE_MAX_ENTRIES', 'value', string(parameters('operationalCacheMaxEntries'))), createObject('name', 'CAO_OPERATIONAL_PROTECTED_MAX_BYTES', 'value', string(parameters('operationalProtectedMaxBytes'))), createObject('name', 'CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES', 'value', string(parameters('operationalProtectedMaxEntries'))), createObject('name', 'CAO_POSTGRES_URL', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/{1})', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri, parameters('postgresUrlSecretName'))), createObject('name', 'CAO_POSTGRES_RUN_RETENTION_DAYS', 'value', string(parameters('postgresRunRetentionDays'))), createObject('name', 'CAO_POSTGRES_LINKED_RETENTION_DAYS', 'value', string(parameters('postgresRunRetentionDays'))), createObject('name', 'CAO_GITHUB_CLIENT_ID', 'value', parameters('githubClientId')), createObject('name', 'CAO_GITHUB_CLIENT_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/github-oauth-client-secret)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)), createObject('name', 'CAO_GITHUB_REDIRECT_URL', 'value', variables('githubRedirectUri')), createObject('name', 'CAO_GITHUB_ALLOWED_ORGS', 'value', join(parameters('githubAllowedOrganizations'), ',')), createObject('name', 'CAO_GITHUB_ALLOWED_TEAMS', 'value', join(parameters('githubAllowedTeams'), ',')), createObject('name', 'CAO_GITHUB_ALLOWED_USERS', 'value', join(parameters('githubAllowedUsers'), ',')), createObject('name', 'CAO_SESSION_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-session-secret)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri))), variables('otlpEnvironment'), if(not(parameters('otlpLogsEnabled')), createArray(), createArray(createObject('name', 'DEBUG', 'value', 'cao:server,cao:telemetry'))), if(not(variables('otlpHeadersEnabled')), createArray(), createArray(createObject('name', 'OTEL_EXPORTER_OTLP_HEADERS', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-otel-headers)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)))), if(empty(parameters('previousSessionSecret')), createArray(), createArray(createObject('name', 'CAO_SESSION_SECRET_PREVIOUS', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-session-secret-previous)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)))), if(not(variables('collectionEnabled')), createArray(), createArray(createObject('name', 'CAO_GITHUB_WEBHOOK_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cao-github-webhook-secret)', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)), createObject('name', 'CAO_GITHUB_ADMIN_USERS', 'value', join(parameters('githubAdminUsers'), ',')), createObject('name', 'CAO_COLLECT_APP_ID', 'value', parameters('collectorGithubAppId')), createObject('name', 'CAO_COLLECT_NATIVE', 'value', 'true'), createObject('name', 'CAO_COLLECT_ADMIT_ONLY', 'value', 'true'), createObject('name', 'CAO_COLLECT_CONTROL_REPOSITORY', 'value', parameters('collectorControlRepository')))))]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'github-oauth-client-secret')]", - "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-otel-headers')]", - "[resourceId('Microsoft.Web/serverfarms', parameters('hostingPlanName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret-previous')]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret')]", - "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "private-network-postgres", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "postgresServerName": { - "value": "[parameters('postgresServerName')]" - }, - "postgresAdministratorLogin": { - "value": "[parameters('postgresAdministratorLogin')]" - }, - "postgresAdministratorPassword": { - "value": "[parameters('postgresAdministratorPassword')]" - }, - "postgresSkuName": { - "value": "[parameters('postgresSkuName')]" - }, - "postgresSkuTier": { - "value": "[parameters('postgresSkuTier')]" - }, - "subnetNetworkSecurityGroupId": { - "value": "[parameters('subnetNetworkSecurityGroupId')]" - }, - "postgresDatabaseName": { - "value": "[parameters('postgresDatabaseName')]" - }, - "adxEnabled": { - "value": "[parameters('adxEnabled')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "10800549044802487649" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "namePrefix": { - "type": "string" - }, - "postgresServerName": { - "type": "string" - }, - "postgresAdministratorLogin": { - "type": "string" - }, - "postgresAdministratorPassword": { - "type": "securestring" - }, - "postgresSkuName": { - "type": "string", - "defaultValue": "Standard_B2s" - }, - "postgresSkuTier": { - "type": "string", - "defaultValue": "Burstable" - }, - "subnetNetworkSecurityGroupId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing network security group to keep attached to every subnet. Empty leaves subnets without an NSG." - } - }, - "postgresDatabaseName": { - "type": "string", - "defaultValue": "cao", - "minLength": 1, - "maxLength": 63, - "metadata": { - "description": "PostgreSQL database that holds the canonical and operational stores. Changing it creates a new, empty database; the previous database is not deleted." - } - }, - "adxEnabled": { - "type": "bool", - "defaultValue": false - } - }, - "variables": { - "subnetNetworkSecurityGroup": "[if(empty(parameters('subnetNetworkSecurityGroupId')), createObject(), createObject('networkSecurityGroup', createObject('id', parameters('subnetNetworkSecurityGroupId'))))]" - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2024-05-01", - "name": "[format('{0}-vnet', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "properties": { - "addressSpace": { - "addressPrefixes": [ - "10.42.0.0/16" - ] - }, - "subnets": "[concat(createArray(createObject('name', 'functions', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.0.0/24', 'delegations', createArray(createObject('name', 'functions', 'properties', createObject('serviceName', 'Microsoft.Web/serverFarms')))))), createObject('name', 'postgres', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.1.0/24', 'delegations', createArray(createObject('name', 'postgres', 'properties', createObject('serviceName', 'Microsoft.DBforPostgreSQL/flexibleServers')))))), createObject('name', 'containers', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.2.0/23')))), if(parameters('adxEnabled'), createArray(createObject('name', 'adx', 'properties', union(variables('subnetNetworkSecurityGroup'), createObject('addressPrefix', '10.42.4.0/24', 'privateEndpointNetworkPolicies', 'Disabled')))), createArray()))]" - } - }, - { - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2024-06-01", - "name": "[format('{0}.private.postgres.database.azure.com', parameters('postgresServerName'))]", - "location": "global" - }, - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')), format('{0}-postgres', parameters('namePrefix')))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - ] - }, - { - "type": "Microsoft.DBforPostgreSQL/flexibleServers", - "apiVersion": "2024-08-01", - "name": "[parameters('postgresServerName')]", - "location": "[parameters('location')]", - "sku": { - "name": "[parameters('postgresSkuName')]", - "tier": "[parameters('postgresSkuTier')]" - }, - "properties": { - "version": "16", - "administratorLogin": "[parameters('postgresAdministratorLogin')]", - "administratorLoginPassword": "[parameters('postgresAdministratorPassword')]", - "network": { - "delegatedSubnetResourceId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'postgres')]", - "privateDnsZoneArmResourceId": "[resourceId('Microsoft.Network/privateDnsZones', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')))]", - "publicNetworkAccess": "Disabled" - }, - "storage": { - "storageSizeGB": 32, - "autoGrow": "Enabled" - }, - "backup": { - "backupRetentionDays": 7, - "geoRedundantBackup": "Disabled" - }, - "highAvailability": { - "mode": "Disabled" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', format('{0}.private.postgres.database.azure.com', parameters('postgresServerName')), format('{0}-postgres', parameters('namePrefix')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - ] - }, - { - "type": "Microsoft.DBforPostgreSQL/flexibleServers/databases", - "apiVersion": "2024-08-01", - "name": "[format('{0}/{1}', parameters('postgresServerName'), parameters('postgresDatabaseName'))]", - "properties": { - "charset": "UTF8", - "collation": "en_US.utf8" - }, - "dependsOn": [ - "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('postgresServerName'))]" - ] - } - ], - "outputs": { - "postgresHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('postgresServerName')), '2024-08-01').fullyQualifiedDomainName]" - }, - "databaseName": { - "type": "string", - "value": "[parameters('postgresDatabaseName')]" - }, - "functionSubnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'functions')]" - }, - "containerSubnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'containers')]" - }, - "virtualNetworkId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks', format('{0}-vnet', parameters('namePrefix')))]" - }, - "adxSubnetId": { - "type": "string", - "value": "[if(parameters('adxEnabled'), resourceId('Microsoft.Network/virtualNetworks/subnets', format('{0}-vnet', parameters('namePrefix')), 'adx'), '')]" - } - } - } - } - }, - { - "condition": "[parameters('adxEnabled')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "adx-destination", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "clusterName": { - "value": "[parameters('adxClusterName')]" - }, - "databaseName": { - "value": "[parameters('adxDatabaseName')]" - }, - "skuName": { - "value": "[parameters('adxSkuName')]" - }, - "skuTier": { - "value": "[parameters('adxSkuTier')]" - }, - "capacity": { - "value": "[parameters('adxCapacity')]" - }, - "virtualNetworkId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.virtualNetworkId.value]" - }, - "privateEndpointSubnetId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.adxSubnetId.value]" - }, - "tags": { - "value": "[variables('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "15737866968243541603" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "clusterName": { - "type": "string" - }, - "databaseName": { - "type": "string" - }, - "virtualNetworkId": { - "type": "string" - }, - "privateEndpointSubnetId": { - "type": "string" - }, - "tags": { - "type": "object", - "defaultValue": {} - }, - "skuName": { - "type": "string", - "defaultValue": "Dev(No SLA)_Standard_E2a_v4", - "metadata": { - "description": "Reviewed paid ADX SKU. The default is a development SKU without an SLA, not a free cluster." - } - }, - "skuTier": { - "type": "string", - "defaultValue": "Basic", - "allowedValues": [ - "Basic", - "Standard" - ] - }, - "capacity": { - "type": "int", - "defaultValue": 1, - "minValue": 1 - } - }, - "resources": [ - { - "type": "Microsoft.Kusto/clusters", - "apiVersion": "2024-04-13", - "name": "[parameters('clusterName')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "sku": { - "name": "[parameters('skuName')]", - "tier": "[parameters('skuTier')]", - "capacity": "[parameters('capacity')]" - }, - "properties": { - "publicNetworkAccess": "Disabled", - "enableDiskEncryption": true, - "enableStreamingIngest": true - } - }, - { - "type": "Microsoft.Kusto/clusters/databases", - "apiVersion": "2024-04-13", - "name": "[format('{0}/{1}', parameters('clusterName'), parameters('databaseName'))]", - "location": "[parameters('location')]", - "kind": "ReadWrite", - "properties": { - "softDeletePeriod": "P7D", - "hotCachePeriod": "P1D" - }, - "dependsOn": [ - "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]" - ] - }, - { - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2024-06-01", - "name": "[format('privatelink.{0}.kusto.windows.net', parameters('location'))]", - "location": "global" - }, - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', format('privatelink.{0}.kusto.windows.net', parameters('location')), format('{0}-adx', parameters('clusterName')))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('virtualNetworkId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.kusto.windows.net', parameters('location')))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-adx', parameters('clusterName'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "properties": { - "subnet": { - "id": "[parameters('privateEndpointSubnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "adx", - "properties": { - "privateLinkServiceId": "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]", - "groupIds": [ - "cluster" - ] - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-adx', parameters('clusterName')), 'adx')]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "adx", - "properties": { - "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.kusto.windows.net', parameters('location')))]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.kusto.windows.net', parameters('location')))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-adx', parameters('clusterName')))]" - ] - } - ], - "outputs": { - "clusterId": { - "type": "string", - "value": "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]" - }, - "queryEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Kusto/clusters', parameters('clusterName')), '2024-04-13').uri]" - }, - "databaseName": { - "type": "string", - "value": "[parameters('databaseName')]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "function-managed-identity-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "storageAccountName": { - "value": "[parameters('storageAccountName')]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "functionPrincipalId": { - "value": "[reference(resourceId('Microsoft.Web/sites', parameters('functionAppName')), '2023-12-01', 'full').identity.principalId]" - }, - "secretNames": { - "value": "[concat(createArray(parameters('postgresUrlSecretName'), 'github-oauth-client-secret', 'cao-session-secret'), if(empty(parameters('previousSessionSecret')), createArray(), createArray('cao-session-secret-previous')), if(not(variables('collectionEnabled')), createArray(), createArray('cao-github-webhook-secret')), if(not(variables('otlpHeadersEnabled')), createArray(), createArray('cao-otel-headers')))]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "11163691175506270168" - } - }, - "parameters": { - "storageAccountName": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "functionPrincipalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('functionPrincipalId'), 'Storage Blob Data Owner')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('functionPrincipalId'), 'Storage Queue Data Contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('functionPrincipalId'), 'Storage Table Data Contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "copy": { - "name": "keyVaultSecretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('functionPrincipalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('functionPrincipalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('functionPrincipalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', parameters('functionAppName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'github-oauth-client-secret')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-github-webhook-secret')]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret-previous')]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-session-secret')]", - "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" - ] - }, - { - "condition": "[variables('collectionEnabled')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "collection-workers", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "environmentName": { - "value": "[format('{0}-jobs', parameters('functionAppName'))]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "collectorImage": { - "value": "[parameters('collectorImage')]" - }, - "postgresRunRetentionDays": { - "value": "[parameters('postgresRunRetentionDays')]" - }, - "keyVaultUri": { - "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "postgresUrlSecretName": { - "value": "[parameters('postgresUrlSecretName')]" - }, - "operationalNamespace": { - "value": "azure-dashboard-operational" - }, - "operationalCacheMaxBytes": { - "value": "[parameters('operationalCacheMaxBytes')]" - }, - "operationalCacheMaxValueBytes": { - "value": "[parameters('operationalCacheMaxValueBytes')]" - }, - "operationalCacheMaxEntries": { - "value": "[parameters('operationalCacheMaxEntries')]" - }, - "operationalProtectedMaxBytes": { - "value": "[parameters('operationalProtectedMaxBytes')]" - }, - "operationalProtectedMaxEntries": { - "value": "[parameters('operationalProtectedMaxEntries')]" - }, - "controlRepository": { - "value": "[parameters('collectorControlRepository')]" - }, - "nativeInventoryDigest": { - "value": "[parameters('collectorNativeInventoryDigest')]" - }, - "githubAppId": { - "value": "[parameters('collectorGithubAppId')]" - }, - "workerReplicas": { - "value": "[parameters('collectorWorkerReplicas')]" - }, - "applicationInsightsConnectionString": { - "value": "[reference(resourceId('Microsoft.Insights/components', parameters('applicationInsightsName')), '2020-02-02').ConnectionString]" - }, - "otlpEnvironment": { - "value": "[variables('otlpEnvironment')]" - }, - "otlpLogsEnabled": { - "value": "[parameters('otlpLogsEnabled')]" - }, - "otlpHeadersEnabled": { - "value": "[variables('otlpHeadersEnabled')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "2327320549105080117" - } - }, - "parameters": { - "location": { - "type": "string", - "metadata": { - "description": "Location for the collection workers." - } - }, - "tags": { - "type": "object", - "metadata": { - "description": "Resource tags." - } - }, - "namePrefix": { - "type": "string", - "minLength": 2, - "maxLength": 22, - "metadata": { - "description": "Name prefix for the collection resources." - } - }, - "collectorImage": { - "type": "string", - "metadata": { - "description": "Container image running the collection role." - } - }, - "environmentName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string", - "metadata": { - "description": "Deployment registry containing the private collector image." - } - }, - "postgresRunRetentionDays": { - "type": "int", - "defaultValue": 45, - "minValue": 7, - "maxValue": 3650 - }, - "keyVaultUri": { - "type": "string", - "metadata": { - "description": "Key Vault URI holding the collection secrets." - } - }, - "keyVaultName": { - "type": "string", - "metadata": { - "description": "Key Vault resource name, used to grant the workers secret access." - } - }, - "postgresUrlSecretName": { - "type": "string", - "defaultValue": "cao-postgres-runtime-url", - "metadata": { - "description": "Key Vault secret that stores the PostgreSQL URL." - } - }, - "operationalCacheMaxBytes": { - "type": "int", - "defaultValue": 33554432, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in the disposable operational cache." - } - }, - "operationalCacheMaxValueBytes": { - "type": "int", - "defaultValue": 4194304, - "minValue": 1, - "metadata": { - "description": "Maximum size of one disposable operational cache value." - } - }, - "operationalCacheMaxEntries": { - "type": "int", - "defaultValue": 1024, - "minValue": 1, - "metadata": { - "description": "Maximum number of disposable operational cache entries." - } - }, - "operationalProtectedMaxBytes": { - "type": "int", - "defaultValue": 134217728, - "minValue": 1, - "metadata": { - "description": "Maximum bytes retained in protected operational records." - } - }, - "operationalProtectedMaxEntries": { - "type": "int", - "defaultValue": 200000, - "minValue": 1, - "metadata": { - "description": "Maximum number of protected operational records." - } - }, - "operationalNamespace": { - "type": "string", - "metadata": { - "description": "Operational PostgreSQL namespace shared with the Function App." - } - }, - "controlRepository": { - "type": "string", - "metadata": { - "description": "Control repository named by the reviewed inventory." - } - }, - "nativeInventoryDigest": { - "type": "string", - "minLength": 64, - "maxLength": 64, - "metadata": { - "description": "Exact provisioned lowercase SHA-256 digest of immutable reviewed inventory bytes. The workers fail startup if this digest is not in their shared PostgreSQL namespace." - } - }, - "githubAppId": { - "type": "string", - "metadata": { - "description": "GitHub App identifier whose installations define ingestion scope." - } - }, - "workerReplicas": { - "type": "int", - "defaultValue": 1, - "minValue": 1, - "metadata": { - "description": "Fixed number of PostgreSQL-backed collection workers." - } - }, - "applicationInsightsConnectionString": { - "type": "securestring", - "metadata": { - "description": "Platform Application Insights setting; does not activate Go OTLP exporters." - } - }, - "otlpEnvironment": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Shared Go OTLP/HTTP endpoint, protocol and explicit log opt-in from the parent deployment." - } - }, - "otlpLogsEnabled": { - "type": "bool", - "defaultValue": false - }, - "otlpHeadersEnabled": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Whether the parent provisioned the cao-otel-headers Key Vault secret." - } - }, - "queueMaxLength": { - "type": "int", - "defaultValue": 200000, - "minValue": 1000, - "metadata": { - "description": "Bound on the collection task and dead-letter streams." - } - } - }, - "variables": { - "collectionBaseEnvironment": [ - { - "name": "CAO_POSTGRES_URL", - "secretRef": "cao-postgres-url" - }, - { - "name": "CAO_POSTGRES_RUN_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POSTGRES_LINKED_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POLICY_PATH", - "value": "/app/.github/workflows/cao.azure.json" - }, - { - "name": "REDIS_NAMESPACE", - "value": "azure-dashboard" - }, - { - "name": "CAO_OPERATIONAL_NAMESPACE", - "value": "[parameters('operationalNamespace')]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_BYTES", - "value": "[string(parameters('operationalCacheMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES", - "value": "[string(parameters('operationalCacheMaxValueBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_ENTRIES", - "value": "[string(parameters('operationalCacheMaxEntries'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_BYTES", - "value": "[string(parameters('operationalProtectedMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES", - "value": "[string(parameters('operationalProtectedMaxEntries'))]" - }, - { - "name": "CAO_COLLECT_APP_ID", - "value": "[parameters('githubAppId')]" - }, - { - "name": "CAO_COLLECT_NATIVE", - "value": "true" - }, - { - "name": "CAO_COLLECT_NATIVE_INVENTORY_DIGEST", - "value": "[parameters('nativeInventoryDigest')]" - }, - { - "name": "CAO_COLLECT_PRIVATE_KEY", - "secretRef": "cao-collect-private-key" - }, - { - "name": "CAO_COLLECT_CONTROL_REPOSITORY", - "value": "[parameters('controlRepository')]" - }, - { - "name": "CAO_COLLECT_QUEUE_MAX_LENGTH", - "value": "[string(parameters('queueMaxLength'))]" - }, - { - "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", - "secretRef": "application-insights-connection-string" - }, - { - "name": "OTEL_SERVICE_NAME", - "value": "[format('{0}-collector', parameters('namePrefix'))]" - }, - { - "name": "DEBUG", - "value": "[if(parameters('otlpLogsEnabled'), 'cao:telemetry,cao:collect:recovery', 'cao:collect:*')]" - } - ], - "collectionEnvironment": "[concat(variables('collectionBaseEnvironment'), parameters('otlpEnvironment'), if(not(parameters('otlpHeadersEnabled')), createArray(), createArray(createObject('name', 'OTEL_EXPORTER_OTLP_HEADERS', 'secretRef', 'cao-otel-headers'))))]", - "collectionSecrets": "[concat(createArray(createObject('name', 'cao-postgres-url', 'keyVaultUrl', format('{0}secrets/{1}', parameters('keyVaultUri'), parameters('postgresUrlSecretName')), 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))), createObject('name', 'cao-collect-private-key', 'keyVaultUrl', format('{0}secrets/cao-collect-private-key', parameters('keyVaultUri')), 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))), createObject('name', 'application-insights-connection-string', 'value', parameters('applicationInsightsConnectionString'))), if(not(parameters('otlpHeadersEnabled')), createArray(), createArray(createObject('name', 'cao-otel-headers', 'keyVaultUrl', format('{0}secrets/cao-otel-headers', parameters('keyVaultUri')), 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))))))]" - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[format('{0}-collector-identity', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.App/containerApps", - "apiVersion": "2024-03-01", - "name": "[format('{0}-collector', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "activeRevisionsMode": "Single", - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]" - } - ], - "secrets": "[variables('collectionSecrets')]" - }, - "template": { - "containers": [ - { - "name": "collector", - "image": "[parameters('collectorImage')]", - "command": [ - "/app/cao-dashboard" - ], - "args": [ - "collect", - "--database-queries", - "/app/queries/database.json" - ], - "env": "[variables('collectionEnvironment')]", - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ], - "scale": { - "minReplicas": "[parameters('workerReplicas')]", - "maxReplicas": "[parameters('workerReplicas')]" - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'collector-roles')]" - ] - }, - { - "type": "Microsoft.App/jobs", - "apiVersion": "2024-03-01", - "name": "[format('{0}-backfill', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "triggerType": "Manual", - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]" - } - ], - "replicaTimeout": 7200, - "replicaRetryLimit": 1, - "manualTriggerConfig": { - "parallelism": 1, - "replicaCompletionCount": 1 - }, - "secrets": "[variables('collectionSecrets')]" - }, - "template": { - "containers": [ - { - "name": "backfill", - "image": "[parameters('collectorImage')]", - "command": [ - "/app/cao-dashboard" - ], - "args": [ - "backfill", - "--database-queries", - "/app/queries/database.json" - ], - "env": "[variables('collectionEnvironment')]", - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'collector-roles')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "collector-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "secretNames": { - "value": "[concat(createArray(parameters('postgresUrlSecretName'), 'cao-collect-private-key'), if(not(parameters('otlpHeadersEnabled')), createArray(), createArray('cao-otel-headers')))]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "12213718997281109477" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "copy": { - "name": "secretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('principalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('principalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix')))]" - ] - } - ], - "outputs": { - "collectorIdentityPrincipalId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-collector-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "collectorAppName": { - "type": "string", - "value": "[format('{0}-collector', parameters('namePrefix'))]" - }, - "backfillJobName": { - "type": "string", - "value": "[format('{0}-backfill', parameters('namePrefix'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-collect-private-key')]", - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'cao-otel-headers')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "snapshot-ingestion", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "environmentName": { - "value": "[format('{0}-jobs', parameters('functionAppName'))]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "ingestionImage": { - "value": "[format('{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('ingestionImageRepository'), variables('ingestionImageSelector'))]" - }, - "postgresRunRetentionDays": { - "value": "[parameters('postgresRunRetentionDays')]" - }, - "postgresUrlSecretName": { - "value": "[parameters('postgresUrlSecretName')]" - }, - "canonicalNamespace": { - "value": "azure-dashboard" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "9245532112067501862" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "namePrefix": { - "type": "string", - "minLength": 2, - "maxLength": 19 - }, - "environmentName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "ingestionImage": { - "type": "string" - }, - "postgresRunRetentionDays": { - "type": "int", - "defaultValue": 45, - "minValue": 7 - }, - "postgresUrlSecretName": { - "type": "string", - "defaultValue": "cao-postgres-runtime-url", - "metadata": { - "description": "Key Vault secret that stores the PostgreSQL URL." - } - }, - "canonicalNamespace": { - "type": "string", - "defaultValue": "azure-dashboard" - } - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[format('{0}-ingest-identity', parameters('namePrefix'))]", - "location": "[parameters('location')]" - }, - { - "type": "Microsoft.App/jobs", - "apiVersion": "2024-03-01", - "name": "[format('{0}-ingest', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "triggerType": "Manual", - "replicaTimeout": 7200, - "replicaRetryLimit": 0, - "manualTriggerConfig": { - "parallelism": 1, - "replicaCompletionCount": 1 - }, - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]" - } - ], - "secrets": [ - { - "name": "cao-postgres-url", - "keyVaultUrl": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri, parameters('postgresUrlSecretName'))]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]" - } - ] - }, - "template": { - "containers": [ - { - "name": "ingest", - "image": "[parameters('ingestionImage')]", - "command": [ - "/app/cao-dashboard" - ], - "args": [ - "ingest", - "--source", - "/data", - "--database-queries", - "/app/queries/database.json", - "--redis-namespace", - "[parameters('canonicalNamespace')]" - ], - "env": [ - { - "name": "CAO_POSTGRES_URL", - "secretRef": "cao-postgres-url" - }, - { - "name": "CAO_POSTGRES_RUN_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POSTGRES_LINKED_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - } - ], - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'ingestion-roles')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "ingestion-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "secretNames": { - "value": [ - "[parameters('postgresUrlSecretName')]" - ] - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "12213718997281109477" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "copy": { - "name": "secretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('principalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('principalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]" - ] - } - ], - "outputs": { - "jobName": { - "type": "string", - "value": "[format('{0}-ingest', parameters('namePrefix'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "database-provisioning", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "namePrefix": { - "value": "[parameters('functionAppName')]" - }, - "environmentName": { - "value": "[format('{0}-jobs', parameters('functionAppName'))]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "provisioningImage": { - "value": "[format('{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('provisioningImageRepository'), variables('provisioningImageSelector'))]" - }, - "postgresUrlSecretName": { - "value": "[parameters('postgresUrlSecretName')]" - }, - "postgresHostName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value]" - }, - "postgresDatabaseName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.databaseName.value]" - }, - "postgresRunRetentionDays": { - "value": "[parameters('postgresRunRetentionDays')]" - }, - "operationalCacheMaxBytes": { - "value": "[parameters('operationalCacheMaxBytes')]" - }, - "operationalCacheMaxValueBytes": { - "value": "[parameters('operationalCacheMaxValueBytes')]" - }, - "operationalCacheMaxEntries": { - "value": "[parameters('operationalCacheMaxEntries')]" - }, - "operationalProtectedMaxBytes": { - "value": "[parameters('operationalProtectedMaxBytes')]" - }, - "operationalProtectedMaxEntries": { - "value": "[parameters('operationalProtectedMaxEntries')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "6625495926426680658" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "namePrefix": { - "type": "string", - "minLength": 2, - "maxLength": 19 - }, - "environmentName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "provisioningImage": { - "type": "string" - }, - "postgresUrlSecretName": { - "type": "string" - }, - "postgresHostName": { - "type": "string" - }, - "postgresDatabaseName": { - "type": "string" - }, - "postgresRunRetentionDays": { - "type": "int", - "minValue": 7, - "maxValue": 3650 - }, - "operationalCacheMaxBytes": { - "type": "int" - }, - "operationalCacheMaxValueBytes": { - "type": "int" - }, - "operationalCacheMaxEntries": { - "type": "int" - }, - "operationalProtectedMaxBytes": { - "type": "int" - }, - "operationalProtectedMaxEntries": { - "type": "int" - } - }, - "variables": { - "secretNames": [ - "[format('{0}-admin', parameters('postgresUrlSecretName'))]", - "[format('{0}-owner-password', parameters('postgresUrlSecretName'))]", - "[format('{0}-runtime-password', parameters('postgresUrlSecretName'))]" - ] - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[format('{0}-db-provision-identity', parameters('namePrefix'))]", - "location": "[parameters('location')]" - }, - { - "type": "Microsoft.App/jobs", - "apiVersion": "2024-03-01", - "name": "[format('{0}-db-provision', parameters('namePrefix'))]", - "location": "[parameters('location')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix'))))]": {} - } - }, - "properties": { - "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('environmentName'))]", - "configuration": { - "copy": [ - { - "name": "secrets", - "count": "[length(variables('secretNames'))]", - "input": { - "name": "[variables('secretNames')[copyIndex('secrets')]]", - "keyVaultUrl": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri, variables('secretNames')[copyIndex('secrets')])]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]" - } - } - ], - "triggerType": "Manual", - "replicaTimeout": 7200, - "replicaRetryLimit": 0, - "manualTriggerConfig": { - "parallelism": 1, - "replicaCompletionCount": 1 - }, - "registries": [ - { - "server": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]", - "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]" - } - ] - }, - "template": { - "containers": [ - { - "name": "db-provision", - "image": "[parameters('provisioningImage')]", - "env": [ - { - "name": "CAO_DATABASE_ADMIN_URL", - "secretRef": "[variables('secretNames')[0]]" - }, - { - "name": "CAO_POSTGRES_OWNER_PASSWORD", - "secretRef": "[variables('secretNames')[1]]" - }, - { - "name": "CAO_POSTGRES_RUNTIME_PASSWORD", - "secretRef": "[variables('secretNames')[2]]" - }, - { - "name": "PGHOST", - "value": "[parameters('postgresHostName')]" - }, - { - "name": "PGDATABASE", - "value": "[parameters('postgresDatabaseName')]" - }, - { - "name": "PGSSLMODE", - "value": "verify-full" - }, - { - "name": "PGSSLROOTCERT", - "value": "system" - }, - { - "name": "CAO_POSTGRES_RUN_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_POSTGRES_LINKED_RETENTION_DAYS", - "value": "[string(parameters('postgresRunRetentionDays'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_BYTES", - "value": "[string(parameters('operationalCacheMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES", - "value": "[string(parameters('operationalCacheMaxValueBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_CACHE_MAX_ENTRIES", - "value": "[string(parameters('operationalCacheMaxEntries'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_BYTES", - "value": "[string(parameters('operationalProtectedMaxBytes'))]" - }, - { - "name": "CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES", - "value": "[string(parameters('operationalProtectedMaxEntries'))]" - } - ], - "resources": { - "cpu": "[json('1.0')]", - "memory": "2Gi" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]", - "[resourceId('Microsoft.Resources/deployments', 'database-provisioning-roles')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "database-provisioning-roles", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "containerRegistryName": { - "value": "[parameters('containerRegistryName')]" - }, - "principalId": { - "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix'))), '2023-01-31').principalId]" - }, - "secretNames": { - "value": "[variables('secretNames')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.48.1.52935", - "templateHash": "12213718997281109477" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "containerRegistryName": { - "type": "string" - }, - "principalId": { - "type": "string" - }, - "secretNames": { - "type": "array" - } - }, - "resources": [ - { - "copy": { - "name": "secretsUser", - "count": "[length(parameters('secretNames'))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()]), parameters('principalId'), 'Key Vault Secrets User')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), parameters('principalId'), 'AcrPull')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[parameters('principalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-db-provision-identity', parameters('namePrefix')))]" - ] - } - ], - "outputs": { - "jobName": { - "type": "string", - "value": "[format('{0}-db-provision', parameters('namePrefix'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.App/managedEnvironments', format('{0}-jobs', parameters('functionAppName')))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.Resources/deployments', 'private-network-postgres')]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), format('{0}-admin', parameters('postgresUrlSecretName')))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), format('{0}-owner-password', parameters('postgresUrlSecretName')))]", - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), format('{0}-runtime-password', parameters('postgresUrlSecretName')))]", - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]" - ] - } - ], - "outputs": { - "collectionEnabled": { - "type": "bool", - "value": "[variables('collectionEnabled')]" - }, - "containerRegistryLoginServer": { - "type": "string", - "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]" - }, - "functionContainerImage": { - "type": "string", - "value": "[format('{0}/{1}{2}', reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer, parameters('functionImageRepository'), variables('functionImageSelector'))]" - }, - "functionHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Web/sites', parameters('functionAppName')), '2023-12-01').defaultHostName]" - }, - "githubOAuthRedirectUri": { - "type": "string", - "value": "[variables('githubRedirectUri')]" - }, - "keyVaultUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri]" - }, - "ingestionJobName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'snapshot-ingestion'), '2025-04-01').outputs.jobName.value]" - }, - "provisioningJobName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'database-provisioning'), '2025-04-01').outputs.jobName.value]" - }, - "logAnalyticsWorkspaceId": { - "type": "string", - "value": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" - }, - "applicationInsightsId": { - "type": "string", - "value": "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]" - }, - "postgresHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'private-network-postgres'), '2025-04-01').outputs.postgresHostName.value]" - }, - "adxClusterId": { - "type": "string", - "value": "[if(parameters('adxEnabled'), reference(resourceId('Microsoft.Resources/deployments', 'adx-destination'), '2025-04-01').outputs.clusterId.value, '')]" - }, - "adxQueryEndpoint": { - "type": "string", - "value": "[if(parameters('adxEnabled'), reference(resourceId('Microsoft.Resources/deployments', 'adx-destination'), '2025-04-01').outputs.queryEndpoint.value, '')]" - }, - "adxDatabaseName": { - "type": "string", - "value": "[if(parameters('adxEnabled'), reference(resourceId('Microsoft.Resources/deployments', 'adx-destination'), '2025-04-01').outputs.databaseName.value, '')]" - } - } -} \ No newline at end of file diff --git a/server/azure/network.bicep b/server/azure/network.bicep deleted file mode 100644 index eeb8a9486c..0000000000 --- a/server/azure/network.bicep +++ /dev/null @@ -1,140 +0,0 @@ -param location string -param namePrefix string -param postgresServerName string -param postgresAdministratorLogin string -@secure() -param postgresAdministratorPassword string -param postgresSkuName string = 'Standard_B2s' -param postgresSkuTier string = 'Burstable' -@description('Optional existing network security group to keep attached to every subnet. Empty leaves subnets without an NSG.') -param subnetNetworkSecurityGroupId string = '' -@minLength(1) -@maxLength(63) -@description('PostgreSQL database that holds the canonical and operational stores. Changing it creates a new, empty database; the previous database is not deleted.') -param postgresDatabaseName string = 'cao' - -param adxEnabled bool = false - -var subnetNetworkSecurityGroup = empty(subnetNetworkSecurityGroupId) ? {} : { - networkSecurityGroup: { - id: subnetNetworkSecurityGroupId - } -} - -resource network 'Microsoft.Network/virtualNetworks@2024-05-01' = { - name: '${namePrefix}-vnet' - location: location - properties: { - addressSpace: { - addressPrefixes: ['10.42.0.0/16'] - } - subnets: concat([ - { - name: 'functions' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.0.0/24' - delegations: [ - { - name: 'functions' - properties: { - serviceName: 'Microsoft.Web/serverFarms' - } - } - ] - }) - } - { - name: 'postgres' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.1.0/24' - delegations: [ - { - name: 'postgres' - properties: { - serviceName: 'Microsoft.DBforPostgreSQL/flexibleServers' - } - } - ] - }) - } - { - name: 'containers' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.2.0/23' - }) - } - ], adxEnabled ? [ - { - name: 'adx' - properties: union(subnetNetworkSecurityGroup, { - addressPrefix: '10.42.4.0/24' - privateEndpointNetworkPolicies: 'Disabled' - }) - } - ] : []) - } -} - -resource dns 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: '${postgresServerName}.private.postgres.database.azure.com' - location: 'global' -} - -resource dnsLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - parent: dns - name: '${namePrefix}-postgres' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: network.id - } - } -} - -resource postgres 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { - name: postgresServerName - location: location - sku: { - name: postgresSkuName - tier: postgresSkuTier - } - properties: { - version: '16' - administratorLogin: postgresAdministratorLogin - administratorLoginPassword: postgresAdministratorPassword - network: { - delegatedSubnetResourceId: resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'postgres') - privateDnsZoneArmResourceId: dns.id - publicNetworkAccess: 'Disabled' - } - storage: { - storageSizeGB: 32 - autoGrow: 'Enabled' - } - backup: { - backupRetentionDays: 7 - geoRedundantBackup: 'Disabled' - } - highAvailability: { - mode: 'Disabled' - } - } - dependsOn: [dnsLink] -} - -resource database 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { - parent: postgres - name: postgresDatabaseName - properties: { - charset: 'UTF8' - collation: 'en_US.utf8' - } -} - -output postgresHostName string = postgres.properties.fullyQualifiedDomainName -output databaseName string = database.name -output functionSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'functions') -output containerSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'containers') -output virtualNetworkId string = network.id -output adxSubnetId string = adxEnabled ? resourceId('Microsoft.Network/virtualNetworks/subnets', network.name, 'adx') : '' diff --git a/server/cmd/cao-dashboard/azure_functions.go b/server/cmd/cao-dashboard/azure_functions.go deleted file mode 100644 index 480a3060f5..0000000000 --- a/server/cmd/cao-dashboard/azure_functions.go +++ /dev/null @@ -1,137 +0,0 @@ -package main - -import ( - "context" - "errors" - "log" - "net" - "net/http" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/spf13/cobra" - - "github.com/githubnext/gh-cao/server/internal/dashboardassets" - "github.com/githubnext/gh-cao/server/internal/logger" - "github.com/githubnext/gh-cao/server/internal/server" -) - -var startupLog = logger.New("cao:functions:startup") - -func newAzureFunctionsCommand() *cobra.Command { - return &cobra.Command{ - Use: "azure-functions", - Short: "Run the Azure Functions custom HTTP handler using platform environment settings", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return runAzureFunctions(cmd.Context()) - }, - } -} - -// functionsConfig holds the resolved configuration used to start the Azure -// Functions custom handler. -type functionsConfig struct { - host string - port string - siteDirectory string - queriesPath string -} - -// resolveFunctionsConfig derives the listen port, site directory, and -// dashboard queries path from the process environment. It is a pure function -// so the resolution and defaulting logic can be exercised without starting a -// listener or an HTTP server. -func resolveFunctionsConfig(getenv func(string) string) (functionsConfig, error) { - port := strings.TrimSpace(getenv("FUNCTIONS_CUSTOMHANDLER_PORT")) - if port == "" { - return functionsConfig{}, errors.New("FUNCTIONS_CUSTOMHANDLER_PORT is required") - } - siteDirectory := strings.TrimSpace(getenv("CAO_AZURE_SITE_DIRECTORY")) - queriesPath := envOrDefault(getenv, "CAO_AZURE_DASHBOARD_QUERIES", dashboardassets.Path(siteDirectory, dashboardassets.DashboardQueries)) - return functionsConfig{ - host: envOrDefault(getenv, "FUNCTIONS_CUSTOMHANDLER_HOST", "127.0.0.1"), - port: port, - siteDirectory: siteDirectory, - queriesPath: queriesPath, - }, nil -} - -func runAzureFunctions(parent context.Context) error { - config, err := resolveFunctionsConfig(os.Getenv) - if err != nil { - return err - } - startupLog.Printf("resolved config site_directory=%q queries_path=%q", config.siteDirectory, config.queriesPath) - - ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) - defer stop() - defer func() { - flushCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - if err := server.ShutdownAzureFunctionsTelemetry(flushCtx); err != nil { - startupLog.Printf("telemetry shutdown failed") - } - }() - handler, err := server.NewAzureFunctionsHandlerFromEnv(ctx, config.siteDirectory, config.queriesPath, log.Default()) - if err != nil { - return err - } - var listenConfig net.ListenConfig - listener, err := listenConfig.Listen(ctx, "tcp", net.JoinHostPort(config.host, config.port)) - if err != nil { - return err - } - startupLog.Printf("listening addr=%q", listener.Addr().String()) - httpServer := &http.Server{ - Handler: handler, - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 65 * time.Second, - WriteTimeout: 65 * time.Second, - IdleTimeout: 90 * time.Second, - } - shutdownDone := make(chan struct{}) - go func() { - defer close(shutdownDone) - shutdownOnDone(ctx, httpServer, 5*time.Second) - }() - err = httpServer.Serve(listener) - stop() - <-shutdownDone - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err -} - -// shutdownOnDone waits for ctx to be cancelled, then gracefully shuts down -// httpServer within timeout. It is extracted from run's inline goroutine so -// the shutdown boundary is independently testable with a real -// *http.Server, without starting a listener or a full process. -// -// The shutdown context survives cancellation of ctx, because ctx is -// normally already cancelled by the signal handler that triggered this -// shutdown. A failed or timed-out shutdown is logged rather than -// propagated: this goroutine has no return path, and Serve's own returned -// error remains the caller's signal for a failed shutdown. -func shutdownOnDone(ctx context.Context, httpServer *http.Server, timeout time.Duration) { - <-ctx.Done() - if drainer, ok := httpServer.Handler.(interface{ Drain() }); ok { - drainer.Drain() - } - shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) - defer cancel() - if err := httpServer.Shutdown(shutdownCtx); err != nil { - startupLog.Printf("graceful shutdown failed") - } -} - -func envOrDefault(getenv func(string) string, name, fallback string) string { - if value := strings.TrimSpace(getenv(name)); value != "" { - return value - } - return fallback -} diff --git a/server/cmd/cao-dashboard/azure_functions_test.go b/server/cmd/cao-dashboard/azure_functions_test.go deleted file mode 100644 index 3d4046c01b..0000000000 --- a/server/cmd/cao-dashboard/azure_functions_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package main - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/githubnext/gh-cao/server/internal/dashboardassets" -) - -func TestAzureFunctionsCommand(t *testing.T) { - t.Setenv("FUNCTIONS_CUSTOMHANDLER_PORT", "") - if err := run([]string{"azure-functions"}); err == nil || - !strings.Contains(err.Error(), "FUNCTIONS_CUSTOMHANDLER_PORT is required") { - t.Fatalf("Azure Functions must require the platform port: %v", err) - } - if err := run([]string{"azure-functions", "unexpected"}); err == nil || - !strings.Contains(err.Error(), "unknown command") { - t.Fatalf("Azure Functions must reject positional arguments: %v", err) - } - if err := run([]string{"azure-functions", "--listen", "0.0.0.0:8080"}); err == nil || - !strings.Contains(err.Error(), "unknown flag") { - t.Fatalf("Azure Functions must not accept ordinary serving flags: %v", err) - } - if err := run([]string{"functions"}); err == nil || - !strings.Contains(err.Error(), "unknown command") { - t.Fatalf("the subcommand must be named azure-functions, not functions: %v", err) - } -} - -func TestResolveFunctionsConfig_RequiresPort(t *testing.T) { - env := map[string]string{} - getenv := func(name string) string { return env[name] } - - _, err := resolveFunctionsConfig(getenv) - if err == nil { - t.Fatal("expected an error when FUNCTIONS_CUSTOMHANDLER_PORT is unset") - } -} - -func TestResolveFunctionsConfig_TrimsAndRequiresNonBlankPort(t *testing.T) { - env := map[string]string{"FUNCTIONS_CUSTOMHANDLER_PORT": " "} - getenv := func(name string) string { return env[name] } - - _, err := resolveFunctionsConfig(getenv) - if err == nil { - t.Fatal("expected an error when FUNCTIONS_CUSTOMHANDLER_PORT is blank") - } -} - -func TestResolveFunctionsConfig_Defaults(t *testing.T) { - env := map[string]string{"FUNCTIONS_CUSTOMHANDLER_PORT": "8080"} - getenv := func(name string) string { return env[name] } - - config, err := resolveFunctionsConfig(getenv) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if config.port != "8080" { - t.Errorf("port = %q, want %q", config.port, "8080") - } - if config.host != "127.0.0.1" { - t.Errorf("host = %q, want %q", config.host, "127.0.0.1") - } - if config.siteDirectory != "" { - t.Errorf("siteDirectory = %q, want embedded resources", config.siteDirectory) - } - wantQueries := dashboardassets.Path("", dashboardassets.DashboardQueries) - if config.queriesPath != wantQueries { - t.Errorf("queriesPath = %q, want %q", config.queriesPath, wantQueries) - } -} - -func TestResolveFunctionsConfig_OverridesAndDerivedQueriesPath(t *testing.T) { - env := map[string]string{ - "FUNCTIONS_CUSTOMHANDLER_PORT": " 9090 ", - "FUNCTIONS_CUSTOMHANDLER_HOST": " 0.0.0.0 ", - "CAO_AZURE_SITE_DIRECTORY": "/srv/dashboard", - } - getenv := func(name string) string { return env[name] } - - config, err := resolveFunctionsConfig(getenv) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if config.port != "9090" { - t.Errorf("port = %q, want %q", config.port, "9090") - } - if config.host != "0.0.0.0" { - t.Errorf("host = %q, want %q", config.host, "0.0.0.0") - } - if config.siteDirectory != "/srv/dashboard" { - t.Errorf("siteDirectory = %q, want %q", config.siteDirectory, "/srv/dashboard") - } - wantQueries := filepath.Join("/srv/dashboard", filepath.FromSlash(dashboardassets.DashboardQueries)) - if config.queriesPath != wantQueries { - t.Errorf("queriesPath = %q, want %q", config.queriesPath, wantQueries) - } -} - -func TestResolveFunctionsConfig_ExplicitQueriesPathOverridesDerivedDefault(t *testing.T) { - env := map[string]string{ - "FUNCTIONS_CUSTOMHANDLER_PORT": "8080", - "CAO_AZURE_SITE_DIRECTORY": "/srv/dashboard", - "CAO_AZURE_DASHBOARD_QUERIES": "/etc/cao/dashboard.json", - } - getenv := func(name string) string { return env[name] } - - config, err := resolveFunctionsConfig(getenv) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if config.queriesPath != "/etc/cao/dashboard.json" { - t.Errorf("queriesPath = %q, want %q", config.queriesPath, "/etc/cao/dashboard.json") - } -} - -func TestEnvOrDefault(t *testing.T) { - env := map[string]string{"SET_VALUE": " configured ", "BLANK_VALUE": " "} - getenv := func(name string) string { return env[name] } - - if got := envOrDefault(getenv, "SET_VALUE", "fallback"); got != "configured" { - t.Errorf("envOrDefault(SET_VALUE) = %q, want %q", got, "configured") - } - if got := envOrDefault(getenv, "BLANK_VALUE", "fallback"); got != "fallback" { - t.Errorf("envOrDefault(BLANK_VALUE) = %q, want %q", got, "fallback") - } - if got := envOrDefault(getenv, "MISSING_VALUE", "fallback"); got != "fallback" { - t.Errorf("envOrDefault(MISSING_VALUE) = %q, want %q", got, "fallback") - } -} - -// TestShutdownOnDone_GracefulShutdownOnCancel verifies that cancelling ctx -// triggers a real *http.Server shutdown that stops it from serving further -// requests, using an actual listener rather than a mock. -func TestShutdownOnDone_GracefulShutdownOnCancel(t *testing.T) { - server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - server.Start() - defer server.Close() - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - shutdownOnDone(ctx, server.Config, time.Second) - close(done) - }() - - cancel() - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("shutdownOnDone did not return after context cancellation") - } - - if err := getWithContext(t, server.URL); err == nil { - t.Fatal("expected requests to fail after shutdown, but request succeeded") - } -} - -// getWithContext issues a context-scoped GET request and closes the response -// body, keeping tests free of the context-less http.Get helper. -func getWithContext(t *testing.T, url string) error { - t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - t.Fatalf("unexpected error building request: %v", err) - } - response, err := http.DefaultClient.Do(request) - if err != nil { - return err - } - defer func() { _ = response.Body.Close() }() - - if _, err := io.Copy(io.Discard, response.Body); err != nil { - t.Fatalf("unexpected error draining response body: %v", err) - } - return nil -} - -// TestShutdownOnDone_WaitsForContext confirms shutdownOnDone blocks until -// ctx is done, rather than shutting down immediately. -func TestShutdownOnDone_WaitsForContext(t *testing.T) { - server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - server.Start() - defer server.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan struct{}) - go func() { - shutdownOnDone(ctx, server.Config, time.Second) - close(done) - }() - - select { - case <-done: - t.Fatal("shutdownOnDone returned before context was cancelled") - case <-time.After(100 * time.Millisecond): - } - - if err := getWithContext(t, server.URL); err != nil { - t.Fatalf("expected server to still accept requests, got error: %v", err) - } -} - -func TestShutdownOnDoneWaitsForActiveRequestBeforeTelemetryFlush(t *testing.T) { - started := make(chan struct{}) - release := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - close(started) - <-release - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil) - if err != nil { - t.Fatal(err) - } - - requestDone := make(chan struct{}) - go func() { - defer close(requestDone) - response, err := http.DefaultClient.Do(request) - if err == nil { - _ = response.Body.Close() - } - }() - <-started - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - shutdownOnDone(ctx, server.Config, time.Second) - }() - cancel() - select { - case <-done: - t.Fatal("HTTP drain finished before active request") - case <-time.After(20 * time.Millisecond): - } - close(release) - <-requestDone - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("HTTP drain did not finish after active request") - } -} - -type drainingTestHandler struct { - http.Handler - drain func() -} - -func (h drainingTestHandler) Drain() { h.drain() } - -func TestShutdownOnDoneDrainsStreams(t *testing.T) { - started := make(chan struct{}) - drained := make(chan struct{}) - server := httptest.NewServer(drainingTestHandler{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - close(started) - <-drained - w.WriteHeader(http.StatusOK) - }), - drain: func() { close(drained) }, - }) - defer server.Close() - - request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil) - if err != nil { - t.Fatal(err) - } - requestDone := make(chan struct{}) - go func() { - defer close(requestDone) - response, err := http.DefaultClient.Do(request) - if err == nil { - _ = response.Body.Close() - } - }() - <-started - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - shutdownOnDone(ctx, server.Config, time.Second) - }() - cancel() - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("stream was not drained before HTTP shutdown") - } - <-requestDone -} diff --git a/server/cmd/cao-dashboard/main.go b/server/cmd/cao-dashboard/main.go index 41d374f70a..c6b77d53fe 100644 --- a/server/cmd/cao-dashboard/main.go +++ b/server/cmd/cao-dashboard/main.go @@ -336,7 +336,6 @@ func newRootCommand() *cobra.Command { newADXCommand(), newAdmissionCommand(), newAppKeyCheckCommand(), - newAzureFunctionsCommand(), newServeCommand(), newServePagesCommand(), newServeHostedCommand(), diff --git a/server/cmd/cao-dashboard/main_test.go b/server/cmd/cao-dashboard/main_test.go index d2fd1d798b..267f99c8f3 100644 --- a/server/cmd/cao-dashboard/main_test.go +++ b/server/cmd/cao-dashboard/main_test.go @@ -800,7 +800,7 @@ func TestActionsEnvironmentPresentReportsPresenceNotValues(t *testing.T) { } func TestRootCommandRegistersEverySubcommand(t *testing.T) { - want := []string{"admission", "adx", "app-key-check", "audit", "azure-functions", "backfill", "benchmark-queries", "collect", "compile-queries", "database", "doctor", "execute-cli", "hosted-cli", "ingest", "local-queue", "otel-smoke", "read", "serve", "serve-hosted", "serve-pages", "simulate-api", "simulate-webhooks"} + want := []string{"admission", "adx", "app-key-check", "audit", "backfill", "benchmark-queries", "collect", "compile-queries", "database", "doctor", "execute-cli", "hosted-cli", "ingest", "local-queue", "otel-smoke", "read", "serve", "serve-hosted", "serve-pages", "simulate-api", "simulate-webhooks"} root := newRootCommand() got := make([]string, 0, len(want)) diff --git a/server/internal/server/azure.go b/server/internal/server/azure.go deleted file mode 100644 index db62b08ec1..0000000000 --- a/server/internal/server/azure.go +++ /dev/null @@ -1,453 +0,0 @@ -package server - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "net/http" - "net/netip" - "net/url" - "os" - "strings" - "sync" - - "github.com/githubnext/gh-cao/server/internal/audit" - "github.com/githubnext/gh-cao/server/internal/dashboardassets" - "github.com/githubnext/gh-cao/server/internal/logger" - "github.com/githubnext/gh-cao/server/internal/operational" - "github.com/githubnext/gh-cao/server/internal/postgresx" - "github.com/githubnext/gh-cao/server/internal/telemetry" -) - -var proxyLog = logger.New("cao:server:proxy") - -type ProxyPolicy struct { - AllowedHosts []string - RequireHTTPS bool - TrustForwarded bool - TrustedProxyPrefixes []netip.Prefix -} - -func validateHostedMode(store operational.Store, config *Config) error { - profile := config.HostProfile - if profile.Authentication != HostAuthenticationOAuth { - if profile.Listener == HostListenerProcess { - if err := ValidateListen(config.Listen, config.CertFile, config.KeyFile); err != nil { - return err - } - if profile.RequiresHTTPS && (config.CertFile == "" || config.KeyFile == "") { - return fmt.Errorf("host profile %q requires HTTPS", profile.Name) - } - } - return nil - } - if strings.TrimSpace(config.AccessToken) != "" { - return errors.New("hosted mode does not support local bearer capabilities") - } - if store == nil { - return errors.New("hosted mode requires operational storage") - } - if len(config.Proxy.AllowedHosts) == 0 { - return errors.New("hosted mode requires an explicit trusted proxy host policy") - } - policy := config.Proxy - if profile.RequiresHTTPS && !policy.RequireHTTPS { - return errors.New("hosted mode requires HTTPS") - } - if profile.Listener == HostListenerExternal && config.Proxy.TrustForwarded && - len(config.Proxy.TrustedProxyPrefixes) == 0 { - return errors.New("externally hosted forwarded headers require explicit trusted proxy CIDRs") - } - if profile.Listener == HostListenerProcess { - if config.CertFile == "" && isLoopbackListen(config.Listen) && !config.Proxy.TrustForwarded { - config.Proxy.TrustForwarded = true - config.Proxy.TrustedProxyPrefixes = loopbackProxyPrefixes() - } - if config.Proxy.TrustForwarded && len(config.Proxy.TrustedProxyPrefixes) == 0 { - return errors.New("hosted forwarded headers require explicit trusted proxy CIDRs") - } - if err := validateHostedListen( - config.Listen, config.CertFile, config.KeyFile, config.Proxy.TrustForwarded, - ); err != nil { - return err - } - } - if config.GitHubOAuth == nil { - return errors.New("hosted mode requires GitHub OAuth configuration") - } - if err := config.GitHubOAuth.validate(); err != nil { - return err - } - if err := validateLocalDevelopmentOAuth(*config); err != nil { - return err - } - return nil -} - -// proxyRejectionReason names why classifyProxyRequest rejected a request. It -// is stable across host-value and header-content changes, so it is useful to -// log without exposing the request's Host, X-Forwarded-* values, or client -// address. -type proxyRejectionReason string - -const ( - proxyRejectionNone proxyRejectionReason = "" - proxyRejectionUntrustedPeer proxyRejectionReason = "untrusted-proxy-peer" - proxyRejectionEmptyHost proxyRejectionReason = "empty-host" - proxyRejectionHostNotAllowed proxyRejectionReason = "host-not-allowed" - proxyRejectionInsecureRequest proxyRejectionReason = "insecure-request" -) - -// classifyProxyRequest decides whether request satisfies policy and, when it -// does not, which precondition failed. It is a pure function extracted from -// validProxyRequest so the trusted-peer check, host allow-listing, and the -// HTTPS requirement are each independently testable against a plain -// *http.Request, and so a rejection can be logged by reason without -// exposing the request's host or forwarded-header values. -func classifyProxyRequest(request *http.Request, policy ProxyPolicy) proxyRejectionReason { - host := request.Host - secure := request.TLS != nil - if policy.TrustForwarded { - if len(policy.TrustedProxyPrefixes) > 0 && !trustedProxyPeer(request.RemoteAddr, policy.TrustedProxyPrefixes) { - return proxyRejectionUntrustedPeer - } - if forwarded := forwardedHeader(request, "X-Forwarded-Host"); forwarded != "" { - host = forwarded - } - proto := strings.ToLower(strings.TrimSpace(strings.Split(forwardedHeader(request, "X-Forwarded-Proto"), ",")[0])) - secure = proto == "https" - } - host = strings.ToLower(strings.TrimSpace(strings.Split(host, ",")[0])) - if host == "" { - return proxyRejectionEmptyHost - } - if strings.Contains(host, ":") { - host = strings.Split(host, ":")[0] - } - allowed := false - for _, candidate := range policy.AllowedHosts { - if strings.EqualFold(strings.TrimSpace(candidate), host) { - allowed = true - break - } - } - if !allowed { - return proxyRejectionHostNotAllowed - } - if policy.RequireHTTPS && !secure { - return proxyRejectionInsecureRequest - } - return proxyRejectionNone -} - -// validProxyRequest reports whether request satisfies policy, logging the -// specific precondition that failed on rejection so a misconfigured proxy -// policy or an unexpected caller is diagnosable without exposing the -// request's host or forwarded-header values. -func validProxyRequest(request *http.Request, policy ProxyPolicy) bool { - reason := classifyProxyRequest(request, policy) - if reason != proxyRejectionNone { - proxyLog.PrintfContext(request.Context(), "proxy request rejected reason=%s", reason) - return false - } - return true -} - -func trustedProxyPeer(remoteAddress string, prefixes []netip.Prefix) bool { - host, _, err := net.SplitHostPort(remoteAddress) - if err != nil { - host = remoteAddress - } - address, err := netip.ParseAddr(strings.Trim(host, "[]")) - if err != nil { - return false - } - address = address.Unmap() - for _, prefix := range prefixes { - if prefix.Contains(address) { - return true - } - } - return false -} - -func forwardedHeader(request *http.Request, name string) string { - values := request.Header.Values(name) - for valueIndex := len(values) - 1; valueIndex >= 0; valueIndex-- { - parts := strings.Split(values[valueIndex], ",") - for partIndex := len(parts) - 1; partIndex >= 0; partIndex-- { - if value := strings.TrimSpace(parts[partIndex]); value != "" { - return value - } - } - } - return "" -} - -// processTelemetry lazily installs OpenTelemetry providers exactly once for -// the life of the process. The Azure Functions runtime reuses the process -// across invocations, so the exporters it configures must outlive any -// single request; bundling the sync.Once and its cached error into one type -// keeps that pairing in a single place instead of spreading a package-level -// mutex and error variable across the file. -type processTelemetry struct { - once sync.Once - err error - shutdown telemetry.Shutdown -} - -// ensure configures telemetry on the first call and returns the cached -// outcome on every call thereafter. A configuration failure is logged at -// most once, on the call that performed the setup, and is treated as -// non-fatal: callers keep serving requests without exported telemetry rather -// than failing the whole handler over a bad exporter configuration. -func (p *processTelemetry) ensure(logger *log.Logger) error { - p.once.Do(func() { - p.shutdown, p.err = configureProcessTelemetry() - if p.err != nil && logger != nil { - logger.Printf("telemetry configuration failed, continuing without exported telemetry") - } - }) - return p.err -} - -// ShutdownAzureFunctionsTelemetry flushes process-owned exporters after the -// custom handler has finished serving requests. -func ShutdownAzureFunctionsTelemetry(ctx context.Context) error { - return azureProcessTelemetry.close(ctx) -} - -func (p *processTelemetry) close(ctx context.Context) error { - if p.shutdown == nil { - return nil - } - return p.shutdown(ctx) -} - -// configureProcessTelemetry installs the providers with a process- -// lifetime context rather than a request-scoped one, since the resulting -// exporter is shared by every future invocation handled by this process. -func configureProcessTelemetry() (telemetry.Shutdown, error) { - version := strings.TrimSpace(os.Getenv("CAO_BUILD_VERSION")) - if version == "" { - version = "unknown" - } - return telemetry.Setup(context.Background(), version) -} - -var azureProcessTelemetry processTelemetry - -func NewAzureFunctionsHandlerFromEnv(ctx context.Context, siteDirectory, dashboardQueriesPath string, logger *log.Logger) (http.Handler, error) { - //nolint:contextcheck // configureProcessTelemetry intentionally uses context.Background(): the exporter it - // configures must outlive the single request/invocation that happens to trigger processTelemetry.ensure. - _ = azureProcessTelemetry.ensure(logger) - localSimulation, err := azureLocalSimulationFromEnv() - if err != nil { - return nil, err - } - host, err := loadHostPolicyFromEnv() - if err != nil { - return nil, err - } - allowedHosts := splitCSV(os.Getenv("CAO_AZURE_ALLOWED_HOSTS")) - redirectURL := os.Getenv("CAO_GITHUB_REDIRECT_URL") - if err := validateAzureLocalEndpoints(localSimulation, allowedHosts, redirectURL); err != nil { - return nil, err - } - if host.Profile.Listener != HostListenerPlatform { - return nil, fmt.Errorf("host target module %q does not delegate listener ownership", host.Profile.Name) - } - if host.OperationalBackend == "redis" { - if err := validateAzureRedisURL(host.RedisURL, localSimulation); err != nil { - return nil, err - } - } - store, revocationPrefix, err := newOperationalStore(ctx, host) - if err != nil { - return nil, err - } - initialized := false - defer func() { - if !initialized { - _ = store.Close() - } - }() - profile := azureLocalSimulationProfile(host.Profile, localSimulation) - definitions, err := ParseDashboardQueries(dashboardQueriesPath) - if err != nil { - return nil, err - } - // The collection profile is optional. When it is unconfigured this reads - // as nil and the Functions front end behaves exactly as before. - collector, err := CollectorConfigFromEnv() - if err != nil { - return nil, err - } - postgresURL := strings.TrimSpace(os.Getenv("CAO_POSTGRES_URL")) - if postgresURL == "" { - return nil, errors.New("azure Functions mode requires CAO_POSTGRES_URL") - } - databaseQueries := strings.TrimSpace(os.Getenv("CAO_DATABASE_QUERIES")) - if databaseQueries == "" { - databaseQueries = dashboardassets.Path(siteDirectory, dashboardassets.DatabaseQueries) - } - databaseNamespace := strings.TrimSpace(host.RedisNamespace) - if databaseNamespace == "" { - databaseNamespace = "hosted-dashboard" - } - database, err := postgresx.OpenWithNamespace(ctx, postgresURL, databaseNamespace) - if err != nil { - return nil, fmt.Errorf("connect to dashboard Postgres: %w", err) - } - auditLog, err := audit.FromEnv() - if err != nil { - _ = database.Close() - return nil, err - } - admissionConfig, err := admissionConfigFromEnv() - if err != nil { - _ = database.Close() - return nil, err - } - brokerConfig, err := tokenBrokerConfigFromEnv() - if err != nil { - _ = database.Close() - return nil, err - } - app, err := New(ctx, store, Config{ - Admission: admissionConfig, - TokenBroker: brokerConfig, - Database: database, - Audit: auditLog, - DatabaseQueriesPath: databaseQueries, - HostProfile: profile, - SingleReplicaConfirmed: host.SingleReplicaConfirmed, - SiteDirectory: siteDirectory, - DashboardQueries: definitions, - Collector: collector, - WebhookSecret: os.Getenv("CAO_GITHUB_WEBHOOK_SECRET"), - AdminUsers: splitCSV(os.Getenv("CAO_GITHUB_ADMIN_USERS")), - CORS: host.CORS, - Proxy: ProxyPolicy{ - AllowedHosts: allowedHosts, - RequireHTTPS: !localSimulation, - TrustForwarded: true, - }, - GitHubOAuth: &GitHubOAuthConfig{ - ClientID: os.Getenv("CAO_GITHUB_CLIENT_ID"), - ClientSecret: os.Getenv("CAO_GITHUB_CLIENT_SECRET"), - RedirectURL: redirectURL, - SessionSecret: os.Getenv("CAO_SESSION_SECRET"), - PreviousSessionSecret: os.Getenv("CAO_SESSION_SECRET_PREVIOUS"), - AllowedUsers: splitCSV(os.Getenv("CAO_GITHUB_ALLOWED_USERS")), - AllowedOrganizations: splitCSV(os.Getenv("CAO_GITHUB_ALLOWED_ORGS")), - AllowedTeams: splitCSV(os.Getenv("CAO_GITHUB_ALLOWED_TEAMS")), - AdminReauthenticationMaxAge: envStrictDuration("CAO_ADMIN_REAUTH_MAX_AGE"), - SSOOrganizations: splitCSV(os.Getenv("CAO_GITHUB_SSO_ORGANIZATIONS")), - RevocationKeyPrefix: revocationPrefix, - }, - Logger: logger, - }) - if err != nil { - _ = database.Close() - return nil, err - } - if err := app.initializeRedisMaintenance(ctx); err != nil { - _ = database.Close() - return nil, err - } - maintenanceCtx, stopMaintenance := context.WithCancel(context.WithoutCancel(ctx)) - app.startTask(func() { app.runRedisMaintenance(maintenanceCtx, redisMaintenanceInterval) }) - app.startTask(func() { app.oauth.runRevocationWorker(maintenanceCtx) }) - app.ownedDatabase = database - app.ownedOperational = store - initialized = true - return &azureFunctionsHandler{Handler: app.AzureFunctionsHandler(), app: app, stopMaintenance: stopMaintenance}, nil -} - -type azureFunctionsHandler struct { - http.Handler - app *App - stopMaintenance context.CancelFunc -} - -func (h *azureFunctionsHandler) Drain() { - h.app.Drain() - if h.stopMaintenance != nil { - h.stopMaintenance() - } -} - -func azureLocalSimulationFromEnv() (bool, error) { - switch value := strings.TrimSpace(os.Getenv("CAO_AZURE_LOCAL_SIMULATION")); value { - case "": - return false, nil - case "1": - return true, nil - default: - return false, errors.New("CAO_AZURE_LOCAL_SIMULATION must be 1 when enabled") - } -} - -func validateAzureRedisURL(redisURL string, localSimulation bool) error { - parsed, err := url.Parse(redisURL) - if err != nil { - return errors.New("azure Functions mode requires a valid Redis URL") - } - if strings.EqualFold(parsed.Scheme, "rediss") { - return nil - } - if !localSimulation || !strings.EqualFold(parsed.Scheme, "redis") { - return errors.New("azure Functions mode requires rediss:// Redis transport") - } - host := parsed.Hostname() - if host == "localhost" { - return nil - } - address := net.ParseIP(host) - if address == nil || !address.IsLoopback() { - return errors.New("local Azure simulation requires loopback Redis") - } - return nil -} - -func validateAzureLocalEndpoints(localSimulation bool, allowedHosts []string, redirectURL string) error { - if !localSimulation { - return nil - } - for _, host := range allowedHosts { - if !isLoopbackHost(host) { - return errors.New("local Azure simulation requires loopback allowed hosts") - } - } - parsed, err := url.Parse(strings.TrimSpace(redirectURL)) - if err != nil || !isLoopbackHost(parsed.Hostname()) { - return errors.New("local Azure simulation requires a loopback OAuth redirect URL") - } - return nil -} - -func isLoopbackHost(host string) bool { - host = strings.TrimSpace(host) - if host == "localhost" { - return true - } - address := net.ParseIP(host) - return address != nil && address.IsLoopback() -} - -func (a *App) AzureFunctionsHandler() http.Handler { - return a.Handler() -} - -func splitCSV(value string) []string { - var output []string - for _, item := range strings.Split(value, ",") { - if trimmed := strings.TrimSpace(item); trimmed != "" { - output = append(output, trimmed) - } - } - return output -} diff --git a/server/internal/server/events_authorization_test.go b/server/internal/server/events_authorization_test.go index 698f0401c2..ccdf243ddb 100644 --- a/server/internal/server/events_authorization_test.go +++ b/server/internal/server/events_authorization_test.go @@ -104,7 +104,7 @@ func eventAuthorizationApp(t *testing.T, membershipState string) (*App, *memory. database.revision.Store(1) app := &App{ services: store.OperationalServices(), database: database, hub: newEventHub(), - config: Config{HostProfile: azureFunctionsHostProfile(), + config: Config{HostProfile: platformHostProfile(), Proxy: ProxyPolicy{AllowedHosts: []string{"dashboard.example.com"}, RequireHTTPS: true}}, oauth: newGitHubOAuth(*config, sessions, store, store), drain: make(chan struct{}), } diff --git a/server/internal/server/host_modules.go b/server/internal/server/host_modules.go index b6671f014a..60c16d8f42 100644 --- a/server/internal/server/host_modules.go +++ b/server/internal/server/host_modules.go @@ -39,9 +39,8 @@ type redisProviderModule struct { } var hostTargetModules = map[string]hostTargetModule{ - "generic": {configurable: true}, - "container": {authentication: HostAuthenticationOAuth, listener: HostListenerProcess, requireHTTPS: true, supportsSingleReplica: true}, - "azure-functions": {authentication: HostAuthenticationOAuth, listener: HostListenerPlatform, requireHTTPS: true, trustPlatformProxy: true}, + "generic": {configurable: true}, + "container": {authentication: HostAuthenticationOAuth, listener: HostListenerProcess, requireHTTPS: true, supportsSingleReplica: true}, } var redisProviderModules = map[string]redisProviderModule{ diff --git a/server/internal/server/host_policy_test.go b/server/internal/server/host_policy_test.go index 98b0348da1..914a5fb7d3 100644 --- a/server/internal/server/host_policy_test.go +++ b/server/internal/server/host_policy_test.go @@ -297,21 +297,6 @@ func TestHostAndRedisModulesComposeIndependently(t *testing.T) { if !resolved.SingleReplicaConfirmed { t.Fatal("policy resolution ignored the single-replica target configuration") } - - azure, err := (hostPolicy{ - Target: targetPolicy{Module: "azure-functions"}, - Redis: redisPolicy{Module: "redis-cloud"}, - }).resolve(mapLookup(map[string]string{ - "REDIS_URL": "rediss://cache.example.com:6379", - })) - if err != nil { - t.Fatal(err) - } - if azure.Profile.Listener != HostListenerPlatform || - !azure.Profile.TrustsPlatformProxy || - azure.Profile.RedisSession != HostRedisPooled { - t.Fatalf("Azure target did not compose with Redis Cloud: %+v", azure.Profile) - } } func TestExternalHostProfileComposesWithRedis(t *testing.T) { @@ -339,7 +324,7 @@ func TestDeploymentModuleRegistry(t *testing.T) { targets = append(targets, name) } sort.Strings(targets) - if !slices.Equal(targets, []string{"azure-functions", "container", "generic"}) { + if !slices.Equal(targets, []string{"container", "generic"}) { t.Fatalf("unexpected host target modules: %v", targets) } providers := make([]string, 0, len(redisProviderModules)) @@ -362,7 +347,7 @@ func TestModulesRejectUnknownOrOverriddenCapabilities(t *testing.T) { {Target: targetPolicy{Module: "container"}, Redis: redisPolicy{Module: "unknown"}}, {Target: targetPolicy{Module: "container", Replicas: 1}, Redis: redisPolicy{Module: "upstash", Session: HostRedisPooled}}, {Target: targetPolicy{Module: "container", Replicas: 1}, Redis: redisPolicy{Module: "upstash", TLS: redisTLSPolicy{Mode: redisTLSDisabled}}}, - {Target: targetPolicy{Module: "azure-functions"}, Redis: redisPolicy{Module: "upstash"}}, + {Target: targetPolicy{Module: "azure-functions"}, Redis: redisPolicy{Module: "generic"}}, {Target: targetPolicy{Module: "container"}, Redis: redisPolicy{Module: "upstash"}}, } for _, policy := range tests { diff --git a/server/internal/server/host_profile.go b/server/internal/server/host_profile.go index 3ce237e387..f11f10dc38 100644 --- a/server/internal/server/host_profile.go +++ b/server/internal/server/host_profile.go @@ -69,13 +69,6 @@ func hostedHostProfile() HostProfile { } } -func azureLocalSimulationProfile(profile HostProfile, localSimulation bool) HostProfile { - if localSimulation { - profile.RequiresHTTPS = false - } - return profile -} - func (profile HostProfile) validate() error { if strings.TrimSpace(profile.Name) == "" { return errors.New("host profile requires a name") diff --git a/server/internal/server/host_profile_test.go b/server/internal/server/host_profile_test.go index 6a6221794d..76aa9e047a 100644 --- a/server/internal/server/host_profile_test.go +++ b/server/internal/server/host_profile_test.go @@ -17,9 +17,9 @@ func upstashHostProfile() HostProfile { return profile } -func azureFunctionsHostProfile() HostProfile { +func platformHostProfile() HostProfile { profile := hostedHostProfile() - profile.Name = "azure-functions" + profile.Name = "platform" profile.Listener = HostListenerPlatform profile.TrustsPlatformProxy = true return profile @@ -35,7 +35,7 @@ func TestBuiltInHostProfilesDeclareExpectedCapabilities(t *testing.T) { }{ {localHostProfile(), HostAuthenticationBearer, HostListenerProcess, HostRedisPooled, true}, {hostedHostProfile(), HostAuthenticationOAuth, HostListenerProcess, HostRedisPooled, true}, - {azureFunctionsHostProfile(), HostAuthenticationOAuth, HostListenerPlatform, HostRedisPooled, true}, + {platformHostProfile(), HostAuthenticationOAuth, HostListenerPlatform, HostRedisPooled, true}, {func() HostProfile { p := hostedHostProfile(); p.Listener = HostListenerExternal; return p }(), HostAuthenticationOAuth, HostListenerExternal, HostRedisPooled, true}, {upstashHostProfile(), HostAuthenticationOAuth, HostListenerProcess, HostRedisSerialized, false}, } @@ -54,27 +54,6 @@ func TestBuiltInHostProfilesDeclareExpectedCapabilities(t *testing.T) { } } -func TestAzureLocalSimulationDisablesHTTPSForConfiguredProfile(t *testing.T) { - configured := HostProfile{ - Name: "azure-functions", - Authentication: HostAuthenticationOAuth, - Listener: HostListenerPlatform, - RequiresHTTPS: true, - TrustsPlatformProxy: true, - RequiresRedis: true, - RedisSession: HostRedisPooled, - SupportsCollection: true, - } - - local := azureLocalSimulationProfile(configured, true) - if local.RequiresHTTPS { - t.Fatal("local Azure simulation retained production HTTPS enforcement") - } - if !configured.RequiresHTTPS { - t.Fatal("local Azure simulation mutated the configured production profile") - } -} - func TestHostProfileRejectsInconsistentCapabilities(t *testing.T) { base := hostedHostProfile() tests := []HostProfile{ @@ -223,7 +202,7 @@ func TestClassifyHostProfileRejectionOrdersChecksAndReportsReason(t *testing.T) }, { name: "platform listener conflict", - profile: azureFunctionsHostProfile(), + profile: platformHostProfile(), config: Config{Listen: "127.0.0.1:8080"}, storeConfigured: true, want: hostProfileRejectionReasonDelegatedListener, @@ -330,7 +309,7 @@ func TestPlatformHostProfileRejectsProcessListenerAndEnforcesTrustedProxy(t *tes store := redisx.NewStore(client, "test") config := Config{ - HostProfile: azureFunctionsHostProfile(), + HostProfile: platformHostProfile(), Listen: "127.0.0.1:8080", Proxy: ProxyPolicy{TrustForwarded: true}, } @@ -349,7 +328,7 @@ func TestPlatformHostProfileRejectsProcessListenerAndEnforcesTrustedProxy(t *tes } func TestPlatformHostProfileCannotServeProcessListener(t *testing.T) { - app := &App{config: Config{HostProfile: azureFunctionsHostProfile()}} + app := &App{config: Config{HostProfile: platformHostProfile()}} if err := app.Serve(t.Context()); err == nil || !strings.Contains(err.Error(), "delegates listener ownership") { t.Fatalf("platform profile opened a process listener: %v", err) diff --git a/server/internal/server/oauth_test.go b/server/internal/server/oauth_test.go index d87aa38666..4e42b7cf3c 100644 --- a/server/internal/server/oauth_test.go +++ b/server/internal/server/oauth_test.go @@ -33,7 +33,7 @@ func TestAzureModeRequiresCompleteGitHubOAuthPolicy(t *testing.T) { } _, err = New(context.Background(), redisx.NewStore(client, "test"), Config{ Database: constructorDatabase(), - HostProfile: azureFunctionsHostProfile(), + HostProfile: platformHostProfile(), SiteDirectory: site, AccessToken: testAccessToken, Proxy: ProxyPolicy{AllowedHosts: []string{"dashboard.example.com"}, RequireHTTPS: true}, @@ -45,7 +45,7 @@ func TestAzureModeRequiresCompleteGitHubOAuthPolicy(t *testing.T) { _, err = New(context.Background(), redisx.NewStore(client, "test"), Config{ Database: constructorDatabase(), - HostProfile: azureFunctionsHostProfile(), + HostProfile: platformHostProfile(), SiteDirectory: site, Proxy: ProxyPolicy{AllowedHosts: []string{"dashboard.example.com"}, RequireHTTPS: true}, GitHubOAuth: &GitHubOAuthConfig{ @@ -1148,7 +1148,7 @@ func newAzureTestApp(t *testing.T, githubURL string) *App { } config := Config{ Database: constructorDatabase(), - HostProfile: azureFunctionsHostProfile(), + HostProfile: platformHostProfile(), SiteDirectory: site, Proxy: ProxyPolicy{AllowedHosts: []string{"dashboard.example.com"}, RequireHTTPS: true}, GitHubOAuth: validOAuthConfig(githubURL), diff --git a/server/internal/server/operational_factory_test.go b/server/internal/server/operational_factory_test.go index c88b9c4d90..afef54691e 100644 --- a/server/internal/server/operational_factory_test.go +++ b/server/internal/server/operational_factory_test.go @@ -152,8 +152,10 @@ func TestOperationalSelectionRejectsAmbiguityAndUnsafeTopology(t *testing.T) { "missing volatile acknowledgement": func(p *hostPolicy) { p.Operational.AllowVolatile = false }, "missing replica confirmation": func(p *hostPolicy) { p.Target.Replicas = 0 }, "multiple replicas": func(p *hostPolicy) { p.Target.Replicas = 2 }, - "platform scaling": func(p *hostPolicy) { p.Target.Module = "azure-functions" }, - "unknown backend": func(p *hostPolicy) { p.Operational.Backend = "sqlite" }, + "platform scaling": func(p *hostPolicy) { + p.Target = targetPolicy{Module: "generic", Authentication: HostAuthenticationOAuth, Listener: HostListenerPlatform} + }, + "unknown backend": func(p *hostPolicy) { p.Operational.Backend = "sqlite" }, } { t.Run(name, func(t *testing.T) { policy := memoryHostPolicy() diff --git a/server/internal/server/proxy.go b/server/internal/server/proxy.go new file mode 100644 index 0000000000..dead91b52c --- /dev/null +++ b/server/internal/server/proxy.go @@ -0,0 +1,197 @@ +package server + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "strings" + + "github.com/githubnext/gh-cao/server/internal/logger" + "github.com/githubnext/gh-cao/server/internal/operational" +) + +var proxyLog = logger.New("cao:server:proxy") + +type ProxyPolicy struct { + AllowedHosts []string + RequireHTTPS bool + TrustForwarded bool + TrustedProxyPrefixes []netip.Prefix +} + +func validateHostedMode(store operational.Store, config *Config) error { + profile := config.HostProfile + if profile.Authentication != HostAuthenticationOAuth { + if profile.Listener == HostListenerProcess { + if err := ValidateListen(config.Listen, config.CertFile, config.KeyFile); err != nil { + return err + } + if profile.RequiresHTTPS && (config.CertFile == "" || config.KeyFile == "") { + return fmt.Errorf("host profile %q requires HTTPS", profile.Name) + } + } + return nil + } + if strings.TrimSpace(config.AccessToken) != "" { + return errors.New("hosted mode does not support local bearer capabilities") + } + if store == nil { + return errors.New("hosted mode requires operational storage") + } + if len(config.Proxy.AllowedHosts) == 0 { + return errors.New("hosted mode requires an explicit trusted proxy host policy") + } + policy := config.Proxy + if profile.RequiresHTTPS && !policy.RequireHTTPS { + return errors.New("hosted mode requires HTTPS") + } + if profile.Listener == HostListenerExternal && config.Proxy.TrustForwarded && + len(config.Proxy.TrustedProxyPrefixes) == 0 { + return errors.New("externally hosted forwarded headers require explicit trusted proxy CIDRs") + } + if profile.Listener == HostListenerProcess { + if config.CertFile == "" && isLoopbackListen(config.Listen) && !config.Proxy.TrustForwarded { + config.Proxy.TrustForwarded = true + config.Proxy.TrustedProxyPrefixes = loopbackProxyPrefixes() + } + if config.Proxy.TrustForwarded && len(config.Proxy.TrustedProxyPrefixes) == 0 { + return errors.New("hosted forwarded headers require explicit trusted proxy CIDRs") + } + if err := validateHostedListen( + config.Listen, config.CertFile, config.KeyFile, config.Proxy.TrustForwarded, + ); err != nil { + return err + } + } + if config.GitHubOAuth == nil { + return errors.New("hosted mode requires GitHub OAuth configuration") + } + if err := config.GitHubOAuth.validate(); err != nil { + return err + } + if err := validateLocalDevelopmentOAuth(*config); err != nil { + return err + } + return nil +} + +// proxyRejectionReason names why classifyProxyRequest rejected a request. It +// is stable across host-value and header-content changes, so it is useful to +// log without exposing the request's Host, X-Forwarded-* values, or client +// address. +type proxyRejectionReason string + +const ( + proxyRejectionNone proxyRejectionReason = "" + proxyRejectionUntrustedPeer proxyRejectionReason = "untrusted-proxy-peer" + proxyRejectionEmptyHost proxyRejectionReason = "empty-host" + proxyRejectionHostNotAllowed proxyRejectionReason = "host-not-allowed" + proxyRejectionInsecureRequest proxyRejectionReason = "insecure-request" +) + +// classifyProxyRequest decides whether request satisfies policy and, when it +// does not, which precondition failed. It is a pure function extracted from +// validProxyRequest so the trusted-peer check, host allow-listing, and the +// HTTPS requirement are each independently testable against a plain +// *http.Request, and so a rejection can be logged by reason without +// exposing the request's host or forwarded-header values. +func classifyProxyRequest(request *http.Request, policy ProxyPolicy) proxyRejectionReason { + host := request.Host + secure := request.TLS != nil + if policy.TrustForwarded { + if len(policy.TrustedProxyPrefixes) > 0 && !trustedProxyPeer(request.RemoteAddr, policy.TrustedProxyPrefixes) { + return proxyRejectionUntrustedPeer + } + if forwarded := forwardedHeader(request, "X-Forwarded-Host"); forwarded != "" { + host = forwarded + } + proto := strings.ToLower(strings.TrimSpace(strings.Split(forwardedHeader(request, "X-Forwarded-Proto"), ",")[0])) + secure = proto == "https" + } + host = strings.ToLower(strings.TrimSpace(strings.Split(host, ",")[0])) + if host == "" { + return proxyRejectionEmptyHost + } + if strings.Contains(host, ":") { + host = strings.Split(host, ":")[0] + } + allowed := false + for _, candidate := range policy.AllowedHosts { + if strings.EqualFold(strings.TrimSpace(candidate), host) { + allowed = true + break + } + } + if !allowed { + return proxyRejectionHostNotAllowed + } + if policy.RequireHTTPS && !secure { + return proxyRejectionInsecureRequest + } + return proxyRejectionNone +} + +// validProxyRequest reports whether request satisfies policy, logging the +// specific precondition that failed on rejection so a misconfigured proxy +// policy or an unexpected caller is diagnosable without exposing the +// request's host or forwarded-header values. +func validProxyRequest(request *http.Request, policy ProxyPolicy) bool { + reason := classifyProxyRequest(request, policy) + if reason != proxyRejectionNone { + proxyLog.PrintfContext(request.Context(), "proxy request rejected reason=%s", reason) + return false + } + return true +} + +func trustedProxyPeer(remoteAddress string, prefixes []netip.Prefix) bool { + host, _, err := net.SplitHostPort(remoteAddress) + if err != nil { + host = remoteAddress + } + address, err := netip.ParseAddr(strings.Trim(host, "[]")) + if err != nil { + return false + } + address = address.Unmap() + for _, prefix := range prefixes { + if prefix.Contains(address) { + return true + } + } + return false +} + +func forwardedHeader(request *http.Request, name string) string { + values := request.Header.Values(name) + for valueIndex := len(values) - 1; valueIndex >= 0; valueIndex-- { + parts := strings.Split(values[valueIndex], ",") + for partIndex := len(parts) - 1; partIndex >= 0; partIndex-- { + if value := strings.TrimSpace(parts[partIndex]); value != "" { + return value + } + } + } + return "" +} + +func isLoopbackHost(host string) bool { + host = strings.TrimSpace(host) + if host == "localhost" { + return true + } + address := net.ParseIP(host) + return address != nil && address.IsLoopback() +} + +func splitCSV(value string) []string { + var output []string + for _, item := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(item); trimmed != "" { + output = append(output, trimmed) + } + } + return output +} diff --git a/server/internal/server/azure_proxy_classification_test.go b/server/internal/server/proxy_classification_test.go similarity index 100% rename from server/internal/server/azure_proxy_classification_test.go rename to server/internal/server/proxy_classification_test.go diff --git a/server/internal/server/redis_memory_test.go b/server/internal/server/redis_memory_test.go index 46076aa157..2d36d40e5f 100644 --- a/server/internal/server/redis_memory_test.go +++ b/server/internal/server/redis_memory_test.go @@ -251,15 +251,6 @@ func (redisPressureMaintenanceClient) DoMany(context.Context, [][]string) ([]any return nil, errors.New("unexpected maintenance pipeline") } -func TestAzureRedisMaintenanceStopsOnDrain(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - handler := &azureFunctionsHandler{app: &App{}, stopMaintenance: cancel} - handler.Drain() - if ctx.Err() == nil { - t.Fatal("platform maintenance outlived drain") - } -} - type managedRedisMaintenanceClient struct{} func (managedRedisMaintenanceClient) Do(_ context.Context, command ...string) (any, error) { diff --git a/server/internal/server/server_test.go b/server/internal/server/server_test.go index 3c0de398c7..6e95b70015 100644 --- a/server/internal/server/server_test.go +++ b/server/internal/server/server_test.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "log" "net" "net/http" "net/http/httptest" @@ -118,69 +117,6 @@ func TestUpstashRevocationKeyPrefixIsStableAndModeSpecific(t *testing.T) { } } -func TestAzureRedisLocalSimulationIsLoopbackOnly(t *testing.T) { - for _, endpoint := range []string{ - "redis://127.0.0.1:6379/0", - "redis://[::1]:6379/0", - "redis://localhost:6379/0", - "rediss://redis.example.com:6380/0", - } { - if err := validateAzureRedisURL(endpoint, true); err != nil { - t.Errorf("local simulation rejected %s: %v", endpoint, err) - } - } - for _, endpoint := range []string{ - "redis://redis.example.com:6379/0", - "http://127.0.0.1:6379/0", - } { - if err := validateAzureRedisURL(endpoint, true); err == nil { - t.Errorf("local simulation accepted %s", endpoint) - } - } - if err := validateAzureRedisURL("redis://127.0.0.1:6379/0", false); err == nil { - t.Fatal("production Azure mode accepted plaintext loopback Redis") - } -} - -func TestAzureLocalSimulationFlagFailsClosed(t *testing.T) { - t.Setenv("CAO_AZURE_LOCAL_SIMULATION", "true") - if _, err := azureLocalSimulationFromEnv(); err == nil { - t.Fatal("malformed local simulation flag was accepted") - } - t.Setenv("CAO_AZURE_LOCAL_SIMULATION", "1") - if enabled, err := azureLocalSimulationFromEnv(); err != nil || !enabled { - t.Fatalf("explicit local simulation flag was not accepted: enabled=%t err=%v", enabled, err) - } -} - -func TestAzureLocalSimulationRequiresLoopbackHTTPBoundary(t *testing.T) { - if err := validateAzureLocalEndpoints( - true, - []string{"localhost", "127.0.0.1", "::1"}, - "http://127.0.0.1:7071/auth/callback", - ); err != nil { - t.Fatalf("loopback local endpoints were rejected: %v", err) - } - for _, test := range []struct { - hosts []string - redirect string - }{ - {hosts: []string{"dashboard.example.com"}, redirect: "http://127.0.0.1:7071/auth/callback"}, - {hosts: []string{"localhost"}, redirect: "http://dashboard.example.com/auth/callback"}, - } { - if err := validateAzureLocalEndpoints(true, test.hosts, test.redirect); err == nil { - t.Fatalf("non-loopback local endpoints were accepted: %#v", test) - } - } - if err := validateAzureLocalEndpoints( - false, - []string{"dashboard.example.com"}, - "https://dashboard.example.com/auth/callback", - ); err != nil { - t.Fatalf("production endpoints were changed by local validation: %v", err) - } -} - func TestHostedProxyHeadersAreTrustedOnlyOnLoopbackBoundary(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://internal.example.test/", nil) request.Host = "internal.example.test" @@ -231,11 +167,11 @@ func TestHostedModesCannotDisableHTTPS(t *testing.T) { t.Fatal("hosted mode accepted disabled HTTPS enforcement") } - config.HostProfile = azureFunctionsHostProfile() + config.HostProfile = platformHostProfile() config.Listen = "" config.Proxy = ProxyPolicy{AllowedHosts: []string{"dashboard.example.com"}} if err := validateHostedMode(&redisx.Store{}, &config); err == nil { - t.Fatal("Azure Functions mode accepted disabled HTTPS enforcement") + t.Fatal("platform mode accepted disabled HTTPS enforcement") } } @@ -633,75 +569,6 @@ func TestAccessFailuresCarryStandardizedTraceIdentifiersWithoutDatabase(t *testi } } -func TestProcessTelemetryClose(t *testing.T) { - var p processTelemetry - if err := p.close(t.Context()); err != nil { - t.Fatalf("unconfigured telemetry shutdown: %v", err) - } - called := false - p.shutdown = func(ctx context.Context) error { - called = true - if ctx.Err() != nil { - t.Fatal("telemetry shutdown received a cancelled context") - } - return nil - } - if err := p.close(t.Context()); err != nil || !called { - t.Fatalf("process telemetry shutdown: called=%t err=%v", called, err) - } -} - -func TestAzureFunctionsHandlerLogsTelemetryFailureOnceAndKeepsServing(t *testing.T) { - // NewAzureFunctionsHandlerFromEnv is the only caller of azureProcessTelemetry - // in the process, and this is its only test, so the shared sync.Once - // has not fired yet; sync.Once cannot be copied/reset, so no - // save/restore is attempted here. - previousErr := azureProcessTelemetry.err - t.Cleanup(func() { azureProcessTelemetry.err = previousErr }) - - // An OTLP endpoint plus a malformed OTEL_RESOURCE_ATTRIBUTES value - // forces telemetry.Setup to fail while building the OpenTelemetry - // resource, without needing a reachable collector. - t.Setenv("OTEL_SDK_DISABLED", "") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318") - t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "not-a-valid-key-value-list") - policyPath := t.TempDir() + "/cao.json" - if err := os.WriteFile(policyPath, []byte(`{ - "control-plane": { - "web": { - "host": { - "target": {"module": "azure-functions"}, - "redis": { - "module": "local", - "url-env": "CAO_REDIS_URL", - "allow-private-plaintext": true, - "tls": {"mode": "disabled"} - } - } - } - } - }`), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("CAO_POLICY_PATH", policyPath) - t.Setenv("CAO_REDIS_URL", "") - - var logOutput strings.Builder - logger := log.New(&logOutput, "", 0) - - for i := 0; i < 2; i++ { - _, err := NewAzureFunctionsHandlerFromEnv(t.Context(), t.TempDir(), "", logger) - if err == nil || !strings.Contains(err.Error(), "CAO_REDIS_URL") { - t.Fatalf("call %d: expected the missing CAO_REDIS_URL error, got %v", i, err) - } - } - - occurrences := strings.Count(logOutput.String(), "telemetry configuration failed") - if occurrences != 1 { - t.Fatalf("expected exactly one telemetry failure log line across repeated invocations, got %d: %q", occurrences, logOutput.String()) - } -} - func TestRefreshAndQueryReturnAuthoritativeEvaluatedAt(t *testing.T) { database := integrationDatabase(t) seedDatabase(t, database, map[string]model.Source{ diff --git a/server/internal/server/site_test.go b/server/internal/server/site_test.go index e3c796b8bb..5f5aee4369 100644 --- a/server/internal/server/site_test.go +++ b/server/internal/server/site_test.go @@ -110,7 +110,7 @@ func testHostedHandlerServesDashboardAndAssets(t *testing.T, embedded bool) { profile := hostedHostProfile() switch name { case "platform": - profile = azureFunctionsHostProfile() + profile = platformHostProfile() case "external": profile.Listener = HostListenerExternal } diff --git a/server/internal/simulator/azure_cli_test.go b/server/internal/simulator/azure_cli_test.go deleted file mode 100644 index 1693d75922..0000000000 --- a/server/internal/simulator/azure_cli_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package simulator - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "sync" - "testing" - "time" -) - -const ( - setupAzureSubscription = "00000000-0000-4000-8000-000000000001" - setupAzureTenant = "00000000-0000-4000-8000-000000000002" - setupAzureApp = "fa-cao-setup-sim" - setupAzureRegistry = "acrcaosetupsim" - setupAzureGroup = "rg-cao-setup-sim" - setupAzureHost = setupAzureApp + ".azurewebsites.net" - setupAzureSCM = setupAzureApp + ".scm.azurewebsites.net" -) - -// Test-only az subprocess adapter. No installed az, Azure login, or cloud -// credential can be used: every command goes to this exact loopback fixture. -func TestSetupAzureCLIProcess(t *testing.T) { - endpoint := os.Getenv("CAO_SETUP_AZURE_ENDPOINT") - if endpoint == "" { - return - } - transport, err := NewLocalTransport(endpoint) - if err != nil { - t.Fatal(err) - } - index := slices.Index(os.Args, "--") - if index < 0 { - t.Fatal("missing Azure command arguments") - } - content, err := json.Marshal(os.Args[index+1:]) - if err != nil { - t.Fatal(err) - } - request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, endpoint, bytes.NewReader(content)) // #nosec G704 -- NewLocalTransport validates and confines traffic to the numeric-loopback endpoint. - if err != nil { - t.Fatal(err) - } - client := &http.Client{Transport: transport, Timeout: 2 * time.Minute} - response, err := client.Do(request) // #nosec G704 -- transport rejects requests outside the exact simulator endpoint, including redirects. - if err != nil { - t.Fatal(err) - } - output, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - _ = response.Body.Close() - transport.CloseIdleConnections() - if err != nil { - t.Fatal(err) - } - if response.StatusCode != http.StatusOK { - _, _ = fmt.Fprint(os.Stderr, string(output)) - os.Exit(1) - } - _, _ = os.Stdout.Write(output) - os.Exit(0) -} - -type azureSetupAPI struct { - mu sync.Mutex - calls, unhandled []string - providers []map[string]any - storage *azureBlobStore - group, registry bool - deployed bool - restarted bool - references bool - referenceReads int - drift bool - failProvision bool - snapshot bool - images map[string]string - locked map[string]bool - configured map[string]string - executions map[string]string - functions *setupAzureFunctions -} - -func newAzureSetupAPI(t *testing.T, catalog string, storage *azureBlobStore) *azureSetupAPI { - t.Helper() - api := &azureSetupAPI{ - storage: storage, images: map[string]string{}, locked: map[string]bool{}, - configured: map[string]string{}, executions: map[string]string{}, - } - var document any - if err := json.Unmarshal(setupRead(t, filepath.Join(catalog, "server/azure/main.json")), &document); err != nil { - t.Fatal(err) - } - providers := map[string][]map[string]any{} - for _, namespace := range []string{"Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.DBforPostgreSQL", - "Microsoft.Insights", "Microsoft.KeyVault", "Microsoft.Network", "Microsoft.OperationalInsights", "Microsoft.Storage", "Microsoft.Web"} { - providers[namespace] = nil - } - var visit func(any) - visit = func(value any) { - switch node := value.(type) { - case map[string]any: - resourceType, _ := node["type"].(string) - namespace, kind, ok := strings.Cut(resourceType, "/") - if ok && strings.HasPrefix(namespace, "Microsoft.") { - providers[namespace] = append(providers[namespace], map[string]any{"resourceType": kind, "locations": []string{"West US 3"}}) - } - for _, child := range node { - visit(child) - } - case []any: - for _, child := range node { - visit(child) - } - } - } - visit(document) - for namespace, types := range providers { - api.providers = append(api.providers, map[string]any{"namespace": namespace, "registrationState": "Registered", "resourceTypes": types}) - } - return api -} - -func azureCommand(args []string) string { - end := slices.IndexFunc(args, func(arg string) bool { return strings.HasPrefix(arg, "-") }) - if end < 0 { - end = len(args) - } - return strings.Join(args[:end], " ") -} - -func azureFlag(args []string, name string) string { - index := slices.Index(args, name) - if index < 0 || index+1 >= len(args) { - return "" - } - return args[index+1] -} - -func (a *azureSetupAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { - a.mu.Lock() - defer a.mu.Unlock() - if r.Method != http.MethodPost || r.URL.Path != "/az" { - a.unhandled = append(a.unhandled, r.Method+" "+r.URL.Path) - http.Error(w, "unsupported Azure simulator route", http.StatusNotFound) - return - } - var args []string - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&args); err != nil || len(args) == 0 { - http.Error(w, "invalid Azure command", http.StatusBadRequest) - return - } - for _, argument := range args { - for _, value := range setupAzureSecrets() { - if strings.Contains(argument, value) { - http.Error(w, "secure parameter value passed in Azure argv", http.StatusBadRequest) - return - } - } - } - command := azureCommand(args) - a.calls = append(a.calls, command) - if subscription := azureFlag(args, "--subscription"); subscription != "" && subscription != setupAzureSubscription { - http.Error(w, "Azure simulator subscription not allowed", http.StatusForbidden) - return - } - result, err := a.run(r, command, args) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(result) -} - -func (a *azureSetupAPI) run(r *http.Request, command string, args []string) (any, error) { - switch command { - case "version": - return map[string]string{"azure-cli": "2.67.0"}, nil - case "account show": - return map[string]any{"id": setupAzureSubscription, "tenantId": setupAzureTenant, "state": "Enabled", "user": map[string]string{"type": "user"}}, nil - case "group exists": - return a.group, nil - case "resource list": - if !a.deployed { - return []any{}, nil - } - return []any{map[string]string{"id": azureSetupResourceID("Microsoft.Web/sites", setupAzureApp), "type": "Microsoft.Web/sites"}}, nil - case "provider list": - return a.providers, nil - case "storage account check-name", "acr check-name": - return map[string]bool{"nameAvailable": !a.deployed}, nil - case "keyvault list-deleted": - return []any{}, nil - case "postgres flexible-server list-skus": - return []any{map[string]any{"name": "Burstable", "supportedServerSkus": []any{map[string]string{"name": "Standard_B2s"}}}}, nil - case "rest": - return a.rest(args) - case "deployment sub what-if", "deployment group what-if": - if _, err := azureSetupParameters(args); err != nil { - return nil, err - } - kind := "Create" - if a.drift { - kind = "Ignore" - } - return map[string]any{"status": "Succeeded", "changes": []any{ - map[string]string{"resourceId": azureSetupResourceID("Microsoft.Web/sites", setupAzureApp), "changeType": kind}, - }}, nil - default: - return a.apply(r, command, args) - } -} - -func (a *azureSetupAPI) rest(args []string) (any, error) { - target := azureFlag(args, "--url") - prefix := "/subscriptions/" + setupAzureSubscription - if azureFlag(args, "--method") == "get" { - switch target { - case prefix + "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01", - prefix + "/resourceGroups/" + setupAzureGroup + "/providers/Microsoft.Authorization/permissions?api-version=2022-04-01": - return map[string]any{"value": []any{map[string]any{"actions": []string{"*"}, "notActions": []string{}}}}, nil - case prefix + "/providers/Microsoft.Web/geoRegions?api-version=2024-11-01&sku=EP1&linuxWorkersEnabled=true": - return map[string]any{"value": []any{map[string]string{"name": "West US 3"}}}, nil - case azureSetupResourceID("Microsoft.Web/sites", setupAzureApp) + "/config/configreferences/appsettings?api-version=2023-12-01": - if !a.restarted { - return nil, fmt.Errorf("Key Vault references read before restart") - } - a.referenceReads++ - status := "AccessToKeyVaultDenied" - if a.referenceReads > 1 { - status = "Resolved" - a.references = true - } - return map[string]any{"value": []any{map[string]any{"name": "CAO_POSTGRES_URL", "properties": map[string]string{"status": status}}}}, nil - } - } - if azureFlag(args, "--method") == "post" && strings.HasPrefix(target, prefix+"/providers/") { - path, _, _ := strings.Cut(strings.TrimPrefix(target, prefix+"/providers/"), "?") - if slices.Contains([]string{"Microsoft.KeyVault/checkNameAvailability", "Microsoft.Web/checkNameAvailability", - "Microsoft.DBforPostgreSQL/locations/westus3/checkNameAvailability"}, path) { - return map[string]bool{"nameAvailable": !a.deployed}, nil - } - } - a.unhandled = append(a.unhandled, "rest "+target) - return nil, fmt.Errorf("unsupported Azure simulator REST request") -} - -func azureSetupResourceID(kind, name string) string { - return "/subscriptions/" + setupAzureSubscription + "/resourceGroups/" + setupAzureGroup + "/providers/" + kind + "/" + name -} - -func installAzureCLIAdapter(t *testing.T, api *azureSetupAPI) (directory, endpoint string) { - t.Helper() - server := httptest.NewServer(api) - t.Cleanup(server.Close) - directory = t.TempDir() - for _, tool := range []string{"gh", "git", "sh"} { - source, err := exec.LookPath(tool) - if err != nil { - t.Fatal(err) - } - if err := os.Symlink(source, filepath.Join(directory, tool)); err != nil { - t.Fatal(err) - } - } - setupWrite(t, filepath.Join(directory, "az"), []byte("#!/bin/sh\nexec \"$CAO_SETUP_TEST_BINARY\" -test.run='^TestSetupAzureCLIProcess$' -- \"$@\"\n")) - if err := os.Chmod(filepath.Join(directory, "az"), 0o700); err != nil { // #nosec G302 -- test-only executable adapter. - t.Fatal(err) - } - return directory, server.URL + "/az" -} diff --git a/server/internal/simulator/azure_contract_test.go b/server/internal/simulator/azure_contract_test.go deleted file mode 100644 index 1bf941f6de..0000000000 --- a/server/internal/simulator/azure_contract_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package simulator - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestSetupAzureSimulatorRejectsUnexpectedRequests(t *testing.T) { - for _, test := range []struct { - name, method, path, body string - status int - }{ - {"wrong method", http.MethodGet, "/az", `[]`, http.StatusNotFound}, - {"wrong route", http.MethodPost, "/unknown", `[]`, http.StatusNotFound}, - {"invalid JSON", http.MethodPost, "/az", `{`, http.StatusBadRequest}, - {"empty command", http.MethodPost, "/az", `[]`, http.StatusBadRequest}, - {"unknown command", http.MethodPost, "/az", `["group","delete"]`, http.StatusBadRequest}, - {"secret in argv", http.MethodPost, "/az", `["account","show","--password","synthetic-admin-password-material-0001"]`, http.StatusBadRequest}, - {"other subscription", http.MethodPost, "/az", `["account","show","--subscription","other"]`, http.StatusForbidden}, - {"external REST URL", http.MethodPost, "/az", `["rest","--method","get","--url","https://management.azure.com/unknown"]`, http.StatusBadRequest}, - } { - t.Run(test.name, func(t *testing.T) { - api := &azureSetupAPI{} - request := httptest.NewRequestWithContext(t.Context(), test.method, test.path, bytes.NewBufferString(test.body)) - response := httptest.NewRecorder() - api.ServeHTTP(response, request) - if response.Code != test.status { - t.Fatalf("status %d, want %d: %s", response.Code, test.status, response.Body) - } - if api.group || api.registry || api.deployed || api.restarted || api.references { - t.Fatal("rejected simulator request changed Azure state") - } - }) - } -} - -func TestSetupAzureSimulatorRequiresDeploymentOrdering(t *testing.T) { - for _, args := range [][]string{ - {"acr", "build", "--registry", setupAzureRegistry, "--platform", "linux/amd64"}, - {"containerapp", "job", "show", "--name", setupAzureApp + "-ingest"}, - {"containerapp", "job", "start", "--name", setupAzureApp + "-ingest"}, - {"containerapp", "job", "execution", "show", "--name", setupAzureApp + "-ingest", "--job-execution-name", setupAzureApp + "-ingest-execution"}, - {"functionapp", "restart", "--name", setupAzureApp}, - {"rest", "--method", "get", "--url", azureSetupResourceID("Microsoft.Web/sites", setupAzureApp) + "/config/configreferences/appsettings?api-version=2023-12-01"}, - } { - t.Run(azureCommand(args), func(t *testing.T) { - api := &azureSetupAPI{} - body, err := json.Marshal(args) - if err != nil { - t.Fatal(err) - } - request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/az", bytes.NewReader(body)) - response := httptest.NewRecorder() - api.ServeHTTP(response, request) - if response.Code != http.StatusBadRequest { - t.Fatalf("simulator accepted an out-of-order operation: %s", response.Body) - } - }) - } -} diff --git a/server/internal/simulator/azure_deployment_test.go b/server/internal/simulator/azure_deployment_test.go deleted file mode 100644 index 39474b8e7f..0000000000 --- a/server/internal/simulator/azure_deployment_test.go +++ /dev/null @@ -1,302 +0,0 @@ -package simulator - -import ( - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "path/filepath" - "slices" - "strings" -) - -func azureSetupParameters(args []string) (map[string]json.RawMessage, error) { - path := strings.TrimPrefix(azureFlag(args, "--parameters"), "@") - info, err := os.Stat(path) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { - return nil, fmt.Errorf("Azure parameters must use an owner-only regular file") - } - content, err := os.ReadFile(path) // #nosec G304 -- CLI-created owner-only parameter file within the isolated test workspace. - if err != nil { - return nil, fmt.Errorf("read Azure parameter file: %w", err) - } - var document struct { - Parameters map[string]json.RawMessage `json:"parameters"` - } - if err := json.Unmarshal(content, &document); err != nil { - return nil, fmt.Errorf("invalid Azure parameter document") - } - if document.Parameters["postgresAdministratorPassword"] != nil { - for field, variable := range map[string]string{ // #nosec G101 -- environment variable names, never credential values. - "postgresAdministratorPassword": "CAO_SIM_POSTGRES_PASSWORD", "postgresOwnerPassword": "CAO_SIM_OWNER_PASSWORD", - "postgresRuntimePassword": "CAO_SIM_RUNTIME_PASSWORD", "githubClientSecret": "CAO_SIM_CLIENT_SECRET", "sessionSecret": "CAO_SIM_SESSION_SECRET", - } { - var parameter struct{ Value string } - if err := json.Unmarshal(document.Parameters[field], ¶meter); err != nil || parameter.Value != setupAzureSecrets()[variable] { - return nil, fmt.Errorf("secure parameters were not delivered through the parameter file") - } - } - } - return document.Parameters, nil -} - -func (a *azureSetupAPI) apply(r *http.Request, command string, args []string) (any, error) { - switch command { - case "group create": - if a.group || azureFlag(args, "--name") != setupAzureGroup { - return nil, fmt.Errorf("resource group must be fresh and exact") - } - a.group = true - return map[string]any{}, nil - case "deployment group create": - return a.deploy(r, args) - case "acr build": - if !a.registry || azureFlag(args, "--registry") != setupAzureRegistry || azureFlag(args, "--platform") != "linux/amd64" { - return nil, fmt.Errorf("build requires the deployed registry and expected platform") - } - image := azureFlag(args, "--image") - repository, _, ok := strings.Cut(image, ":") - target := map[string]string{"cao-functions": "azure-functions-runtime", "cao-ingest": "azure-empty-ingest-runtime", "cao-db-provision": "azure-db-provision"}[repository] - if repository == "cao-ingest" && a.snapshot { - target = "azure-ingest-runtime" - } - if !ok || target == "" || azureFlag(args, "--target") != target || a.images[image] != "" { - return nil, fmt.Errorf("unexpected or repeated image build") - } - directory := args[len(args)-1] - source, err := os.OpenRoot(directory) - if err != nil { - return nil, fmt.Errorf("open build context: %w", err) - } - defer func() { _ = source.Close() }() - if target == "azure-functions-runtime" && a.functions != nil { - if err := a.functions.build(directory); err != nil { - return nil, err - } - } - for _, name := range []string{"server/Dockerfile", ".github/workflows/cao.json", ".github/workflows/cao.azure.json"} { - if _, err := os.Stat(filepath.Join(directory, name)); err != nil { - return nil, fmt.Errorf("build context is missing a required product or policy input") - } - } - for _, name := range []string{"consumer.txt", ".env", ".git/config"} { - if _, err := os.Lstat(filepath.Join(directory, name)); !os.IsNotExist(err) { - return nil, fmt.Errorf("build context contains a consumer-owned or credential file") - } - } - contextHash := sha256.New() - err = filepath.WalkDir(directory, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - name, err := filepath.Rel(directory, path) - if err != nil { - return err - } - content, err := source.ReadFile(name) - if err != nil { - return err - } - _, _ = fmt.Fprintf(contextHash, "%s:%x\n", filepath.ToSlash(name), sha256.Sum256(content)) - return nil - }) - if err != nil { - return nil, fmt.Errorf("read build context: %w", err) - } - manifest, err := json.Marshal(map[string]string{"target": target, "contextSha256": fmt.Sprintf("%x", contextHash.Sum(nil))}) - if err != nil { - return nil, err - } - if _, err := a.storage.request(r.Context(), http.MethodPut, "/setup/"+repository+".json", manifest); err != nil { - return nil, fmt.Errorf("persist simulated ACR source upload: %w", err) - } - a.images[image] = fmt.Sprintf("sha256:%x", sha256.Sum256(manifest)) - return map[string]any{}, nil - case "acr repository show", "acr repository update": - image := azureFlag(args, "--image") - digest := a.images[image] - if digest == "" { - return nil, fmt.Errorf("image was not built") - } - if command == "acr repository update" { - if azureFlag(args, "--write-enabled") != "false" || azureFlag(args, "--delete-enabled") != "false" { - return nil, fmt.Errorf("image tag must be locked") - } - a.locked[image] = true - } - return map[string]string{"digest": digest}, nil - case "functionapp config show": - if !a.deployed { - return nil, fmt.Errorf("Function App was not deployed") - } - return "DOCKER|" + a.configured[setupAzureApp], nil - case "containerapp job show": - reference := a.configured[azureFlag(args, "--name")] - if reference == "" { - return nil, fmt.Errorf("job was not deployed") - } - return map[string]any{"containers": []any{map[string]string{"image": reference}}}, nil - case "containerapp job execution list": - if a.configured[azureFlag(args, "--name")] == "" { - return nil, fmt.Errorf("job was not deployed") - } - return []any{}, nil - case "containerapp job start": - job := azureFlag(args, "--name") - if a.configured[job] == "" || a.executions[job] != "" || - job == setupAzureApp+"-ingest" && a.executions[setupAzureApp+"-db-provision"] != "Succeeded" { - return nil, fmt.Errorf("jobs must be deployed and provision before ingestion without overlap") - } - a.executions[job] = "Running" - return map[string]string{"name": job + "-execution"}, nil - case "containerapp job execution show": - job := azureFlag(args, "--name") - if a.executions[job] != "Running" || azureFlag(args, "--job-execution-name") != job+"-execution" { - return nil, fmt.Errorf("only the exact started execution can be polled") - } - status := "Succeeded" - if a.failProvision && job == setupAzureApp+"-db-provision" { - status = "Failed" - } - if a.functions != nil { - if err := a.functions.runJob(job); err != nil { - return nil, err - } - } - a.executions[job] = status - return map[string]any{"name": job + "-execution", "properties": map[string]string{"status": status}}, nil - case "containerapp job replica list": - return []any{map[string]any{"properties": map[string]any{"containers": []any{ - map[string]string{"name": "database-provision", "runningState": "Terminated", "runningStateDetails": "simulated provisioning failure"}, - }}}}, nil - case "functionapp restart": - if a.executions[setupAzureApp+"-ingest"] != "Succeeded" { - return nil, fmt.Errorf("Function App cannot restart before ingestion succeeds") - } - if a.functions != nil { - if err := a.functions.start(r.Context()); err != nil { - return nil, err - } - } - a.restarted = true - return map[string]any{}, nil - case "resource show": - if strings.HasSuffix(azureFlag(args, "--ids"), "/config/web") { - return map[string]any{"ipSecurityRestrictionsDefaultAction": "Allow", "scmIpSecurityRestrictionsDefaultAction": "Deny", "scmIpSecurityRestrictionsUseMain": false}, nil - } - return map[string]string{"publicNetworkAccess": "Enabled"}, nil - case "functionapp config appsettings list": - return []any{map[string]string{"name": "CAO_POSTGRES_RUN_RETENTION_DAYS", "value": "45"}, - map[string]string{"name": "CAO_POSTGRES_LINKED_RETENTION_DAYS", "value": "45"}}, nil - default: - a.unhandled = append(a.unhandled, command) - return nil, fmt.Errorf("unsupported Azure simulator command %q", command) - } -} - -func (a *azureSetupAPI) deploy(r *http.Request, args []string) (any, error) { - if !a.group || azureFlag(args, "--resource-group") != setupAzureGroup { - return nil, fmt.Errorf("deployment requires the exact existing resource group") - } - parameters, err := azureSetupParameters(args) - if err != nil { - return nil, err - } - content, err := os.ReadFile(azureFlag(args, "--template-file")) - if err != nil || !json.Valid(content) { - return nil, fmt.Errorf("deployment requires a valid ARM template") - } - if strings.HasSuffix(azureFlag(args, "--name"), "-registry") { - if a.registry || len(parameters) != 2 { - return nil, fmt.Errorf("registry must be deployed once from the extracted template") - } - a.registry = true - return map[string]any{}, nil - } - if a.deployed || len(a.locked) != 3 { - return nil, fmt.Errorf("full deployment requires three built and locked targets") - } - for repository, binding := range map[string][2]string{ - "cao-functions": {"functionImageDigest", setupAzureApp}, - "cao-ingest": {"ingestionImageDigest", setupAzureApp + "-ingest"}, - "cao-db-provision": {"provisioningImageDigest", setupAzureApp + "-db-provision"}, - } { - var parameter struct{ Value string } - if err := json.Unmarshal(parameters[binding[0]], ¶meter); err != nil { - return nil, fmt.Errorf("missing image digest parameter") - } - matched := false - for image, digest := range a.images { - if strings.HasPrefix(image, repository+":") && a.locked[image] && parameter.Value == digest { - a.configured[binding[1]] = setupAzureRegistry + ".azurecr.io/" + repository + "@" + digest - matched = true - } - } - if !matched { - return nil, fmt.Errorf("deployment image digest does not match a locked build") - } - } - // The emulator stores only non-secret image bindings, never ARM parameters. - state, err := json.Marshal(a.configured) - if err != nil { - return nil, err - } - if _, err := a.storage.request(r.Context(), http.MethodPut, "/setup/deployment.json", state); err != nil { - return nil, fmt.Errorf("persist simulated deployment: %w", err) - } - a.deployed = true - outputs := map[string]any{} - for name, value := range map[string]string{"functionHostName": setupAzureHost, "ingestionJobName": setupAzureApp + "-ingest", "provisioningJobName": setupAzureApp + "-db-provision"} { - outputs[name] = map[string]string{"value": value} - } - return map[string]any{"id": azureSetupResourceID("Microsoft.Resources/deployments", azureFlag(args, "--name")), - "properties": map[string]any{"provisioningState": "Succeeded", "outputs": outputs}}, nil -} - -func (a *azureSetupAPI) serveIngress(w http.ResponseWriter, r *http.Request) { - a.mu.Lock() - defer a.mu.Unlock() - a.calls = append(a.calls, "HTTPS "+r.Host+r.URL.Path) - if !a.references || !a.restarted || a.executions[setupAzureApp+"-ingest"] != "Succeeded" { - http.Error(w, "simulated deployment is not ready", http.StatusServiceUnavailable) - return - } - if !slices.Contains([]string{setupAzureHost, setupAzureSCM}, r.Host) { - http.Error(w, "unexpected Azure ingress request", http.StatusForbidden) - return - } - if r.Host == setupAzureSCM { - w.WriteHeader(http.StatusForbidden) - return - } - if a.functions != nil { - a.functions.handler.ServeHTTP(w, r) - return - } - if r.Method != http.MethodGet { - http.Error(w, "unexpected Azure ingress method", http.StatusForbidden) - return - } - switch r.URL.Path { - case "/api/health", "/api/readiness": - if _, err := a.storage.request(r.Context(), http.MethodGet, "/setup/deployment.json", nil); err != nil { - http.Error(w, "Azurite deployment state unavailable", http.StatusServiceUnavailable) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"redis":{"connected":true},"data":{"available":true}}`)) - case "/api/v1/campaigns": - w.WriteHeader(http.StatusUnauthorized) - case "/": - w.Header().Set("Location", "/auth/login") - w.WriteHeader(http.StatusFound) - case "/auth/login": - w.Header().Set("Location", "https://github.com/login/oauth/authorize?client_id=Iv1.caosetupsim&redirect_uri="+url.QueryEscape("https://"+setupAzureHost+"/auth/callback")) - w.WriteHeader(http.StatusFound) - default: - a.unhandled = append(a.unhandled, "HTTPS "+r.Host+r.URL.Path) - http.Error(w, "unsupported Azure ingress route", http.StatusNotFound) - } -} diff --git a/server/internal/simulator/azure_docker_relay_test.go b/server/internal/simulator/azure_docker_relay_test.go deleted file mode 100644 index afa0ae777e..0000000000 --- a/server/internal/simulator/azure_docker_relay_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package simulator - -import ( - "bytes" - "context" - "errors" - "io" - "net" - "net/http" - "net/http/httptest" - "net/url" - "os" - "os/exec" - "sync" - "testing" - "time" -) - -const setupAzuriteRelayScript = ` -const socket = require("node:net").connect(Number(process.argv[1]), "127.0.0.1"); -socket.on("error", () => process.exit(1)); -socket.on("close", () => process.stdin.destroy()); -process.stdin.pipe(socket); -socket.pipe(process.stdout); -` - -func startSetupAzuriteRelay(t *testing.T, ctx context.Context, command func(context.Context) *exec.Cmd) string { - t.Helper() - ctx, cancel := context.WithCancel(ctx) - listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") // #nosec G102 -- numeric loopback only. - if err != nil { - cancel() - t.Fatal(err) - } - stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) - var workers sync.WaitGroup - workers.Go(func() { - for { - connection, err := listener.Accept() - if err != nil { - return - } - workers.Go(func() { - defer func() { _ = connection.Close() }() - stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) - defer stop() - process := command(ctx) - process.Stdin = connection - process.WaitDelay = time.Second - output, err := process.StdoutPipe() - if err != nil { - t.Error(err) - return - } - if err := process.Start(); err != nil { - if ctx.Err() == nil { - t.Logf("Azurite loopback relay could not start: %v", err) - } - return - } - _, _ = io.Copy(connection, output) - _ = connection.Close() - if err := process.Wait(); err != nil && ctx.Err() == nil { - t.Logf("Azurite loopback relay exited: %v", err) - } - }) - } - }) - t.Cleanup(func() { - cancel() - _ = listener.Close() - workers.Wait() - stop() - }) - return listener.Addr().String() -} - -func TestSetupAzureDockerRelay(t *testing.T) { - if os.Getenv("CAO_SETUP_AZURE_INTEGRATION") != "1" { - t.Skip("run npm run test:integration:setup-azure to exercise the container-loopback relay") - } - body := bytes.Repeat([]byte{0, '\r', '\n', 0xff}, 4096) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - content, err := io.ReadAll(r.Body) - if err != nil || r.Method != http.MethodPut || !bytes.Equal(content, body) { - t.Error("relay changed the upstream request") - } - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = w.Write(content) - })) - defer upstream.Close() - target, err := url.Parse(upstream.URL) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - address := startSetupAzuriteRelay(t, ctx, func(ctx context.Context) *exec.Cmd { - command := exec.CommandContext(ctx, "node", "-e", setupAzuriteRelayScript, target.Port()) // #nosec G204 -- fixed relay script and test-owned loopback port. - command.Env = setupAzureToolEnvironment("PATH") - return command - }) - transport, err := NewLocalTransport("http://" + address) - if err != nil { - t.Fatal(err) - } - defer transport.CloseIdleConnections() - store := &azureBlobStore{endpoint: "http://" + address, key: []byte("test-only-key"), client: &http.Client{Transport: transport, Timeout: 5 * time.Second}} - for range 3 { - content, err := store.request(ctx, http.MethodPut, "/setup/blob", body) - if err != nil || !bytes.Equal(content, body) { - t.Fatalf("loopback relay round trip: %v", err) - } - } - upstream.Close() - transport.CloseIdleConnections() - if _, err := store.request(ctx, http.MethodPut, "/setup/blob", body); err == nil { - t.Fatal("relay accepted a request with no container listener") - } else if errors.Is(err, context.DeadlineExceeded) { - t.Fatal("relay did not close its connection when the container command failed") - } - connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", address) - if err != nil { - t.Fatal(err) - } - defer func() { _ = connection.Close() }() - cancel() - if err := connection.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { - t.Fatal(err) - } - var timeout net.Error - if _, err := connection.Read(make([]byte, 1)); err == nil { - t.Fatal("cancellation left a relay connection open") - } else if errors.As(err, &timeout) && timeout.Timeout() { - t.Fatal("cancellation did not promptly close the relay connection") - } -} diff --git a/server/internal/simulator/azure_functions_test.go b/server/internal/simulator/azure_functions_test.go deleted file mode 100644 index 92711a42bd..0000000000 --- a/server/internal/simulator/azure_functions_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package simulator - -import ( - "bytes" - "context" - "crypto/rand" - "errors" - "fmt" - "net" - "net/http" - "net/http/httputil" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "time" - - "github.com/jackc/pgx/v5" -) - -// The successful deployment uses the reviewed build context and real Functions -// process. Only Azure's resource/control APIs and public ingress are simulated. -type setupAzureFunctions struct { - ctx context.Context - directory, source, binary, dsn string - proxy, ca string - env []string - handler http.Handler - client *http.Client - address string - process *exec.Cmd - done chan error -} - -func newSetupAzureFunctions(t *testing.T, ctx context.Context) *setupAzureFunctions { - t.Helper() - rawEndpoint := os.Getenv("POSTGRES_URL") - config, err := setupAzureFunctionsPostgresConfig(rawEndpoint) - if err != nil { - t.Fatal(err) - } - admin, err := pgx.ConnectConfig(ctx, config) - if err != nil { - t.Fatal("connect to local PostgreSQL: ", err) - } - schema := "cao_functions_" + strings.ToLower(rand.Text()) - if _, err := admin.Exec(ctx, "CREATE SCHEMA "+pgx.Identifier{schema}.Sanitize()); err != nil { - _ = admin.Close(ctx) - t.Fatal(err) - } - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) - defer cancel() - _, err := admin.Exec(cleanup, "DROP SCHEMA "+pgx.Identifier{schema}.Sanitize()+" CASCADE") - if err != nil { - t.Error(err) - } - _ = admin.Close(cleanup) - }) - endpoint, err := url.Parse(rawEndpoint) - if err != nil { - t.Fatal(err) - } - query := endpoint.Query() - query.Set("search_path", schema) - endpoint.RawQuery = query.Encode() - directory := t.TempDir() - f := &setupAzureFunctions{ctx: ctx, directory: directory, binary: filepath.Join(directory, "cao-dashboard"), dsn: endpoint.String()} - t.Cleanup(func() { - if err := f.stop(); err != nil { - t.Error(err) - } - if f.client != nil { - f.client.CloseIdleConnections() - } - }) - return f -} - -func setupAzureFunctionsPostgresConfig(raw string) (*pgx.ConnConfig, error) { - endpoint, err := url.Parse(raw) - if err != nil || endpoint.Scheme != "postgres" && endpoint.Scheme != "postgresql" || - net.ParseIP(endpoint.Hostname()) == nil || !net.ParseIP(endpoint.Hostname()).IsLoopback() { - return nil, errors.New("Azure Functions integration requires POSTGRES_URL with a numeric-loopback PostgreSQL instance") - } - config, err := pgx.ParseConfig(raw) - if err != nil { - return nil, errors.New("Azure Functions integration requires a valid PostgreSQL connection URL") - } - hosts := []string{config.Host} - for _, fallback := range config.Fallbacks { - hosts = append(hosts, fallback.Host) - } - for _, host := range hosts { - if !net.ParseIP(host).IsLoopback() { - return nil, errors.New("Azure Functions integration requires every PostgreSQL connection host to be numeric loopback") - } - } - return config, nil -} - -func TestSetupAzureFunctionsPostgresConfig(t *testing.T) { - for _, test := range []struct { - name, endpoint string - allowed bool - }{ - {"IPv4 loopback", "postgres://test@127.0.0.1/cao", true}, - {"IPv6 loopback", "postgres://test@[::1]/cao", true}, - {"remote authority", "postgres://test@192.0.2.1/cao", false}, - {"hostname", "postgres://test@localhost/cao", false}, - {"remote query override", "postgres://test@127.0.0.1/cao?host=192.0.2.1", false}, - {"remote fallback", "postgres://test@127.0.0.1/cao?host=127.0.0.1,192.0.2.1", false}, - {"socket query override", "postgres://test@127.0.0.1/cao?host=%2Ftmp", false}, - } { - t.Run(test.name, func(t *testing.T) { - _, err := setupAzureFunctionsPostgresConfig(test.endpoint) - if (err == nil) != test.allowed { - t.Fatalf("endpoint allowed = %t, want %t", err == nil, test.allowed) - } - }) - } -} - -func (f *setupAzureFunctions) build(source string) error { - root, err := os.OpenRoot(source) - if err != nil { - return err - } - defer func() { _ = root.Close() }() - dockerfile, err := root.ReadFile("server/Dockerfile") - if err != nil { - return err - } - _, runtime, ok := strings.Cut(string(dockerfile), " AS azure-functions-runtime\n") - runtime, _, _ = strings.Cut(runtime, "\nFROM ") - if !ok || !strings.Contains(runtime, `ENTRYPOINT ["/app/cao-dashboard"]`) || - !strings.Contains(runtime, `CMD ["azure-functions"]`) { - return errors.New("Functions image must launch cao-dashboard azure-functions") - } - f.source = source - queries, err := root.ReadFile("dashboard/site/src/data/queries/database.json") - if err != nil { - return err - } - queryPath := filepath.Join(f.directory, "database-queries.json") - if err := os.WriteFile(queryPath, queries, 0o600); err != nil { // #nosec G703 -- fixed test-owned durable image input. - return err - } - command := exec.CommandContext(f.ctx, "go", "build", "-mod=readonly", "-o", f.binary, "./cmd/cao-dashboard") // #nosec G204,G702 -- fixed build command in the exact isolated ACR context. - command.Dir = filepath.Join(source, "server") - command.Env = setupAzureEnvironment(setupAzureToolEnvironment("PATH", "HOME", "TMPDIR", "GOPATH", "GOMODCACHE", "GOCACHE", "CGO_ENABLED", "SDKROOT", "DEVELOPER_DIR"), - map[string]string{"GOTOOLCHAIN": "local", "GOPROXY": "off", "GOSUMDB": "off", "GOVCS": "*:off", "GOENV": "off"}) - if output, err := command.CombinedOutput(); err != nil { - return fmt.Errorf("build Functions command from ACR context: %w\n%s", err, output) - } - site := filepath.Join(f.directory, "site") - if err := os.Mkdir(site, 0o700); err != nil { // #nosec G703 -- fixed child of t.TempDir, not an Azure argument. - return err - } - if err := os.WriteFile(filepath.Join(site, "index.html"), []byte("Functions lifecycle fixture"), 0o600); err != nil { // #nosec G703 -- fixed child of t.TempDir. - return err - } - f.env = setupAzureEnvironment(setupAzureToolEnvironment("PATH", "HOME", "TMPDIR"), map[string]string{ - "OTEL_SDK_DISABLED": "true", "CAO_POSTGRES_URL": f.dsn, - // Snapshot refresh assertions need fresh reads, not the production - // five-minute query cache admitted when CI queries exceed 100 ms. - "CAO_QUERY_CACHE_DISABLED": "true", - "DEBUG": "cao:functions:startup", - "CAO_POLICY_PATH": filepath.Join(source, ".github/workflows/cao.azure.json"), - "CAO_DATABASE_QUERIES": queryPath, - "CAO_AZURE_DASHBOARD_QUERIES": filepath.Join(source, "dashboard/site/dashboard.json"), - "CAO_AZURE_SITE_DIRECTORY": site, "CAO_AZURE_ALLOWED_HOSTS": setupAzureHost, - "REDIS_NAMESPACE": "azure-dashboard", "CAO_OPERATIONAL_NAMESPACE": "azure-dashboard-operational", - "CAO_GITHUB_CLIENT_ID": "Iv1.caosetupsim", "CAO_GITHUB_CLIENT_SECRET": setupAzureSecrets()["CAO_SIM_CLIENT_SECRET"], - "CAO_SESSION_SECRET": setupAzureSecrets()["CAO_SIM_SESSION_SECRET"], - "CAO_GITHUB_ALLOWED_ORGS": "platform", "CAO_GITHUB_REDIRECT_URL": "https://" + setupAzureHost + "/auth/callback", - "HTTPS_PROXY": "http://127.0.0.1:1", "HTTP_PROXY": "http://127.0.0.1:1", - }) - if f.proxy != "" { - f.env = setupAzureEnvironment(f.env, map[string]string{ - "HTTPS_PROXY": f.proxy, "HTTP_PROXY": f.proxy, "SSL_CERT_FILE": f.ca, - "NO_PROXY": "", "no_proxy": "", - }) - } - return nil -} - -func (f *setupAzureFunctions) runJob(job string) error { - var commands [][]string - env := f.env - if job == setupAzureApp+"-db-provision" { - env = setupAzureEnvironment(env, map[string]string{"CAO_DATABASE_ADMIN_URL": f.dsn}) - commands = [][]string{ - {"database", "bootstrap", "--store", "canonical", "--namespace", "azure-dashboard"}, - {"database", "bootstrap", "--store", "operational", "--namespace", "azure-dashboard-operational"}, - } - } else { - source := filepath.Join(f.source, "server/azure/empty-data") - if _, err := os.Stat(filepath.Join(f.source, "cao-deploy-evidence")); err == nil { // #nosec G703 -- fixed child of the isolated reviewed ACR context. - source = filepath.Join(f.source, "cao-deploy-evidence") - } else if !os.IsNotExist(err) { - return err - } - commands = [][]string{{"ingest", "--source", source, "--redis-namespace", "azure-dashboard", - "--database-queries", filepath.Join(f.directory, "database-queries.json")}} - } - for _, args := range commands { - command := exec.CommandContext(f.ctx, f.binary, args...) // #nosec G204,G702 -- test-built binary and fixed provisioning/ingestion arguments. - command.Dir, command.Env = f.directory, env - if output, err := command.CombinedOutput(); err != nil { - return fmt.Errorf("Functions deployment job: %w\n%s", err, output) - } - } - return nil -} - -func (f *setupAzureFunctions) start(ctx context.Context) error { - listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") // #nosec G102 -- numeric loopback only. - if err != nil { - return err - } - f.address = listener.Addr().String() - _ = listener.Close() - _, port, err := net.SplitHostPort(f.address) - if err != nil { - return err - } - target, err := url.Parse("http://" + f.address) - if err != nil { - return err - } - transport, err := NewLocalTransport(target.String()) - if err != nil { - return err - } - f.client = &http.Client{Transport: transport, Timeout: time.Second} - proxy := &httputil.ReverseProxy{ - Transport: transport, - Rewrite: func(r *httputil.ProxyRequest) { - r.SetURL(target) - r.Out.Host = setupAzureHost - r.Out.Header.Set("X-Forwarded-Host", setupAzureHost) - r.Out.Header.Set("X-Forwarded-Proto", "https") - }, - } - f.handler = proxy - //nolint:contextcheck // The process belongs to the test lifetime, not the single Azure restart request. - command := exec.CommandContext(f.ctx, f.binary, "azure-functions") // #nosec G204 -- binary compiled from the materialized ACR context. - command.Dir = f.directory - command.Env = setupAzureEnvironment(f.env, map[string]string{"FUNCTIONS_CUSTOMHANDLER_HOST": "127.0.0.1", "FUNCTIONS_CUSTOMHANDLER_PORT": port}) - log, err := os.OpenFile(filepath.Join(f.directory, "functions.log"), os.O_CREATE|os.O_WRONLY, 0o600) // #nosec G304 -- fixed test-owned log. - if err != nil { - return err - } - command.Stdout, command.Stderr = log, log - if err := command.Start(); err != nil { - _ = log.Close() - return err - } - f.process, f.done = command, make(chan error, 1) - go func() { - f.done <- command.Wait() - _ = log.Close() - }() - ctx, cancel := context.WithTimeout(ctx, 20*time.Second) - defer cancel() - for { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String()+"/api/readiness", nil) - if err != nil { - return err - } - request.Header.Set("X-Forwarded-Host", setupAzureHost) - request.Header.Set("X-Forwarded-Proto", "https") - response, err := f.client.Do(request) // #nosec G704 -- exact numeric-loopback transport. - if err == nil { - _ = response.Body.Close() - if response.StatusCode == http.StatusOK { - return nil - } - } - select { - case err := <-f.done: - f.process = nil - return fmt.Errorf("Functions process exited before readiness: %w", err) - case <-ctx.Done(): - return errors.New("Functions process did not become ready") - case <-time.After(50 * time.Millisecond): - } - } -} - -func (f *setupAzureFunctions) stop() error { - if f.process == nil { - return nil - } - if err := f.process.Process.Signal(syscall.SIGTERM); err != nil { - return err - } - select { - case err := <-f.done: - f.process = nil - return err - case <-time.After(10 * time.Second): - _ = f.process.Process.Kill() - <-f.done - f.process = nil - return errors.New("Functions process did not gracefully exit after SIGTERM") - } -} - -func (f *setupAzureFunctions) assertShutdown(t *testing.T, ctx context.Context) { - t.Helper() - if f.process == nil { - t.Fatal("Azure deployment did not start the Functions command") - } - if err := f.stop(); err != nil { - t.Fatal(err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+f.address+"/api/health", nil) - if err != nil { - t.Fatal(err) - } - if response, err := f.client.Do(request); err == nil { // #nosec G704 -- exact numeric-loopback transport. - _ = response.Body.Close() - t.Fatal("Functions listener still accepts requests after shutdown") - } - content := setupRead(t, filepath.Join(f.directory, "functions.log")) - assertSetupAzureNoSecrets(t, content) - if !bytes.Contains(content, []byte("cao:functions:startup listening addr=")) { - t.Fatal("Functions startup logging is required to verify shutdown failures") - } - if bytes.Contains(content, []byte("shutdown failed")) { - t.Fatal("Functions shutdown did not drain HTTP and telemetry cleanly") - } - t.Log("cao-dashboard azure-functions built from the ACR context, served Azure receipt checks against PostgreSQL, and exited successfully on SIGTERM") -} diff --git a/server/internal/simulator/azure_isolation_test.go b/server/internal/simulator/azure_isolation_test.go deleted file mode 100644 index 90b5202b24..0000000000 --- a/server/internal/simulator/azure_isolation_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package simulator - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strings" - "sync/atomic" - "testing" - "time" -) - -func TestSetupAzureProxyCannotReachLiveResources(t *testing.T) { - var localRequests atomic.Int64 - blocked := make(chan string, 1) - proxy, ca := bootstrapProxyWithRejection(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - localRequests.Add(1) - _, _ = io.WriteString(w, "local simulator") - }), func(method, host string) { blocked <- method + " " + host }, setupAzureHost, setupAzureSCM) - target, err := url.Parse(proxy) - if err != nil { - t.Fatal(err) - } - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(setupRead(t, ca)) { - t.Fatal("invalid simulator CA") - } - transport := &http.Transport{ - Proxy: http.ProxyURL(target), - TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, - DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { - if address != target.Host { - return nil, fmt.Errorf("test client attempted to bypass its loopback proxy") - } - return (&net.Dialer{}).DialContext(ctx, network, address) - }, - } - t.Cleanup(transport.CloseIdleConnections) - client := &http.Client{Transport: transport, Timeout: 5 * time.Second} - for _, host := range []string{"github.com", "api.github.com", setupAzureHost, setupAzureSCM} { - request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://"+host+"/", nil) - if err != nil { - t.Fatal(err) - } - response, err := client.Do(request) - if err != nil { - t.Fatal(err) - } - body, err := io.ReadAll(response.Body) - _ = response.Body.Close() - if err != nil || string(body) != "local simulator" { - t.Fatalf("%s was not handled by the local simulator", host) - } - } - for _, host := range []string{"management.azure.com", "login.microsoftonline.com", "uploads.github.com", "raw.githubusercontent.com", "169.254.169.254"} { - request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://"+host+"/", nil) - if err != nil { - t.Fatal(err) - } - response, err := client.Do(request) - if err == nil { - _ = response.Body.Close() - t.Fatalf("proxy accepted an unapproved destination: %s", host) - } - select { - case rejected := <-blocked: - if rejected != "CONNECT "+host+":443" { - t.Fatalf("incorrect proxy rejection: %s", rejected) - } - default: - t.Fatalf("request was not rejected by the local proxy: %s", host) - } - } - if localRequests.Load() != 4 { - t.Fatal("denied destinations reached the simulator's upstream") - } -} - -func TestSetupAzureEnvironmentDoesNotInheritCloudCredentials(t *testing.T) { - for _, name := range []string{"GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", - "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "AZURE_TENANT_ID", "AZURE_SUBSCRIPTION_ID", - "AZURE_FEDERATED_TOKEN_FILE", "AZURE_CONFIG_DIR", "ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"} { - t.Setenv(name, "ambient-value-must-not-be-inherited") - } - - t.Setenv("GH_TOKEN", "ambient-value-must-not-be-inherited") - environment := bootstrapEnvironment(t, t.TempDir(), "http://127.0.0.1:1", "simulator-ca.pem") - values := map[string]string{} - for _, item := range environment { - name, value, _ := strings.Cut(item, "=") - values[name] = value - if value == "ambient-value-must-not-be-inherited" { - t.Fatalf("simulation inherited ambient cloud configuration: %s", name) - } - } - if values["GH_TOKEN"] != "simulator-token-with-no-external-authority" { - t.Fatal("simulation must use only its inert GitHub token") - } - for _, name := range []string{"HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"} { - if values[name] != "http://127.0.0.1:1" { - t.Fatalf("simulation did not fix its local proxy: %s", name) - } - } - if values["NO_PROXY"] != "" || values["no_proxy"] != "" { - t.Fatal("simulation allows a proxy bypass") - } -} - -func TestSetupAzureDockerRejectsRemoteDaemons(t *testing.T) { - for _, endpoint := range []string{"unix:///var/run/docker.sock", "unix:///home/test/.docker/run/docker.sock"} { - if !validSetupDockerHost(endpoint) { - t.Fatalf("local Docker socket rejected: %s", endpoint) - } - } - for _, endpoint := range []string{"", "tcp://127.0.0.1:2375", "tcp://remote.example:2376", - "ssh://remote.example", "https://management.azure.com", "unix://remote/docker.sock", "unix:relative.sock"} { - if validSetupDockerHost(endpoint) { - t.Fatalf("simulation accepted a remote or ambiguous Docker endpoint: %s", endpoint) - } - } -} diff --git a/server/internal/simulator/azure_journey_test.go b/server/internal/simulator/azure_journey_test.go deleted file mode 100644 index bc730e8a64..0000000000 --- a/server/internal/simulator/azure_journey_test.go +++ /dev/null @@ -1,351 +0,0 @@ -package simulator - -import ( - "bytes" - "context" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "path/filepath" - "slices" - "strings" - "sync" - "testing" - "time" - - "github.com/githubnext/gh-cao/server/internal/model" -) - -func TestCAOAzureInstallJourneyIntegration(t *testing.T) { - testCAOSetupAzureIntegration(t, "private farm journey") -} - -// Replays the private hosted branch of bradygaster/gh-aw-cao#26. Human -// approvals are fixture inputs, not authority to contact either cloud. -func setupAzurePrivateFarm(t *testing.T, ctx context.Context, f *setupFixture, catalog, binary string) { - t.Helper() - assets := bootstrapReleaseAssets(t, binary) - f.api.mu.Lock() - f.api.Assets = assets - for _, repository := range f.api.Repositories { - repository.Private = true - } - f.api.mu.Unlock() - for _, item := range f.env { - if data, ok := strings.CutPrefix(item, "XDG_DATA_HOME="); ok { - if err := os.Remove(filepath.Join(data, "gh/extensions/gh-cao/gh-cao")); err != nil { - t.Fatal(err) - } - } - } - installerEnv := setupAzureEnvironment(f.env, map[string]string{"PATH": os.Getenv("PATH")}) - bootstrapRun(t, ctx, f.checkout, installerEnv, "bash", filepath.Join(catalog, "install-gh-cao.sh"), catalog) - for _, name := range []string{".github/workflows/cao-validate.yml"} { - if len(setupRead(t, filepath.Join(f.checkout, name))) == 0 { - t.Fatalf("installer omitted %s", name) - } - } - // Hosted setup uses reviewed policy, not the public monitoring shortcut. - f.control()["scope"] = map[string]any{"allowed-owners": []string{"platform"}, - "allowed-repositories": []string{"platform/control", "platform/alpha", "platform/zeta"}} - f.control()["activity"] = map[string]any{"repositories": []string{"platform/control", "platform/alpha", "platform/zeta"}, - "public-only": false} - setupSaveFixturePolicy(t, f) - output := bootstrapRun(t, ctx, f.checkout, f.env, "gh", "cao", "setup", "auth", "github-app", "--dry-run", "--no-open") - var apps map[string]any - if err := json.Unmarshal([]byte(output), &apps); err != nil { - t.Fatal(err) - } - setupAssertJSON(t, apps["installationTargets"], map[string]any{ - "read": []map[string]any{{"owner": "platform", "repositories": []string{"alpha", "control", "zeta"}}}, - "write": []map[string]any{{"owner": "platform", "repositories": []string{"control"}}}, - }) - setupAssertGitHubAppPlans(t, apps, false, "", "", "github.com") - // Model the owner completing selected-repository installation and storing - // PEMs outside the agent. Secret-name presence is not working-key proof. - f.api.mu.Lock() - f.api.Repositories["platform/control"].Secrets = []string{"GH_AW_GITHUB_READ_APP_PRIVATE_KEY", "GH_AW_GITHUB_WRITE_APP_PRIVATE_KEY"} - f.api.mu.Unlock() - for name, value := range map[string]string{ - "GH_AW_GITHUB_READ_APP_ID": "1001", "GH_AW_GITHUB_WRITE_APP_ID": "1002", "GH_AW_GITHUB_AUTH_MODE": "app", - } { - bootstrapRun(t, ctx, f.checkout, f.env, "gh", "variable", "set", name, "--body", value) - if got := strings.TrimSpace(bootstrapRun(t, ctx, f.checkout, f.env, "gh", "variable", "get", name)); got != value { - t.Fatalf("App variable %s was not configured on the control repository", name) - } - } - remote := filepath.Join(f.root, "published-control.git") - bootstrapRun(t, ctx, f.root, f.env, "git", "init", "--bare", "--quiet", "--initial-branch=main", remote) - bootstrapRun(t, ctx, f.checkout, f.env, "git", "add", ".github") - bootstrapRun(t, ctx, f.checkout, f.env, "git", "-c", "user.name=CAO simulator", "-c", "user.email=simulator@example.invalid", - "commit", "-qm", "Publish reviewed private control plane") - bootstrapRun(t, ctx, f.checkout, f.env, "git", "push", remote, "HEAD:main") - published := bootstrapRun(t, ctx, f.root, f.env, "git", "--git-dir="+remote, "show", "main:.github/workflows/cao.json") - if !bytes.Equal(bytes.TrimSpace([]byte(published)), bytes.TrimSpace(setupRead(t, f.policyPath()))) { - t.Fatal("published policy differs from reviewed private farm authority") - } - f.api.mu.Lock() - defer f.api.mu.Unlock() - if len(f.api.Unhandled) != 0 { - t.Fatalf("installer used unsupported GitHub routes: %v", f.api.Unhandled) - } - for _, request := range f.api.Requests { - if strings.Contains(request, "/pages") { - t.Fatalf("private hosted journey contacted Pages: %s", request) - } - } -} - -func setupAzureFarmSnapshot(t *testing.T, attempts int) string { - t.Helper() - directory := t.TempDir() - var shard bytes.Buffer - encoder := json.NewEncoder(&shard) - write := func(value any) { - if err := encoder.Encode(value); err != nil { - t.Fatal(err) - } - } - write(map[string]any{"kind": "metadata", "schemaVersion": model.SchemaVersion, "ingestionVersion": 3, "phase": "runs", "records": 5 + 2*attempts}) - record := func(collection string, row model.Row) { - write(map[string]any{"kind": "record", "collection": collection, "record": row}) - } - now := time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) - for _, name := range []string{"control", "alpha", "zeta"} { - record("repositories", model.Row{"id": "repository:platform/" + name, "owner": "platform", "name": name, - "visibility": "private", "rolloutMode": "review", "observedAt": now}) - } - for _, name := range []string{"alpha", "zeta"} { - record("workflows", model.Row{"id": "workflow:platform/" + name + ":build", "repositoryId": "repository:platform/" + name, - "path": ".github/workflows/build.yml", "name": "Build", "state": "active", "observedAt": now}) - for attempt := 1; attempt <= attempts; attempt++ { - record("runs", model.Row{"id": fmt.Sprintf("run:%s-%d", name, attempt), - "repositoryId": "repository:platform/" + name, "workflowId": "workflow:platform/" + name + ":build", - "owner": "platform", "repository": name, "githubRunId": fmt.Sprintf("%s-%d", name, attempt), - "attempt": 1, "status": "completed", "conclusion": "success", "createdAt": now, "updatedAt": now, - "startedAt": now, "completedAt": now, "observedAt": now}) - } - } - name := "gh-aw-logs-runs/farm.jsonl" - setupWrite(t, filepath.Join(directory, name), shard.Bytes()) - setupWrite(t, filepath.Join(directory, "inventory-sources.json"), []byte("{}")) - manifest, err := json.Marshal(map[string]string{name: fmt.Sprintf("%x", sha256.Sum256(shard.Bytes()))}) - if err != nil { - t.Fatal(err) - } - setupWrite(t, filepath.Join(directory, "payload-hashes.json"), manifest) - return directory -} - -type setupAzureOAuth struct { - mu sync.Mutex - codes map[string]string - requests []string - revoked []string -} - -func newSetupAzureOAuth() *setupAzureOAuth { - return &setupAzureOAuth{codes: map[string]string{}} -} - -func (a *setupAzureOAuth) serveHTTP(w http.ResponseWriter, r *http.Request) bool { - if !strings.HasPrefix(r.URL.Path, "/login/oauth/") && r.URL.Path != "/user/memberships/orgs/platform" && - !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer journey-") && - r.URL.Path != "/applications/Iv1.caosetupsim/token" { - return false - } - a.mu.Lock() - defer a.mu.Unlock() - a.requests = append(a.requests, r.Method+" "+r.URL.Path) - w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == http.MethodGet && r.URL.Path == "/login/oauth/authorize": - query := r.URL.Query() - if query.Get("client_id") != "Iv1.caosetupsim" || query.Get("redirect_uri") != "https://"+setupAzureHost+"/auth/callback" || query.Get("state") == "" { - http.Error(w, "incorrect OAuth registration or state", http.StatusBadRequest) - return true - } - code := fmt.Sprintf("code-%d", len(a.requests)) - a.codes[code] = query.Get("simulated_user") - http.Redirect(w, r, "https://"+setupAzureHost+"/auth/callback?code="+code+"&state="+url.QueryEscape(query.Get("state")), http.StatusFound) - case r.Method == http.MethodPost && r.URL.Path == "/login/oauth/access_token": - if err := r.ParseForm(); err != nil { - http.Error(w, "invalid token request", http.StatusBadRequest) - return true - } - login, exists := a.codes[r.Form.Get("code")] - if !exists || r.Form.Get("client_id") != "Iv1.caosetupsim" || - r.Form.Get("client_secret") != setupAzureSecrets()["CAO_SIM_CLIENT_SECRET"] || - r.Form.Get("redirect_uri") != "https://"+setupAzureHost+"/auth/callback" { - http.Error(w, "unregistered OAuth client or code", http.StatusUnauthorized) - return true - } - delete(a.codes, r.Form.Get("code")) - _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "journey-" + login, "token_type": "bearer", "expires_in": 3600}) - case r.Method == http.MethodGet && r.URL.Path == "/user": - _ = json.NewEncoder(w).Encode(map[string]any{"id": 123, "login": strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer journey-")}) - case r.Method == http.MethodGet && r.URL.Path == "/user/memberships/orgs/platform": - state := "pending" - if r.Header.Get("Authorization") == "Bearer journey-member" { - state = "active" - } - _ = json.NewEncoder(w).Encode(map[string]string{"state": state}) - case r.Method == http.MethodDelete && r.URL.Path == "/applications/Iv1.caosetupsim/token": - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid revocation", http.StatusBadRequest) - return true - } - a.revoked = append(a.revoked, body["access_token"]) - w.WriteHeader(http.StatusNoContent) - default: - http.Error(w, "unsupported simulated OAuth request", http.StatusNotFound) - } - return true -} - -func setupAzureBrowser(t *testing.T, proxy, ca string) *http.Client { - t.Helper() - endpoint, err := url.Parse(proxy) - if err != nil { - t.Fatal(err) - } - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(setupRead(t, ca)) { - t.Fatal("invalid simulator CA") - } - transport := &http.Transport{Proxy: http.ProxyURL(endpoint), - TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, - DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { - if address != endpoint.Host { - return nil, fmt.Errorf("browser attempted to bypass its loopback proxy") - } - return (&net.Dialer{}).DialContext(ctx, network, address) - }} - t.Cleanup(transport.CloseIdleConnections) - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatal(err) - } - return &http.Client{Transport: transport, Jar: jar, Timeout: 10 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} -} - -func setupAzureBrowserRequest(t *testing.T, ctx context.Context, client *http.Client, method, target, body string, status int) ([]byte, http.Header) { - t.Helper() - request, err := http.NewRequestWithContext(ctx, method, target, strings.NewReader(body)) - if err != nil { - t.Fatal(err) - } - if body != "" { - request.Header.Set("Content-Type", "application/json") - } - for _, cookie := range client.Jar.Cookies(request.URL) { - if cookie.Name == "cao_csrf" { - request.Header.Set("X-CSRF-Token", cookie.Value) - } - } - response, err := client.Do(request) // #nosec G704 -- transport permits only the test-owned loopback proxy. - if err != nil { - t.Fatal(err) - } - content, err := io.ReadAll(response.Body) - _ = response.Body.Close() - if err != nil || response.StatusCode != status { - t.Fatalf("%s %s: status %d, want %d: %s (%v)", method, request.URL.Path, response.StatusCode, status, content, err) - } - assertSetupAzureNoSecrets(t, content) - return content, response.Header -} - -func assertSetupAzureFarmJourney(t *testing.T, ctx context.Context, functions *setupAzureFunctions, oauth *setupAzureOAuth, proxy, ca string) { - t.Helper() - const origin = "https://" + setupAzureHost - const query = `{"sourceNames":["$repositories","$runs"]}` - member := setupAzureBrowser(t, proxy, ca) - setupAzureBrowserRequest(t, ctx, member, http.MethodPost, origin+"/api/v1/query", query, http.StatusUnauthorized) - login := func(client *http.Client, user string, status int) { - _, headers := setupAzureBrowserRequest(t, ctx, client, http.MethodGet, origin+"/auth/login", "", http.StatusFound) - authorization, err := url.Parse(headers.Get("Location")) - if err != nil { - t.Fatal(err) - } - parameters := authorization.Query() - parameters.Set("simulated_user", user) - authorization.RawQuery = parameters.Encode() - _, headers = setupAzureBrowserRequest(t, ctx, client, http.MethodGet, authorization.String(), "", http.StatusFound) - setupAzureBrowserRequest(t, ctx, client, http.MethodGet, headers.Get("Location"), "", status) - } - login(member, "member", http.StatusFound) - page, _ := setupAzureBrowserRequest(t, ctx, member, http.MethodGet, origin+"/", "", http.StatusOK) - if !bytes.Contains(page, []byte("server-http")) { - t.Fatal("authenticated dashboard did not select the hosted backend") - } - read := func(wantRuns int) int64 { - content, _ := setupAzureBrowserRequest(t, ctx, member, http.MethodPost, origin+"/api/v1/query", query, http.StatusOK) - var result struct { - Revision int64 - Sources map[string]model.Source - } - if err := json.Unmarshal(content, &result); err != nil { - t.Fatal(err) - } - repositories := make([]string, 0, len(result.Sources["$repositories"].Rows)) - for _, row := range result.Sources["$repositories"].Rows { - repositories = append(repositories, row["owner"].(string)+"/"+row["name"].(string)) - if row["visibility"] != "private" { - t.Fatal("farm repository lost private visibility") - } - } - slices.Sort(repositories) - if !slices.Equal(repositories, []string{"platform/alpha", "platform/control", "platform/zeta"}) || len(result.Sources["$runs"].Rows) != wantRuns { - t.Fatalf("hosted query did not return the exact farm and %d runs: %s", wantRuns, content) - } - ids := make([]string, 0, wantRuns) - for _, row := range result.Sources["$runs"].Rows { - if row["owner"] != "platform" || (row["repository"] != "alpha" && row["repository"] != "zeta") { - t.Fatalf("hosted run is outside the farm: %s", content) - } - ids = append(ids, row["id"].(string)) - } - slices.Sort(ids) - expected := make([]string, 0, wantRuns) - for _, name := range []string{"alpha", "zeta"} { - for attempt := 1; attempt <= wantRuns/2; attempt++ { - expected = append(expected, fmt.Sprintf("run:%s-%d", name, attempt)) - } - } - if !slices.Equal(ids, expected) { - t.Fatalf("hosted query returned incorrect run identities: %v", ids) - } - return result.Revision - } - previous := read(2) - setupAzureBrowserRequest(t, ctx, member, http.MethodGet, origin+"/api/admin/collection/status", "", http.StatusForbidden) - outsider := setupAzureBrowser(t, proxy, ca) - login(outsider, "outsider", http.StatusForbidden) - setupAzureBrowserRequest(t, ctx, outsider, http.MethodPost, origin+"/api/v1/query", query, http.StatusUnauthorized) - // Exercise the chosen controlled-snapshot refresh branch, not just initial - // readiness. This deliberately does not pretend a collector was installed. - snapshot := setupAzureFarmSnapshot(t, 2) - bootstrapRun(t, ctx, functions.directory, functions.env, functions.binary, "ingest", "--source", snapshot, - "--redis-namespace", "azure-dashboard", "--database-queries", filepath.Join(functions.directory, "database-queries.json")) - setupAzureBrowserRequest(t, ctx, member, http.MethodPost, origin+"/api/v1/refresh", "{}", http.StatusOK) - if read(4) <= previous { - t.Fatal("new snapshot did not advance the hosted data revision") - } - oauth.mu.Lock() - defer oauth.mu.Unlock() - if !slices.Contains(oauth.revoked, "journey-outsider") { - t.Fatal("denied sign-in did not revoke its simulated provider credential") - } - t.Log("private installer -> reviewed App scope -> snapshot deploy -> OAuth login/denial -> PostgreSQL farm query -> refreshed evidence; no Pages or live services") -} diff --git a/server/internal/simulator/azure_storage_test.go b/server/internal/simulator/azure_storage_test.go deleted file mode 100644 index cf15a38659..0000000000 --- a/server/internal/simulator/azure_storage_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package simulator - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" -) - -// Use the same official emulator image as scripts/azure-local/azure-local.sh. -const setupAzuriteImage = "mcr.microsoft.com/azure-storage/azurite:3.37.0@sha256:830430c1da1a2d537e08f3e6764dd1f5ae00cf0346bcaf625b968ec3f0971fd5" - -type azureBlobStore struct { - endpoint string - key []byte - client *http.Client -} - -func startSetupAzurite(t *testing.T, ctx context.Context) *azureBlobStore { - t.Helper() - key := make([]byte, 32) - if _, err := rand.Read(key); err != nil { - t.Fatal(err) - } - account := "AZURITE_ACCOUNTS=stcaosetupsim:" + base64.StdEncoding.EncodeToString(key) - var port string - if binary := os.Getenv("CAO_SETUP_AZURITE_BINARY"); binary != "" { - port = startSetupAzuriteProcess(t, ctx, binary, account) - } else { - host := setupDockerHost(t, ctx) - env := setupAzureEnvironment(setupAzureToolEnvironment("PATH", "HOME", "TMPDIR", "XDG_CONFIG_HOME", "DOCKER_CONFIG"), map[string]string{ - "DOCKER_CONTEXT": "", "DOCKER_HOST": host, "DOCKER_TLS_VERIFY": "", "DOCKER_CERT_PATH": "", - "AZURITE_ACCOUNTS": strings.TrimPrefix(account, "AZURITE_ACCOUNTS="), - }) - docker := func(ctx context.Context, args ...string) string { - return bootstrapRun(t, ctx, ".", env, "docker", append([]string{"--host", host}, args...)...) - } - docker(ctx, "image", "inspect", setupAzuriteImage, "--format", "{{.Id}}") - network := strings.TrimSpace(docker(ctx, "network", "create", "--internal", "cao-setup-"+rand.Text())) - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) - defer cancel() - docker(cleanup, "network", "rm", network) - }) - id := strings.TrimSpace(docker(ctx, "run", "--pull=never", "--detach", "--rm", - "--env", "AZURITE_ACCOUNTS", "--network", network, setupAzuriteImage, - "azurite-blob", "--blobHost", "127.0.0.1", "--silent")) - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) - defer cancel() - docker(cleanup, "rm", "--force", id) - }) - // Docker does not publish ports on an internal-only network. Relay over - // the validated local Unix socket instead of granting container egress. - port = startSetupAzuriteRelay(t, ctx, func(ctx context.Context) *exec.Cmd { - command := exec.CommandContext(ctx, "docker", "--host", host, "exec", "--interactive", id, "node", "-e", setupAzuriteRelayScript, "10000") // #nosec G204 -- local Docker socket, test-owned container, and fixed container-loopback relay. - command.Env = env - return command - }) - } - if !strings.HasPrefix(port, "127.0.0.1:") || strings.ContainsAny(port, "\r\n") { - t.Fatalf("Azurite did not bind one numeric-loopback endpoint: %q", port) - } - endpoint := "http://" + port + "/stcaosetupsim" - transport, err := NewLocalTransport(endpoint) - if err != nil { - t.Fatal(err) - } - store := &azureBlobStore{ - endpoint: endpoint, - key: key, - client: &http.Client{Transport: transport, Timeout: 5 * time.Second}, - } - t.Cleanup(store.client.CloseIdleConnections) - deadline := time.NewTimer(30 * time.Second) - defer deadline.Stop() - for { - _, err := store.request(ctx, http.MethodPut, "/setup?restype=container", nil) - if err == nil { - return store - } - select { - case <-ctx.Done(): - t.Fatalf("start Azurite: %v", ctx.Err()) - case <-deadline.C: - t.Fatalf("Azurite was not ready: %v", err) - case <-time.After(100 * time.Millisecond): - } - } -} - -func setupDockerHost(t *testing.T, ctx context.Context) string { - t.Helper() - host := os.Getenv("DOCKER_HOST") - if selected := os.Getenv("DOCKER_CONTEXT"); selected != "" || host == "" { - args := []string{"context", "inspect", "--format", "{{.Endpoints.docker.Host}}"} - if selected != "" { - args = append(args, "--", selected) - } - environment := setupAzureToolEnvironment("PATH", "HOME", "TMPDIR", "XDG_CONFIG_HOME", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST") - host = strings.TrimSpace(bootstrapRun(t, ctx, ".", environment, "docker", args...)) - } - if !validSetupDockerHost(host) { - t.Fatal("Azure simulation requires a local Unix Docker socket; remote Docker endpoints are forbidden; use CAO_SETUP_AZURITE_BINARY instead") - } - return host -} - -func validSetupDockerHost(host string) bool { - endpoint, err := url.Parse(host) - return err == nil && endpoint.Scheme == "unix" && endpoint.Host == "" && endpoint.User == nil && - endpoint.RawQuery == "" && endpoint.Fragment == "" && filepath.IsAbs(endpoint.Path) -} - -func startSetupAzuriteProcess(t *testing.T, ctx context.Context, binary, account string) string { - t.Helper() - listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") // #nosec G102 -- numeric loopback only. - if err != nil { - t.Fatal(err) - } - address := listener.Addr().String() - if err := listener.Close(); err != nil { - t.Fatal(err) - } - _, port, err := net.SplitHostPort(address) - if err != nil { - t.Fatal(err) - } - directory := t.TempDir() - command := exec.CommandContext(ctx, binary, "--blobHost", "127.0.0.1", "--blobPort", port, "--location", directory, "--silent") // #nosec G204,G702 -- explicitly selected official emulator, loopback-only test arguments. - command.Dir = directory - command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + directory, account, "DO_NOT_TRACK=1", - "HTTPS_PROXY=http://127.0.0.1:1", "HTTP_PROXY=http://127.0.0.1:1", "ALL_PROXY=http://127.0.0.1:1", - "https_proxy=http://127.0.0.1:1", "http_proxy=http://127.0.0.1:1", "all_proxy=http://127.0.0.1:1"} - log, err := os.OpenFile(filepath.Join(directory, "azurite.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- fixed log filename in t.TempDir. - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = log.Close() }) - command.Stdout, command.Stderr = log, log - if err := command.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := command.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { - t.Error(err) - } - _ = command.Wait() - }) - return address -} - -func (s *azureBlobStore) request(ctx context.Context, method, path string, body []byte) ([]byte, error) { - request, err := http.NewRequestWithContext(ctx, method, s.endpoint+path, bytes.NewReader(body)) // #nosec G704 -- NewLocalTransport confines the request to the emulator's numeric-loopback endpoint. - if err != nil { - return nil, err - } - request.Header.Set("x-ms-date", time.Now().UTC().Format(http.TimeFormat)) - request.Header.Set("x-ms-version", "2021-12-02") - headers := "" - if method == http.MethodPut && !strings.Contains(path, "?") { - request.Header.Set("x-ms-blob-type", "BlockBlob") - headers = "x-ms-blob-type:BlockBlob\n" - } - headers += "x-ms-date:" + request.Header.Get("x-ms-date") + "\nx-ms-version:2021-12-02\n" - length := "" - if len(body) > 0 { - length = fmt.Sprint(len(body)) - } - resource := "/stcaosetupsim" + request.URL.Path - if request.URL.Query().Get("restype") != "" { - resource += "\nrestype:" + request.URL.Query().Get("restype") - } - signature := hmac.New(sha256.New, s.key) - _, _ = fmt.Fprintf(signature, "%s\n\n\n%s\n\n\n\n\n\n\n\n\n%s%s", method, length, headers, resource) - request.Header.Set("Authorization", "SharedKey stcaosetupsim:"+base64.StdEncoding.EncodeToString(signature.Sum(nil))) - response, err := s.client.Do(request) // #nosec G704 -- transport rejects destinations outside the exact numeric-loopback Azurite endpoint. - if err != nil { - return nil, err - } - defer func() { _ = response.Body.Close() }() - content, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - if err != nil { - return nil, err - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, fmt.Errorf("Azurite %s returned HTTP %d", method, response.StatusCode) - } - return content, nil -} diff --git a/server/internal/simulator/setup_azure_integration_test.go b/server/internal/simulator/setup_azure_integration_test.go deleted file mode 100644 index 62f264eba7..0000000000 --- a/server/internal/simulator/setup_azure_integration_test.go +++ /dev/null @@ -1,345 +0,0 @@ -package simulator - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "os" - "os/exec" - "path/filepath" - "reflect" - "slices" - "strings" - "testing" - "time" -) - -func TestCAOSetupAzureIntegration(t *testing.T) { - testCAOSetupAzureIntegration(t, "successful deployment", "plan drift", "provisioning failure") -} - -func testCAOSetupAzureIntegration(t *testing.T, scenarios ...string) { - t.Helper() - if os.Getenv("CAO_SETUP_AZURE_INTEGRATION") != "1" { - t.Skip("run npm run test:integration:setup-azure to exercise setup with GitHub and Azure simulators") - } - tools := []string{"go", "gh", "git", "sh"} - if os.Getenv("CAO_SETUP_AZURITE_BINARY") == "" { - tools = append(tools, "docker") - } - for _, tool := range tools { - if _, err := exec.LookPath(tool); err != nil { - t.Fatal(err) - } - } - catalog, err := filepath.Abs("../../..") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 8*time.Minute) - defer cancel() - root := t.TempDir() - binary := filepath.Join(root, "gh-cao") - buildEnvironment := setupAzureEnvironment(setupAzureToolEnvironment("PATH", "HOME", "TMPDIR", "GOPATH", "GOMODCACHE", "GOCACHE", "CGO_ENABLED", "SDKROOT", "DEVELOPER_DIR"), map[string]string{ - "GOPROXY": "off", "GOSUMDB": "off", "GOVCS": "*:off", "GOTOOLCHAIN": "local", "GOENV": "off", "GOFLAGS": "-mod=readonly", - }) - bootstrapRun(t, ctx, catalog, buildEnvironment, "go", "build", "-mod=readonly", "-ldflags=-X main.version=v0.0.1", "-o", binary, "./cmd/gh-cao") - runtimeFiles := setupRuntimeFixtureFiles(t, catalog) - storage := startSetupAzurite(t, ctx) - for _, scenario := range scenarios { - t.Run(scenario, func(t *testing.T) { - journey := scenario == "private farm journey" - fixtureFiles := runtimeFiles - if journey { - fixtureFiles = nil - } - f := newSetupFixture(t, ctx, root, catalog, binary, fixtureFiles) - api := newAzureSetupAPI(t, catalog, storage) - oauth := newSetupAzureOAuth() - adapter, endpoint := installAzureCLIAdapter(t, api) - proxy, ca := bootstrapProxy(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Host == setupAzureHost || r.Host == setupAzureSCM { - api.serveIngress(w, r) - return - } - if oauth.serveHTTP(w, r) { - return - } - f.api.ServeHTTP(w, r) - }), setupAzureHost, setupAzureSCM) - testBinary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - overrides := map[string]string{ - "PATH": adapter, - "CAO_SETUP_TEST_BINARY": testBinary, "CAO_SETUP_AZURE_ENDPOINT": endpoint, - "GORACE": "atexit_sleep_ms=0", - "HTTPS_PROXY": proxy, "HTTP_PROXY": proxy, "ALL_PROXY": proxy, - "https_proxy": proxy, "http_proxy": proxy, "all_proxy": proxy, - "SSL_CERT_FILE": ca, "CURL_CA_BUNDLE": ca, "GIT_SSL_CAINFO": ca, "NODE_EXTRA_CA_CERTS": ca, - "AZURE_CONFIG_DIR": filepath.Join(f.root, "azure-config"), - "GIT_ALLOW_PROTOCOL": "file", - "AZURE_CORE_COLLECT_TELEMETRY": "false", - } - for name, value := range setupAzureSecrets() { - overrides[name] = value - } - f.env = setupAzureEnvironment(f.env, overrides) - policy, err := json.Marshal(f.policy) - if err != nil { - t.Fatal(err) - } - setupWrite(t, f.policyPath(), policy) - if journey { - setupAzurePrivateFarm(t, ctx, f, catalog, binary) - } else { - f.assertSuccess(t, f.run(t, ctx, ""), []string{"platform/control"}, "workflow-token") - f.assertRequests(t, ctx, false) - } - configuredPolicy, githubState := setupRead(t, f.policyPath()), f.state(t) - setupWrite(t, filepath.Join(f.checkout, ".github/workflows/cao.azure.json"), - []byte(`{"extends":"cao.json","control-plane":{"web":{"host":{"target":{"module":"azure-functions"},"operational-store":{"backend":"postgres"}}}}}`)) - if scenario == "successful deployment" || journey { - api.functions = newSetupAzureFunctions(t, ctx) - if journey { - api.functions.proxy, api.functions.ca = proxy, ca - } - } - config := filepath.Join(f.checkout, ".github/cao/deploy-azure.json") - setupWrite(t, config, setupAzureConfig(t)) - tree := setupCheckoutState(t, f.checkout) - planPath, receiptPath := filepath.Join(f.root, "plan.json"), filepath.Join(f.root, "receipt.json") - common := []string{"--config", config, "--template", filepath.Join(catalog, "server/azure/main.json")} - preflight := runSetupAzure(t, ctx, f, "", slices.Concat(common, []string{"--preflight", "--json"})...) - var report struct { - Kind, Status string - Checks []struct{ Status string } - } - if err := json.Unmarshal(preflight, &report); err != nil || - report.Kind != "cao.deploy.azure.preflight" || report.Status != "pass" || len(report.Checks) != 13 { - t.Fatalf("unexpected preflight report: %s (%v)", preflight, err) - } - for _, check := range report.Checks { - if check.Status != "pass" && check.Status != "info" { - t.Fatalf("preflight did not pass every required check: %s", preflight) - } - } - runSetupAzure(t, ctx, f, "", slices.Concat(common, []string{"--plan", "--out", planPath})...) - plan := setupRead(t, planPath) - assertSetupAzureNoSecrets(t, plan) - api.mu.Lock() - mutated := api.group || api.registry || api.deployed || len(api.images) != 0 - api.mu.Unlock() - if mutated { - t.Fatal("preflight or plan mutated Azure state") - } - if scenario == "plan drift" { - api.mu.Lock() - api.drift = true - api.mu.Unlock() - } - if scenario == "provisioning failure" { - api.mu.Lock() - api.failProvision = true - api.mu.Unlock() - } - failure := map[string]string{"plan drift": "azure plan drift detected", "provisioning failure": "database provision failed before ingestion"}[scenario] - apply := slices.Concat(common, []string{"--apply", "--plan-file", planPath, "--source-dir", catalog, - "--repo-dir", f.checkout, "--empty-evidence", "--receipt", receiptPath, "--timeout", "2m"}) - if journey { - api.snapshot = true - snapshot := setupAzureFarmSnapshot(t, 1) - apply = slices.Concat(common, []string{"--apply", "--plan-file", planPath, "--source-dir", catalog, - "--repo-dir", f.checkout, "--snapshot-dir", snapshot, "--receipt", receiptPath, "--timeout", "2m"}) - } - runSetupAzure(t, ctx, f, failure, apply...) - if failure == "" { - assertSetupAzureReceipt(t, ctx, api, receiptPath, plan) - if journey { - assertSetupAzureFarmJourney(t, ctx, api.functions, oauth, proxy, ca) - } - api.functions.assertShutdown(t, ctx) - before := setupRead(t, receiptPath) - runSetupAzure(t, ctx, f, "already exists", apply...) - if !bytes.Equal(before, setupRead(t, receiptPath)) { - t.Fatal("repeated apply overwrote the deployment receipt") - } - // A successful deployment is not an upgrade or repeat-apply authority. - runSetupAzure(t, ctx, f, "only fresh deployments are supported", slices.Concat(common, []string{"--plan", "--out", planPath})...) - } else if _, err := os.Stat(receiptPath); !os.IsNotExist(err) { - t.Fatal("failed apply created a success-shaped receipt") - } - if !bytes.Equal(plan, setupRead(t, planPath)) { - t.Fatal("apply or rejected replanning changed the reviewed plan") - } - api.mu.Lock() - defer api.mu.Unlock() - if len(api.unhandled) != 0 { - t.Fatalf("unsupported Azure simulator requests: %v", api.unhandled) - } - if scenario == "plan drift" && (api.group || api.registry || api.deployed || len(api.images) != 0) { - t.Fatal("plan drift caused an Azure mutation") - } - if scenario == "provisioning failure" && (api.executions[setupAzureApp+"-ingest"] != "" || api.restarted || api.references) { - t.Fatal("failed provisioning proceeded to ingestion, restart, or verification") - } - if !bytes.Equal(configuredPolicy, setupRead(t, f.policyPath())) || !bytes.Equal(githubState, f.state(t)) || - !reflect.DeepEqual(tree, setupCheckoutState(t, f.checkout)) { - t.Fatal("Azure deployment changed reviewed GitHub authority or consumer files") - } - t.Logf("real setup and Azure plan/apply completed scenario %q through %d simulated Azure commands/HTTPS requests", scenario, len(api.calls)) - }) - } -} - -func setupAzureEnvironment(environment []string, overrides map[string]string) []string { - result := make([]string, 0, len(environment)+len(overrides)) - for _, item := range environment { - name, _, _ := strings.Cut(item, "=") - if _, replaced := overrides[name]; !replaced { - result = append(result, item) - } - } - for name, value := range overrides { - result = append(result, name+"="+value) - } - return result -} - -func setupAzureToolEnvironment(names ...string) []string { - environment := make([]string, 0, len(names)) - for _, name := range names { - if value, ok := os.LookupEnv(name); ok { - environment = append(environment, name+"="+value) - } - } - return environment -} - -func setupAzureSecrets() map[string]string { - return map[string]string{ // #nosec G101 -- synthetic test-only values without any external authority. - "CAO_SIM_POSTGRES_PASSWORD": "synthetic-admin-password-material-0001", - "CAO_SIM_OWNER_PASSWORD": "synthetic-owner-password-material-0002", - "CAO_SIM_RUNTIME_PASSWORD": "synthetic-runtime-password-material-0003", - "CAO_SIM_CLIENT_SECRET": "synthetic-oauth-client-secret-0004", - "CAO_SIM_SESSION_SECRET": "synthetic-session-secret-material-0005", - } -} - -func setupAzureConfig(t *testing.T) []byte { - t.Helper() - secrets := map[string]any{} - for field, variable := range map[string]string{ // #nosec G101 -- environment variable names, never credential values. - "postgresAdministratorPassword": "CAO_SIM_POSTGRES_PASSWORD", "postgresOwnerPassword": "CAO_SIM_OWNER_PASSWORD", - "postgresRuntimePassword": "CAO_SIM_RUNTIME_PASSWORD", "githubClientSecret": "CAO_SIM_CLIENT_SECRET", "sessionSecret": "CAO_SIM_SESSION_SECRET", - } { - secrets[field] = map[string]string{"env": variable} - } - content, err := json.Marshal(map[string]any{ - "schemaVersion": 1, "environment": "development", "subscription": setupAzureSubscription, "resourceGroup": setupAzureGroup, "location": "westus3", - "resources": map[string]string{"functionApp": setupAzureApp, "storageAccount": "stcaosetupsim", "containerRegistry": setupAzureRegistry, "keyVault": "kv-cao-setup-sim"}, - "image": map[string]string{"tag": "v0.0.0-test"}, "allowedHosts": []string{setupAzureHost}, - "access": map[string]any{"organizations": []string{"platform"}, "users": []string{}, "teams": []string{}}, - "retentionDays": 45, "oauth": map[string]string{"clientId": "Iv1.caosetupsim"}, "secrets": secrets, - }) - if err != nil { - t.Fatal(err) - } - return content -} - -func runSetupAzure(t *testing.T, ctx context.Context, f *setupFixture, failure string, args ...string) []byte { - t.Helper() - command := exec.CommandContext(ctx, "gh", append([]string{"cao", "deploy", "azure"}, args...)...) // #nosec G204 -- real CLI and test-owned arguments in an isolated environment. - command.Dir, command.Env = f.checkout, f.env - var stdout, stderr bytes.Buffer - command.Stdout, command.Stderr = &stdout, &stderr - err := command.Run() - assertSetupAzureNoSecrets(t, stdout.Bytes()) - assertSetupAzureNoSecrets(t, stderr.Bytes()) - if failure != "" { - if err == nil || !strings.Contains(stderr.String(), failure) { - t.Fatalf("want Azure failure %q, got %v\nstdout: %s\nstderr: %s", failure, err, &stdout, &stderr) - } - } else if err != nil { - t.Fatalf("Azure command failed: %v\nstdout: %s\nstderr: %s", err, &stdout, &stderr) - } - return stdout.Bytes() -} - -func assertSetupAzureNoSecrets(t *testing.T, content []byte) { - t.Helper() - for _, value := range setupAzureSecrets() { - if bytes.Contains(content, []byte(value)) { - t.Fatal("Azure setup exposed a secure parameter value") - } - } -} - -func assertSetupAzureReceipt(t *testing.T, ctx context.Context, api *azureSetupAPI, path string, plan []byte) { - t.Helper() - content := setupRead(t, path) - assertSetupAzureNoSecrets(t, content) - var receipt struct { - Kind, PlanSHA256, Environment, ImageAssurance, SourceRevision, ContextSHA256 string - JobExecution, ProvisionExecution string - Images []struct{ Target, Repository, Reference, ConfiguredReference, Digest, Scan, SBOM, Provenance string } - Verification []struct { - Check string - Passed bool - } - } - if err := json.Unmarshal(content, &receipt); err != nil { - t.Fatal(err) - } - if receipt.Kind != "cao.deploy.azure.receipt" || receipt.PlanSHA256 != fmt.Sprintf("%x", sha256.Sum256(plan)) || - receipt.Environment != "development" || receipt.ImageAssurance != "not-evaluated-development-only" || - len(receipt.SourceRevision) != 40 || len(receipt.ContextSHA256) != 64 || - receipt.JobExecution != setupAzureApp+"-ingest-execution" || receipt.ProvisionExecution != setupAzureApp+"-db-provision-execution" || - len(receipt.Images) != 3 || len(receipt.Verification) != 6 { - t.Fatalf("incomplete or incorrectly bound Azure receipt: %s", content) - } - for _, image := range receipt.Images { - if image.Reference != image.ConfiguredReference || !strings.HasSuffix(image.Reference, "@"+image.Digest) || - image.Scan != "not-performed" || image.SBOM != "not-generated" || image.Provenance != "local-context-not-attested" { - t.Fatalf("incorrect development image binding: %+v", image) - } - manifest, err := api.storage.request(ctx, http.MethodGet, "/setup/"+image.Repository+".json", nil) - if err != nil { - t.Fatal(err) - } - var build struct{ Target, ContextSHA256 string } - if err := json.Unmarshal(manifest, &build); err != nil || build.Target != image.Target || - len(build.ContextSHA256) != 64 || image.Digest != fmt.Sprintf("sha256:%x", sha256.Sum256(manifest)) { - t.Fatal("receipt image does not match the Azurite-backed simulated build") - } - } - checks := make([]string, 0, len(receipt.Verification)) - for _, verification := range receipt.Verification { - if !verification.Passed { - t.Fatalf("verification did not pass: %s", verification.Check) - } - checks = append(checks, verification.Check) - } - if !slices.Equal(checks, []string{"health", "readiness", "signed-out-campaigns", "root-login", "github-oauth", "scm-denied"}) { - t.Fatalf("incorrect Azure verification checks: %v", checks) - } - state, err := api.storage.request(ctx, http.MethodGet, "/setup/deployment.json", nil) - if err != nil { - t.Fatal(err) - } - assertSetupAzureNoSecrets(t, state) - var stored map[string]string - if err := json.Unmarshal(state, &stored); err != nil { - t.Fatal(err) - } - api.mu.Lock() - defer api.mu.Unlock() - if !reflect.DeepEqual(stored, api.configured) || len(api.locked) != 3 || api.referenceReads != 2 || !api.references { - t.Fatal("deployment receipt was not backed by persisted Azurite state, locked images, and resolved references") - } -} diff --git a/server/internal/telemetry/azure_deployment_test.go b/server/internal/telemetry/azure_deployment_test.go deleted file mode 100644 index 56f340601c..0000000000 --- a/server/internal/telemetry/azure_deployment_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package telemetry - -import ( - "bytes" - "context" - "encoding/json" - "encoding/pem" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "go.opentelemetry.io/otel" - logspb "go.opentelemetry.io/proto/otlp/collector/logs/v1" - metricspb "go.opentelemetry.io/proto/otlp/collector/metrics/v1" - tracespb "go.opentelemetry.io/proto/otlp/collector/trace/v1" - "google.golang.org/protobuf/proto" - - "github.com/githubnext/gh-cao/server/internal/logger" -) - -const azureTelemetryHelper = "CAO_TEST_AZURE_TELEMETRY" - -func TestAzureDeploymentTelemetryReceipts(t *testing.T) { - // Consume the generated deployment settings, not a parallel hand-written - // OTEL configuration. Template contracts verify all three runtime consumers. - content, err := os.ReadFile("../../azure/main.json") - if err != nil { - t.Fatal(err) - } - var template struct { - Variables struct { - Environment []struct { - Name string `json:"name"` - Value string `json:"value"` - } `json:"otlpEnvironment"` - } `json:"variables"` - } - if err := json.Unmarshal(content, &template); err != nil { - t.Fatal(err) - } - if len(template.Variables.Environment) != 3 { - t.Fatal("expected the deployment endpoint, protocol and log opt-in") - } - for _, scenario := range []struct { - name string - endpoint bool - logs bool - disabled bool - reject bool - }{ - {name: "template defaults do not export"}, - {name: "TLS traces and metrics", endpoint: true}, - {name: "TLS all signals with explicit log opt-in", endpoint: true, logs: true}, - {name: "SDK disable overrides deployment", endpoint: true, logs: true, disabled: true}, - {name: "unavailable exporter does not gate requests", endpoint: true, logs: true, reject: true}, - } { - t.Run(scenario.name, func(t *testing.T) { - t.Parallel() - var mu sync.Mutex - receipts := map[string]int{} - receiver := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/x-protobuf" || - r.Header.Get("Authorization") != "Bearer synthetic-azure-otlp" { - t.Error("export did not use authenticated OTLP/HTTP protobuf") - w.WriteHeader(http.StatusBadRequest) - return - } - if scenario.reject { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - body, err := io.ReadAll(r.Body) - if err != nil { - t.Error("read synthetic OTLP request") - return - } - var records int - switch r.URL.Path { - case "/otlp/v1/traces": - var request tracespb.ExportTraceServiceRequest - if err := proto.Unmarshal(body, &request); err != nil { - t.Error(err) - } - for _, resource := range request.GetResourceSpans() { - for _, scope := range resource.GetScopeSpans() { - for _, span := range scope.GetSpans() { - if span.GetName() == "azure.synthetic.request" { - records++ - } - } - } - } - case "/otlp/v1/metrics": - var request metricspb.ExportMetricsServiceRequest - if err := proto.Unmarshal(body, &request); err != nil { - t.Error(err) - } - for _, resource := range request.GetResourceMetrics() { - for _, scope := range resource.GetScopeMetrics() { - for _, metric := range scope.GetMetrics() { - if metric.GetName() == "azure.synthetic.requests" { - records += len(metric.GetSum().GetDataPoints()) - } - } - } - } - case "/otlp/v1/logs": - var request logspb.ExportLogsServiceRequest - if err := proto.Unmarshal(body, &request); err != nil { - t.Error(err) - } - for _, resource := range request.GetResourceLogs() { - for _, scope := range resource.GetScopeLogs() { - for _, record := range scope.GetLogRecords() { - if record.GetBody().GetStringValue() == "synthetic request status=200" { - records++ - } - } - } - } - default: - t.Error("export used an unexpected signal path") - } - mu.Lock() - receipts[r.URL.Path] += records - mu.Unlock() - w.Header().Set("Content-Type", "application/x-protobuf") - })) - defer receiver.Close() - certificate := filepath.Join(t.TempDir(), "collector.pem") - if err := os.WriteFile(certificate, pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", Bytes: receiver.Certificate().Raw, - }), 0o600); err != nil { - t.Fatal(err) - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - defer cancel() - command := exec.CommandContext(ctx, executable, "-test.run=^TestAzureTelemetryHelper$") // #nosec G204 -- this test binary and fixed helper. - for _, entry := range os.Environ() { - key, _, _ := strings.Cut(entry, "=") - if strings.HasPrefix(key, "OTEL_") || key == "DEBUG" || key == "ACTIONS_RUNNER_DEBUG" || - key == "CAO_OTEL_LOGS_ENABLED" || key == azureTelemetryHelper { - continue - } - command.Env = append(command.Env, entry) - } - for _, setting := range template.Variables.Environment { - value := setting.Value - switch value { - case "[parameters('otlpEndpoint')]": - value = "" - if scenario.endpoint { - value = receiver.URL + "/otlp" - } - case "[string(parameters('otlpLogsEnabled'))]": - value = fmt.Sprint(scenario.logs) - case "http/protobuf": - default: - t.Fatal("unrecognized generated telemetry setting") - } - command.Env = append(command.Env, setting.Name+"="+value) - } - command.Env = append(command.Env, - azureTelemetryHelper+"=1", - "CAO_TEST_EXPORT_REJECTED="+fmt.Sprint(scenario.reject), - "OTEL_SDK_DISABLED="+fmt.Sprint(scenario.disabled), - "OTEL_EXPORTER_OTLP_CERTIFICATE="+certificate, - "OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20synthetic-azure-otlp", - "DEBUG=cao:server,cao:telemetry", "DEBUG_COLORS=0", - ) - var output bytes.Buffer - command.Stdout, command.Stderr = &output, &output - if err := command.Run(); err != nil { - t.Fatalf("deployment helper: %v\n%s", err, output.String()) - } - if strings.Contains(output.String(), "synthetic-azure-otlp") { - t.Fatal("exporter credentials appeared in process diagnostics") - } - mu.Lock() - defer mu.Unlock() - for _, signal := range []string{"traces", "metrics", "logs"} { - want := scenario.endpoint && !scenario.disabled && !scenario.reject && (signal != "logs" || scenario.logs) - if (receipts["/otlp/v1/"+signal] > 0) != want { - t.Errorf("%s receipt present=%t, want %t", signal, receipts["/otlp/v1/"+signal] > 0, want) - } - } - }) - } -} - -func TestAzureTelemetryHelper(t *testing.T) { - if os.Getenv(azureTelemetryHelper) != "1" { - return - } - shutdown, err := Setup(t.Context(), "synthetic") - if err != nil { - t.Fatal(err) - } - counter, err := otel.Meter("azure.synthetic").Int64Counter("azure.synthetic.requests") - if err != nil { - t.Fatal(err) - } - app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, span := Tracer().Start(r.Context(), "azure.synthetic.request") - defer span.End() - counter.Add(ctx, 1) - logger.New("cao:server").Printf("synthetic request status=%d", http.StatusOK) - w.WriteHeader(http.StatusOK) - })) - defer app.Close() - request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, app.URL, nil) - if err != nil { - t.Fatal(err) - } - response, err := app.Client().Do(request) - if err != nil { - t.Fatal(err) - } - if err := response.Body.Close(); err != nil { - t.Fatal(err) - } - if response.StatusCode != http.StatusOK { - t.Fatal("telemetry backend gated application availability") - } - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - err = shutdown(ctx) - if (err != nil) != (os.Getenv("CAO_TEST_EXPORT_REJECTED") == "true") { - t.Fatalf("unexpected exporter shutdown outcome: %v", err) - } -} diff --git a/specs/dashboard-data.md b/specs/dashboard-data.md index 7fe6b45509..16e8190989 100644 --- a/specs/dashboard-data.md +++ b/specs/dashboard-data.md @@ -1029,7 +1029,7 @@ Conflicts SHALL NOT mutate either store. Compact request retention SHALL remain separate from ordinary canonical evidence retention; no public raw-session endpoint, additional credential grant or financial mutation is implied. -Hosted container, Azure Functions, and external-listener deployments SHALL +Hosted container and external-listener deployments SHALL serve the built dashboard HTML and assets through the complete Go handler on the same origin as OAuth, query APIs, and revision events. They SHALL NOT require a separately hosted Pages or static frontend. Hosted startup SHALL diff --git a/specs/server-hosted-cli.md b/specs/server-hosted-cli.md index 8c8373d8cb..cc1488f63d 100644 --- a/specs/server-hosted-cli.md +++ b/specs/server-hosted-cli.md @@ -896,8 +896,8 @@ workload termination in this model. enabled, missing Podman, image or delegated controllers fails the test rather than skipping workload-isolation evidence. `.github/workflows/podman.yml` runs this test on relevant pull requests and pushes using a rootless user session, - preloads the production digest-pinned image, and runs the real Azure local - service integration with `CAO_CONTAINER_ENGINE=podman`. Enforcing-SELinux + preloads the production digest-pinned image, and checks container-engine + selection. Enforcing-SELinux validation still requires a separate host with SELinux enabled. - `server/internal/operational/postgres/cli_execution_test.go`: durable execution identity, immutable registration, and lease-fenced recovery. diff --git a/specs/server-soc2-compliance.md b/specs/server-soc2-compliance.md index 4bbba554f1..9f4012a816 100644 --- a/specs/server-soc2-compliance.md +++ b/specs/server-soc2-compliance.md @@ -105,7 +105,7 @@ version, component owners, evaluated profiles/features, data classes, system boundaries, external dependencies, explicit exclusions, and deployed evidence availability. The component inventory in `server/SOC2.md` is the coverage index. -**S2-S02** Local PostgreSQL/capability access, hosted/external listeners, Azure Functions, +**S2-S02** Local PostgreSQL/capability access, hosted/external listeners, Azure Container Apps, PostgreSQL/Redis/memory operational adapters, admission-only operation, gateway ingress/dispatch, and experimental CLI executor platforms MUST be distinguished. Optional features MUST NOT be assumed enabled. A checked-in diff --git a/specs/session-archive.md b/specs/session-archive.md index e69bd1d0f9..86f88e5c9e 100644 --- a/specs/session-archive.md +++ b/specs/session-archive.md @@ -351,7 +351,7 @@ acceptance flag without actual evidence MUST NOT be presented as live Azure acceptance. Without that evidence, hosted querying remains unavailable. Setup privileges remain separate from runtime credentials. No runtime -provisioning or credential changes are implied. Per-request Azure Functions, +provisioning or credential changes are implied. Per-request hosts, ephemeral staging, standalone acquisition roles without an owning publisher, and multi-replica archives are unsupported. diff --git a/tests/helpers/azure-local-go.mjs b/tests/helpers/azure-local-go.mjs deleted file mode 100644 index 99c6e0fb93..0000000000 --- a/tests/helpers/azure-local-go.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { basename, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; - -const root = fileURLToPath(new URL("../../", import.meta.url)); -const execute = promisify(execFile); - -export function localEndpoint(value, protocol) { - const url = new URL(value); - assert.equal(url.protocol, protocol); - assert.equal(url.hostname, "127.0.0.1", "service tests require numeric loopback"); - assert.ok(url.port, "service tests require an explicit port"); - assert.equal(url.username, ""); - assert.equal(url.password, ""); - assert.equal(url.search, ""); - assert.equal(url.hash, ""); - return url; -} - -export async function goCase(directory, packages, patterns, services = {}, runtime) { - const env = Object.fromEntries([ - "PATH", "HOME", "TMPDIR", "GOPATH", "GOMODCACHE", "GOCACHE", "SDKROOT", "DEVELOPER_DIR", - ].filter((name) => process.env[name] !== undefined).map((name) => [name, process.env[name]])); - Object.assign(env, { - GOTOOLCHAIN: "local", GOPROXY: "off", GOSUMDB: "off", GOVCS: "*:off", - GOENV: "off", GOFLAGS: "-mod=readonly", DO_NOT_TRACK: "1", GH_TELEMETRY: "0", - OTEL_SDK_DISABLED: "true", - }); - if (services.postgres) { - const url = new URL(runtime.postgresUrl); - assert.equal(url.protocol, "postgres:"); - assert.equal(url.hostname, "127.0.0.1"); - assert.ok(url.port); - assert.equal(url.search, "?sslmode=disable", "connection overrides are forbidden"); - env.POSTGRES_URL = url.href; - } - if (services.blob) { - localEndpoint(runtime.storage.blobEndpoint, "http:"); - Object.assign(env, { - CAO_AZURITE_BLOB_URL: runtime.storage.blobEndpoint, - CAO_AZURITE_ACCOUNT: runtime.storage.account, - CAO_AZURITE_KEY: runtime.storage.key, - }); - } - const binaries = process.env.AZURE_LOCAL_TEST_BINARIES; - const options = { env, timeout: 240_000, maxBuffer: 8 << 20 }; - const pattern = patterns.join("|"); - let stdout; - if (binaries) { - const results = await Promise.all(packages.map((pkg) => execute(join(binaries, "test2json"), [ - "-t", "-p", pkg, join(binaries, directory || "root", `${basename(pkg)}.test`), - "-test.run", pattern, "-test.count=1", "-test.timeout=3m", "-test.v=test2json", - ], { ...options, cwd: `${root}${directory}/${pkg}` }))); - stdout = results.map((result) => result.stdout.trim()).join("\n"); - } else { - ({ stdout } = await execute("go", [ - "test", "-race", ...packages, "-run", pattern, "-count=1", "-timeout=3m", "-json", - ], { ...options, cwd: `${root}${directory}` })); - } - assertGoCoverage(stdout, packages, patterns); -} - -export function assertGoCoverage(stdout, packages, patterns) { - const events = stdout.trim().split("\n").map((line) => JSON.parse(line)); - for (const pattern of patterns) { - const segments = pattern.split("/").map((part) => new RegExp(part)); - assert.ok(events.some((event) => event.Test && event.Action === "pass" - && segments.every((segment, index) => { - const part = event.Test.split("/")[index]; - return part !== undefined && segment.test(part); - })), `selector ${pattern} must execute tests`); - } - for (const pkg of packages) { - assert.ok(events.some((event) => event.Test && event.Action === "pass" - && event.Package.endsWith(pkg.slice(1))), `package ${pkg} must execute tests`); - } - assert.deepEqual(events.filter((event) => event.Action === "skip"), [], "selected service coverage cannot skip"); -} diff --git a/tests/integration/azure-local-dev-browser.test.mjs b/tests/integration/azure-local-dev-browser.test.mjs deleted file mode 100644 index 9cb40d230c..0000000000 --- a/tests/integration/azure-local-dev-browser.test.mjs +++ /dev/null @@ -1,88 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile, readdir } from "node:fs/promises"; -import { get } from "node:http"; -import { join } from "node:path"; -import test from "node:test"; -import { chromium } from "../../dashboard/site/node_modules/playwright/index.mjs"; - -if (!process.argv[2]) throw new Error("explicit GitHub development runtime JSON is required"); -const runtime = JSON.parse(await readFile(process.argv[2], "utf8")); -assert.equal(runtime.profile, "github"); -const origin = new URL(runtime.baseUrl); -assert.equal(origin.protocol, "http:"); -assert.equal(origin.hostname, "localhost"); -assert.equal(origin.username + origin.password + origin.search + origin.hash, ""); - -test("real Functions OAuth preserves Secure browser cookies and validates callback state", async () => { - const browser = await chromium.launch({ - headless: true, - args: ["--no-proxy-server"], - ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } : {}), - }); - try { - const context = await browser.newContext(); - let state; - await context.route(`${runtime.baseUrl}/auth/login`, async (route) => { - // Fetch independently so only the browser can accept the state cookie. - const response = await new Promise((resolve, reject) => { - get(route.request().url(), { headers: route.request().headers() }, resolve).on("error", reject); - }); - response.resume(); - assert.equal(response.statusCode, 302); - const target = new URL(response.headers.location); - assert.equal(target.origin, "https://github.com"); - assert.equal(target.pathname, "/login/oauth/authorize"); - assert.equal(target.searchParams.get("redirect_uri"), `${runtime.baseUrl}/auth/callback`); - state = target.searchParams.get("state"); - // Preserve the actual cookie headers, but do not follow the GitHub redirect. - const headers = Object.fromEntries(Object.entries(response.headers) - .map(([name, value]) => [name, Array.isArray(value) ? value.join("\n") : value])); - delete headers["content-length"]; - await route.fulfill({ - status: 200, - headers, - body: "Intercepted GitHub authorization", - }); - }); - const page = await context.newPage(); - await page.goto(`${runtime.baseUrl}/auth/login`); - assert.ok(state); - const cookies = () => context.cookies(`${runtime.baseUrl}/auth/callback`); - const hasState = async () => (await cookies()).some((cookie) => cookie.name === "cao_oauth_state"); - const cookie = (await cookies()).find((cookie) => cookie.name === "cao_oauth_state"); - assert.ok(cookie, "browser must preserve the state cookie on HTTP localhost"); - assert.equal(cookie.secure, true); - assert.equal(cookie.httpOnly, true); - assert.equal(cookie.sameSite, "Lax"); - const invalid = await page.goto(`${runtime.baseUrl}/auth/callback?state=wrong&error=access_denied`); - assert.equal(invalid.status(), 400); - assert.ok(await hasState(), "invalid state must not consume the real login cookie"); - const denied = await page.goto(`${runtime.baseUrl}/auth/callback?state=${encodeURIComponent(state)}&error=access_denied`); - assert.equal(denied.status(), 400); - assert.equal(await hasState(), false, "valid callback state is consumed before provider denial is handled"); - assert.equal((await context.request.get(`${runtime.baseUrl}/api/repositories`)).status(), 401); - } finally { - await browser.close(); - } -}); - -test("real development host binds only numeric loopback and does not log configured secrets", async () => { - const settings = JSON.parse(await readFile(join(runtime.applicationDirectory, "local.settings.json"), "utf8")).Values; - for (const name of await readdir(join(runtime.stateDirectory, "logs"))) { - const text = await readFile(join(runtime.stateDirectory, "logs", name), "utf8"); - for (const secret of [ - settings.CAO_GITHUB_CLIENT_SECRET, - settings.CAO_SESSION_SECRET, - new URL(settings.CAO_POSTGRES_URL).password, - ]) { - assert.ok(secret && !text.includes(secret), `configured secrets must not appear in ${name}`); - } - } - const port = Number(origin.port).toString(16).toUpperCase().padStart(4, "0"); - const listeners = (await readFile("/proc/net/tcp", "utf8")).split("\n") - .map((line) => line.trim().split(/\s+/)) - .filter((fields) => fields[1]?.endsWith(`:${port}`) && fields[3] === "0A"); - assert.ok(listeners.length > 0); - assert.ok(listeners.every((fields) => fields[1] === `0100007F:${port}`), "Functions must bind only 127.0.0.1"); -}); diff --git a/tests/integration/azure-local-functions.test.mjs b/tests/integration/azure-local-functions.test.mjs deleted file mode 100644 index 2130257ad6..0000000000 --- a/tests/integration/azure-local-functions.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const runtimePath = process.argv[2]; -if (!runtimePath) throw new Error("runtime state JSON path is required"); -const runtime = JSON.parse(await readFile(runtimePath, "utf8")); - -async function request(path, options = {}) { - return fetch(`${runtime.baseUrl}${path}`, { - redirect: "manual", - ...options, - }); -} - -test("Azure Functions routes requests through shared CAO logic and PostgreSQL", async () => { - const health = await request("/api/health"); - assert.equal(health.status, 200); - assert.deepEqual((await health.json()).redis, { connected: true }); - - const readiness = await request("/api/readiness"); - assert.equal(readiness.status, 200); - - const wrongMethod = await request("/api/health", { method: "POST" }); - assert.equal(wrongMethod.status, 404); - - const first = await request("/api/repositories"); - const second = await request("/api/repositories"); - assert.equal(first.status, 401); - assert.equal(second.status, 401); - const firstRemaining = Number(first.headers.get("ratelimit-remaining")); - const secondRemaining = Number(second.headers.get("ratelimit-remaining")); - assert.ok(Number.isInteger(firstRemaining), "first request must expose PostgreSQL-backed rate-limit state"); - assert.equal(secondRemaining, firstRemaining - 1); -}); - -test("missing local configuration fails predictably", () => { - const result = spawnSync(`${runtime.applicationDirectory}/cao-dashboard`, ["azure-functions"], { - encoding: "utf8", - env: { - FUNCTIONS_CUSTOMHANDLER_PORT: "1", - PATH: process.env.PATH, - CAO_AZURE_LOCAL_SIMULATION: "1", - }, - }); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /cao\.json requires control-plane\.web\.host/); -}); diff --git a/tests/integration/azure-local-protocols.test.mjs b/tests/integration/azure-local-protocols.test.mjs deleted file mode 100644 index 6fa1a3ca66..0000000000 --- a/tests/integration/azure-local-protocols.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import { execFile } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; -import test from "node:test"; -import { goCase } from "../helpers/azure-local-go.mjs"; - -test("Azure deployment protocols: registry, jobs, Functions configuration and ARM preflight", async () => { - await goCase("", ["./internal/cli"], [ - "^TestDeployAzure(ImageBindingsFailClosed|ImageReadBackBlocksJobsOnMismatch)$", - "^TestDeployAzureJobWaitCancellation$", - "^TestDeployAzureApplyFailsClosedWhenKeyVaultReferencesStayUnresolved$", - "^TestDeployAzure(PlanRunsOnlyReadOnlyCommands|PreflightChecks|PreflightHealthy)$", - ]); -}); - -test("Azure networking, RBAC and optional ADX: exhaustive Bicep contract coverage", async () => { - await promisify(execFile)(process.execPath, [ - "--test", "tests/unit/server-azure-service-coverage.test.mjs", - "tests/unit/server-azure-bicep.test.mjs", "tests/unit/server-azure-adx.test.mjs", - ], { cwd: fileURLToPath(new URL("../../", import.meta.url)), timeout: 30_000, maxBuffer: 4 << 20 }); -}); diff --git a/tests/integration/azure-local-services.test.mjs b/tests/integration/azure-local-services.test.mjs deleted file mode 100644 index ce4b94c013..0000000000 --- a/tests/integration/azure-local-services.test.mjs +++ /dev/null @@ -1,160 +0,0 @@ -import assert from "node:assert/strict"; -import { createHmac, randomBytes } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import http from "node:http"; -import test from "node:test"; -import { goCase, localEndpoint } from "../helpers/azure-local-go.mjs"; - -const runtimePath = process.argv[2]; -if (!runtimePath) throw new Error("runtime state JSON path is required"); -const runtime = JSON.parse(await readFile(runtimePath, "utf8")); - -function storageRequest(service, method, path, body = "", headers = {}, invalidKey = false) { - const endpoint = localEndpoint(runtime.storage[`${service}Endpoint`], "http:"); - assert.match(runtime.storage.account, /^[a-z0-9]+$/); - assert.equal(endpoint.pathname, `/${runtime.storage.account}`); - const url = new URL(`${endpoint}${path}`); - const date = new Date().toUTCString(); - const content = Buffer.from(body); - headers = { - "x-ms-date": date, - "x-ms-version": "2019-02-02", - "content-length": String(content.length), - ...headers, - }; - const resource = `/${runtime.storage.account}${url.pathname}`; - let canonical; - if (service === "table") { - canonical = `${date}\n${resource}`; - } else { - const standard = [ - method, headers["content-encoding"], headers["content-language"], - content.length ? String(content.length) : "", headers["content-md5"], - headers["content-type"], "", headers["if-modified-since"], headers["if-match"], - headers["if-none-match"], headers["if-unmodified-since"], headers.range, - ].map((value) => value || "").join("\n"); - const azureHeaders = Object.keys(headers).filter((name) => name.startsWith("x-ms-")) - .sort().map((name) => `${name}:${headers[name]}\n`).join(""); - const query = [...new Set([...url.searchParams.keys()].map((name) => name.toLowerCase()))] - .sort().map((name) => `\n${name}:${url.searchParams.getAll(name).sort().join(",")}`).join(""); - canonical = `${standard}\n${azureHeaders}${resource}${query}`; - } - const key = invalidKey ? randomBytes(32) : Buffer.from(runtime.storage.key, "base64"); - const signature = createHmac("sha256", key).update(canonical).digest("base64"); - headers.authorization = `${service === "table" ? "SharedKeyLite" : "SharedKey"} ${runtime.storage.account}:${signature}`; - // Node's HTTP client does not consult ambient proxy settings or follow redirects. - return new Promise((resolve, reject) => { - const request = http.request(url, { method, headers, agent: false }, (response) => { - const chunks = []; - let length = 0; - response.on("data", (chunk) => { - length += chunk.length; - if (length > 1 << 20) response.destroy(new Error("emulator response exceeds test limit")); - else chunks.push(chunk); - }); - response.on("error", reject); - response.on("end", () => resolve({ - status: response.statusCode, - headers: response.headers, - body: Buffer.concat(chunks).toString("utf8"), - })); - }); - request.setTimeout(5000, () => request.destroy(new Error("emulator request timed out"))); - request.on("error", reject); - request.end(content); - }); -} - -test("Azure Storage Blob: signed writes, reads, ETag conflicts and denied credentials", async (t) => { - const container = `cao-test-${randomBytes(8).toString("hex")}`; - assert.equal((await storageRequest("blob", "PUT", `/${container}?restype=container`)).status, 201); - t.after(async () => { - assert.equal((await storageRequest("blob", "DELETE", `/${container}?restype=container`)).status, 202); - }); - const path = `/${container}/evidence.json`; - const written = await storageRequest("blob", "PUT", path, '{"synthetic":true}', { - "x-ms-blob-type": "BlockBlob", - }); - assert.equal(written.status, 201); - const read = await storageRequest("blob", "GET", path); - assert.equal(read.status, 200); - assert.deepEqual(JSON.parse(read.body), { synthetic: true }); - assert.equal(read.headers.etag, written.headers.etag); - assert.equal((await storageRequest("blob", "PUT", path, "replacement", { - "x-ms-blob-type": "BlockBlob", "if-match": '"stale"', - })).status, 412); - assert.equal((await storageRequest("blob", "GET", path, "", {}, true)).status, 403); - assert.equal((await storageRequest("blob", "GET", path)).body, read.body); - assert.equal((await storageRequest("blob", "GET", `/${container}/missing`)).status, 404); -}); - -test("Azure Storage Queue: visibility, receipts and acknowledgement", async (t) => { - const queue = `cao-test-${randomBytes(8).toString("hex")}`; - assert.equal((await storageRequest("queue", "PUT", `/${queue}`)).status, 201); - t.after(async () => { - assert.equal((await storageRequest("queue", "DELETE", `/${queue}`)).status, 204); - }); - const messages = `/${queue}/messages`; - assert.equal((await storageRequest("queue", "POST", messages, - "synthetic-message", - { "content-type": "application/xml" })).status, 201); - const received = await storageRequest("queue", "GET", `${messages}?visibilitytimeout=30`); - assert.equal(received.status, 200); - assert.match(received.body, /synthetic-message<\/MessageText>/); - const id = received.body.match(/([^<]+)<\/MessageId>/)?.[1]; - const receipt = received.body.match(/([^<]+)<\/PopReceipt>/)?.[1]; - assert.ok(id); - assert.ok(receipt); - const hidden = await storageRequest("queue", "GET", messages); - assert.equal(hidden.status, 200); - assert.doesNotMatch(hidden.body, //); - assert.equal((await storageRequest("queue", "DELETE", - `${messages}/${encodeURIComponent(id)}?popreceipt=${encodeURIComponent(receipt)}`)).status, 204); - const empty = await storageRequest("queue", "GET", messages); - assert.equal(empty.status, 200); - assert.doesNotMatch(empty.body, //); -}); - -test("Azure Storage Table: entity persistence and stale ETag rejection", async (t) => { - const table = `CaoTest${randomBytes(8).toString("hex")}`; - const headers = { "content-type": "application/json", accept: "application/json;odata=nometadata" }; - assert.equal((await storageRequest("table", "POST", "/Tables", - JSON.stringify({ TableName: table }), headers)).status, 201); - t.after(async () => { - assert.equal((await storageRequest("table", "DELETE", `/Tables('${table}')`, "", headers)).status, 204); - }); - const entity = { PartitionKey: "synthetic", RowKey: "one", value: "original" }; - const inserted = await storageRequest("table", "POST", `/${table}`, JSON.stringify(entity), headers); - assert.equal(inserted.status, 201); - const path = `/${table}(PartitionKey='synthetic',RowKey='one')`; - const read = await storageRequest("table", "GET", path, "", headers); - assert.equal(read.status, 200); - assert.equal(JSON.parse(read.body).value, "original"); - assert.ok(read.headers.etag); - assert.equal((await storageRequest("table", "PUT", path, - JSON.stringify({ ...entity, value: "updated" }), { ...headers, "if-match": read.headers.etag })).status, 204); - assert.equal((await storageRequest("table", "PUT", path, - JSON.stringify({ ...entity, value: "stale" }), { ...headers, "if-match": read.headers.etag })).status, 412); - assert.equal(JSON.parse((await storageRequest("table", "GET", path, "", headers)).body).value, "updated"); - assert.equal((await storageRequest("table", "GET", - `/${table}(PartitionKey='synthetic',RowKey='missing')`, "", headers)).status, 404); -}); - -test("Azure Go service contracts", { concurrency: true }, async (t) => { - await Promise.all([ - t.test("Azure PostgreSQL operational adapter: sessions, CAS, queue capacity and leases", async () => { - await goCase("server", ["./internal/operational"], ["^TestAdapters$/^postgres$"], { postgres: true }, runtime); - }), - t.test("Azure server adapters: canonical PostgreSQL, Blob archive, Key Vault and monitoring", async () => { - await goCase("server", [ - "./internal/postgresx", "./internal/sessionarchiveblob", "./internal/tokenbroker", "./internal/telemetry", - ], [ - "^TestFreshSchemaReopen$", - "^TestAzuriteBlobStoreIntegration$", - "^TestArchive(ServiceInjectionPreservesActivationGates|InjectedServicesHTTPIntegration)$", - "^TestAzureKeyVaultHTTPIntegration$", - "^TestAzureDeploymentTelemetryReceipts$", - ], { postgres: true, blob: true }, runtime); - }), - ]); -}); diff --git a/tests/unit/azure-local-dev.test.mjs b/tests/unit/azure-local-dev.test.mjs deleted file mode 100644 index ce4f166581..0000000000 --- a/tests/unit/azure-local-dev.test.mjs +++ /dev/null @@ -1,291 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile, spawn } from "node:child_process"; -import { access, copyFile, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; -import { dirname, join, resolve } from "node:path"; -import test from "node:test"; -import { promisify } from "node:util"; -import { githubProfile, validateEnvironment, writeSettings } from "../../scripts/azure-local/profile.mjs"; - -const execute = promisify(execFile); -const root = resolve("."); -const scratch = join(root, ".tmp/azure-local-unit"); -const credentials = { - CAO_GITHUB_CLIENT_ID: "regression-client", - CAO_GITHUB_CLIENT_SECRET: 'regression-secret-"\\\n-not-a-real-credential', - CAO_GITHUB_ALLOWED_ORGS: "githubnext, example", -}; - -async function directory() { - await mkdir(scratch, { recursive: true }); - return mkdtemp(join(scratch, "dev-")); -} - -test("GitHub Azure profile requires explicit credentials, orgs and exact loopback callback", () => { - assert.equal(githubProfile(credentials).redirect, "http://localhost:7071/auth/callback"); - assert.equal(githubProfile({ ...credentials, AZURE_LOCAL_FUNCTIONS_PORT: "17071" }).port, "17071"); - for (const invalid of [ - { CAO_GITHUB_CLIENT_ID: "" }, - { CAO_GITHUB_CLIENT_SECRET: "local-client-secret" }, - { CAO_GITHUB_ALLOWED_ORGS: "" }, - { CAO_GITHUB_ALLOWED_ORGS: "*" }, - { CAO_GITHUB_ALLOWED_ORGS: "example," }, - { CAO_GITHUB_ALLOWED_ORGS: "owner/repo" }, - { AZURE_LOCAL_FUNCTIONS_PORT: "0" }, - { AZURE_LOCAL_FUNCTIONS_PORT: "65536" }, - { AZURE_LOCAL_FUNCTIONS_PORT: "7071/evil" }, - { CAO_GITHUB_REDIRECT_URL: "http://127.0.0.1:7071/auth/callback" }, - { CAO_GITHUB_REDIRECT_URL: "http://127.0.0.1:7071/other" }, - { CAO_GITHUB_REDIRECT_URL: "https://example.com/auth/callback" }, - { CAO_SESSION_SECRET: "short" }, - { CAO_SESSION_SECRET: " ".repeat(32) }, - { AZURE_LOCAL_SOURCE_DIRECTORY: "relative" }, - { AZURE_LOCAL_SOURCE_DIRECTORY: join(root, "nonexistent-snapshot") }, - ]) { - assert.throws(() => githubProfile({ ...credentials, ...invalid })); - } - for (const name of ["CAO_GITHUB_LOCAL_USER", "CAO_COLLECT_ENABLED", "CAO_POLICY_PATH", "CAO_GITHUB_ALLOWED_USERS"]) { - assert.throws(() => validateEnvironment({ ...credentials, [name]: "unexpected" }), /unset other CAO/); - } - assert.throws(() => validateEnvironment({ ...credentials, GITHUB_SERVER_URL: "https://github.example.com" }), /GitHub.com only/); - for (const engine of ["docker", "podman"]) { - assert.equal(validateEnvironment({ ...credentials, CAO_CONTAINER_ENGINE: engine }).port, "7071"); - } - for (const engine of ["", "auto", "podman --remote"]) { - assert.throws(() => validateEnvironment({ ...credentials, CAO_CONTAINER_ENGINE: engine }), /must be docker or podman/); - } -}); - -test("GitHub settings safely serialize credentials and use built server queries", async () => { - const dir = await directory(); - try { - const path = join(dir, "local.settings.json"); - await writeFile(path, JSON.stringify({ Values: { FUNCTIONS_WORKER_RUNTIME: "custom" } })); - writeSettings(path, "github", credentials); - const first = JSON.parse(await readFile(path, "utf8")); - assert.equal(first.Values.CAO_GITHUB_CLIENT_SECRET, credentials.CAO_GITHUB_CLIENT_SECRET); - assert.equal(first.Values.CAO_GITHUB_ALLOWED_ORGS, "githubnext,example"); - assert.equal(first.Values.CAO_AZURE_DASHBOARD_QUERIES, join(dir, "site/dashboard.json")); - assert.equal(first.Values.FUNCTIONS_WORKER_RUNTIME, "custom"); - assert.match(first.Values.CAO_SESSION_SECRET, /^[a-f\d]{64}$/); - assert.equal((await stat(path)).mode & 0o777, 0o600); - writeSettings(path, "github", credentials); - assert.notEqual(JSON.parse(await readFile(path, "utf8")).Values.CAO_SESSION_SECRET, first.Values.CAO_SESSION_SECRET); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -for (const engine of ["docker", "podman"]) { -test(`GitHub stack uses built assets, explicit or empty evidence, isolated environment and exact teardown (${engine})`, async () => { - const dir = await directory(); - const script = join(dir, "scripts/azure-local/azure-local.sh"); - const state = join(dir, "state"); - const tools = join(dir, "bin"); - const trace = join(dir, "commands.log"); - const executable = (path, content) => writeFile(path, `#!/usr/bin/env bash\nset -eu\n${content}`, { mode: 0o700 }); - const port = await new Promise((resolvePort) => { - const listener = createServer(); - listener.listen(0, "127.0.0.1", () => { - const { port } = listener.address(); - listener.close(() => resolvePort(String(port))); - }); - }); - const env = { - PATH: `${tools}:${process.env.PATH}`, - HOME: process.env.HOME, - ...credentials, - CAO_CONTAINER_ENGINE: engine, - AZURE_LOCAL_STATE_DIR: state, - AZURE_LOCAL_PROFILE: "github", - AZURE_LOCAL_FUNCTIONS_PORT: port, - AZURE_LOCAL_WAIT_TIMEOUT: "2", - UNRELATED_SECRET: "must-not-reach-functions", - }; - const run = (command, environment = env) => execute("bash", [script, command], { env: environment, timeout: 15_000 }); - try { - await mkdir(dirname(script), { recursive: true }); - await copyFile(join(root, "scripts/azure-local/azure-local.sh"), script); - await copyFile(join(root, "scripts/container-engine.sh"), join(dir, "scripts/container-engine.sh")); - await copyFile(join(root, "scripts/azure-local/profile.mjs"), join(dirname(script), "profile.mjs")); - await copyFile(join(root, "scripts/azure-local/platform.py"), join(dirname(script), "platform.py")); - await mkdir(join(dir, ".github/workflows"), { recursive: true }); - for (const file of ["cao.json", "cao.azure.json"]) { - await copyFile(join(root, ".github/workflows", file), join(dir, ".github/workflows", file)); - } - await mkdir(tools); - const platform = `${process.platform === "darwin" ? "osx" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}`; - const func = join(dir, `.tmp/azure-local/tools/core-tools-4.15.1-${platform}/func`); - await mkdir(dirname(func), { recursive: true }); - await symlink(process.execPath, func); - await executable(join(tools, engine), ` -echo "${engine} $*" >> "${trace}" -case "$1" in - run) case "$*" in *-postgres*) echo postgres-id ;; *) echo azurite-id ;; esac ;; - port) echo 127.0.0.1:12345 ;; -esac -`); - await executable(join(tools, "curl"), "echo 200\n"); - await executable(join(tools, "npm"), ` -echo "npm $*" >> "${trace}" -if [[ "\${HARNESS_FAIL_BUILD:-}" == 1 ]]; then - echo "$CAO_GITHUB_CLIENT_SECRET" - exit 42 -fi -if [[ "\${HARNESS_HOLD_BUILD:-}" == 1 ]]; then - echo waiting > "${dir}/build-blocked" - sleep 30 -fi -while [[ "$1" != -- ]]; do shift; done -mkdir -p "$2" -echo 'built dashboard' > "$2/index.html" -echo '{"dashboard":{"queries":[]}}' > "$2/dashboard.json" -`); - await executable(join(tools, "go"), ` -while [[ "$1" != -o ]]; do shift; done -cp "${dir}/dashboard" "$2" -cp "${dir}/host.mjs" "$(dirname "$2")/start" -`); - await executable(join(dir, "dashboard"), `echo "dashboard $*" >> "${trace}"\n`); - await writeFile(join(dir, "host.mjs"), ` -import { writeFileSync } from "node:fs"; -writeFileSync("${dir}/host-observed.json", JSON.stringify({ args: process.argv.slice(2), env: process.env })); -setInterval(() => {}, 1000); -`); - for (const source of [undefined, join(dir, "snapshot")]) { - if (source) await mkdir(source); - const selectedEnv = { ...env, ...(source ? { AZURE_LOCAL_SOURCE_DIRECTORY: source } : {}) }; - await run("start", selectedEnv); - await run("wait", selectedEnv); - // The stub host runs a real process but does not emulate Functions or Docker. - for (let attempt = 0; attempt < 50; attempt++) { - try { await access(join(dir, "host-observed.json")); break; } - catch { await new Promise((done) => setTimeout(done, 20)); } - } - const settings = JSON.parse(await readFile(join(state, "function-app/local.settings.json"), "utf8")).Values; - assert.equal(settings.CAO_GITHUB_REDIRECT_URL, `http://localhost:${port}/auth/callback`); - assert.equal(settings.CAO_GITHUB_CLIENT_SECRET, credentials.CAO_GITHUB_CLIENT_SECRET); - assert.equal(settings.CAO_AZURE_LOCAL_SIMULATION, "1"); - assert.equal(settings.CAO_AZURE_ALLOWED_HOSTS, "127.0.0.1,localhost"); - assert.equal(settings.CAO_AZURE_SITE_DIRECTORY, join(state, "function-app/site")); - assert.match(await readFile(join(state, "function-app/site/index.html"), "utf8"), /built dashboard/); - assert.equal(JSON.parse(await readFile(join(state, "runtime.json"), "utf8")).profile, "github"); - assert.equal(JSON.parse(await readFile(join(state, "runtime.json"), "utf8")).baseUrl, `http://localhost:${port}`); - const observed = JSON.parse(await readFile(join(dir, "host-observed.json"), "utf8")); - assert.deepEqual(observed.args, ["--address", "127.0.0.1", "--port", port]); - assert.equal(observed.env.UNRELATED_SECRET, undefined); - assert.equal(observed.env.CAO_GITHUB_CLIENT_SECRET, undefined, "Core Tools loads private settings itself"); - assert.equal(observed.env.CAO_CONTAINER_ENGINE, undefined); - assert.equal(observed.env.HOME, join(state, "function-app/.home")); - assert.equal(observed.env.TMPDIR, join(state, "function-app/.scratch")); - assert.equal((await stat(state)).mode & 0o777, 0o700); - assert.equal((await stat(join(state, "function-app/local.settings.json"))).mode & 0o777, 0o600); - assert.equal((await stat(join(state, "runtime.env"))).mode & 0o777, 0o600); - const output = await run("logs", selectedEnv); - assert.doesNotMatch(output.stdout + output.stderr, /regression-secret/); - await assert.rejects(run("test", selectedEnv), /synthetic profile/); - await assert.rejects(run("start", selectedEnv), /stop it explicitly/); - const synthetic = { PATH: env.PATH, AZURE_LOCAL_STATE_DIR: state }; - await assert.rejects(run("stop", synthetic), /different or invalid profile/); - await assert.rejects(run("start", synthetic), /different or invalid profile/); - await assert.rejects(run("logs", synthetic), /different or invalid profile/); - await assert.rejects(run("run", selectedEnv), /synthetic profile/); - await access(join(state, "runtime.env")); - const commands = await readFile(trace, "utf8"); - assert.ok(commands.includes(`dashboard ingest --source ${source ?? join(dir, "server/azure/empty-data")}`)); - assert.doesNotMatch(commands, /fixture/); - for (const name of ["home", "scratch"]) { - await mkdir(join(state, name), { recursive: true }); - await writeFile(join(state, name, "operator-owned"), "must survive cleanup\n"); - } - await run("stop", selectedEnv); - await run("stop", selectedEnv); - for (const name of ["home", "scratch"]) { - assert.equal(await readFile(join(state, name, "operator-owned"), "utf8"), "must survive cleanup\n"); - } - assert.equal(await readFile(join(state, "profile"), "utf8"), "github\n"); - await assert.rejects(run("logs", synthetic), /different or invalid profile/); - await assert.rejects(run("start", synthetic), /different or invalid profile/); - await assert.rejects(access(join(state, "runtime.env")), { code: "ENOENT" }); - await assert.rejects(access(join(state, "function-app")), { code: "ENOENT" }); - await rm(join(dir, "host-observed.json")); - } - const commands = await readFile(trace, "utf8"); - assert.equal((commands.match(new RegExp(`${engine} rm -f postgres-id`, "g")) ?? []).length, 2); - assert.equal((commands.match(new RegExp(`${engine} rm -f azurite-id`, "g")) ?? []).length, 2); - assert.doesNotMatch(commands, new RegExp(`^${engine === "docker" ? "podman" : "docker"} `, "m")); - const { CAO_GITHUB_CLIENT_SECRET: omitted, ...missingSecret } = env; - assert.ok(omitted); - await assert.rejects(run("dev", missingSecret), /requires real GitHub.com OAuth credentials/); - assert.equal(await readFile(trace, "utf8"), commands, "missing credentials must not touch services"); - const occupied = createServer(); - await new Promise((done) => occupied.listen(Number(port), "127.0.0.1", done)); - try { - await assert.rejects(run("start"), /requested Functions port is unavailable/); - assert.equal(await readFile(trace, "utf8"), commands, "a busy port must not touch services"); - } finally { - await new Promise((done) => occupied.close(done)); - } - const failure = await run("start", { ...env, HARNESS_FAIL_BUILD: "1" }).then( - () => assert.fail("expected build failure"), - (error) => error, - ); - assert.equal(failure.code, 42); - assert.match(failure.stderr, /dashboard site build failed/); - assert.doesNotMatch(failure.stdout + failure.stderr, /regression-secret/); - assert.match(await readFile(join(state, "logs/site-build.log"), "utf8"), /regression-secret/); - assert.equal((await stat(join(state, "logs/site-build.log"))).mode & 0o777, 0o600); - await assert.rejects(access(join(state, "runtime.env")), { code: "ENOENT" }); - const syntheticAfterFailure = { PATH: env.PATH, AZURE_LOCAL_STATE_DIR: state }; - for (const command of ["logs", "start", "stop"]) { - const rejected = await run(command, syntheticAfterFailure).then( - () => assert.fail("stopped GitHub logs must retain their profile boundary"), - (error) => error, - ); - assert.match(rejected.stderr, /different or invalid profile/); - assert.doesNotMatch(rejected.stdout + rejected.stderr, /regression-secret/); - } - await writeFile(join(state, "runtime.env"), "STATE_PROFILE=synthetic\nPOSTGRES_CONTAINER_ID=do-not-remove\n"); - const before = await readFile(trace, "utf8"); - await assert.rejects(run("dev"), /different profile/); - assert.doesNotMatch((await readFile(trace, "utf8")).slice(before.length), /(?:docker|podman) rm/); - await access(join(state, "runtime.env")); - await rm(join(state, "runtime.env")); - const startup = spawn("bash", [script, "start"], { - env: { ...env, HARNESS_HOLD_BUILD: "1" }, - detached: true, - stdio: "ignore", - }); - const completion = new Promise((done, reject) => { - startup.once("error", reject); - startup.once("exit", (code, signal) => done({ code, signal })); - }); - try { - for (let attempt = 0; attempt < 100; attempt++) { - try { await access(join(dir, "build-blocked")); break; } - catch { await new Promise((done) => setTimeout(done, 20)); } - } - await access(join(dir, "build-blocked")); - process.kill(-startup.pid, "SIGTERM"); - assert.deepEqual(await completion, { code: 143, signal: null }); - await assert.rejects(access(join(state, "runtime.env")), { code: "ENOENT" }); - await assert.rejects(access(join(state, "function-app")), { code: "ENOENT" }); - const afterCancellation = (await readFile(trace, "utf8")).slice(before.length); - assert.match(afterCancellation, new RegExp(`${engine} rm -f postgres-id --volumes`)); - assert.match(afterCancellation, new RegExp(`${engine} rm -f azurite-id --volumes`)); - await rm(join(state, "profile")); - await assert.rejects(run("logs", syntheticAfterFailure), /existing unmarked state/); - await assert.rejects(run("start"), /existing unmarked state/); - await writeFile(join(state, "profile"), "github\n"); - } finally { - if (startup.exitCode === null && startup.signalCode === null) { - process.kill(-startup.pid, "SIGKILL"); - await completion; - } - } - } finally { - await run("stop").catch(() => {}); - await rm(dir, { recursive: true, force: true }); - } -}); -} diff --git a/tests/unit/azure-local-go.test.mjs b/tests/unit/azure-local-go.test.mjs deleted file mode 100644 index 9dad87711a..0000000000 --- a/tests/unit/azure-local-go.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { assertGoCoverage } from "../helpers/azure-local-go.mjs"; - -const packages = ["./internal/one", "./internal/two"]; -const patterns = ["^TestFirst$/^postgres$", "^TestSecond$"]; -const passes = [ - { Package: "./internal/one", Test: "TestFirst/postgres", Action: "pass" }, - { Package: "./internal/two", Test: "TestSecond", Action: "pass" }, -]; -const output = (events) => events.map((event) => JSON.stringify(event)).join("\n"); - -test("batched Azure evidence requires every selected test and package", () => { - assertGoCoverage(output(passes), packages, patterns); - assert.throws(() => assertGoCoverage(output(passes.slice(0, 1)), packages, patterns), /selector.*TestSecond/); - assert.throws(() => assertGoCoverage(output(passes), [...packages, "./internal/missing"], patterns), /package.*missing/); -}); - -test("batched Azure evidence cannot substitute another adapter or skip services", () => { - const wrongAdapter = [{ ...passes[0], Test: "TestFirst/memory" }, passes[1]]; - assert.throws(() => assertGoCoverage(output(wrongAdapter), packages, patterns), /selector.*postgres/); - assert.throws(() => assertGoCoverage(output([ - ...passes, { Package: "./internal/one", Test: "TestFirst/postgres/required", Action: "skip" }, - ]), packages, patterns), /selected service coverage cannot skip/); -}); diff --git a/tests/unit/azure-local-harness.test.mjs b/tests/unit/azure-local-harness.test.mjs deleted file mode 100644 index fd83ce475e..0000000000 --- a/tests/unit/azure-local-harness.test.mjs +++ /dev/null @@ -1,377 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { access, copyFile, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import test from "node:test"; -import { promisify } from "node:util"; - -const execute = promisify(execFile); -const failures = [ - ["server-build", "dashboard server build", "server-build.log"], - ["canonical-database-bootstrap", "canonical database bootstrap", "database-bootstrap.log"], - ["operational-database-bootstrap", "operational database bootstrap", "operational-database-bootstrap.log"], - ["ingest", "fixture ingestion", "ingest.log"], -]; - -const failureCases = failures.flatMap((entry) => ["docker", "podman"].map((engine) => [...entry, engine])); -failureCases.push(...["docker", "podman"].map((engine) => [...failures[3], engine, true])); -failureCases.push([...failures[0], "podman", "missing"]); -for (const [stage, label, logName, engine, prebuilt = false] of failureCases) { - test(`Azure local harness reports ${stage} failures before cleaning up (${engine}${prebuilt ? ", prebuilt" : ""})`, async () => { - await mkdir(resolve(".tmp"), { recursive: true }); - const directory = await mkdtemp(resolve(".tmp/azure-local-harness-")); - const script = join(directory, "scripts/azure-local/azure-local.sh"); - const tools = join(directory, "bin"); - const state = join(directory, "state"); - const trace = join(directory, "commands.log"); - const message = stage === "ingest" - ? 'error: payload hash mismatch for "gh-aw-logs-records/subset.jsonl"' - : `${stage} failed for regression test`; - const executable = (path, content) => writeFile(path, `#!/usr/bin/env bash\nset -eu\n${content}`, { mode: 0o700 }); - try { - await mkdir(dirname(script), { recursive: true }); - await copyFile(resolve("scripts/azure-local/azure-local.sh"), script); - await copyFile(resolve("scripts/container-engine.sh"), join(directory, "scripts/container-engine.sh")); - await copyFile(resolve("scripts/azure-local/platform.py"), join(dirname(script), "platform.py")); - await mkdir(join(directory, "tests/e2e"), { recursive: true }); - await mkdir(join(directory, "tests/integration"), { recursive: true }); - await writeFile(join(directory, "tests/integration/azure-local-protocols.test.mjs"), ""); - await copyFile(resolve("tests/e2e/dashboard-server-fixture.mjs"), join(directory, "tests/e2e/dashboard-server-fixture.mjs")); - await mkdir(join(directory, "server/testdata"), { recursive: true }); - await cp(resolve("server/testdata/deployed-subset"), join(directory, "server/testdata/deployed-subset"), { recursive: true }); - await mkdir(tools); - await mkdir(join(directory, ".github/workflows"), { recursive: true }); - await writeFile(join(directory, ".github/workflows/cao.json"), '{"control-plane":{"web":{}}}\n'); - await copyFile(resolve(".github/workflows/cao.azure.json"), join(directory, ".github/workflows/cao.azure.json")); - const func = join(directory, ".tmp/azure-local/tools/core-tools-4.15.1-linux-x64/func"); - await mkdir(dirname(func), { recursive: true }); - await executable(func, "exit 99\n"); - await executable(join(tools, "uname"), 'if [[ "$1" == -s ]]; then echo Linux; else echo x86_64; fi\n'); - for (const tool of ["curl", "unzip"]) { - await executable(join(tools, tool), "exit 99\n"); - } - await executable(join(tools, engine), ` -echo "${engine} $*" >> "$HARNESS_TRACE" -if [[ "$1" == run ]]; then cp "$AZURE_LOCAL_STATE_DIR/runtime.env" "$HARNESS_SAVED_ENV"; fi -case "$1" in - run) - for argument in "$@"; do - case "$argument" in - *-postgres) echo postgres-id; exit 0 ;; - *-azurite) echo azurite-id; exit 0 ;; - esac - done - exit 99 ;; - port) echo 127.0.0.1:12345 ;; - exec) - case "$3" in - pg_isready) echo accepting ;; - esac ;; - logs) echo "$2 service output" ;; -esac -`); - await executable(join(tools, "go"), ` -echo "go $*" >> "$HARNESS_TRACE" -output="" -stage="" -while [[ "$#" -gt 0 ]]; do - case "$1" in - -o) output="$2"; shift ;; - ./cmd/cao-dashboard) stage=server-build ;; - esac - shift -done -if [[ "$stage" == "$HARNESS_FAILURE" ]]; then - echo "captured stdout" - echo "$HARNESS_MESSAGE" >&2 - exit 42 -fi -cp "$HARNESS_BINARY" "$output" -chmod +x "$output" -`); - const binary = join(directory, "fake-dashboard"); - await executable(binary, ` -echo "dashboard $*" >> "$HARNESS_TRACE" -cp "$(dirname "$0")/host.json" "$HARNESS_HOST" -case "$1" in - database) - test -n "$CAO_DATABASE_ADMIN_URL" - while [[ "$#" -gt 0 ]]; do - if [[ "$1" == "--store" ]]; then - stage="$2-database-bootstrap" - break - fi - shift - done - ;; - ingest) stage=ingest ;; - *) exit 99 ;; -esac -if [[ "$stage" == "$HARNESS_FAILURE" ]]; then - echo "captured stdout" - echo "$HARNESS_MESSAGE" >&2 - exit 42 -fi -`); - const binaryDirectory = join(directory, "test-binaries"); - if (prebuilt) { - await mkdir(binaryDirectory); - if (prebuilt !== "missing") await copyFile(binary, join(binaryDirectory, "cao-dashboard")); - } - // Readiness must succeed without contacting any real services. - await executable(join(tools, "curl"), "echo 200\n"); - const result = await execute("bash", [script, prebuilt ? "run-services" : "run"], { - env: { - ...process.env, - CAO_CONTAINER_ENGINE: engine, - PATH: `${tools}:${process.env.PATH}`, - AZURE_LOCAL_STATE_DIR: state, - AZURE_LOCAL_WAIT_TIMEOUT: "1", - AZURE_LOCAL_TEST_BINARIES: prebuilt ? binaryDirectory : "", - HARNESS_TRACE: trace, - HARNESS_FAILURE: stage, - HARNESS_MESSAGE: message, - HARNESS_BINARY: binary, - HARNESS_HOST: join(directory, "host.json"), - HARNESS_SAVED_ENV: join(directory, "saved-runtime.env"), - }, - timeout: 10_000, - }).then( - (value) => ({ ...value, code: 0 }), - (error) => ({ stdout: error.stdout, stderr: error.stderr, code: error.code }), - ); - if (prebuilt === "missing") { - assert.equal(result.code, 1, result.stderr); - assert.match(result.stderr, /explicit test binary is missing or not executable/); - const commands = await readFile(trace, "utf8"); - assert.doesNotMatch(commands, /go .* build /); - for (const service of ["postgres", "azurite"]) { - assert.match(commands, new RegExp(`${engine} rm -f ${service}-id`)); - } - await assert.rejects(access(join(state, "runtime.env")), { code: "ENOENT" }); - return; - } - assert.equal(result.code, 42, result.stderr); - assert.ok(result.stderr.includes(`azure-local: ${label} failed (exit 42)`), result.stderr); - assert.ok(result.stderr.includes(message), result.stderr); - assert.ok(result.stderr.includes("captured stdout"), result.stderr); - const logPath = join(state, "logs", logName); - assert.ok(result.stderr.includes(logPath), result.stderr); - assert.equal(await readFile(logPath, "utf8"), `captured stdout\n${message}\n`); - const commands = await readFile(trace, "utf8"); - assert.match(await readFile(join(directory, "saved-runtime.env"), "utf8"), new RegExp(`CAO_CONTAINER_ENGINE=${engine}`)); - assert.doesNotMatch(commands, /(?:docker|podman) .*redis/); - assert.match(commands, /docker.io\/library\/postgres:16-alpine/); - assert.equal((commands.match(/go .* build /g) || []).length, prebuilt ? 0 : 1, - "explicit prebuilt executables must run without rebuilding"); - if (stage !== "server-build") { - const host = JSON.parse(await readFile(join(directory, "host.json"), "utf8")); - assert.deepEqual(host.customHandler.description, { - defaultExecutablePath: "cao-dashboard", - arguments: ["azure-functions"], - }); - assert.equal(host.customHandler.enableForwardingHttpRequest, true); - assert.equal(host.extensions.http.routePrefix, ""); - } - - if (stage === "canonical-database-bootstrap" || stage === "operational-database-bootstrap") { - assert.match(commands, /dashboard database bootstrap --store canonical --namespace azure-local-[a-f0-9]{12}/); - } - if (stage === "operational-database-bootstrap") { - assert.match(commands, /dashboard database bootstrap --store operational --namespace azure-local-[a-f0-9]{12}-operational/); - } - for (const service of ["postgres", "azurite"]) { - assert.match(commands, new RegExp(`${engine} rm -f ${service}-id`)); - assert.equal(await readFile(join(state, "logs", `${service}.log`), "utf8"), `${service}-id service output\n`); - } - await assert.rejects(access(join(state, "runtime.env")), { code: "ENOENT" }); - await assert.rejects(access(join(state, "runtime.json")), { code: "ENOENT" }); - await assert.rejects(access(join(state, "function-app")), { code: "ENOENT" }); - assert.doesNotMatch(commands, /func start/); - if (stage !== "ingest") assert.doesNotMatch(commands, /dashboard ingest/); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); -} - -for (const original of ["docker", "podman", "legacy"]) { - for (const action of ["stop", "logs", "wait"]) { - test(`Azure ${action} restores ${original} engine instead of caller selection`, async () => { - await mkdir(resolve(".tmp"), { recursive: true }); - const directory = await mkdtemp(resolve(".tmp/azure-engine-")); - const state = join(directory, "state"); - const trace = join(directory, "commands.log"); - const engine = original === "legacy" ? "docker" : original; - try { - await mkdir(join(state, "logs"), { recursive: true }); - await writeFile(join(state, "profile"), "synthetic\n"); - for (const name of ["docker", "podman"]) { - await writeFile(join(directory, name), `#!/bin/bash -printf '${name} %s\\n' "$*" >> "$HARNESS_TRACE" -if [[ "$1" == logs ]]; then echo service-output; fi -`, { mode: 0o700 }); - } - await writeFile(join(directory, "curl"), "#!/bin/bash\nexit 0\n", { mode: 0o700 }); - await writeFile(join(state, "runtime.env"), [ - ...(original === "legacy" ? [] : [`CAO_CONTAINER_ENGINE=${original}`]), - "POSTGRES_CONTAINER_ID=postgres-original", - "AZURITE_CONTAINER_ID=azurite-original", - "AZURITE_BLOB_PORT=12345", - "FUNCTIONS_PORT=12346", - "", - ].join("\n")); - await execute("bash", [resolve("scripts/azure-local/azure-local.sh"), action], { - env: { - ...process.env, - PATH: `${directory}:${process.env.PATH}`, - CAO_CONTAINER_ENGINE: engine === "docker" ? "podman" : "docker", - AZURE_LOCAL_STATE_DIR: state, - HARNESS_TRACE: trace, - }, - }); - const commands = await readFile(trace, "utf8"); - assert.doesNotMatch(commands, new RegExp(`^${engine === "docker" ? "podman" : "docker"} `, "m")); - if (action === "wait") assert.match(commands, new RegExp(`${engine} exec postgres-original pg_isready`)); - else assert.match(commands, new RegExp(`${engine} logs postgres-original`)); - if (action === "stop") assert.match(commands, new RegExp(`${engine} rm -f postgres-original`)); - else assert.doesNotMatch(commands, / rm /); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - } -} - -test("Azure restart never removes original container IDs from the new engine", async () => { - await mkdir(resolve(".tmp"), { recursive: true }); - const directory = await mkdtemp(resolve(".tmp/azure-restart-")); - const state = join(directory, "state"); - const trace = join(directory, "commands.log"); - try { - await mkdir(state); - await writeFile(join(state, "profile"), "synthetic\n"); - for (const engine of ["docker", "podman"]) { - await writeFile(join(directory, engine), `#!/bin/bash -echo '${engine}' "$@" >> "$HARNESS_TRACE" -if [[ '${engine}' == podman && "$1" == run ]]; then exit 42; fi -`, { mode: 0o700 }); - } - await writeFile(join(state, "runtime.env"), "CAO_CONTAINER_ENGINE=docker\nPOSTGRES_CONTAINER_ID=old-postgres\nAZURITE_CONTAINER_ID=old-azurite\n"); - await assert.rejects(execute("bash", [resolve("scripts/azure-local/azure-local.sh"), "start"], { - env: { - ...process.env, - PATH: `${directory}:${process.env.PATH}`, - CAO_CONTAINER_ENGINE: "podman", - AZURE_LOCAL_STATE_DIR: state, - HARNESS_TRACE: trace, - }, - }), { code: 42 }); - const commands = await readFile(trace, "utf8"); - assert.match(commands, /docker rm -f old-postgres/); - assert.match(commands, /docker rm -f old-azurite/); - assert.match(commands, /podman run /); - assert.doesNotMatch(commands, /podman (?:rm|logs|inspect) .*old-/); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); - -test("Azure cleanup fails closed on invalid saved engine and retains recovery state", async () => { - await mkdir(resolve(".tmp"), { recursive: true }); - const directory = await mkdtemp(resolve(".tmp/azure-invalid-engine-")); - try { - const runtime = join(directory, "runtime.env"); - await writeFile(join(directory, "profile"), "synthetic\n"); - const contents = "CAO_CONTAINER_ENGINE=invalid\nPOSTGRES_CONTAINER_ID=original\n"; - await writeFile(runtime, contents); - await assert.rejects(execute("bash", [resolve("scripts/azure-local/azure-local.sh"), "stop"], { - env: { ...process.env, CAO_CONTAINER_ENGINE: "docker", AZURE_LOCAL_STATE_DIR: directory }, - }), /must be docker or podman/); - assert.equal(await readFile(runtime, "utf8"), contents); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); - -for (const action of ["start", "stop"]) { - for (const failure of ["offline", "removal-failed", "inventory-failed"]) { - test(`Azure ${action} retains original recovery state on ${failure}`, async () => { - await mkdir(resolve(".tmp"), { recursive: true }); - const directory = await mkdtemp(resolve(".tmp/azure-recovery-")); - const state = join(directory, "state"); - const trace = join(directory, "commands.log"); - try { - await mkdir(join(state, "function-app"), { recursive: true }); - await writeFile(join(state, "profile"), "synthetic\n"); - const runtime = "CAO_CONTAINER_ENGINE=docker\nPOSTGRES_CONTAINER_ID=old-postgres\nAZURITE_CONTAINER_ID=old-azurite\n"; - await writeFile(join(state, "runtime.env"), runtime); - await writeFile(join(state, "runtime.json"), "{}\n"); - await writeFile(join(state, "function-app/owned"), "retain\n"); - await writeFile(join(directory, "docker"), `#!/bin/bash -echo docker "$@" >> "$HARNESS_TRACE" -case "$1" in - inspect|rm) exit 41 ;; - info) [[ "$HARNESS_FAILURE" != offline ]] ;; - ps) - if [[ "$HARNESS_FAILURE" == removal-failed ]]; then echo old-postgres; else exit 42; fi - ;; -esac -`, { mode: 0o700 }); - await writeFile(join(directory, "podman"), '#!/bin/bash\necho podman "$@" >> "$HARNESS_TRACE"\nexit 0\n', { mode: 0o700 }); - await assert.rejects(execute("bash", [resolve("scripts/azure-local/azure-local.sh"), action], { - env: { - ...process.env, - PATH: `${directory}:${process.env.PATH}`, - CAO_CONTAINER_ENGINE: "podman", - AZURE_LOCAL_STATE_DIR: state, - HARNESS_TRACE: trace, - HARNESS_FAILURE: failure, - }, - }), /cannot confirm removal.*recovery state retained/); - assert.equal(await readFile(join(state, "runtime.env"), "utf8"), runtime); - assert.equal(await readFile(join(state, "runtime.json"), "utf8"), "{}\n"); - assert.equal(await readFile(join(state, "function-app/owned"), "utf8"), "retain\n"); - const commands = await readFile(trace, "utf8"); - assert.match(commands, /docker ps --all --quiet --no-trunc --filter id=old-postgres/); - assert.match(commands, /docker ps --all --quiet --no-trunc --filter id=old-azurite/); - assert.doesNotMatch(commands, /podman (?:run|rm|inspect|ps|logs)/); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - } -} - -test("Azure stop accepts already absent containers only after successful inventory", async () => { - await mkdir(resolve(".tmp"), { recursive: true }); - const directory = await mkdtemp(resolve(".tmp/azure-absent-")); - const trace = join(directory, "commands.log"); - try { - await mkdir(join(directory, "function-app")); - await writeFile(join(directory, "profile"), "synthetic\n"); - await writeFile(join(directory, "runtime.env"), "CAO_CONTAINER_ENGINE=docker\nPOSTGRES_CONTAINER_ID=old-postgres\nAZURITE_CONTAINER_ID=old-azurite\n"); - await writeFile(join(directory, "runtime.json"), "{}\n"); - await writeFile(join(directory, "docker"), `#!/bin/bash -echo docker "$@" >> "$HARNESS_TRACE" -case "$1" in inspect|rm) exit 41 ;; ps) exit 0 ;; esac -`, { mode: 0o700 }); - await execute("bash", [resolve("scripts/azure-local/azure-local.sh"), "stop"], { - env: { - ...process.env, - PATH: `${directory}:${process.env.PATH}`, - CAO_CONTAINER_ENGINE: "podman", - AZURE_LOCAL_STATE_DIR: directory, - HARNESS_TRACE: trace, - }, - }); - const commands = await readFile(trace, "utf8"); - assert.match(commands, /docker ps --all --quiet --no-trunc --filter id=old-postgres/); - assert.match(commands, /docker ps --all --quiet --no-trunc --filter id=old-azurite/); - for (const file of ["runtime.env", "runtime.json", "function-app"]) { - await assert.rejects(access(join(directory, file)), { code: "ENOENT" }); - } - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); diff --git a/tests/unit/azure-local-platform.test.mjs b/tests/unit/azure-local-platform.test.mjs deleted file mode 100644 index a4d75589dd..0000000000 --- a/tests/unit/azure-local-platform.test.mjs +++ /dev/null @@ -1,118 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import test from "node:test"; -import { promisify } from "node:util"; - -const execute = promisify(execFile); -const helper = resolve("scripts/azure-local/platform.py"); -const platform = (...args) => execute("python3", [helper, ...args], { timeout: 10_000 }); - -for (const [system, architecture, asset] of [ - ["Linux", "x86_64", "linux-x64"], - ["Linux", "aarch64", "linux-arm64"], - ["Darwin", "x86_64", "osx-x64"], - ["Darwin", "arm64", "osx-arm64"], -]) { - test(`Azure Core Tools selects pinned ${asset} and rejects corrupt downloads`, async () => { - const dir = await mkdtemp(join(tmpdir(), "azure-platform-")); - try { - const script = join(dir, "scripts/azure-local/azure-local.sh"); - const tools = join(dir, "bin"); - const trace = join(dir, "download"); - await mkdir(dirname(script), { recursive: true }); - await mkdir(tools); - await copyFile(resolve("scripts/azure-local/azure-local.sh"), script); - await copyFile(resolve("scripts/container-engine.sh"), join(dir, "scripts/container-engine.sh")); - await copyFile(helper, join(dirname(script), "platform.py")); - await writeFile(join(tools, "uname"), - `#!/bin/bash\nif [[ "$1" == -s ]]; then echo ${system}; else echo ${architecture}; fi\n`, - { mode: 0o700 }); - await writeFile(join(tools, "curl"), - '#!/bin/bash\nprintf "%s\\n" "$2" > "$DOWNLOAD_TRACE"\nprintf corrupt > "$4"\n', - { mode: 0o700 }); - await assert.rejects(execute("/bin/bash", [script, "install-core-tools"], { - env: { ...process.env, PATH: `${tools}:${process.env.PATH}`, DOWNLOAD_TRACE: trace }, - }), /checksum verification failed/); - assert.equal((await readFile(trace, "utf8")).trim(), - `https://github.com/Azure/azure-functions-core-tools/releases/download/4.15.1/Azure.Functions.Cli.${asset}.4.15.1.zip`); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -} - -test("Azure platform verifies streamed checksums and serializes installers", async () => { - const dir = await mkdtemp(join(tmpdir(), "azure-lock-")); - try { - const archive = join(dir, "archive"); - const content = Buffer.alloc((1 << 20) + 1, 123); - await writeFile(archive, content); - await platform("verify", archive, createHash("sha256").update(content).digest("hex")); - await assert.rejects(platform("verify", archive, "0".repeat(64))); - const trace = join(dir, "trace"); - await Promise.all(["a", "b", "c"].map((id) => - platform("lock", join(dir, "lock"), process.execPath, "-e", ` - const {appendFileSync} = require("node:fs"); - appendFileSync(process.argv[1], process.argv[2] + "\\n"); - setTimeout(() => appendFileSync(process.argv[1], process.argv[2] + "\\n"), 30); - `, trace, id))); - const lines = (await readFile(trace, "utf8")).trim().split("\n"); - assert.equal(lines.length, 6); - for (let index = 0; index < lines.length; index += 2) { - assert.equal(lines[index], lines[index + 1], "the installer lock spans the whole subprocess"); - } - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("Azure host tracks native executable and start identity, including orphaned handlers", async () => { - const dir = await mkdtemp(join(tmpdir(), "azure-process-")); - let host; - let handler; - const handlerFile = join(dir, "handler"); - try { - const { stdout } = await platform("launch", dir, join(dir, "host.log"), process.execPath, "-e", ` - const {spawn} = require("node:child_process"); - const {writeFileSync} = require("node:fs"); - const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"}); - writeFileSync(process.argv[1], String(child.pid)); - setInterval(() => {}, 1000); - `, handlerFile); - host = stdout.trim(); - const start = (await platform("identity", host)).stdout.trim(); - assert.ok(start); - await platform("matches", host, start, process.execPath); - await assert.rejects(platform("matches", host, `${start}-stale`, process.execPath)); - await assert.rejects(platform("matches", host, start, "/bin/bash")); - await platform("group", host, process.execPath); - await assert.rejects(platform("group", host, "/bin/bash")); - for (let attempt = 0; attempt < 100; attempt++) { - try { handler = (await readFile(handlerFile, "utf8")).trim(); break; } - catch (error) { - if (error.code !== "ENOENT") throw error; - await new Promise((done) => setTimeout(done, 20)); - } - } - assert.ok(handler); - process.kill(Number(host), "SIGTERM"); - for (let attempt = 0; attempt < 100; attempt++) { - if (!(await platform("identity", host).catch(() => undefined))) break; - await new Promise((done) => setTimeout(done, 20)); - } - await assert.rejects(platform("matches", host, start, process.execPath)); - await platform("group", host, process.execPath); - await assert.rejects(platform("group", "2147483647", process.execPath)); - } finally { - for (const pid of [handler, host]) { - if (!pid) continue; - try { process.kill(Number(pid), "SIGKILL"); } - catch (error) { if (error.code !== "ESRCH") throw error; } - } - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/unit/cao-azure-assets.test.mjs b/tests/unit/cao-azure-assets.test.mjs index 3c60e10e40..525b08cec1 100644 --- a/tests/unit/cao-azure-assets.test.mjs +++ b/tests/unit/cao-azure-assets.test.mjs @@ -94,9 +94,9 @@ test("Azure asset bundle contains exactly the allowlisted Docker build inputs", "dashboard/site/src/", "dashboard/site/tsconfig.json", "internal/dashboardquery/", + "internal/topology/assets/container-apps/", "server/Dockerfile", "server/azure/empty-data/", - "server/azure/main.json", "server/azure/provision.sh", "server/azure/provision.sql", "server/cmd/cao-dashboard/", @@ -148,8 +148,8 @@ test("Azure asset manifest records the source revision and every file digest", ( assert.equal(manifest.schemaVersion, 1); assert.equal(manifest.revision, revision); assert.equal(manifest.dockerfile, "server/Dockerfile"); - assert.equal(manifest.template, "server/azure/main.json"); - assert.deepEqual(manifest.targets, ["azure-functions-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"]); + assert.equal(manifest.template, "internal/topology/assets/container-apps/main.generated.json"); + assert.deepEqual(manifest.targets, ["azure-container-apps-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"]); const contents = entries.filter((entry) => entry.path !== AZURE_ASSET_MANIFEST); assert.deepEqual(Object.keys(manifest.files), contents.map((entry) => entry.path)); for (const entry of contents) { @@ -197,9 +197,9 @@ test("Azure Dockerfile legacy ACR targets and their preceding stages read only b visit(/^FROM (\S+)/.exec(stage)[1]); for (const match of stage.matchAll(/--from=(\S+)/g)) visit(match[1]); }; - for (const target of ["azure-functions-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"]) visit(target); + for (const target of ["azure-container-apps-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"]) visit(target); const order = [...stages.keys()].filter(Boolean); - for (const target of ["azure-functions-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"]) { + for (const target of ["azure-container-apps-runtime", "azure-ingest-runtime", "azure-empty-ingest-runtime", "azure-db-provision"]) { assert.ok(order.includes(target), `${target} must exist`); for (const preceding of order.slice(0, order.indexOf(target) + 1)) reachable.add(preceding); } @@ -227,11 +227,9 @@ test("Azure Dockerfile legacy ACR targets and their preceding stages read only b assert.match(stages.get("azure-ingest-runtime"), /^COPY --from=cao-ingest-source --chown=cao:cao \/ \/data\/$/m); }); -test("development CLI and release bundles consume the same reviewed Azure asset allowlist", () => { - const shared = readFileSync(path.join(root, "internal/cli/deploy_azure_assets.json"), "utf8"); +test("release bundles consume the reviewed Container Apps asset allowlist", () => { + const shared = readFileSync(path.join(root, "scripts/cao-azure-assets.json"), "utf8"); assert.deepEqual(AZURE_ASSET_ALLOWLIST, JSON.parse(shared)); assert.ok(AZURE_ASSET_ALLOWLIST.includes("dashboard/site/manifest.webmanifest")); - const native = readFileSync(path.join(root, "internal/cli/deploy_azure_images.go"), "utf8"); - assert.match(native, /\/\/go:embed deploy_azure_assets\.json/); - assert.match(native, /for _, entry := range deployAzureAssetAllowlist/); + assert.ok(AZURE_ASSET_ALLOWLIST.includes("internal/topology/assets/container-apps/")); }); diff --git a/tests/unit/cao-ci-tiers.test.mjs b/tests/unit/cao-ci-tiers.test.mjs index e52df76fb4..4e84d5b172 100644 --- a/tests/unit/cao-ci-tiers.test.mjs +++ b/tests/unit/cao-ci-tiers.test.mjs @@ -44,7 +44,7 @@ test("daily calls every suite at the same revision and reports even on failure", assert.equal(daily.on.schedule.length, 1); assert.equal(daily.concurrency["cancel-in-progress"], false); const suites = Object.entries(daily.jobs).filter(([, job]) => job.uses); - assert.equal(suites.length, 15); + assert.equal(suites.length, 14); for (const [id, job] of suites) { assert.match(job.uses, /^\.\/\.github\/workflows\/[^/]+\.yml$/); assert.equal(job.needs, "revision"); diff --git a/tests/unit/cao-package-contract.test.mjs b/tests/unit/cao-package-contract.test.mjs index f6cf8a8277..ac1d5be534 100644 --- a/tests/unit/cao-package-contract.test.mjs +++ b/tests/unit/cao-package-contract.test.mjs @@ -190,7 +190,7 @@ test("Docker dashboard builder installs production tooling without shipping buil const manifest = JSON.parse(await text("dashboard/site/package.json")); assert.ok(manifest.dependencies.esbuild, "esbuild is required by the production build"); assert.doesNotMatch(builder, /--include=dev/); - for (const name of ["dashboard-runtime", "azure-functions-runtime"]) { + for (const name of ["dashboard-runtime"]) { const runtime = stages.find((stage) => stage.includes(` AS ${name}`)); assert.ok(runtime); assert.doesNotMatch(runtime, /node_modules|control-plane-inventory\.json/); diff --git a/tests/unit/control-policy.test.mjs b/tests/unit/control-policy.test.mjs index a690132ebf..472cd3ee6a 100644 --- a/tests/unit/control-policy.test.mjs +++ b/tests/unit/control-policy.test.mjs @@ -544,7 +544,7 @@ test("control policy rejects inconsistent host capabilities", () => { }); test("control policy validates every deployment module and rejects incompatible composition", () => { - const targetModules = ["container", "azure-functions"]; + const targetModules = ["container"]; const redisModules = [ "generic", "local", "upstash", "aws-elasticache", "redis-cloud", "gcp-memorystore", "railway", "render", "digitalocean", @@ -573,7 +573,7 @@ test("control policy validates every deployment module and rejects incompatible const incompatible = JSON.parse(minimalPolicy); incompatible["control-plane"].web = { host: { - target: { module: "azure-functions" }, + target: { module: "generic", authentication: "github-oauth", listener: "external" }, redis: { module: "upstash" }, }, }; diff --git a/tests/unit/dashboard-container-contract.test.mjs b/tests/unit/dashboard-container-contract.test.mjs index e8c065c5fb..39901ddb92 100644 --- a/tests/unit/dashboard-container-contract.test.mjs +++ b/tests/unit/dashboard-container-contract.test.mjs @@ -18,7 +18,6 @@ test("server and native CLI builds require the CA-compromise-patched Go toolchai ".github/workflows/cao-package.yml", ".github/workflows/release-promote.yml", ".github/workflows/backfill-stress.yml", - ".github/workflows/azure-local-integration.yml", ".github/workflows/copilot-setup-steps.yml", ]) { const workflow = await text(path); diff --git a/tests/unit/podman-ci.test.mjs b/tests/unit/podman-ci.test.mjs index db3008f341..0c66ad7ae6 100644 --- a/tests/unit/podman-ci.test.mjs +++ b/tests/unit/podman-ci.test.mjs @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -12,9 +11,9 @@ const root = new URL("../../", import.meta.url); const execute = promisify(execFile); const workflow = parse(await readFile(new URL(".github/workflows/podman.yml", root), "utf8")); -test("Podman CI runs real rootless boundaries and Azure services on pull requests", () => { +test("Podman CI runs real rootless boundaries on pull requests without retired Azure services", () => { for (const event of ["pull_request", "push"]) { - for (const path of [".github/workflows/podman.yml", "server/**", "scripts/container-*.sh", "scripts/azure-local/**"]) { + for (const path of [".github/workflows/podman.yml", "server/**", "scripts/container-*.sh"]) { assert.ok(workflow.on[event].paths.includes(path), `${event} must cover ${path}`); } } @@ -33,23 +32,13 @@ test("Podman CI runs real rootless boundaries and Azure services on pull request assert.match(commands, /container_runner\.go/); assert.match(commands, /podman-bin\/server\/hostedcli\.test/); assert.match(commands, /-test.run '\^TestPodmanRealBoundary\$' -test.count=1/); - const services = workflow.jobs["azure-services"]; - assert.equal(services.needs, undefined, "real services must not contend with hosted-CLI workloads"); - assert.equal(services.if, undefined); - assert.equal(services.env.CAO_CONTAINER_ENGINE, "podman"); - assert.match(services.steps.map((step) => step.run ?? "").join("\n"), /azure-local\.sh run-services/); - assert.equal(services.steps.find((step) => step.id === "binaries").with.key, - job.steps.find((step) => step.id === "binaries").with.key); - const protocols = workflow.jobs["azure-protocols"]; - assert.equal(protocols.needs, undefined, "protocol contracts must not wait for service startup"); - assert.equal(protocols.if, undefined); - assert.match(protocols.steps.map((step) => step.run ?? "").join("\n"), /azure-local-protocols\.test\.mjs/); - assert.equal(protocols.steps.find((step) => step.id === "binaries").with.key, - job.steps.find((step) => step.id === "binaries").with.key); + assert.deepEqual(Object.keys(workflow.jobs), ["podman"]); + assert.match(commands, /node --test tests\/unit\/container-engine\.test\.mjs/); + assert.doesNotMatch(JSON.stringify(workflow), /azure-local|Core Tools|azure-services|azure-protocols/); assert.doesNotMatch(commands, /sudo (?:podman|go)|\|\| true/); }); -test("Podman CI reuses only exact-source executables and verified platform-specific Core Tools", async () => { +test("Podman CI reuses only exact-source race-instrumented executables", () => { const steps = workflow.jobs.podman.steps; const go = steps.find((step) => step.uses?.startsWith("actions/setup-go@")); assert.equal(go.if, "steps.binaries.outputs.cache-hit != 'true'"); @@ -61,19 +50,14 @@ test("Podman CI reuses only exact-source executables and verified platform-speci assert.ok(binaries.with.key.includes(input), `binary cache must bind ${input}`); } assert.equal(binaries.with["restore-keys"], undefined, "stale-source executables must never be reused"); - const build = await readFile(new URL("scripts/azure-local/prepare-test-binaries.sh", root), "utf8"); + const build = steps.find((step) => step.name === "Build current-source executable on cache miss").run; assert.match(build, /test -race -c/); - const tools = workflow.jobs["azure-services"].steps.find((step) => step.with?.path === ".tmp/azure-local/tools"); - assert.equal(tools.with.path, ".tmp/azure-local/tools"); - assert.ok(tools.with.key.includes("runner.os")); - assert.ok(tools.with.key.includes("runner.arch")); - assert.ok(tools.with.key.includes("hashFiles('scripts/azure-local/azure-local.sh')")); - assert.equal(tools.with["restore-keys"], undefined, "do not restore tooling with stale checksum pins"); + assert.match(build, /\.\/internal\/hostedcli/); }); for (const failure of ["", "boundary", "contracts"]) { test(`Podman concurrent checks wait for every lane and preserve ${failure || "success"}`, async () => { - const directory = await mkdtemp(join(tmpdir(), "cao-podman-ci-")); + const directory = await mkdtemp(join(fileURLToPath(root), ".podman-ci-")); const trace = join(directory, "trace"); try { for (const [command, lane] of [["podman", "boundary"], ["node", "contracts"]]) { @@ -93,7 +77,7 @@ exit 99 `, { mode: 0o700 }); } await writeFile(join(directory, "systemd-run"), '#!/bin/bash\nprintf "boundary-run\\n" >> "$CHECK_TRACE"\n', { mode: 0o700 }); - const script = workflow.jobs.podman.steps.find((step) => step.name === "Run independent rootless and harness checks concurrently").run; + const script = workflow.jobs.podman.steps.find((step) => step.name === "Run independent rootless and container checks concurrently").run; const result = await execute("/bin/bash", ["-e", "-c", script], { cwd: fileURLToPath(root), env: { diff --git a/tests/unit/server-azure-adx.test.mjs b/tests/unit/server-azure-adx.test.mjs index 539085fc18..3ffaff5454 100644 --- a/tests/unit/server-azure-adx.test.mjs +++ b/tests/unit/server-azure-adx.test.mjs @@ -2,79 +2,23 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -const template = JSON.parse(await readFile(new URL('../../server/azure/main.json', import.meta.url), 'utf8')); -const resources = Object.values(template.resources); -const adx = resources.find((resource) => resource.name === 'adx-destination'); -const destination = adx.properties.template; -const children = Object.values(destination.resources); -const network = resources.find((resource) => resource.name === 'private-network-postgres'); -const networkTemplate = network.properties.template; +const source = await readFile(new URL('../../server/azure/adx.bicep', import.meta.url), 'utf8'); -test('ADX provisioning is opt-in and passes reviewed destination and capacity parameters', () => { - assert.equal(template.parameters.adxEnabled.defaultValue, false); - assert.equal(adx.condition, "[parameters('adxEnabled')]"); - for (const [child, parent] of [ - ['clusterName', 'adxClusterName'], ['databaseName', 'adxDatabaseName'], - ['skuName', 'adxSkuName'], ['skuTier', 'adxSkuTier'], ['capacity', 'adxCapacity'], - ]) { - assert.equal(adx.properties.parameters[child].value, `[parameters('${parent}')]`); - } - assert.equal(template.parameters.adxClusterName.minLength, 4); - assert.equal(template.parameters.adxClusterName.maxLength, 22); - assert.equal(template.parameters.adxDatabaseName.maxLength, 100); - assert.equal(template.parameters.adxDatabaseName.defaultValue, 'CAO'); - assert.equal(template.parameters.adxSkuName.defaultValue, 'Dev(No SLA)_Standard_E2a_v4'); - assert.equal(template.parameters.adxSkuTier.defaultValue, 'Basic'); - assert.equal(template.parameters.adxCapacity.defaultValue, 1); - assert.equal(template.parameters.adxCapacity.minValue, 1); - const cluster = children.find((resource) => resource.type === 'Microsoft.Kusto/clusters'); - assert.deepEqual(cluster.sku, { - name: "[parameters('skuName')]", tier: "[parameters('skuTier')]", capacity: "[parameters('capacity')]", - }); +test('Standalone optional ADX retains reviewed capacity and private streaming ingestion', () => { + assert.match(source, /param skuName string = 'Dev\(No SLA\)_Standard_E2a_v4'/); + assert.match(source, /@minValue\(1\)\s+param capacity int = 1/); + assert.match(source, /publicNetworkAccess: 'Disabled'/); + assert.match(source, /enableDiskEncryption: true/); + assert.match(source, /enableStreamingIngest: true/); + assert.match(source, /subnet:\s*\{\s*id: privateEndpointSubnetId/); + assert.match(source, /groupIds: \['cluster'\]/); + assert.match(source, /registrationEnabled: false/); + assert.match(source, /virtualNetwork:\s*\{\s*id: virtualNetworkId/); }); -test('ADX has private-only streaming ingestion with private endpoint DNS on its own subnet', () => { - const cluster = children.find((resource) => resource.type === 'Microsoft.Kusto/clusters'); - assert.deepEqual(cluster.properties, { - publicNetworkAccess: 'Disabled', enableDiskEncryption: true, enableStreamingIngest: true, - }); - const endpoint = children.find((resource) => resource.type === 'Microsoft.Network/privateEndpoints'); - assert.equal(endpoint.properties.subnet.id, "[parameters('privateEndpointSubnetId')]"); - assert.deepEqual(endpoint.properties.privateLinkServiceConnections[0].properties, { - privateLinkServiceId: "[resourceId('Microsoft.Kusto/clusters', parameters('clusterName'))]", - groupIds: ['cluster'], - }); - const dns = children.find((resource) => resource.type === 'Microsoft.Network/privateDnsZones'); - assert.equal(dns.name, "[format('privatelink.{0}.kusto.windows.net', parameters('location'))]"); - const link = children.find((resource) => resource.type === 'Microsoft.Network/privateDnsZones/virtualNetworkLinks'); - assert.equal(link.properties.registrationEnabled, false); - assert.equal(link.properties.virtualNetwork.id, "[parameters('virtualNetworkId')]"); - const group = children.find((resource) => resource.type === 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups'); - assert.equal(group.properties.privateDnsZoneConfigs[0].properties.privateDnsZoneId, - "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.{0}.kusto.windows.net', parameters('location')))]"); - assert.equal(networkTemplate.parameters.adxEnabled.defaultValue, false); - assert.equal(network.properties.parameters.adxEnabled.value, "[parameters('adxEnabled')]"); - const vnet = Object.values(networkTemplate.resources).find((resource) => resource.type === 'Microsoft.Network/virtualNetworks'); - assert.match(vnet.properties.subnets, /if\(parameters\('adxEnabled'\), createArray\(createObject\('name', 'adx'/); - assert.match(vnet.properties.subnets, /union\(variables\('subnetNetworkSecurityGroup'\), createObject\('addressPrefix', '10\.42\.4\.0\/24', 'privateEndpointNetworkPolicies', 'Disabled'\)\)/); - for (const [parameter, output] of [['privateEndpointSubnetId', 'adxSubnetId'], ['virtualNetworkId', 'virtualNetworkId']]) { - assert.match(adx.properties.parameters[parameter].value, new RegExp(`outputs\\.${output}\\.value`)); - } -}); - -test('ADX provisions a bounded-retention database without schema execution or runtime grants', () => { - const database = children.find((resource) => resource.type === 'Microsoft.Kusto/clusters/databases'); - assert.equal(database.kind, 'ReadWrite'); - assert.deepEqual(database.properties, { softDeletePeriod: 'P7D', hotCachePeriod: 'P1D' }); - assert.doesNotMatch(JSON.stringify(children), /principalAssignments|roleAssignments|\/scripts|deploymentScripts|clientSecret/i); - for (const [output, child] of [['adxClusterId', 'clusterId'], ['adxQueryEndpoint', 'queryEndpoint'], ['adxDatabaseName', 'databaseName']]) { - assert.match(template.outputs[output].value, /^\[if\(parameters\('adxEnabled'\),/); - assert.match(template.outputs[output].value, new RegExp(`outputs\\.${child}\\.value, ''\\)\\]$`)); - } - assert.match(destination.outputs.queryEndpoint.value, /\.uri\]$/); -}); - -test('Provisioning ADX never enables export on unsupported Functions or acquisition jobs', () => { - assert.doesNotMatch(JSON.stringify(resources), /CAO_ADX_/); - assert.match(template.parameters.adxEnabled.metadata.description, /Azure Functions is not a supported exporter host/); +test('ADX is bounded-retention destination infrastructure, not an exporter or authority grant', () => { + assert.match(source, /softDeletePeriod: 'P7D'/); + assert.match(source, /hotCachePeriod: 'P1D'/); + assert.doesNotMatch(source, /principalAssignments|roleAssignments|\/scripts|deploymentScripts|clientSecret|CAO_ADX_/i); + assert.match(source, /output queryEndpoint string = cluster\.properties\.uri/); }); diff --git a/tests/unit/server-azure-bicep.test.mjs b/tests/unit/server-azure-bicep.test.mjs index 7664ace5d7..c96441bdda 100644 --- a/tests/unit/server-azure-bicep.test.mjs +++ b/tests/unit/server-azure-bicep.test.mjs @@ -2,615 +2,69 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -const bicep = await readFile(new URL('../../server/azure/main.bicep', import.meta.url), 'utf8'); -const collector = await readFile(new URL('../../server/azure/collector.bicep', import.meta.url), 'utf8'); -const functionRoles = await readFile(new URL('../../server/azure/function-roles.bicep', import.meta.url), 'utf8'); -const generated = JSON.parse(await readFile(new URL('../../server/azure/main.json', import.meta.url), 'utf8')); -const resources = Object.values(generated.resources); -const profile = JSON.parse(await readFile(new URL('../../.github/workflows/cao.azure.json', import.meta.url), 'utf8')); -const network = await readFile(new URL('../../server/azure/network.bicep', import.meta.url), 'utf8'); -const ingestion = await readFile(new URL('../../server/azure/ingestion.bicep', import.meta.url), 'utf8'); -const provisioning = await readFile(new URL('../../server/azure/provisioning.bicep', import.meta.url), 'utf8'); +const template = JSON.parse(await readFile(new URL('../../internal/topology/assets/container-apps/main.generated.json', import.meta.url), 'utf8')); +const resources = template.resources; +const moduleFor = (name) => resources.find((resource) => resource.name === name).properties.template; +const resourcesFor = (name) => Object.values(moduleFor(name).resources); +const provisioning = resourcesFor('container-apps-provisioning'); +const ingestion = resourcesFor('container-apps-ingestion'); const provisionScript = await readFile(new URL('../../server/azure/provision.sh', import.meta.url), 'utf8'); -const provisionSql = await readFile(new URL('../../server/azure/provision.sql', import.meta.url), 'utf8'); -const dockerfile = await readFile(new URL('../../server/Dockerfile', import.meta.url), 'utf8'); -const deploymentDocs = await readFile(new URL('../../docs/deployment-azure.md', import.meta.url), 'utf8'); -test('Azure uses the consolidated dashboard subcommand without changing image contracts', () => { - const runtime = dockerfile.split(' AS azure-functions-runtime\n')[1]?.split('\nFROM ')[0]; - assert.ok(runtime, 'the supported Functions image target must remain available'); - assert.match(runtime, /COPY --from=server-build --chown=cao:cao \/out\/cao-dashboard \/app\/cao-dashboard/); - assert.match(runtime, /^ENTRYPOINT \["\/app\/cao-dashboard"\]$/m); - assert.match(runtime, /^CMD \["azure-functions"\]$/m); - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - assert.equal(app.properties.siteConfig.appCommandLine, 'azure-functions'); - assert.match(bicep, /appCommandLine: 'azure-functions'/); - assert.match(runtime, /FUNCTIONS_CUSTOMHANDLER_HOST=0\.0\.0\.0/); - assert.match(runtime, /FUNCTIONS_CUSTOMHANDLER_PORT=8080/); - assert.doesNotMatch(dockerfile, /\/out\/cao-functions|\/app\/cao-functions|\.\/cmd\/cao-functions/); - assert.equal(generated.parameters.functionImageRepository.defaultValue, 'cao-functions'); - assert.match(bicep, /param functionImageRepository string = 'cao-functions'/); -}); - -test('Derived Container Apps names are bounded before Azure creates any resources', () => { - assert.equal(generated.parameters.functionAppName.minLength, 2); - assert.equal(generated.parameters.functionAppName.maxLength, 19); - for (const [moduleName, limit, suffixes] of [ - ['database-provisioning', 19, ['-db-provision']], - ['snapshot-ingestion', 19, ['-ingest']], - ['collection-workers', 22, ['-collector', '-backfill']], - ]) { - const template = resources.find((resource) => resource.name === moduleName)?.properties.template; - assert.ok(template, `${moduleName} must have a compiled template`); - assert.equal(template.parameters.namePrefix.maxLength, limit); - const children = Object.values(template.resources); - for (const suffix of suffixes) { - assert.ok(children.some((resource) => ['Microsoft.App/jobs', 'Microsoft.App/containerApps'].includes(resource.type) - && resource.name.includes(suffix)), `${moduleName} must declare ${suffix}`); - assert.ok(limit + suffix.length <= 32, `${moduleName}${suffix} exceeds the Azure name limit`); - } - for (const resource of children.filter((resource) => resource.type === 'Microsoft.ManagedIdentity/userAssignedIdentities')) { - const suffix = resource.name.match(/\{0\}([^']+)'/)?.[1]; - assert.ok(suffix, `expected an explicit identity suffix: ${resource.name}`); - assert.ok(limit + suffix.length <= 128, 'managed identity name exceeds the Azure name limit'); - } - } -}); - -test('Key Vault purge protection remains default-on with an explicit disposable opt-out', () => { - assert.equal(generated.parameters.keyVaultPurgeProtection.type, 'bool'); - assert.equal(generated.parameters.keyVaultPurgeProtection.defaultValue, true); - const vault = resources.find((resource) => resource.type === 'Microsoft.KeyVault/vaults'); - assert.equal(vault.properties.enablePurgeProtection, "[if(parameters('keyVaultPurgeProtection'), true(), null())]"); - assert.equal(vault.properties.softDeleteRetentionInDays, 90); - assert.match(bicep, /enablePurgeProtection: keyVaultPurgeProtection \? true : null/); -}); - -test('Native source-build digest selectors bind each distinct runtime and job target', () => { - for (const role of ['function', 'ingestion', 'provisioning']) { - assert.equal(generated.parameters[`${role}ImageDigest`].type, 'string'); - assert.equal(generated.parameters[`${role}ImageDigest`].defaultValue, ''); - assert.equal(generated.variables[`${role}ImageSelector`], - `[if(empty(parameters('${role}ImageDigest')), format(':{0}', parameters('${role}ImageTag')), format('@{0}', parameters('${role}ImageDigest')))]`); - } - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - assert.match(app.properties.siteConfig.linuxFxVersion, /variables\('functionImageSelector'\)/); - assert.match(generated.outputs.functionContainerImage.value, /variables\('functionImageSelector'\)/); - for (const [moduleName, role] of [['snapshot-ingestion', 'ingestion'], ['database-provisioning', 'provisioning']]) { - const module = resources.find((resource) => resource.name === moduleName); - assert.match(module.properties.parameters[`${role}Image`].value, new RegExp(`variables\\('${role}ImageSelector'\\)`)); - assert.match(module.properties.parameters[`${role}Image`].value, new RegExp(`parameters\\('${role}ImageRepository'\\)`)); - const job = Object.values(module.properties.template.resources).find((resource) => resource.type === 'Microsoft.App/jobs'); - assert.equal(job.properties.template.containers.length, 1); - assert.equal(job.properties.template.containers[0].image, `[parameters('${role}Image')]`); - } -}); - -test('Documented bootstrap supplies required inputs and explicit reviewed retention', () => { - const command = deploymentDocs.match(/```bash\n(\s*az deployment group create[\s\S]*?)```/)?.[1]; - assert.ok(command, 'expected the documented bootstrap command'); - const supplied = new Set([...command.matchAll(/^\s+(\w+)=/gm)].map((match) => match[1])); - for (const secret of ['githubClientSecret', 'sessionSecret', 'postgresAdministratorPassword', 'postgresOwnerPassword', 'postgresRuntimePassword']) { - supplied.add(secret); - assert.match(deploymentDocs, new RegExp(`\`${secret}\``)); - } - assert.equal(supplied.has('postgresRunRetentionDays'), true); - const missing = Object.entries(generated.parameters) - .filter(([name, parameter]) => !supplied.has(name) - && !Object.hasOwn(parameter, 'defaultValue')) - .map(([name]) => name); - assert.deepEqual(missing, [], 'the bootstrap must supply every parameter without a default'); -}); - -test('Azure ingress defaults to public main site behind GitHub sign-in with default-deny SCM', () => { - assert.equal(generated.parameters.functionPublicNetworkAccess.defaultValue, 'Enabled'); - assert.deepEqual(generated.parameters.functionPublicNetworkAccess.allowedValues, ['Disabled', 'Enabled']); - assert.equal(generated.parameters.functionAccessRestrictionsDefaultAction.type, 'string'); - assert.equal(generated.parameters.functionAccessRestrictionsDefaultAction.defaultValue, 'Allow'); - assert.deepEqual(generated.parameters.functionAccessRestrictionsDefaultAction.allowedValues, ['Allow', 'Deny']); - assert.deepEqual(generated.parameters.functionAccessRestrictions.defaultValue, []); - assert.deepEqual(generated.parameters.functionScmAccessRestrictions.defaultValue, []); - assert.equal(generated.parameters.functionScmAccessRestrictionsUseMain.type, 'bool'); - assert.equal(generated.parameters.functionScmAccessRestrictionsUseMain.defaultValue, false); - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - assert.equal(app.properties.publicNetworkAccess, "[parameters('functionPublicNetworkAccess')]"); - assert.equal(app.properties.siteConfig.ipSecurityRestrictionsDefaultAction, "[parameters('functionAccessRestrictionsDefaultAction')]"); - assert.equal(app.properties.siteConfig.scmIpSecurityRestrictionsDefaultAction, 'Deny'); - assert.equal(app.properties.siteConfig.scmIpSecurityRestrictionsUseMain, "[parameters('functionScmAccessRestrictionsUseMain')]"); - assert.equal(app.properties.siteConfig.scmIpSecurityRestrictions, "[parameters('functionScmAccessRestrictions')]"); - assert.equal(app.properties.siteConfig.ipSecurityRestrictions, "[parameters('functionAccessRestrictions')]"); -}); - -test('Documented public-main opt-in uses Allow with no custom rules and independent default-deny SCM', () => { - const example = deploymentDocs.match(/#### Explicit public-main access with independently restricted SCM[\s\S]*?```json\n([\s\S]*?)```/)?.[1]; - assert.ok(example, 'expected a generic public-main parameter example'); - const parameters = JSON.parse(example); - assert.deepEqual(parameters.functionPublicNetworkAccess, { value: 'Enabled' }); - assert.deepEqual(parameters.functionAccessRestrictionsDefaultAction, { value: 'Allow' }); - assert.deepEqual(parameters.functionAccessRestrictions, { value: [] }); - assert.deepEqual(parameters.functionScmAccessRestrictionsUseMain, { value: false }); - assert.deepEqual(parameters.functionScmAccessRestrictions.value, [{ - name: 'approved-admin-network', - ipAddress: '192.0.2.0/24', - action: 'Allow', - priority: 100, - }]); - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - const config = app.properties.siteConfig; - for (const [property, parameter] of [ - ['ipSecurityRestrictionsDefaultAction', 'functionAccessRestrictionsDefaultAction'], - ['ipSecurityRestrictions', 'functionAccessRestrictions'], - ['scmIpSecurityRestrictions', 'functionScmAccessRestrictions'], - ['scmIpSecurityRestrictionsUseMain', 'functionScmAccessRestrictionsUseMain'], - ]) { - assert.equal(config[property], `[parameters('${parameter}')]`); - assert.equal(Object.hasOwn(parameters, parameter), true); - } - assert.equal(app.properties.publicNetworkAccess, "[parameters('functionPublicNetworkAccess')]"); - assert.equal(config.scmIpSecurityRestrictionsDefaultAction, 'Deny'); - assert.equal(JSON.stringify(generated).split("[parameters('functionAccessRestrictionsDefaultAction')]").length - 1, 1, - 'the opt-in default action must be wired only to the main site'); - assert.equal(app.properties.httpsOnly, true); - assert.equal(config.minTlsVersion, '1.2'); - assert.equal(config.ftpsState, 'Disabled'); -}); - -test('Every Azure role explicitly preserves the run window for linked evidence', () => { - for (const source of [bicep, ingestion, collector]) { - assert.match(source, /name:\s*'CAO_POSTGRES_LINKED_RETENTION_DAYS'\s+value:\s*string\(postgresRunRetentionDays\)/); - } - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - for (const setting of ['CAO_POSTGRES_RUN_RETENTION_DAYS', 'CAO_POSTGRES_LINKED_RETENTION_DAYS']) { - assert.ok(app.properties.siteConfig.appSettings.includes(`createObject('name', '${setting}', 'value', string(parameters('postgresRunRetentionDays')))`)); - } - let checkedContainers = 0; - for (const name of ['snapshot-ingestion', 'collection-workers']) { - const module = resources.find((resource) => resource.name === name); - assert.equal(module.properties.parameters.postgresRunRetentionDays.value, "[parameters('postgresRunRetentionDays')]"); - const template = module.properties.template; - const workers = Object.values(template.resources).filter((resource) => - ['Microsoft.App/jobs', 'Microsoft.App/containerApps'].includes(resource.type)); - assert.equal(workers.length, name === 'snapshot-ingestion' ? 1 : 2); - for (const worker of workers) { - for (const container of worker.properties.template.containers) { - const environment = Array.isArray(container.env) ? container.env : template.variables.collectionBaseEnvironment; - if (!Array.isArray(container.env)) { - assert.equal(container.env, "[variables('collectionEnvironment')]"); - assert.match(template.variables.collectionEnvironment, /concat\(variables\('collectionBaseEnvironment'\), parameters\('otlpEnvironment'\)/); - } - for (const setting of ['CAO_POSTGRES_RUN_RETENTION_DAYS', 'CAO_POSTGRES_LINKED_RETENTION_DAYS']) { - assert.deepEqual(environment.filter((entry) => entry.name === setting), [{ - name: setting, - value: "[string(parameters('postgresRunRetentionDays'))]", - }], `${container.name} must preserve ${setting}`); - } - checkedContainers++; - } - } - } - assert.equal(checkedContainers, 3, 'ingestion, collector and backfill must each receive retention settings'); -}); - -test('Azure selects PostgreSQL operational storage without Redis', () => { - assert.equal(profile.extends, 'cao.json'); - assert.deepEqual(profile['control-plane'].web.host, { - target: { module: 'azure-functions' }, - 'operational-store': { backend: 'postgres' }, - }); - assert.doesNotMatch(bicep, /Microsoft\.Cache|CAO_REDIS_URL|redisEnterprise|redisConnectionString/i); - assert.doesNotMatch(collector, /redis-streams|cao-redis|CAO_REDIS_URL/i); - assert.match(bicep, /name:\s*'CAO_POLICY_PATH'\s+value:\s*'\.github\/workflows\/cao\.azure\.json'/); - assert.match(collector, /name:\s*'CAO_POLICY_PATH'\s+value:\s*'\/app\/\.github\/workflows\/cao\.azure\.json'/); -}); - - test('Azure baseline provisions one shared telemetry pair and private PostgreSQL', () => { - const count = (type) => resources.filter((resource) => resource.type === type).length; - assert.equal(count('Microsoft.Insights/components'), 1); - assert.equal(count('Microsoft.OperationalInsights/workspaces'), 1); - assert.equal(count('Microsoft.Storage/storageAccounts'), 1); - assert.equal(count('Microsoft.App/managedEnvironments'), 1); - assert.match(bicep, /WorkspaceResourceId:\s*workspace\.id/); - assert.match(bicep, /workspaceId:\s*workspace\.id/); - assert.match(bicep, /virtualNetworkSubnetId:\s*network\.outputs\.functionSubnetId/); - assert.match(bicep, /infrastructureSubnetId:\s*network\.outputs\.containerSubnetId/); - assert.match(bicep, /sslmode=verify-full/); - assert.match(network, /Microsoft\.DBforPostgreSQL\/flexibleServers@/); - assert.match(network, /publicNetworkAccess:\s*'Disabled'/); - assert.match(network, /Microsoft\.Network\/privateDnsZones@/); - assert.match(network, /backupRetentionDays:\s*7/); - assert.doesNotMatch(network, /githubClient|sessionSecret|Microsoft\.Web\/sites@/); - assert.doesNotMatch(collector, /resource environment '[^']+' = \{/); - }); - - test('Private ingestion and collection pull ACR images with managed identity', () => { - assert.match(ingestion, /triggerType:\s*'Manual'/); - assert.match(ingestion, /registries:\s*\[/); - assert.match(ingestion, /server:\s*registry\.properties\.loginServer/); - assert.match(ingestion, /identity:\s*identity\.id/); - assert.match(ingestion, /keyVaultUrl:.*secrets\/\$\{postgresUrlSecretName\}/); - assert.match(ingestion, /'--redis-namespace'\s+canonicalNamespace/); - assert.match(ingestion, /param canonicalNamespace string = 'azure-dashboard'/); - assert.match(ingestion, /dependsOn:\s*\[roles\]/); - assert.equal((collector.match(/registries:\s*registries/g) ?? []).length, 2); - assert.equal((collector.match(/dependsOn:\s*\[roles\]/g) ?? []).length, 2); - assert.equal((collector.match(/'--database-queries', '\/app\/queries\/database.json'/g) ?? []).length, 2); - assert.match(collector, /name:\s*'CAO_COLLECT_NATIVE'\s+value:\s*'true'/); - assert.match(collector, /name:\s*'CAO_COLLECT_NATIVE_INVENTORY_DIGEST'\s+value:\s*nativeInventoryDigest/); - assert.match(collector, /name:\s*'CAO_POSTGRES_RUN_RETENTION_DAYS'/); - }); - - test('Azure image inputs are explicit and default Docker target remains hosted dashboard', () => { - const stages = dockerfile.split('\n').filter((line) => line.startsWith('FROM ')); - assert.equal(stages.at(-1), 'FROM dashboard-runtime AS final'); - assert.doesNotMatch(dockerfile, /cao\.mjs download/); - assert.match(dockerfile, /FROM scratch AS cao-ingest-source\nARG CAO_INGEST_SOURCE\nCOPY \$\{CAO_INGEST_SOURCE:-\.cao-ingest-source-required\}\/ \/\n/); - assert.match(dockerfile, /COPY --from=cao-ingest-source --chown=cao:cao \/ \/data\//); - assert.match(dockerfile, /FROM dashboard-runtime AS azure-empty-ingest-runtime/); - assert.match(dockerfile, /COPY --chown=cao:cao server\/azure\/empty-data\/ \/data\//); - }); -test('Azure shares PostgreSQL and operational budgets across dashboard and collection roles', () => { - for (const source of [bicep, collector]) { - assert.match(source, /name:\s*'CAO_POSTGRES_URL'\s+secretRef:|'CAO_POSTGRES_URL'[\s\S]*?secrets\/\$\{postgresUrlSecretName\}/); - assert.match(source, /CAO_OPERATIONAL_CACHE_MAX_BYTES/); - assert.match(source, /CAO_OPERATIONAL_CACHE_MAX_VALUE_BYTES/); - assert.match(source, /CAO_OPERATIONAL_CACHE_MAX_ENTRIES/); - assert.match(source, /CAO_OPERATIONAL_PROTECTED_MAX_BYTES/); - assert.match(source, /CAO_OPERATIONAL_PROTECTED_MAX_ENTRIES/); - } - assert.equal(generated.parameters.operationalCacheMaxBytes.defaultValue, 33554432); - assert.equal(generated.parameters.operationalCacheMaxValueBytes.defaultValue, 4194304); - assert.equal(generated.parameters.operationalCacheMaxEntries.defaultValue, 1024); - assert.equal(generated.parameters.operationalProtectedMaxBytes.defaultValue, 134217728); - assert.equal(generated.parameters.operationalProtectedMaxEntries.defaultValue, 200000); - assert.ok(!('redisMaxBytes' in generated.parameters)); -}); - -test('Azure collection uses fixed PostgreSQL-backed workers', () => { - assert.match(collector, /param workerReplicas int = 1/); - assert.match(collector, /minReplicas:\s*workerReplicas/); - assert.match(collector, /maxReplicas:\s*workerReplicas/); - assert.doesNotMatch(collector, /rules:\s*\[/); - assert.doesNotMatch(collector, /taskStreamKey|streamKey|consumerGroup/); -}); - -test('Azure Bicep and compiled collector select native Go acquisition and require provisioned inventory', () => { - assert.match(bicep, /param collectorNativeInventoryDigest string = ''/); - assert.match(bicep, /nativeInventoryDigest:\s*collectorNativeInventoryDigest/); - assert.match(collector, /@minLength\(64\)\s+@maxLength\(64\)[\s\S]*?param nativeInventoryDigest string/); - assert.doesNotMatch(bicep, /collectorLakeStorageSku/); - assert.doesNotMatch(collector, /lakeStorageSku|lakeAccount|evidence-lake|CAO_COLLECT_LAKE_DIRECTORY/); - assert.match(bicep, /name:\s*'CAO_COLLECT_NATIVE'\s+value:\s*'true'/); - assert.equal(generated.variables.collectionEnabled, "[not(empty(parameters('collectorImage')))]"); - assert.equal(generated.parameters.collectorNativeInventoryDigest.defaultValue, ''); - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - const admission = app.properties.siteConfig.appSettings; - assert.match(admission, /if\(not\(variables\('collectionEnabled'\)\), createArray\(\), createArray\(/); - assert.match(admission, /'CAO_COLLECT_APP_ID', 'value', parameters\('collectorGithubAppId'\).*?'CAO_COLLECT_NATIVE', 'value', 'true'.*?'CAO_COLLECT_ADMIT_ONLY', 'value', 'true'/); - assert.doesNotMatch(admission, /CAO_COLLECT_NATIVE_INVENTORY_DIGEST|CAO_COLLECT_PRIVATE_KEY/); - - const workers = resources.find((resource) => resource.name === 'collection-workers'); - assert.equal(workers.condition, "[variables('collectionEnabled')]"); - assert.equal(workers.properties.parameters.nativeInventoryDigest.value, - "[parameters('collectorNativeInventoryDigest')]"); - const template = workers.properties.template; - assert.equal(template.parameters.nativeInventoryDigest.minLength, 64); - assert.equal(template.parameters.nativeInventoryDigest.maxLength, 64); - const environment = Object.fromEntries(template.variables.collectionBaseEnvironment.map(({ name, ...setting }) => [name, setting])); - assert.deepEqual(environment.CAO_COLLECT_NATIVE, { value: 'true' }); - assert.deepEqual(environment.CAO_COLLECT_NATIVE_INVENTORY_DIGEST, { - value: "[parameters('nativeInventoryDigest')]", - }); - assert.deepEqual(environment.CAO_COLLECT_APP_ID, { value: "[parameters('githubAppId')]" }); - assert.deepEqual(environment.CAO_POSTGRES_URL, { secretRef: 'cao-postgres-url' }); - assert.deepEqual(environment.CAO_OPERATIONAL_NAMESPACE, { value: "[parameters('operationalNamespace')]" }); - assert.equal(workers.properties.parameters.operationalNamespace.value, 'azure-dashboard-operational'); - assert.equal(template.resources.filter((resource) => resource.type === 'Microsoft.Storage/storageAccounts').length, 0); - for (const role of template.resources.filter((resource) => - ['Microsoft.App/jobs', 'Microsoft.App/containerApps'].includes(resource.type))) { - assert.equal(role.properties.template.containers[0].env, "[variables('collectionEnvironment')]"); - assert.equal(role.properties.template.containers[0].command[0], '/app/cao-dashboard'); - assert.ok(['collect', 'backfill'].includes(role.properties.template.containers[0].args[0])); - assert.equal(role.properties.template.volumes, undefined); - assert.equal(role.properties.template.containers[0].volumeMounts, undefined); - } - for (const retired of ['CAO_COLLECT_LAKE_DIRECTORY', 'CAO_COLLECT_STATIC_INVENTORY', 'CAO_COLLECT_GH_BINARY', - 'CAO_COLLECT_CATALOG_ROOT', 'collectorLakeStorageSku', 'lakeStorageAccountName', 'evidence-lake']) { - assert.doesNotMatch(JSON.stringify(generated), new RegExp(retired)); - } - const runtime = dockerfile.split(' AS collector-runtime\n')[1]?.split('\nFROM ')[0]; - assert.match(dockerfile, /^FROM alpine:3\.22@sha256:[0-9a-f]{64} AS collector-runtime$/m); - assert.match(runtime, /^COPY --from=server-build --chown=cao:cao \/out\/cao-dashboard \/app\/cao-dashboard$/m); - assert.match(runtime, /^COPY --from=dashboard-build --chown=cao:cao \/workspace\/dashboard\/site\/src\/data\/queries\/database\.json \/app\/queries\/database\.json$/m); - for (const policy of ['cao.json', 'cao.azure.json']) { - assert.ok(runtime.includes(`COPY --from=dashboard-build --chown=cao:cao /workspace/.github/workflows/${policy} /app/.github/workflows/${policy}`)); - } - assert.doesNotMatch(runtime, /node:|gh-aw|\/app\/catalog|\/app\/evidence/); - assert.match(runtime, /^ENTRYPOINT \["\/app\/cao-dashboard"\]$/m); - assert.match(runtime, /^CMD \["collect", "--database-queries", "\/app\/queries\/database\.json"\]$/m); -}); - -test('Azure dashboard Bicep does not output secrets or PAT-based configuration', () => { - const outputLines = bicep.split('\n').filter((line) => line.trim().startsWith('output ')); - assert.ok(outputLines.length > 0, 'expected deterministic non-secret outputs'); - for (const line of outputLines) { - const [, name] = line.trim().split(/\s+/); - assert.doesNotMatch(name, /secret|token|connection|accessKey|primaryKey/i); - } - assert.doesNotMatch(bicep, /CAO_GITHUB_PAT|GITHUB_PAT|PERSONAL_ACCESS_TOKEN/i); - assert.match(bicep, /OAuth authorization-code flow/); -}); - -test('Azure dashboard Bicep keeps secret-bearing settings in Key Vault', () => { - assert.match(bicep, /resource keyVault 'Microsoft\.KeyVault\/vaults@/); - assert.match(bicep, /enableRbacAuthorization:\s*true/); - assert.match(bicep, /enableSoftDelete:\s*true/); - assert.match(bicep, /enablePurgeProtection:\s*keyVaultPurgeProtection \? true : null/); - assert.match(bicep, /enabledForTemplateDeployment:\s*false/); - assert.match(bicep, /param previousSessionSecret string = ''/); - assert.match(bicep, /name:\s*'cao-session-secret-previous'/); - assert.match(bicep, /identity:\s*{\s*type:\s*'SystemAssigned'/); - assert.match(functionRoles, /Key Vault Secrets User/); - - for (const setting of ['CAO_POSTGRES_URL', 'CAO_GITHUB_CLIENT_SECRET', 'CAO_SESSION_SECRET']) { - const pattern = new RegExp(`name:\\s*'${setting}'[\\s\\S]*?value:\\s*'@Microsoft\\.KeyVault\\(SecretUri=`); - assert.match(bicep, pattern, `${setting} must use a Key Vault reference`); - } - assert.match(bicep, /name:\s*'AzureWebJobsStorage__accountName'/); - assert.match(bicep, /name:\s*'AzureWebJobsStorage__credential'\s+value:\s*'managedidentity'/); - assert.match(bicep, /allowSharedKeyAccess:\s*false/); - assert.match(functionRoles, /Storage Blob Data Owner/); - assert.match(functionRoles, /Storage Queue Data Contributor/); - assert.match(functionRoles, /Storage Table Data Contributor/); - assert.match(functionRoles, /AcrPull/); - assert.match( - functionRoles, - /guid\(storage\.id,\s*functionPrincipalId,\s*'Storage Blob Data Owner'\)/, - 'storage role assignment IDs must change when the Function App managed identity is replaced', - ); - assert.match( - functionRoles, - /guid\(secrets\[i\]\.id,\s*functionPrincipalId,\s*'Key Vault Secrets User'\)/, - 'Key Vault role assignment IDs must change when the Function App managed identity is replaced', - ); - assert.match( - functionRoles, - /guid\(registry\.id,\s*functionPrincipalId,\s*'AcrPull'\)/, - 'registry role assignment IDs must change when the Function App managed identity is replaced', - ); - assert.doesNotMatch(bicep, /secretUriWithVersion/, 'Key Vault references must follow the current secret version for rotation'); - - assert.match(bicep, /httpsOnly:\s*true/); - assert.match(bicep, /kind:\s*'functionapp,linux,container'/); - assert.match(bicep, /linuxFxVersion:\s*'DOCKER\|\$\{registry\.properties\.loginServer\}/); - assert.match(bicep, /acrUseManagedIdentityCreds:\s*true/); - assert.match(bicep, /name:\s*'FUNCTIONS_WORKER_RUNTIME'\s+value:\s*'custom'/); - assert.match(bicep, /name:\s*'WEBSITES_PORT'\s+value:\s*'8080'/); - assert.match(bicep, /name:\s*'WEBSITES_ENABLE_APP_SERVICE_STORAGE'\s+value:\s*'false'/); - assert.match(bicep, /name:\s*'CAO_DATABASE_QUERIES'\s+value:\s*'\/app\/queries\/database\.json'/); - assert.match(bicep, /name:\s*'CAO_GITHUB_ALLOWED_USERS'\s+value:\s*join\(githubAllowedUsers,\s*','\)/); - assert.match(bicep, /alwaysOn:\s*true/); - assert.match(bicep, /minimumElasticInstanceCount:\s*1/); - assert.match(bicep, /ftpsState:\s*'Disabled'/); - assert.match(bicep, /allowBlobPublicAccess:\s*false/); - assert.match(bicep, /supportsHttpsTrafficOnly:\s*true/); -}); - -test('Azure redeployment can keep an existing subnet NSG without forcing one on other deployments', () => { - assert.equal(generated.parameters.subnetNetworkSecurityGroupId.type, 'string'); - assert.equal(generated.parameters.subnetNetworkSecurityGroupId.defaultValue, ''); - assert.match(bicep, /subnetNetworkSecurityGroupId: subnetNetworkSecurityGroupId/); - assert.match(network, /param subnetNetworkSecurityGroupId string = ''/); - assert.equal(network.match(/properties: union\(subnetNetworkSecurityGroup, \{/g)?.length, 4, - 'every subnet must carry the optional NSG'); -}); - -test('Azure database and runtime URL secret names remain configurable without reusing the admin URL', () => { - assert.equal(generated.parameters.postgresDatabaseName.defaultValue, 'cao'); - assert.equal(generated.parameters.postgresUrlSecretName.defaultValue, 'cao-postgres-runtime-url'); - assert.equal(generated.parameters.postgresUrlSecretName.maxLength, 110); - assert.match(network, /param postgresDatabaseName string = 'cao'/); - assert.match(network, /parent: postgres\s+name: postgresDatabaseName/); - assert.match(bicep, /postgresDatabaseName: postgresDatabaseName/); - assert.match(bicep, /parent: keyVault\s+name: postgresUrlSecretName/); - assert.match(bicep, /name:\s*'CAO_POSTGRES_URL'\s+value:\s*'@Microsoft\.KeyVault\(SecretUri=\$\{keyVault\.properties\.vaultUri\}secrets\/\$\{postgresUrlSecretName\}\)'/); - for (const source of [ingestion, collector]) { - assert.match(source, /param postgresUrlSecretName string = 'cao-postgres-runtime-url'/); - assert.match(source, /keyVaultUrl: '\$\{[^}]+\}secrets\/\$\{postgresUrlSecretName\}'/); - } - for (const source of [bicep, network, ingestion, collector]) { - assert.doesNotMatch(source, /secrets\/cao-postgres-url|name: 'cao'\s*$/m, 'Key Vault and database names must come from parameters'); - } - for (const name of ['snapshot-ingestion', 'collection-workers']) { - const module = resources.find((resource) => resource.name === name); - assert.equal(module.properties.parameters.postgresUrlSecretName.value, "[parameters('postgresUrlSecretName')]"); - } - assert.match(deploymentDocs, /### Moving to a new database/); -}); - -test('Azure ingestion has no privileged initialization or secret access', () => { - const module = resources.find((resource) => resource.name === 'snapshot-ingestion'); - const job = Object.values(module.properties.template.resources).find((resource) => resource.type === 'Microsoft.App/jobs'); - assert.equal(job.properties.template.initContainers, undefined); - assert.equal(module.properties.parameters.canonicalNamespace.value, 'azure-dashboard'); - assert.doesNotMatch(JSON.stringify(module), /CAO_DATABASE_ADMIN_URL|bootstrap|owner-password|runtime-password/); - assert.deepEqual(job.properties.configuration.secrets, [{ - name: 'cao-postgres-url', - keyVaultUrl: "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri, parameters('postgresUrlSecretName'))]", - identity: "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-ingest-identity', parameters('namePrefix')))]", - }]); - assert.deepEqual(job.properties.template.containers[0].env.find((entry) => entry.name === 'CAO_POSTGRES_URL'), - { name: 'CAO_POSTGRES_URL', secretRef: 'cao-postgres-url' }); - assert.match(deploymentDocs, /database bootstrap --store canonical/); - assert.match(deploymentDocs, /database bootstrap --store operational/); -}); - -test('Runtime URL uses only the restricted login and secure runtime password', () => { - const runtimeSecret = resources.find((resource) => resource.type === 'Microsoft.KeyVault/vaults/secrets' - && resource.name === "[format('{0}/{1}', parameters('keyVaultName'), parameters('postgresUrlSecretName'))]"); - assert.ok(runtimeSecret); - assert.match(runtimeSecret.properties.value, /postgresql:\/\/cao_runtime_login:/); - assert.match(runtimeSecret.properties.value, /uriComponent\(parameters\('postgresRuntimePassword'\)\)/); - assert.match(runtimeSecret.properties.value, /options=-csearch_path%3Dcao/); - assert.doesNotMatch(runtimeSecret.properties.value, /Administrator|OwnerPassword/); - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - assert.doesNotMatch(app.properties.siteConfig.appSettings, /CAO_DATABASE_ADMIN_URL|owner-password|runtime-password/); - assert.match(app.properties.siteConfig.appSettings, - /CAO_POSTGRES_URL.*?secrets\/\{1\}.*?parameters\('postgresUrlSecretName'\)/); - for (const name of ['snapshot-ingestion', 'collection-workers']) { - const module = resources.find((resource) => resource.name === name); - assert.equal(module.properties.parameters.postgresUrlSecretName.value, "[parameters('postgresUrlSecretName')]"); - assert.doesNotMatch(JSON.stringify(module), /CAO_DATABASE_ADMIN_URL|owner-password|runtime-password/); - } - for (const name of ['postgresAdministratorPassword', 'postgresOwnerPassword', 'postgresRuntimePassword']) { - assert.equal(generated.parameters[name].type.toLowerCase(), 'securestring'); - assert.equal(generated.parameters[name].defaultValue, undefined); - } -}); - -test('Only the separate provisioning identity can resolve the administrator secret', () => { - const module = resources.find((resource) => resource.name === 'database-provisioning'); - const template = module.properties.template; - const job = template.resources.find((resource) => resource.type === 'Microsoft.App/jobs'); - const identity = template.resources.find((resource) => resource.type === 'Microsoft.ManagedIdentity/userAssignedIdentities'); - assert.equal(identity.name, "[format('{0}-db-provision-identity', parameters('namePrefix'))]"); - assert.equal(job.properties.configuration.triggerType, 'Manual'); - assert.equal(job.properties.configuration.replicaRetryLimit, 0); - assert.equal(job.properties.template.containers[0].image, "[parameters('provisioningImage')]"); - assert.deepEqual(job.properties.template.containers[0].env.find((entry) => entry.name === 'CAO_DATABASE_ADMIN_URL'), - { name: 'CAO_DATABASE_ADMIN_URL', secretRef: "[variables('secretNames')[0]]" }); - assert.equal(template.variables.secretNames[0], "[format('{0}-admin', parameters('postgresUrlSecretName'))]"); - const grants = template.resources.find((resource) => resource.type === 'Microsoft.Resources/deployments'); - assert.equal(grants.properties.parameters.secretNames.value, "[variables('secretNames')]"); - assert.match(grants.properties.parameters.principalId.value, /db-provision-identity/); - - // Inspect every nested RBAC template, not just the provisioning module. - function checkRoles(current) { - for (const resource of Object.values(current.resources ?? {})) { - if (resource.type === 'Microsoft.Authorization/roleAssignments' - && resource.properties.roleDefinitionId.includes('4633458b-17de-408a-b874-0445c86b69e6')) { - assert.equal(resource.scope, - "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretNames')[copyIndex()])]"); - assert.equal(resource.copy.count, "[length(parameters('secretNames'))]"); - } - if (resource.properties?.template) checkRoles(resource.properties.template); - } - } - checkRoles(generated); - const functionGrants = resources.find((resource) => resource.name === 'function-managed-identity-roles'); - assert.match(functionGrants.properties.parameters.secretNames.value, /parameters\('postgresUrlSecretName'\)/); - assert.doesNotMatch(functionGrants.properties.parameters.secretNames.value, /-admin|owner-password|runtime-password/); - const ingestGrants = resources.find((resource) => resource.name === 'snapshot-ingestion') - .properties.template.resources.find((resource) => resource.type === 'Microsoft.Resources/deployments'); - assert.deepEqual(ingestGrants.properties.parameters.secretNames.value, ["[parameters('postgresUrlSecretName')]"]); - const collectorGrants = resources.find((resource) => resource.name === 'collection-workers') - .properties.template.resources.find((resource) => resource.name === 'collector-roles'); - assert.equal(collectorGrants.properties.parameters.secretNames.value, - "[concat(createArray(parameters('postgresUrlSecretName'), 'cao-collect-private-key'), if(not(parameters('otlpHeadersEnabled')), createArray(), createArray('cao-otel-headers')))]"); -}); - -test('Provisioning verifies PostgreSQL TLS against the system CA bundle', () => { - const job = provisioning.slice(provisioning.indexOf("name: 'PGSSLMODE'")); - assert.match(job, /name: 'PGSSLMODE'\s+value: 'verify-full'/); - assert.match(job, /name: 'PGSSLROOTCERT'\s+value: 'system'/); - assert.match(provisionScript, /export PGSSLMODE="\$\{PGSSLMODE:-verify-full\}"/); - assert.match(provisionScript, /export PGSSLROOTCERT="\$\{PGSSLROOTCERT:-system\}"/); - assert.ok(provisionScript.indexOf('export PGSSLROOTCERT') < provisionScript.indexOf('psql -X')); - assert.doesNotMatch(provisionScript + provisioning + bicep, /sslmode=(require|verify-ca|prefer|disable)/); -}); - -test('Provisioning uses the deployment ACR and checked-in grants under the non-login owner', () => { - const module = resources.find((resource) => resource.name === 'database-provisioning'); - assert.match(module.properties.parameters.provisioningImage.value, /Microsoft\.ContainerRegistry\/registries/); - assert.match(module.properties.parameters.provisioningImage.value, /provisioningImageRepository/); - assert.doesNotMatch(module.properties.parameters.provisioningImage.value, /docker\.io|postgres:16/); - assert.match(dockerfile, /FROM postgres:16-alpine@sha256:[a-f0-9]{64} AS azure-db-provision/); - assert.match(dockerfile, /COPY server\/postgres\/\*\.sql \/app\/postgres\//); - assert.match(provisionScript, /PGOPTIONS='-c role=cao_owner -c search_path=cao'/); - for (const store of ['canonical', 'operational']) { - assert.match(provisionScript, new RegExp(`database bootstrap --store ${store}`)); - assert.match(provisionScript, new RegExp(`-f /app/postgres/${store}-grants.sql`)); - } - for (const source of [bicep, ingestion, provisioning]) { - assert.doesNotMatch(source, /GRANT |REVOKE |CREATE ROLE|cao_operational_audit_events/); - } - for (const role of ['cao_owner', 'cao_runtime', 'cao_retention', 'cao_evidence']) { - assert.ok(provisionSql.includes(`'${role}'`)); - } - assert.match(provisionSql, /NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS/); - assert.match(provisionSql, /GRANT TEMPORARY ON DATABASE %I TO cao_runtime/); - assert.match(provisionSql, /CREATE SCHEMA IF NOT EXISTS cao AUTHORIZATION cao_owner/); - assert.doesNotMatch(provisionSql, /cao_retention_login|cao_evidence_login|GRANT ALL|ALL TABLES/); - for (const name of ['CacheMaxBytes', 'CacheMaxValueBytes', 'CacheMaxEntries', 'ProtectedMaxBytes', 'ProtectedMaxEntries']) { - assert.equal(module.properties.parameters[`operational${name}`].value, `[parameters('operational${name}')]`); - } - for (const setting of ['CAO_POSTGRES_RUN_RETENTION_DAYS', 'CAO_POSTGRES_LINKED_RETENTION_DAYS']) { - assert.deepEqual(module.properties.template.resources.find((resource) => resource.type === 'Microsoft.App/jobs') - .properties.template.containers[0].env.find((entry) => entry.name === setting), - { name: setting, value: "[string(parameters('postgresRunRetentionDays'))]" }); - } +test('Container Apps starts only the reviewed hosted Go runtime after explicit activation', () => { + const web = resources.find((resource) => resource.type === 'Microsoft.App/containerApps'); + assert.equal(template.parameters.runtimeEnabled.defaultValue, false); + assert.equal(web.condition, "[parameters('runtimeEnabled')]"); + assert.equal(web.properties.configuration.ingress.allowInsecure, false); + assert.equal(web.properties.configuration.ingress.targetPort, 8080); + assert.deepEqual(web.properties.template.scale, { minReplicas: 1, maxReplicas: 1 }); + const container = web.properties.template.containers[0]; + assert.equal(container.args[0], 'serve-hosted'); + assert.ok(container.args.includes('postgres')); + const settings = Object.fromEntries(container.env.map((entry) => [entry.name, entry])); + assert.equal(settings.CAO_EXTERNAL_INGESTION.value, 'true'); + assert.equal(settings.CAO_POSTGRES_URL.secretRef, "[variables('postgresUrlSecretName')]"); + for (const name of ['CAO_DATABASE_ADMIN_URL', 'CAO_POSTGRES_OWNER_PASSWORD', 'CAO_POSTGRES_RUNTIME_PASSWORD']) { + assert.equal(settings[name], undefined); + } + assert.doesNotMatch(JSON.stringify(template), /Microsoft\.Web\/|FUNCTIONS_WORKER_RUNTIME|AzureWebJobsStorage/); +}); + +test('Provisioning and ingestion are manual jobs with separate identities and credential scopes', () => { + for (const children of [provisioning, ingestion]) { + const job = children.find((resource) => resource.type === 'Microsoft.App/jobs'); + assert.equal(job.properties.configuration.triggerType, 'Manual'); + assert.equal(job.properties.configuration.replicaRetryLimit, 0); + assert.equal(job.identity.type, 'UserAssigned'); + } + const provisionRoles = provisioning.find((resource) => resource.name === 'database-provisioning-roles'); + const ingestionRoles = ingestion.find((resource) => resource.name === 'ingestion-roles'); + assert.match(provisionRoles.properties.parameters.secretNames.value, /variables\('secretNames'\)/); + assert.deepEqual(moduleFor('container-apps-provisioning').variables.secretNames, [ + "[format('{0}-admin', parameters('postgresUrlSecretName'))]", + "[format('{0}-owner-password', parameters('postgresUrlSecretName'))]", + "[format('{0}-runtime-password', parameters('postgresUrlSecretName'))]", + ]); + assert.deepEqual(ingestionRoles.properties.parameters.secretNames.value, ["[parameters('postgresUrlSecretName')]"]); + assert.doesNotMatch(JSON.stringify(ingestion), /CAO_DATABASE_ADMIN_URL|CAO_POSTGRES_OWNER_PASSWORD|CAO_POSTGRES_RUNTIME_PASSWORD/); + const webRoles = resources.find((resource) => resource.name === 'container-apps-web-roles'); + assert.doesNotMatch(webRoles.properties.parameters.secretNames.value, /-admin|owner-password|runtime-password/); }); -test('No nested deployment outputs a password or credential-bearing URL', () => { - function checkOutputs(template) { - for (const [name, output] of Object.entries(template.outputs ?? {})) { - assert.doesNotMatch(name, /password|secret|token|connection/i); - assert.doesNotMatch(JSON.stringify(output), /Password|postgresql:\/\/|listSecrets|listKeys|ADMIN_URL/); - } - for (const resource of Object.values(template.resources ?? {})) { - if (resource.properties?.template) checkOutputs(resource.properties.template); - } - } - checkOutputs(generated); +test('Private PostgreSQL and verified provisioning TLS remain mandatory', () => { + const postgres = resourcesFor('container-apps-postgres').find((resource) => resource.type === 'Microsoft.DBforPostgreSQL/flexibleServers'); + assert.equal(postgres.properties.network.publicNetworkAccess, 'Disabled'); + const job = provisioning.find((resource) => resource.type === 'Microsoft.App/jobs'); + const settings = Object.fromEntries(job.properties.template.containers[0].env.map((entry) => [entry.name, entry.value])); + assert.equal(settings.PGSSLMODE, 'verify-full'); + assert.equal(settings.PGSSLROOTCERT, 'system'); + assert.match(provisionScript, /ON_ERROR_STOP/); }); -test('Azure Go exporters have an explicit shared OTLP route and secret-scoped authentication', () => { - assert.equal(generated.parameters.otlpEndpoint.defaultValue, ''); - assert.equal(generated.parameters.otlpLogsEnabled.defaultValue, false); - assert.equal(generated.parameters.otlpHeaders.type, 'securestring'); - assert.equal(generated.parameters.otlpHeaders.defaultValue, ''); - assert.deepEqual(generated.variables.otlpEnvironment, [ - { name: 'OTEL_EXPORTER_OTLP_ENDPOINT', value: "[parameters('otlpEndpoint')]" }, - { name: 'OTEL_EXPORTER_OTLP_PROTOCOL', value: 'http/protobuf' }, - { name: 'CAO_OTEL_LOGS_ENABLED', value: "[string(parameters('otlpLogsEnabled'))]" }, - ]); - assert.equal(generated.variables.otlpHeadersEnabled, - "[and(not(empty(parameters('otlpEndpoint'))), not(empty(parameters('otlpHeaders'))))]"); - const headers = resources.find((resource) => resource.type === 'Microsoft.KeyVault/vaults/secrets' - && resource.name.includes('cao-otel-headers')); - assert.equal(headers.condition, "[variables('otlpHeadersEnabled')]"); - assert.equal(headers.properties.value, "[parameters('otlpHeaders')]"); - - const app = resources.find((resource) => resource.type === 'Microsoft.Web/sites'); - assert.match(app.properties.siteConfig.appSettings, /variables\('otlpEnvironment'\)/); - assert.match(app.properties.siteConfig.appSettings, /if\(not\(variables\('otlpHeadersEnabled'\)\)/); - assert.match(app.properties.siteConfig.appSettings, - /'OTEL_EXPORTER_OTLP_HEADERS', 'value', format\('@Microsoft.KeyVault\(SecretUri=\{0\}secrets\/cao-otel-headers\)'/); - assert.match(app.properties.siteConfig.appSettings, /if\(not\(parameters\('otlpLogsEnabled'\)\).*?'DEBUG', 'value', 'cao:server,cao:telemetry'/); - const grants = resources.find((resource) => resource.name === 'function-managed-identity-roles'); - assert.match(grants.properties.parameters.secretNames.value, - /if\(not\(variables\('otlpHeadersEnabled'\)\), createArray\(\), createArray\('cao-otel-headers'\)\)/); - - const workers = resources.find((resource) => resource.name === 'collection-workers'); - assert.equal(workers.properties.parameters.otlpEnvironment.value, "[variables('otlpEnvironment')]"); - assert.equal(workers.properties.parameters.otlpHeadersEnabled.value, "[variables('otlpHeadersEnabled')]"); - assert.equal(workers.properties.parameters.otlpLogsEnabled.value, "[parameters('otlpLogsEnabled')]"); - const template = workers.properties.template; - assert.match(template.variables.collectionEnvironment, - /concat\(variables\('collectionBaseEnvironment'\), parameters\('otlpEnvironment'\)/); - assert.match(template.variables.collectionEnvironment, /'OTEL_EXPORTER_OTLP_HEADERS', 'secretRef', 'cao-otel-headers'/); - assert.match(template.variables.collectionSecrets, - /'cao-otel-headers', 'keyVaultUrl', format\('\{0\}secrets\/cao-otel-headers', parameters\('keyVaultUri'\)\), 'identity', resourceId/); - const debug = template.variables.collectionBaseEnvironment.find((entry) => entry.name === 'DEBUG'); - assert.equal(debug.value, - "[if(parameters('otlpLogsEnabled'), 'cao:telemetry,cao:collect:recovery', 'cao:collect:*')]"); - const consumers = template.resources.filter((resource) => - ['Microsoft.App/jobs', 'Microsoft.App/containerApps'].includes(resource.type)); - assert.equal(consumers.length, 2); - for (const consumer of consumers) { - assert.equal(consumer.properties.configuration.secrets, "[variables('collectionSecrets')]"); - assert.equal(consumer.properties.template.containers[0].env, "[variables('collectionEnvironment')]"); - assert.doesNotMatch(JSON.stringify(consumer.properties.template), /livenessProbe|readinessProbe|4318|otlpHeaders/); +test('Every hosted role preserves the same explicit retained-evidence window', () => { + for (const children of [resources, provisioning, ingestion]) { + const role = children.find((resource) => ['Microsoft.App/containerApps', 'Microsoft.App/jobs'].includes(resource.type)); + const settings = Object.fromEntries(role.properties.template.containers[0].env.map((entry) => [entry.name, entry.value])); + assert.equal(settings.CAO_POSTGRES_RUN_RETENTION_DAYS, "[string(parameters('postgresRunRetentionDays'))]"); + assert.equal(settings.CAO_POSTGRES_LINKED_RETENTION_DAYS, settings.CAO_POSTGRES_RUN_RETENTION_DAYS); } - assert.doesNotMatch(app.properties.siteConfig.appSettings, /parameters\('otlpHeaders'\)/); - assert.doesNotMatch(JSON.stringify(generated.outputs), /otlpHeaders|OTEL_EXPORTER_OTLP_HEADERS/); }); diff --git a/tests/unit/server-azure-service-coverage.test.mjs b/tests/unit/server-azure-service-coverage.test.mjs deleted file mode 100644 index 3b96bbf138..0000000000 --- a/tests/unit/server-azure-service-coverage.test.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "node:assert/strict"; -import { readdir, readFile } from "node:fs/promises"; -import test from "node:test"; - -// This is an inventory guard, not evidence that ARM or managed services ran. -const coverage = { - "Microsoft.Web/sites": "Functions Core Tools", - "Microsoft.Web/serverfarms": "deployment contract only", - "Microsoft.Storage/storageAccounts": "Azurite Blob, Queue and Table", - "Microsoft.DBforPostgreSQL/flexibleServers": "PostgreSQL 16", - "Microsoft.DBforPostgreSQL/flexibleServers/databases": "PostgreSQL 16", - "Microsoft.ContainerRegistry/registries": "Azure CLI protocol simulator", - "Microsoft.App/managedEnvironments": "deployment contract only", - "Microsoft.App/containerApps": "deployment contract only", - "Microsoft.App/jobs": "Azure CLI job protocol simulator", - "Microsoft.KeyVault/vaults": "local identity/secret protocol fixture", - "Microsoft.KeyVault/vaults/secrets": "local identity/secret protocol fixture", - "Microsoft.ManagedIdentity/userAssignedIdentities": "local identity protocol fixture", - "Microsoft.Authorization/roleAssignments": "deployment contract only", - "Microsoft.OperationalInsights/workspaces": "deployment contract only", - "Microsoft.Insights/components": "OTLP protocol receiver, not Application Insights", - "Microsoft.Insights/diagnosticSettings": "deployment contract only", - "Microsoft.Network/virtualNetworks": "deployment contract only", - "Microsoft.Network/privateDnsZones": "deployment contract only", - "Microsoft.Network/privateDnsZones/virtualNetworkLinks": "deployment contract only", - "Microsoft.Network/privateEndpoints": "deployment contract only", - "Microsoft.Network/privateEndpoints/privateDnsZoneGroups": "deployment contract only", - "Microsoft.Kusto/clusters": "optional official Kusto engine", - "Microsoft.Kusto/clusters/databases": "optional official Kusto engine", -}; -const directory = new URL("../../server/azure/", import.meta.url); -const bicepTypes = new Set(); -for (const name of await readdir(directory)) { - if (!name.endsWith(".bicep")) continue; - const source = await readFile(new URL(name, directory), "utf8"); - for (const match of source.matchAll(/^\s*resource\s+\w+\s+'([^'@]+)@[^']+'/gm)) { - bicepTypes.add(match[1]); - } -} -const generated = JSON.parse(await readFile(new URL("main.json", directory), "utf8")); -const compiledTypes = new Set(); -function visit(value) { - if (!value || typeof value !== "object") return; - if (typeof value.type === "string" && value.type.startsWith("Microsoft.") - && value.type !== "Microsoft.Resources/deployments") compiledTypes.add(value.type); - for (const child of Object.values(value)) visit(child); -} -visit(generated); - -test("Every Bicep service has an explicit local mechanism or unavailable managed-service boundary", () => { - assert.deepEqual([...bicepTypes].sort(), Object.keys(coverage).sort(), - "new or removed Bicep resource types require a local-coverage review"); - assert.deepEqual([...compiledTypes].sort(), [...bicepTypes].sort(), - "compiled resources must match the reviewed Bicep service inventory"); -}); - -const documentation = await readFile(new URL("../../scripts/azure-local/README.md", import.meta.url), "utf8"); -for (const [type, mechanism] of Object.entries(coverage)) { - test(`${type}: ${mechanism}`, () => { - assert.ok(documentation.includes(`\`${type}\``), "document the service's local coverage and limitations"); - }); -} diff --git a/tests/unit/unit-area-selection.test.mjs b/tests/unit/unit-area-selection.test.mjs index 69002ea5f9..86b99d3b40 100644 --- a/tests/unit/unit-area-selection.test.mjs +++ b/tests/unit/unit-area-selection.test.mjs @@ -14,7 +14,6 @@ test("every root unit test runs in exactly one topical area", () => { assert.deepEqual(Object.values(partition).flat().sort(), files); assert.ok(partition.activity.includes("activity-normalized-shards.test.mjs")); assert.ok(partition.documentation.includes("docs-workflow-ecosystem-layout.test.mjs")); - assert.ok(partition.tooling.includes("azure-local-harness.test.mjs")); assert.ok(partition.tooling.includes("backfill-stress-report.test.mjs")); assert.ok(partition.tooling.includes("company-stress.test.mjs")); assert.ok(partition.tooling.includes("source-map-compat.test.mjs")); diff --git a/tests/unit/workflow-contract-ci-triggers.test.mjs b/tests/unit/workflow-contract-ci-triggers.test.mjs index 50dd20db3c..a6bd39d68d 100644 --- a/tests/unit/workflow-contract-ci-triggers.test.mjs +++ b/tests/unit/workflow-contract-ci-triggers.test.mjs @@ -8,7 +8,6 @@ import { root, workflow } from "./workflow-contract.helpers.mjs"; const mainOnlyWorkflows = [ "actions.yml", - "azure-local-integration.yml", "dashboard-deployed-integration.yml", "dashboard-views.yml", ]; @@ -91,13 +90,6 @@ test("slow integration workflows run on relevant main pushes, not pull requests" }); -test("service integration harnesses provision canonical storage before starting runtime processes", () => { - const azure = readFileSync(join(root, "scripts/azure-local/azure-local.sh"), "utf8"); - assert.match(azure, /CAO_DATABASE_ADMIN_URL="\$CAO_POSTGRES_URL" run_logged "canonical database bootstrap" database-bootstrap\.log \\\n\s+"\$APP_DIR\/cao-dashboard" database bootstrap --store canonical --namespace "\$namespace"/); - assert.ok(azure.indexOf('"$APP_DIR/cao-dashboard" database bootstrap') < azure.indexOf('"$APP_DIR/cao-dashboard" ingest')); - assert.doesNotMatch(azure, /export CAO_DATABASE_ADMIN_URL/); -}); - test("Go dashboard integration bootstraps both stores in the runtime namespace", () => { const job = parse(workflow("cgo.yml")).jobs["redis-dashboard-integration"]; const steps = job.steps; @@ -212,15 +204,14 @@ test("Go extension quality checks cover root-module sources and lint configurati "package.json", "package-lock.json", "aw.yml", - "server/cmd/cao-dashboard/azure_functions.go", - "server/internal/server/azure.go", + "server/cmd/cao-dashboard/serve_hosted.go", + "server/internal/server/proxy.go", "server/internal/server/server.go", "server/internal/simulator/setup_integration_test.go", "server/internal/simulator/setup_hosted_cli_integration_test.go", - "server/internal/simulator/setup_azure_integration_test.go", "server/internal/simulator/bootstrap_integration_test.go", - "server/azure/main.bicep", - "server/azure/main.json", + "internal/topology/assets/container-apps/main.bicep", + "internal/topology/assets/container-apps/main.generated.json", "server/Dockerfile", "server/go.mod", "server/go.sum", @@ -308,23 +299,12 @@ test("Go extension setup simulator runs only on main pushes, independently witho const go = steps.find((step) => step.uses?.startsWith("actions/setup-go@")); const install = steps.find((step) => step.uses === "./.github/actions/setup-gh-aw"); const run = steps.find((step) => step.run === "npm run test:integration:setup"); - const azure = steps.find((step) => step.name === "Run Azure setup with official Azurite and local control-plane simulation"); - const azurePrerequisites = steps.find((step) => step.name === "Prepare offline Azure simulation prerequisites"); - assert.ok(azure); - assert.deepEqual(azure.run.trim().split("\n"), [ - "npm run test:integration:setup-azure", - "npm run test:integration:azure-install-journey", - ]); assert.deepEqual(permissions, { contents: "read" }); assert.equal(checkout.with["persist-credentials"], false); assert.equal(go.with["go-version-file"], "go.mod"); assert.ok(go.with["cache-dependency-path"].includes("server/go.sum")); assert.ok(install); assert.ok(steps.indexOf(install) < steps.indexOf(run)); - assert.ok(steps.indexOf(install) < steps.indexOf(azure)); - assert.ok(steps.indexOf(azurePrerequisites) < steps.indexOf(azure)); - assert.match(azurePrerequisites.run, /go mod download/); - assert.match(azurePrerequisites.run, /docker pull mcr\.microsoft\.com\/azure-storage\/azurite:3\.37\.0@sha256:[a-f0-9]{64}/); assert.deepEqual(run.env, { GH_TOKEN: "", GITHUB_TOKEN: "", @@ -337,27 +317,10 @@ test("Go extension setup simulator runs only on main pushes, independently witho assert.ok(triggers.pull_request, "PR quality and authenticated doctor checks remain enabled"); assert.equal(job.needs, undefined); assert.equal(run["continue-on-error"], undefined); - assert.deepEqual(azure.env, { - ...run.env, - POSTGRES_URL: "postgres://postgres@127.0.0.1:5432/cao?sslmode=disable", - }); - assert.match(job.services.postgres.image, /^postgres:16-alpine@sha256:[a-f0-9]{64}$/); - assert.deepEqual(job.services.postgres.env, { - POSTGRES_DB: "cao", - POSTGRES_HOST_AUTH_METHOD: "trust", - }); - assert.deepEqual(job.services.postgres.ports, ["5432:5432"]); - assert.match(job.services.postgres.options, /pg_isready -U postgres -d cao/); - assert.equal(azure.if, undefined); - assert.equal(azure["continue-on-error"], undefined); assert.doesNotMatch(JSON.stringify(job), /secrets\.|github\.token|id-token/); const { scripts } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); assert.match(scripts["test:integration:setup"], /CAO_SETUP_INTEGRATION=1 go -C server test -race/); assert.match(scripts["test:integration:setup"], /-run '\^\(TestCAO\(Setup\|HostedCLISetup\)Integration\|TestSetup\)'/); - assert.match(scripts["test:integration:setup-azure"], /CAO_SETUP_AZURE_INTEGRATION=1 GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off GOVCS='\*:off' go -C server test -race/); - assert.match(scripts["test:integration:setup-azure"], /-run '\^\(TestCAOSetupAzureIntegration\|TestSetupAzure\|TestLocalTransport\)'/); - assert.match(scripts["test:integration:azure-install-journey"], /CAO_SETUP_AZURE_INTEGRATION=1 GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off GOVCS='\*:off' go -C server test -race/); - assert.match(scripts["test:integration:azure-install-journey"], /-run '\^TestCAOAzureInstallJourneyIntegration\$'/); }); test("Docker samples runtime has an independent read-only workflow", () => { @@ -451,7 +414,6 @@ test("main pushes retain browser and integration coverage for representative inp ["actions.yml", "tests/helpers/dashboard-pages-preview.mjs"], ["actions.yml", "tests/e2e/dashboard-tree-analysis.mjs"], ["actions.yml", "self-care/dashboard-fragments/overview.json"], - ["azure-local-integration.yml", "scripts/azure-local/azure-local.sh"], ["dashboard-deployed-integration.yml", "server/go.mod"], ["dashboard-deployed-integration.yml", "dashboard/site/package-lock.json"], ["dashboard-views.yml", "self-care/dashboard.json"], diff --git a/tests/unit/workflow-contract-selfcare-database-architecture.test.mjs b/tests/unit/workflow-contract-selfcare-database-architecture.test.mjs index 6ecc434373..a9317b62c8 100644 --- a/tests/unit/workflow-contract-selfcare-database-architecture.test.mjs +++ b/tests/unit/workflow-contract-selfcare-database-architecture.test.mjs @@ -105,7 +105,8 @@ test("cloud-architect guide is discoverable, diagram-rich, and preserves evidenc test("database architecture worker maintains the private lake and final Azure mapping without new authority", () => { for (const path of [ "specs/session-archive.md", "docs/deployment-azure.md", "server/SESSION-ARCHIVE.md", - "server/ADX-INGEST.md", "server/ADX-TRACES.md", "server/azure/main.bicep", + "server/ADX-INGEST.md", "server/ADX-TRACES.md", + "internal/topology/assets/container-apps/main.bicep", ]) { assert.ok(source.includes(path), `missing worker evidence source: ${path}`); } @@ -115,7 +116,7 @@ test("database architecture worker maintains the private lake and final Azure ma assert.match(source, /authorized artifact visit before normalization/); assert.match(source, /metadata-only PostgreSQL source privacy authority/); assert.match(source, /archive `BlobStore`\/`QueryService` adapters/); - assert.match(source, /Functions runtime storage\nis not the session lake/); + assert.match(source, /ephemeral container storage\s+is not the session lake/); assert.match(source, /baseline-provisioned, conditionally provisioned,\noperator-managed or unsupported/); assert.match(source, /real destination acceptance gates/); assert.match(source, /without provisioning resources or widening authority/); @@ -149,21 +150,24 @@ test("Azure mapping is the final guide section and distinguishes resources from assert.equal(headings.at(-1), "13. Azure service mapping"); const azure = guide.split("## 13. Azure service mapping\n")[1]; for (const service of [ - "Azure Functions", "PostgreSQL Flexible Server", "Azure Container Apps", - "Azure Container Registry", "Azure Key Vault", "Azure Storage account", + "PostgreSQL Flexible Server", "Azure Container Apps", + "Azure Container Registry", "Azure Key Vault", "Azure Blob Storage", "Azure Data Explorer", "Application Insights", "Log Analytics", "private DNS", "`BlobStore`", "`QueryService`", ]) { assert.ok(azure.includes(service), `missing Azure mapping: ${service}`); } - assert.match(azure, /Runtime state only, \*\*not the session lake\*\*/); + assert.match(azure, /ephemeral container storage is not the session lake/); assert.match(azure, /requires neither Redis nor Azure Service Bus/); assert.match(azure, /GitHub remains the OAuth and membership provider, not Entra/); assert.match(azure, /no external table, exporter or archive activation/); - assert.match(azure, /not the Functions platform listener/); - assert.match(azure, /host Insights settings alone do not enable Go export/); + assert.match(azure, /No Application Insights or OTLP in the base profile/); + assert.match(azure, /operators execute\nreviewed templates separately/); + assert.match(azure, /offline checks are not real destination acceptance/); + assert.match(azure, /ARM dependencies\ndo not execute or verify jobs/); + assert.doesNotMatch(azure, /Azure Functions|server\/azure\/main\.bicep|deploy azure/); assert.match(azure, /Managed-identity Key Vault access does not itself authenticate Blob\/ADX/); assert.match(azure, /digest pinning alone is not production/); assert.match(azure, /```mermaid\n/); - assert.ok(azure.includes("server/azure/main.bicep")); + assert.ok(azure.includes("internal/topology/assets/container-apps/main.bicep")); });