From 69c24c1c2cfdddd4432071396f4b96455baf891b Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:40:14 -0400 Subject: [PATCH] feat(terraform): ship dev content CD through Terraform (SH-300) (#180) * feat(terraform): ship dev content CD through Terraform (SH-300) GitHub uploads immutable release prefixes; Terraform owns live publish. Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set. * fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300) --- .github/workflows/ci.yaml | 19 +- .github/workflows/deploy.yml | 369 ++++++++++--- .gitignore | 10 - QUALITY_GATES.md | 45 +- README.md | 109 ++-- e2e/vendors/vendors.visual.spec.ts | 2 + eslint.config.js | 13 +- infra/cdk/README.md | 270 --------- infra/cdk/bin/app.ts | 58 -- infra/cdk/cdk.json | 19 - infra/cdk/lib/frontend-stack.ts | 481 ---------------- .../cdk/lib/retain-for-terraform-adoption.ts | 63 --- infra/cdk/package-lock.json | 514 ----------------- infra/cdk/package.json | 31 -- infra/cdk/test/frontend-stack.test.mjs | 232 -------- infra/cdk/tsconfig.json | 25 - package.json | 5 +- scripts/check-github-workflows.sh | 50 ++ scripts/check-terraform-import-plan.py | 0 scripts/check-terraform-isolation.mjs | 8 + scripts/check-terraform-isolation.test.mjs | 12 +- scripts/check-terraform-release-plan.py | 521 ++++++++++++++++++ scripts/governance-check.mjs | 5 +- scripts/hcp-run-guard.py | 207 +++++++ scripts/read-release-pointer.py | 29 + scripts/summarize-cloudfront-live-state.sh | 23 + scripts/terraform_import_plan_resources.py | 0 scripts/test-hcp-run-guard.py | 243 ++++++++ scripts/test-terraform-import-plan-check.py | 14 +- scripts/test-terraform-release-plan-check.py | 331 +++++++++++ scripts/test-verify-cloudfront-release.sh | 251 +++++++++ .../terraform-release-plans/create.json | 94 ++++ .../terraform-release-plans/delete.json | 94 ++++ .../terraform-release-plans/dns-update.json | 116 ++++ .../terraform-release-plans/empty.json | 9 + .../terraform-release-plans/extra-action.json | 106 ++++ .../extra-origin-change.json | 102 ++++ .../terraform-release-plans/iam-update.json | 116 ++++ .../missing-action.json | 97 ++++ .../multiple-updates.json | 130 +++++ .../nested-unknown.json | 105 ++++ .../terraform-release-plans/replace.json | 58 ++ .../terraform-release-plans/unknown-only.json | 103 ++++ .../terraform-release-plans/version-only.json | 102 ++++ .../terraform-release-plans/wrong-before.json | 102 ++++ .../terraform-release-plans/wrong-label.json | 102 ++++ scripts/upload-sourcemaps.sh | 16 +- scripts/verify-cloudfront-release.sh | 177 ++++++ terraform/README.md | 105 ++-- terraform/live/dev/main.tf | 2 + terraform/live/dev/outputs.tf | 8 + terraform/live/dev/variables.tf | 33 ++ terraform/live/dev/versions.tf | 2 +- .../live/modules/environment-owned/main.tf | 144 +++-- .../live/modules/environment-owned/outputs.tf | 30 + .../modules/environment-owned/variables.tf | 36 +- 56 files changed, 3998 insertions(+), 1950 deletions(-) delete mode 100644 infra/cdk/README.md delete mode 100644 infra/cdk/bin/app.ts delete mode 100644 infra/cdk/cdk.json delete mode 100644 infra/cdk/lib/frontend-stack.ts delete mode 100644 infra/cdk/lib/retain-for-terraform-adoption.ts delete mode 100644 infra/cdk/package-lock.json delete mode 100644 infra/cdk/package.json delete mode 100644 infra/cdk/test/frontend-stack.test.mjs delete mode 100644 infra/cdk/tsconfig.json create mode 100755 scripts/check-github-workflows.sh mode change 100644 => 100755 scripts/check-terraform-import-plan.py create mode 100755 scripts/check-terraform-release-plan.py create mode 100755 scripts/hcp-run-guard.py create mode 100755 scripts/read-release-pointer.py create mode 100755 scripts/summarize-cloudfront-live-state.sh mode change 100644 => 100755 scripts/terraform_import_plan_resources.py create mode 100755 scripts/test-hcp-run-guard.py mode change 100644 => 100755 scripts/test-terraform-import-plan-check.py create mode 100755 scripts/test-terraform-release-plan-check.py create mode 100755 scripts/test-verify-cloudfront-release.sh create mode 100644 scripts/testdata/terraform-release-plans/create.json create mode 100644 scripts/testdata/terraform-release-plans/delete.json create mode 100644 scripts/testdata/terraform-release-plans/dns-update.json create mode 100644 scripts/testdata/terraform-release-plans/empty.json create mode 100644 scripts/testdata/terraform-release-plans/extra-action.json create mode 100644 scripts/testdata/terraform-release-plans/extra-origin-change.json create mode 100644 scripts/testdata/terraform-release-plans/iam-update.json create mode 100644 scripts/testdata/terraform-release-plans/missing-action.json create mode 100644 scripts/testdata/terraform-release-plans/multiple-updates.json create mode 100644 scripts/testdata/terraform-release-plans/nested-unknown.json create mode 100644 scripts/testdata/terraform-release-plans/replace.json create mode 100644 scripts/testdata/terraform-release-plans/unknown-only.json create mode 100644 scripts/testdata/terraform-release-plans/version-only.json create mode 100644 scripts/testdata/terraform-release-plans/wrong-before.json create mode 100644 scripts/testdata/terraform-release-plans/wrong-label.json create mode 100755 scripts/verify-cloudfront-release.sh create mode 100644 terraform/live/dev/variables.tf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9a39ad3b..8a49b667 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,10 +24,10 @@ jobs: # `npm run verify` is the single command that chains: format check, lint # (--max-warnings=0), type-check + build, unit tests, then the governance # checks in scripts/governance-check.mjs (godfile ratchet, changed-file - # maintainability gate, Terraform fmt/validate, Terraform import-plan guard - # tests, Terraform isolation gate tests, CDK build/test/synth). If the - # reusable workflow is later confirmed to run every gate, this job can be - # slimmed to `npm run governance`. + # maintainability gate, Terraform fmt/validate, Terraform import-plan and + # release-plan guards, isolation tests, HCP run guard, CloudFront verify, + # and GitHub workflow shell). If the reusable workflow is later confirmed + # to run every gate, this job can be slimmed to `npm run governance`. # # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) @@ -66,6 +66,17 @@ jobs: with: node-version: "24" cache: npm + - name: Install actionlint + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + set -euo pipefail + curl -fsSL -o actionlint.tar.gz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - + tar -xzf actionlint.tar.gz actionlint + sudo mv actionlint /usr/local/bin/actionlint - run: npm ci - run: npm run verify env: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 099b55de..4423e6ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,40 +1,26 @@ -name: Deploy dev content +name: Validate and deploy -# Manual dev content deployment during the Terraform adoption (SH-300). -# -# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are -# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are -# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the -# resources, and move to HCP Terraform (`terraform/README.md`) as adoption -# completes. Automatic push-to-`dev` releases return with the Terraform -# content-CD change, gated on a repository variable. -# -# This workflow publishes only content: verify, build, `aws s3 sync`, and a -# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC -# deploy role. The bucket and distribution are pinned here so a content deploy -# keeps working after CloudFormation relinquishes the stack outputs. +# Dev content CD through Terraform (SH-300). GitHub uploads an immutable +# releases/--/ prefix. Terraform owns the pointer, origin +# group, and invalidation. Push-to-dev stays off until +# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true. on: + pull_request: + branches: [dev] + push: + branches: [dev] + paths-ignore: + - "terraform/**" workflow_dispatch: {} permissions: - id-token: write contents: read -concurrency: - group: deploy-dev - cancel-in-progress: false - jobs: - deploy: - name: Publish content to dev - # Deploy only the exact dev branch ref: workflow_dispatch can be invoked - # from arbitrary refs, and the deploy role trusts only refs/heads/dev. - if: github.ref == 'refs/heads/dev' + validate: + name: Validate production build runs-on: ubuntu-latest - env: - AWS_REGION: us-east-1 - VITE_APP_COMMIT_SHA: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -44,65 +30,316 @@ jobs: node-version: "24" cache: npm - name: Set up Terraform - # Required by `npm run verify` (governance runs terraform fmt/validate). uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: terraform_version: "1.16.0" terraform_wrapper: false - - name: Quality gates (full verify before any deploy) + - name: Install actionlint + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + set -euo pipefail + curl -fsSL -o actionlint.tar.gz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - + tar -xzf actionlint.tar.gz actionlint + sudo mv actionlint /usr/local/bin/actionlint + - name: Quality gates run: npm ci && npm run verify env: - GOVERNANCE_BASE: origin/dev + GOVERNANCE_BASE: ${{ github.event.pull_request.base.sha || 'origin/dev' }} + - name: Build with pinned API URL + env: + VITE_API_URL: https://api.dev.seahaven.com/api + VITE_APP_COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + npm run build + if grep -Rq "api.staging.seahaven.com" dist/; then + echo "::error::Built assets contain the staging API URL." >&2 + exit 1 + fi + if grep -Rq "localhost:5141" dist/; then + echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 + exit 1 + fi + grep -Rq "api.dev.seahaven.com" dist/ - - name: Assume dev deploy role (OIDC) + deploy-dev: + name: Deploy shoc-frontend-new-dev through Terraform + if: > + (github.event_name == 'push' && github.ref == 'refs/heads/dev' && + vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + contents: read + id-token: write + concurrency: + group: deploy-dev + cancel-in-progress: false + env: + AWS_REGION: us-east-1 + TF_CLOUD_ORGANIZATION: seahaven + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + SITE_BUCKET: seahaven-shoc-frontend-dev + DISTRIBUTION_ID: E2CWLM1AFB964P + SITE_URL: https://dev.seahaven.com + VITE_API_URL: https://api.dev.seahaven.com/api + VITE_APP_COMMIT_SHA: ${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Build SPA + run: | + set -euo pipefail + npm ci + npm run build + if grep -Rq "api.staging.seahaven.com" dist/; then + echo "::error::Built assets contain the staging API URL." >&2 + exit 1 + fi + if grep -Rq "localhost:5141" dist/; then + echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 + exit 1 + fi + grep -Rq "api.dev.seahaven.com" dist/ + + - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev aws-region: us-east-1 + audience: sts.amazonaws.com - # Builds with the dev values committed in .env.production (VITE_API_URL, - # Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront. - - name: Build and publish SPA - run: bash scripts/deploy-web.sh - env: - SITE_BUCKET: seahaven-shoc-frontend-dev - CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P - WAIT_FOR_INVALIDATION: "true" + - name: Assign immutable release identity + id: release + run: | + set -euo pipefail + version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + prefix="releases/${version_label}" + { + echo "version_label=${version_label}" + echo "prefix=${prefix}" + } >> "${GITHUB_OUTPUT}" - name: Upload private source maps run: bash scripts/upload-sourcemaps.sh env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_RELEASE: ${{ steps.release.outputs.version_label }} - - name: Verify deployment + - name: Read previous release pointer + id: pointer run: | set -euo pipefail - SITE_URL="https://dev.seahaven.com" - if grep -Rq "api.staging.seahaven.com" dist/; then - echo "::error::Built assets contain the staging API URL." >&2 - exit 1 - fi - grep -Rq "api.dev.seahaven.com" dist/ - echo "Built assets reference the dev API URL." + body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)" + printf '%s' "${body}" | python3 scripts/read-release-pointer.py - # The invalidation has completed, but give edges a short window to - # converge before calling the served index.html wrong. - remote_dir="$(mktemp -d)" - trap 'rm -rf "${remote_dir}"' EXIT - matched=false - for i in 1 2 3 4 5 6; do - if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \ - && cmp -s dist/index.html "${remote_dir}/index.html"; then - matched=true - break - fi - echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..." - sleep 20 - done - if [[ "${matched}" != "true" ]]; then - echo "::error::Served index.html does not match the build just published." >&2 + - name: Upload immutable release prefix + run: | + set -euo pipefail + prefix="${{ steps.release.outputs.prefix }}" + aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \ + --exclude "index.html" \ + --exclude "*.map" \ + --cache-control "public,max-age=31536000,immutable" + aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" + aws s3 ls "s3://${SITE_BUCKET}/${prefix}/" | grep -q index.html + index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')" + echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}" + echo "Uploaded ${prefix}; index.html sha256=${index_sha}" + + - name: Capture previous served hash + id: previous-hash + run: | + set -euo pipefail + hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)" + echo "sha256=${hash}" >> "${GITHUB_OUTPUT}" + + - name: Discard blocking VCS run before GitHub CD + id: discard-vcs + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev + + - name: Create Terraform release run + id: release-run + uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + env: + TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"' + TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"' + with: + workspace: shoc-frontend-new-dev + message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions" + + - name: Read Terraform release plan counts + id: release-plan + uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + plan: ${{ steps.release-run.outputs.plan_id }} + + - name: Reject non-release resource counts + env: + PLAN_ADD: ${{ steps.release-plan.outputs.add }} + PLAN_CHANGE: ${{ steps.release-plan.outputs.change }} + PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }} + run: | + set -euo pipefail + if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then + echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2 exit 1 fi - echo "Served index.html matches the published build." - curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login" - echo "Extensionless SPA route serves." + + - name: Guard pointer-and-origin-path Terraform plan + run: | + set -euo pipefail + # Flags must match check-terraform-release-plan.py. Pointer `before` + # and origin-ID-set stability are asserted from the plan JSON. + python3 scripts/check-terraform-release-plan.py \ + --plan-id "${{ steps.release-run.outputs.plan_id }}" \ + --expected-version-label "${{ steps.release.outputs.version_label }}" \ + --expected-previous-version-label "${{ steps.pointer.outputs.live_current }}" + + - name: Discard release run when the guard fails + if: failure() && steps.release-run.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.release-run.outputs.run_id }} + comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions + + - name: Apply Terraform release run + id: release-apply + continue-on-error: true + uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.release-run.outputs.run_id }} + comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }} + + - name: Treat already-applied release run as success + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: | + python3 scripts/hcp-run-guard.py reconcile-apply \ + --run-id "${{ steps.release-run.outputs.run_id }}" \ + --apply-outcome "${{ steps.release-apply.outcome }}" + + - name: Verify CloudFront release + env: + EXPECTED_LABEL: ${{ steps.release.outputs.version_label }} + EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }} + PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }} + run: bash scripts/verify-cloudfront-release.sh + + - name: Restore previous release on failure + if: failure() + id: rollback-prepare + run: | + set -euo pipefail + prev="${{ steps.pointer.outputs.live_current }}" + if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then + echo "No Terraform-managed previous label; cannot roll back through HCP." >&2 + exit 0 + fi + echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}" + echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}" + + - name: Discard blocking VCS run before GitHub rollback + id: rollback-discard-vcs + if: failure() && steps.rollback-prepare.outputs.rollback_label != '' + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev + + - name: Create Terraform rollback run + id: rollback-run + if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + env: + TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"' + TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"' + with: + workspace: shoc-frontend-new-dev + message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions" + + - name: Read Terraform rollback plan counts + id: rollback-plan + if: failure() && steps.rollback-run.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + plan: ${{ steps.rollback-run.outputs.plan_id }} + + - name: Reject non-release rollback counts + id: rollback-count-guard + if: failure() && steps.rollback-plan.outcome == 'success' + env: + PLAN_ADD: ${{ steps.rollback-plan.outputs.add }} + PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }} + PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }} + run: | + set -euo pipefail + if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then + echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2 + exit 1 + fi + + - name: Guard pointer-and-origin-path Terraform rollback plan + id: rollback-json-guard + if: failure() && steps.rollback-count-guard.outcome == 'success' + run: | + set -euo pipefail + python3 scripts/check-terraform-release-plan.py \ + --plan-id "${{ steps.rollback-run.outputs.plan_id }}" \ + --expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \ + --expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}" + + - name: Discard rollback run when the guard fails + if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success' + uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.rollback-run.outputs.run_id }} + comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions + + - name: Apply Terraform rollback run + id: rollback-apply + if: failure() && steps.rollback-json-guard.outcome == 'success' + continue-on-error: true + uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.rollback-run.outputs.run_id }} + comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }} + + - name: Treat already-applied rollback run as success + id: rollback-apply-result + if: failure() && steps.rollback-apply.outcome != 'skipped' + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: | + python3 scripts/hcp-run-guard.py reconcile-apply \ + --run-id "${{ steps.rollback-run.outputs.run_id }}" \ + --apply-outcome "${{ steps.rollback-apply.outcome }}" + + - name: Verify CloudFront rollback + if: failure() && steps.rollback-apply-result.outcome == 'success' + env: + EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }} + run: | + set -euo pipefail + expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" + export EXPECTED_INDEX_SHA256="${expected_sha}" + bash scripts/verify-cloudfront-release.sh + + - name: Live-state summary + if: always() + continue-on-error: true + run: bash scripts/summarize-cloudfront-live-state.sh diff --git a/.gitignore b/.gitignore index 5e4ce209..9536adc7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,16 +38,6 @@ seed-data.sql # typescript *.tsbuildinfo -# cdk (infra/cdk) -infra/cdk/node_modules -infra/cdk/cdk.out -infra/cdk/cdk.context.json -infra/cdk/*.d.ts -infra/cdk/bin/*.d.ts -infra/cdk/bin/*.js -infra/cdk/lib/*.d.ts -infra/cdk/lib/*.js - # terraform (the provider lock file is committed) **/.terraform/* *.tfstate diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 64d5292e..61fd26f6 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -8,29 +8,32 @@ npm run verify `verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) → `test` (`vitest run`) → `governance`. Governance also runs the repository -gates: the Terraform import-plan checker tests, the Terraform isolation gate -tests, Terraform formatting and validation, and the CDK build, template tests, -and synthesis. A task is not done until this is green. +gates: Terraform import-plan and release-plan checkers, isolation tests, +Terraform formatting and validation, the HCP run guard, CloudFront verify, and +workflow shell checks. A task is not done until this is green. ## Gate matrix -| Gate | Command / rule source | Enforced by | Scope | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ | -| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | -| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | -| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | -| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | -| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | -| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | -| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | -| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | -| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | -| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | -| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | -| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier | -| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` | -| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes | -| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | +| Gate | Command / rule source | Enforced by | Scope | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- | +| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | +| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | +| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | +| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | +| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | +| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | +| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | +| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | +| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | +| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | +| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | +| Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures | +| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier | +| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` | +| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | +| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | +| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | +| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | ## No-false-pass guarantees @@ -76,5 +79,5 @@ and synthesis. A task is not done until this is green. Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use `corepack npm …` if your default `npm` is older). The lockfile is `package-lock.json` v3; install with `npm ci`. Governance also needs -`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and +`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.14.0, < 2.0.0`) and `python3` (3.10+) on `PATH`. diff --git a/README.md b/README.md index 1abeac15..b96e8b1e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white) -![AWS CDK](https://img.shields.io/badge/AWS_CDK-FF9900?logo=amazonwebservices&logoColor=white) +![Terraform](https://img.shields.io/badge/Terraform-844FBA?logo=terraform&logoColor=white) Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor portal, uplifts, and related admin features. This is the selective rebuild of @@ -18,25 +18,24 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md). ## Architecture -Static SPA hosting on AWS, provisioned by a CDK app local to this repo -([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/` -from a private S3 bucket; the SPA calls the backend directly over HTTPS at -`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS). +Static SPA hosting on AWS, owned by HCP Terraform +([`terraform/README.md`](terraform/README.md)). CloudFront serves the built +`dist/` from a private S3 bucket using a current/previous origin group; +the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api` +proxy at the CDN — the backend allows CORS). ```mermaid graph LR U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront] - CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev] + CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev] CF -.->|viewer-request fn| FN[SPA rewrite → /index.html] U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API] - GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev] - ROLE -->|s3 sync + invalidation| S3 - TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3 + GH[GitHub Actions] -->|OIDC upload releases/*| S3 + TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF ``` -Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see -[`terraform/README.md`](terraform/README.md) for the phase runbook and the -current ownership state. +Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still +uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287). Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack Query, React Router (via `@generouted/react-router`), React Hook Form + Zod, @@ -45,8 +44,8 @@ architecture plan for the keep/discard migration matrix). ## AWS Resources -Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region -`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts). +HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region +`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev). | Resource | Name | Purpose | | ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | @@ -83,9 +82,8 @@ repo secret** is: build otherwise. See [`.env.example`](.env.example), [`.env.development`](.env.development), and [`.env.production`](.env.production). -CDK context (domain, certificate ARN, hosted zone) lives in -[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs -`cdk deploy` with no flags. +Pinned hosting constants (domain, certificate ARN, hosted zone) live in +[`terraform/live/dev/main.tf`](terraform/live/dev/main.tf). ## Local Development @@ -102,22 +100,20 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`; override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use the deployed dev API). -| Command | Description | -| ------------------------------------------ | ------------------------------------------------------------ | -| `npm run dev` | Start Vite dev server on port 3000 | -| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` | -| `npm run preview` | Preview the production build locally | -| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) | -| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) | -| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) | -| `npm run format` / `npm run format:check` | Prettier (write / check) | -| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) | -| `npm run verify` | **All gates**: format + lint + build + test + governance | +| Command | Description | +| ------------------------------------------ | ------------------------------------------------------------------ | +| `npm run dev` | Start Vite dev server on port 3000 | +| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` | +| `npm run preview` | Preview the production build locally | +| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) | +| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) | +| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) | +| `npm run format` / `npm run format:check` | Prettier (write / check) | +| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CD guards) | +| `npm run verify` | **All gates**: format + lint + build + test + governance | `npm run governance` needs `terraform` and `python3` on `PATH` for the -Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`, -`npm run test:terraform-isolation`) and installs `infra/cdk` for -`npm run test:infra`. +Terraform and content-CD gates. Husky + lint-staged run ESLint and Prettier on staged files at commit; commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or @@ -137,8 +133,8 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or Merged branches are deleted automatically. - A PR that changes `terraform/**` may not also change application code (the `terraform-isolation` CI job); ship Terraform in its own PR. -- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the - **Deploy dev content** workflow while the Terraform adoption is in progress) +- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through + Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`) `→ main` (production promotion — no prod environment exists yet). ## Deployment @@ -151,7 +147,7 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately: format check, lint, build, tests; **and** runs a repo-owned `governance` job that calls `npm run verify` so every gate (including the maintainability ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs), - the Terraform gates, and the CDK template tests) is guaranteed from this + the Terraform gates, and the content-CD guards) is guaranteed from this repository. Conventions and gates are documented under [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and @@ -161,28 +157,21 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately: — fails a PR that mixes `terraform/**` with application code, so a Terraform merge never races a content release for the HCP workspace. - **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) - — `workflow_dispatch` on `dev` only while the Terraform adoption is in - progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`, - and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, - `aws s3 sync dist/` (hashed assets immutable, `index.html` never cached), - CloudFront invalidation, then uploads source maps and checks the served - `index.html` matches the build. Push-to-`dev` releases return with the - Terraform content-CD change. + — `workflow_dispatch` on `dev`, and push-to-`dev` when + `vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`). + GitHub uploads `releases/--/` only. Terraform updates + `.release/current`, both origin paths, and the invalidation action. Verify + and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints + a live-state summary. - **Staging content** ([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml)) — on push to `staging`, unchanged. -- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence - and the HCP Terraform workspace `shoc-frontend-new-dev` - ([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy` - ([`infra/cdk/README.md`](infra/cdk/README.md)). +- **Infrastructure** — administrator-run HCP Terraform workspace + `shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)). + Staging hosting stays on the existing CloudFormation stack until SH-287. -Manual content deploy (emergency/reference only — needs credentials for the -external-dev AWS account): - -```bash -SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \ - AWS_REGION=us-east-1 bash scripts/deploy-web.sh -``` +Do not run `scripts/deploy-web.sh` against dev. That script remains the staging +content publisher only. ## Operations @@ -193,8 +182,9 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P are no CloudWatch application logs — the stack is static hosting; runtime errors surface in the browser and on the backend API's side. - **Common failure modes:** - - _Stale content after deploy_ — the CloudFront invalidation step failed or - is still propagating; re-run the Deploy workflow or invalidate `/*` manually. + - _Stale content after deploy_ — CloudFront is still `InProgress` or an edge + still serves the previous `index.html` hash. Read the live-state summary + before assuming the site is down. - _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref on this repo; dispatching the workflow from another branch is rejected by design. @@ -202,14 +192,13 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P suffix or carrying the wrong environment's host (it is baked in at build time). - _CORS errors_ — the backend must allow the frontend origin; CloudFront does not proxy `/api`. -- **Dev has no push-triggered deploy during the adoption.** Merging to `dev` - runs CI only; publish through the **Deploy dev content** workflow. Merging a - `terraform/**` change also queues an HCP Terraform run that a human confirms - or discards (see the operational rules in `terraform/README.md`). +- **Push-to-`dev` is gated.** Merging to `dev` publishes only when + `TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues + an HCP Terraform run that a human confirms or discards before the next + content release (see the operational rules in `terraform/README.md`). ## Documentation -- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md) -- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md) +- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md) - Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md); design system and UI docs under [`docs/`](docs/) diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts index 705c4f4e..fea343d4 100644 --- a/e2e/vendors/vendors.visual.spec.ts +++ b/e2e/vendors/vendors.visual.spec.ts @@ -244,7 +244,9 @@ test.describe("Vendor deterministic pixel regression", () => { await openVendorPage(page, "error"); await expect(page.getByRole("main").getByRole("alert")).toContainText( /server error|vendor directory unavailable/i, + { timeout: 15_000 }, ); + await expect(page.getByRole("progressbar")).toHaveCount(0); await expectStableScreenshot(page, "vendor-error.png"); }); diff --git a/eslint.config.js b/eslint.config.js index 10c0aa46..ca716ef7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,18 +30,7 @@ const legacyIgnores = [ export default tseslint.config( { - ignores: [ - "dist/**", - "build/**", - "node_modules/**", - "coverage/**", - "infra/cdk/cdk.out/**", - "infra/cdk/bin/**/*.d.ts", - "infra/cdk/bin/**/*.js", - "infra/cdk/lib/**/*.d.ts", - "infra/cdk/lib/**/*.js", - ...legacyIgnores, - ], + ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/infra/cdk/README.md b/infra/cdk/README.md deleted file mode 100644 index cf7d466f..00000000 --- a/infra/cdk/README.md +++ /dev/null @@ -1,270 +0,0 @@ -# Infrastructure & CI/CD — Sea Haven SHOC frontend - -AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo. -Infrastructure deploys are administrator-run; GitHub Actions publishes content -only. - -> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack -> `shoc-frontend-dev` is in the retain/transfer sequence described under -> [Terraform adoption mode](#terraform-adoption-mode) and in -> [`terraform/README.md`](../terraform/README.md). Do not run a plain -> `cdk deploy` against dev while that sequence is in progress. Staging is -> unaffected and stays on this CDK path (SH-287 tracks its cutover). - -- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom - domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias). -- **API:** the SPA calls the backend **directly** over HTTPS at - `https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend - allows CORS). CloudFront serves static content only — no `/api` proxy. -- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks - them up with no flags. `VITE_API_URL` is baked into the build, so it's - per-environment (see the note under "Adding staging / prod"). -- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys) -- **Content workflows:** `.github/workflows/deploy.yml` (dev, - `workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to - `staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy - role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was - retired with the adoption PR. -- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is - created by this stack, not added to the central `oidc-deploy-roles.yaml`. - -``` -infra/cdk/ - bin/app.ts entry point (reads -c context) - lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role - lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition) - test/frontend-stack.test.mjs template assertions for both modes -scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation -.github/workflows/ - ci.yaml quality gates (lint / build / test / governance / terraform isolation) - deploy.yml dev content publish (workflow_dispatch on dev) - deploy-staging.yml standalone staging deploy (push to staging) -``` - -## What the stack creates - -| Resource | Purpose | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) | -| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) | -| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) | -| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | - -The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants: -`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`, -read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It -is left byte-identical on purpose so the Terraform import is a no-op; the -Terraform content-CD change narrows it. The OIDC **provider** is a singleton -account resource — the stack only _imports_ it (created in step 2), so -`cdk destroy` can't delete a resource shared by other roles. - -## Terraform adoption mode - -`-c retainForTerraformAdoption=true` switches the stack into the safety mode -used only while HCP Terraform adopts the dev resources. It is off by default -and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts -both). In adoption mode the stack: - -- pins the origin ID CloudFormation generated for the live distribution - (`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is - metadata-only; environments without a verified value fail synthesis -- attaches the `seahaven-org-baseline` permissions boundary - `shoc-frontend-new-dev-deploy-boundary` and the - `HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role -- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the - same exact value -- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13 - transferred resources (bucket, bucket policy, distribution, OAC, SPA - function, A and AAAA records, deploy role, inline policy) and to - `SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider - Lambda and role stay unretained -- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no - default) and conditions those same resources and every output on it -- emits `TerraformImport*` outputs carrying the exact import IDs - -`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must -state the ownership phase: - -```bash -cd infra/cdk && npm ci - -# Phase 1, before the Terraform import: keep the resources in the stack and -# install Retain on them. Update-only change set. -npx cdk deploy shoc-frontend-dev \ - -c retainForTerraformAdoption=true \ - --parameters ManageSiteInfrastructure=true - -# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish -# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource. -npx cdk deploy shoc-frontend-dev \ - -c retainForTerraformAdoption=true \ - --parameters ManageSiteInfrastructure=false -``` - -Both deploys must use the same reviewed SHA. Review the change set before -confirming: Phase 1 must show no create, delete, or replace. After the -`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used -again. If the `true` deploy rolls back, inspect the stack resources and the -live bucket before retrying; retained resources can outlive a failed update and -must not be cleaned up automatically. Never delete the auto-delete custom -resource while its handler can still empty the versioned bucket. - -Local checks (`npm run test:infra` from the repo root) build the app, run the -template assertions, and synthesize both modes. - ---- - -## One-time setup (run by a human with admin AWS creds) - -### 1. Authenticate to the AWS account - -```bash -aws configure # or: aws sso login --profile -aws sts get-caller-identity # confirm the right account + region (us-east-1) -``` - -### 2. Ensure the GitHub OIDC provider exists (once per account) - -```bash -aws iam list-open-id-connect-providers -# If none ends in token.actions.githubusercontent.com, create it (thumbprint is -# no longer required — AWS validates GitHub against its own trust store): -aws iam create-open-id-connect-provider \ - --url https://token.actions.githubusercontent.com \ - --client-id-list sts.amazonaws.com -``` - -### 3. CDK bootstrap (once per account/region) - -```bash -cd infra/cdk -npm ci -npx cdk bootstrap aws:///us-east-1 -``` - -### 4. Domain, cert, and API URL (already wired for dev) - -Domain/cert/zone are set in `cdk.json` context (account `396287094661`): - -| Context key | Value | -| --------------------------------- | ------------------------------------------------------------ | -| `domainNames` | `dev.seahaven.com` | -| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) | -| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` | - -The stack creates the apex A/AAAA alias in the hosted zone (in this account, -delegated from the parent `seahaven.com` zone). The **API URL is not infra** — -it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`), -baked into the build. Per-environment; override for staging/prod. - -### 5. First deploy (locally, with admin creds) - -The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it -locally. This provisions infra + the role: - -```bash -cd infra/cdk -npx cdk deploy -``` - -Note the `DeployRoleArn` output. Then publish the first content manually: - -```bash -# from repo root, optional manual first content publish: -STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh -``` - -### 6. Content deploys - -The deploy role ARN is deterministic and pinned in -`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the -Terraform adoption, dev content deploys run only through **Actions → Deploy dev -content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes -`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the -pinned bucket and distribution, uploads source maps, and verifies the served -`index.html` matches the build. Automatic push-to-`dev` releases return with the -Terraform content-CD change. - ---- - -## Staging environment (same account, exact OIDC subject) - -Staging lives in the same AWS account (396287094661) and deploys through its -own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to -`staging`: - -- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role - (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub - environment subject - `repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging` - (`StringEquals` on both `aud` and `sub`). The workflow declares - `environment: staging`, so only runs in that environment can assume the role. - Without `githubEnvironment`, the dev stack keeps its branch-ref trust - unchanged. -- **No secret:** the role ARN is static (the role name is deterministic), so - the workflow pins - `arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging` - directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set. -- **Gates first:** the workflow runs the full `npm run verify` before assuming - the staging role, then runs `scripts/deploy-web.sh` with - `STACK_NAME=shoc-frontend-staging`, - `VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the - CloudFront invalidation to complete. -- **Application-only role:** the recurring staging workflow can describe only - its exact stack, publish only to its exact bucket, and invalidate only its - exact distribution. It cannot assume the shared CDK bootstrap roles or - modify infrastructure. Staging infrastructure changes use the Administrator - command below. -- **Post-deploy checks:** bucket + distribution existence, HTTPS on - `https://staging.seahaven.com`, and the actual post-invalidation remote assets - contain the staging API URL and no dev API URL. (Not browser QA.) - -### One-time setup (run by a human with admin AWS creds + GitHub Admin) - -1. **GitHub Admin — create the `staging` environment** (Settings → - Environments → New environment → `staging`). Add protection rules as - appropriate (e.g. required reviewers, restrict to the `staging` branch). If - the environment does not exist, GitHub creates it unprotected on first use. -2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the - OIDC provider and bootstrap already exist in this account): - - ```bash - cd infra/cdk - npx cdk deploy shoc-frontend-staging \ - -c envName=staging \ - -c deployBranch=staging \ - -c githubEnvironment=staging \ - -c domainNames=staging.seahaven.com \ - -c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \ - -c hostedZoneId=Z02602739VQWBWCAGXP4 \ - -c hostedZoneName=staging.seahaven.com - ``` - - The `DeployRoleArn` output must match the ARN pinned in - `deploy-staging.yml` (it will — the role name is deterministic). - -3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must - allow the `https://staging.seahaven.com` origin. -4. Push to `staging` — `ci.yaml` runs the quality gates and - `deploy-staging.yml` deploys. - -### Adding prod later - -Same pattern: a prod account/stack with its own contexts and, ideally, its own -`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked -into each environment's build, and the bucket's `RemovalPolicy.DESTROY` + -`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited -for prod. - -## Notes - -- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + - `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. - Never run it against dev during or after the Terraform adoption: the - adoption-mode stack retains the transferred resources, and after Phase 2 - Terraform owns them. -- **CI and staging CD both fire on push to `staging`** in parallel; the - staging CD workflow runs `npm run verify` itself before deploying. Dev has - no push-triggered deploy during the adoption. -- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses - lockfileVersion 3, matching the Node 24 / npm 11 CI environment. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts deleted file mode 100644 index afb51ff9..00000000 --- a/infra/cdk/bin/app.ts +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import { App, Tags } from "aws-cdk-lib"; -import { FrontendStack } from "../lib/frontend-stack"; - -const app = new App(); - -// Defaults match the dev setup; override via `-c key=value` on the CLI. -const envName = app.node.tryGetContext("envName") ?? "dev"; -const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; -const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; -// When set (e.g. "staging"), the deploy role trusts the exact GitHub -// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style -// branch-ref trust. -const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? ""; - -// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com -// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. -const domainNames = (app.node.tryGetContext("domainNames") ?? "") - .split(",") - .map((d: string) => d.trim()) - .filter((d: string) => d.length > 0); -const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; - -// Route 53 hosted zone (this account) for the custom-domain alias record. -const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; -const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; - -// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required -// ManageSiteInfrastructure parameter and Retain policies on the transferred -// resources. Off by default so ordinary synthesis is unchanged. -const retainForTerraformAdoption = - String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true"; - -// Staging and beyond protect their stacks from accidental deletion; dev -// stays teardown-friendly (its artifacts are reproducible). CDK applies this -// at deploy time — it is not part of the synthesized template. -const terminationProtection = envName !== "dev"; - -const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { - envName, - githubRepo, - deployBranch, - githubEnvironment, - terminationProtection, - domainNames, - certificateArn, - hostedZoneId, - hostedZoneName, - retainForTerraformAdoption, - env: { - account: process.env.CDK_DEFAULT_ACCOUNT, - region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", - }, -}); - -Tags.of(stack).add("Project", "shoc-frontend"); -Tags.of(stack).add("Environment", envName); -Tags.of(stack).add("ManagedBy", "cdk"); diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json deleted file mode 100644 index aaecf396..00000000 --- a/infra/cdk/cdk.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "app": "npx ts-node --prefer-ts-exts bin/app.ts", - "watch": { - "include": ["**"], - "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"] - }, - "context": { - "@aws-cdk/aws-iam:minimizePolicies": true, - "@aws-cdk/core:checkSecretUsage": true, - "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, - "@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true, - - "//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.", - "domainNames": "dev.seahaven.com", - "certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00", - "hostedZoneId": "Z07671212N75U4YLPWZR8", - "hostedZoneName": "dev.seahaven.com" - } -} diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts deleted file mode 100644 index 88ccb6d9..00000000 --- a/infra/cdk/lib/frontend-stack.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { - Aspects, - CfnCondition, - CfnOutput, - CfnParameter, - CfnResource, - Duration, - Fn, - RemovalPolicy, - Stack, - StackProps, - Tags, -} from "aws-cdk-lib"; -import { Construct } from "constructs"; -import * as s3 from "aws-cdk-lib/aws-s3"; -import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; -import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; -import * as iam from "aws-cdk-lib/aws-iam"; -import * as acm from "aws-cdk-lib/aws-certificatemanager"; -import * as route53 from "aws-cdk-lib/aws-route53"; -import * as targets from "aws-cdk-lib/aws-route53-targets"; -import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption"; - -export interface FrontendStackProps extends StackProps { - /** Environment label, e.g. "dev". Used in names/tags. */ - readonly envName: string; - /** GitHub repo in owner/name form, for OIDC trust scoping. */ - readonly githubRepo: string; - /** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */ - readonly deployBranch: string; - /** - * GitHub Actions environment name (e.g. "staging"). When set, the OIDC - * trust uses the EXACT environment subject - * `repo::environment:` (StringEquals) instead of the - * deploy-branch ref match below. Unset = dev-style branch-ref trust. - */ - readonly githubEnvironment?: string; - /** - * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. - * Empty = serve on the default *.cloudfront.net domain. - */ - readonly domainNames: string[]; - /** - * ARN of an ACM certificate (us-east-1, SAME account as this stack) covering - * `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot - * use a certificate from another account, so for Option B the cert must live - * in whichever account this stack deploys to. - */ - readonly certificateArn: string; - /** - * Route 53 hosted zone (in THIS account) to create the custom-domain alias - * record in. Empty = don't manage DNS (add the record manually). When set, - * hostedZoneName must also be provided. - */ - readonly hostedZoneId: string; - /** Name of the hosted zone above, e.g. "dev.seahaven.com". */ - readonly hostedZoneName: string; - /** - * Opt-in safety mode used only during the reviewed Terraform adoption. - * Normal dev/staging synthesis remains unchanged when false. - */ - readonly retainForTerraformAdoption?: boolean; -} - -/** - * Static SPA hosting for the Sea Haven SHOC frontend: - * - private S3 bucket (no public access; CloudFront reads it via OAC) - * - CloudFront distribution (HTTPS, SPA deep-link fallback) - * - a GitHub Actions OIDC deploy role - * - * Content (the built `dist/`) is NOT uploaded here. Manual environment - * workflows run `scripts/deploy-web.sh` independently of infrastructure - * changes, so this stack only owns infrastructure and the deploy role carries - * content-publication permissions. - */ -export class FrontendStack extends Stack { - constructor(scope: Construct, id: string, props: FrontendStackProps) { - super(scope, id, props); - - const { - envName, - githubRepo, - deployBranch, - githubEnvironment = "", - domainNames, - certificateArn, - hostedZoneId, - hostedZoneName, - retainForTerraformAdoption = false, - } = props; - - const manageSiteInfrastructureCondition = retainForTerraformAdoption - ? new CfnCondition(this, "ManageSiteInfrastructureCondition", { - expression: Fn.conditionEquals( - new CfnParameter(this, "ManageSiteInfrastructure", { - type: "String", - allowedValues: ["true", "false"], - description: - "Set true only before Terraform adoption. After ownership transfer, always reuse false.", - }).valueAsString, - "true", - ), - }) - : undefined; - - const hasCustomDomain = domainNames.length > 0; - if (hasCustomDomain && !certificateArn) { - throw new Error( - "certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).", - ); - } - - // --- Origin bucket: private, encrypted, no public access ---------------- - const bucket = new s3.Bucket(this, "SiteBucket", { - bucketName: `seahaven-shoc-frontend-${envName}`, - blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, - objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED, - encryption: s3.BucketEncryption.S3_MANAGED, - enforceSSL: true, - versioned: true, - // dev artifacts are reproducible from the build — safe to tear down. - removalPolicy: RemovalPolicy.DESTROY, - autoDeleteObjects: true, - }); - - // SPA client-side routing: rewrite extensionless paths (e.g. /work-orders) - // to /index.html so deep links resolve. Done with a CloudFront Function - // rather than customErrorResponses so real asset 404s stay 404s. - const spaRewrite = new cloudfront.Function(this, "SpaRewrite", { - comment: "SPA routing: rewrite extensionless paths to /index.html", - code: cloudfront.FunctionCode.fromInline( - [ - "function handler(event) {", - " var request = event.request;", - " var uri = request.uri;", - " // No file extension after the last slash -> a client-side route.", - " if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {", - " request.uri = '/index.html';", - " }", - " return request;", - "}", - ].join("\n"), - ), - }); - - // --- CloudFront: serves the static SPA from S3 ------------------------- - // The SPA calls the backend directly at its absolute HTTPS URL - // (VITE_API_URL, cross-origin), so CloudFront hosts only static content. - // Adoption mode pins the origin ID CloudFormation generated for the live - // distribution so the retention deploy is a metadata-only update. Only - // environments with a read-back-verified value may enter adoption mode. - const adoptionOriginIds: Record = { - dev: "shocfrontenddevDistributionOrigin10CCD0EE1", - }; - const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined; - if (retainForTerraformAdoption && !originId) { - throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`); - } - const distribution = new cloudfront.Distribution(this, "Distribution", { - comment: `SeaHaven SHOC frontend (${envName})`, - defaultRootObject: "index.html", - priceClass: cloudfront.PriceClass.PRICE_CLASS_100, - httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, - // Option B: serve on the custom domain(s) with the ACM cert. When unset, - // CloudFront uses its default *.cloudfront.net domain + certificate. - domainNames: hasCustomDomain ? domainNames : undefined, - certificate: hasCustomDomain - ? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn) - : undefined, - minimumProtocolVersion: hasCustomDomain - ? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021 - : undefined, - defaultBehavior: { - // withOriginAccessControl wires up OAC + the bucket policy automatically. - origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, { - originId, - }), - viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, - allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, - compress: true, - functionAssociations: [ - { - function: spaRewrite, - eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, - }, - ], - }, - }); - - // --- GitHub Actions OIDC deploy role ----------------------------------- - // The OIDC provider is a singleton account-global resource, created once - // out-of-band (see README step 2) — we only IMPORT it here so this stack's - // lifecycle (including `cdk destroy`) never deletes a resource shared by - // every role in the account. - const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn( - this, - "GitHubOidcProvider", - `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`, - ); - - // Trust conditions for the OIDC principal. With a GitHub environment - // (staging): exact StringEquals match on both aud and the environment - // subject — the staging workflow declares `environment: staging`, so only - // runs in that environment can assume the role. Normal dev synthesis keeps - // the current branch-ref StringLike trust. The adoption prerequisite - // narrows that already-exact value to StringEquals before Terraform import. - const oidcConditions = githubEnvironment - ? { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, - }, - } - : retainForTerraformAdoption - ? { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, - }, - } - : { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - }, - StringLike: { - // Tightly scoped: only pushes to this repo's deploy branch. For a - // reusable-workflow run the OIDC `sub` is still caller-based, so this - // matches even though the deploy job lives in the `.github` repo. - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, - }, - }; - - const deployPermissionsBoundary = retainForTerraformAdoption - ? iam.ManagedPolicy.fromManagedPolicyArn( - this, - "GithubDeployPermissionsBoundary", - `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, - ) - : undefined; - - const deployRole = new iam.Role(this, "GithubDeployRole", { - roleName: `githubdeploy-shoc-frontend-new-${envName}`, - description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, - maxSessionDuration: Duration.hours(1), - assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions), - permissionsBoundary: deployPermissionsBoundary, - }); - if (retainForTerraformAdoption) { - Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`); - } - - // Preserve dev's legacy CDK capability until the reviewed adoption update - // replaces this inline policy. Staging is intentionally narrower: its - // content role only publishes application assets to this stack's - // bucket/distribution. Infrastructure changes remain administrator-run. - if (!githubEnvironment) { - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "AssumeCdkBootstrapRoles", - actions: ["sts:AssumeRole"], - resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], - }), - ); - } - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "DescribeStack", - actions: ["cloudformation:DescribeStacks"], - resources: [ - `arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`, - ], - }), - ); - bucket.grantReadWrite(deployRole); - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "InvalidateDistribution", - actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"], - resources: [ - `arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`, - ], - }), - ); - - // --- DNS: point the custom domain at CloudFront ------------------------ - // Only when a hosted zone is supplied (it must be in THIS account). Creates - // A + AAAA aliases; for the zone apex, recordName is the zone itself. - let aliasA: route53.ARecord | undefined; - let aliasAaaa: route53.AaaaRecord | undefined; - if (hostedZoneId && hasCustomDomain) { - const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", { - hostedZoneId, - zoneName: hostedZoneName, - }); - const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)); - // apex record when the domain equals the zone name. - const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0]; - - aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target }); - aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", { - zone, - recordName, - target, - }); - } - - const gateOutput = (output: CfnOutput): CfnOutput => { - if (manageSiteInfrastructureCondition) { - output.condition = manageSiteInfrastructureCondition; - } - return output; - }; - - // --- Outputs ----------------------------------------------------------- - // scripts/deploy-web.sh reads BucketName + DistributionId from these. - gateOutput( - new CfnOutput(this, "SiteUrl", { - value: hasCustomDomain - ? `https://${domainNames[0]}` - : `https://${distribution.distributionDomainName}`, - description: "Public URL of the deployed SPA", - }), - ); - gateOutput( - new CfnOutput(this, "DistributionDomainName", { - value: distribution.distributionDomainName, - description: "CloudFront domain — point the custom-domain DNS record here", - }), - ); - gateOutput( - new CfnOutput(this, "BucketName", { - value: bucket.bucketName, - }), - ); - gateOutput( - new CfnOutput(this, "DistributionId", { - value: distribution.distributionId, - }), - ); - gateOutput( - new CfnOutput(this, "DeployRoleArn", { - value: deployRole.roleArn, - description: "Pinned GitHub OIDC content-deployment role", - }), - ); - - if (retainForTerraformAdoption) { - const originAccessControl = distribution.node - .findAll() - .find( - (node): node is cloudfront.CfnOriginAccessControl => - node instanceof cloudfront.CfnOriginAccessControl, - ); - if (!originAccessControl || !aliasA || !aliasAaaa) { - throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records."); - } - const originAccessControlConfig = - originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty; - - const rolePolicy = deployRole.node - .findAll() - .find((node): node is iam.Policy => node instanceof iam.Policy); - const autoDeleteProviderRole = this.node - .findAll() - .find( - (node): node is CfnResource => - node instanceof CfnResource && - node.cfnResourceType === "AWS::IAM::Role" && - node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"), - ); - const autoDeleteProviderHandler = this.node - .findAll() - .find( - (node): node is CfnResource => - node instanceof CfnResource && - node.cfnResourceType === "AWS::Lambda::Function" && - node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler"), - ); - if (!rolePolicy || !autoDeleteProviderRole || !autoDeleteProviderHandler) { - throw new Error("Terraform adoption outputs require deploy and auto-delete roles."); - } - // The provider Lambda stays unconditioned so it remains after - // ManageSiteInfrastructure=false. Its generated Description Refs the - // conditioned bucket and CloudFormation rejects that when the condition - // is false. Keep a static description. - autoDeleteProviderHandler.addPropertyOverride( - "Description", - "Lambda function for auto-deleting objects in the site S3 bucket.", - ); - - const recordName = domainNames[0]; - gateOutput( - new CfnOutput(this, "TerraformWorkspaceTag", { - value: `shoc-frontend-new-${envName}`, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformDeployBoundaryArn", { - value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, - }), - ); - gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName })); - gateOutput( - new CfnOutput(this, "TerraformImportBucketPolicy", { - value: bucket.bucketName, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformImportDistribution", { - value: distribution.distributionId, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformImportOriginAccessControl", { - value: originAccessControl.attrId, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformOriginAccessControlName", { - value: originAccessControlConfig.name, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformOriginAccessControlDescription", { - value: "EMPTY_STRING", - description: "Use an empty Terraform string because the generated OAC has no description", - }), - ); - gateOutput( - new CfnOutput(this, "TerraformDistributionOriginId", { - value: originId!, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformImportSpaRewriteFunction", { - value: spaRewrite.functionName, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformImportAliasA", { - value: `${hostedZoneId}_${recordName}_A`, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformImportAliasAAAA", { - value: `${hostedZoneId}_${recordName}_AAAA`, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformImportDeployRole", { - value: deployRole.roleName, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformImportDeployRolePolicy", { - value: `${deployRole.roleName}:${rolePolicy.policyName}`, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformDeployInlinePolicyName", { - value: rolePolicy.policyName, - }), - ); - gateOutput( - new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", { - value: autoDeleteProviderRole.getAtt("Arn").toString(), - }), - ); - gateOutput( - new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", { - value: "SiteBucket/AutoDeleteObjectsCustomResource", - description: - "CloudFormation custom resource retained to prevent bucket emptying during detachment", - }), - ); - - Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition)); - } - } -} diff --git a/infra/cdk/lib/retain-for-terraform-adoption.ts b/infra/cdk/lib/retain-for-terraform-adoption.ts deleted file mode 100644 index c02aa3e9..00000000 --- a/infra/cdk/lib/retain-for-terraform-adoption.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib"; -import { IConstruct } from "constructs"; - -const TRANSFERRED_RESOURCE_TYPES = new Set([ - "AWS::S3::Bucket", - "AWS::S3::BucketPolicy", - "AWS::CloudFront::Distribution", - "AWS::CloudFront::Function", - "AWS::CloudFront::OriginAccessControl", - "AWS::Route53::RecordSet", -]); - -function isTransferredResource(resource: CfnResource): boolean { - if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) { - return true; - } - - if ( - resource.cfnResourceType === "Custom::S3AutoDeleteObjects" && - resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource") - ) { - return true; - } - - return ( - (resource.cfnResourceType === "AWS::IAM::Role" || - resource.cfnResourceType === "AWS::IAM::Policy") && - resource.node.path.includes("/GithubDeployRole") - ); -} - -/** - * Retains only the resources in the approved Terraform transfer set. - * - * The bucket auto-delete custom resource is intentionally retained while the - * generated provider Lambda, role, log group, and CDK metadata remain excluded. - * When a management condition is supplied, those same resources share it so - * CloudFormation can later relinquish them without deleting them. - */ -export class RetainForTerraformAdoption implements IAspect { - constructor(private readonly manageCondition?: CfnCondition) {} - - public visit(node: IConstruct): void { - if (!(node instanceof CfnResource) || !isTransferredResource(node)) { - return; - } - - // Keep the L2 bucket's configured DESTROY policy visible to its - // AutoDeleteObjects validator while overriding the emitted CloudFormation - // resource. This preserves the custom resource and retains both together. - if (node.cfnResourceType === "AWS::S3::Bucket") { - node.addOverride("DeletionPolicy", "Retain"); - node.addOverride("UpdateReplacePolicy", "Retain"); - } else { - node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN; - node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN; - } - - if (this.manageCondition) { - node.cfnOptions.condition = this.manageCondition; - } - } -} diff --git a/infra/cdk/package-lock.json b/infra/cdk/package-lock.json deleted file mode 100644 index c5730f17..00000000 --- a/infra/cdk/package-lock.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "name": "shoc-frontend-infra", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "shoc-frontend-infra", - "version": "0.1.0", - "dependencies": { - "aws-cdk-lib": "^2.261.0", - "constructs": "^10.4.2" - }, - "bin": { - "app": "bin/app.ts" - }, - "devDependencies": { - "@types/node": "^24.13.3", - "aws-cdk": "^2.1130.0", - "ts-node": "^10.9.2", - "typescript": "~6.0.3" - }, - "engines": { - "node": ">=22.22.1" - } - }, - "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.282", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", - "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", - "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "54.9.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz", - "integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==", - "bundleDependencies": [ - "jsonschema", - "semver" - ], - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.5" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.8.5", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/aws-cdk": { - "version": "2.1130.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz", - "integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "cdk": "bin/cdk" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/aws-cdk-lib": { - "version": "2.261.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz", - "integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==", - "bundleDependencies": [ - "@balena/dockerignore", - "@aws-cdk/cloud-assembly-api", - "case", - "fs-extra", - "ignore", - "jsonschema", - "minimatch", - "punycode", - "semver", - "yaml", - "mime-types" - ], - "license": "Apache-2.0", - "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.282", - "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", - "@aws-cdk/cloud-assembly-api": "^2.2.5", - "@aws-cdk/cloud-assembly-schema": "^54.0.0", - "@balena/dockerignore": "^1.0.2", - "case": "1.6.3", - "fs-extra": "^11.3.5", - "ignore": "^5.3.2", - "jsonschema": "^1.5.0", - "mime-types": "^2.1.35", - "minimatch": "^10.2.5", - "punycode": "^2.3.1", - "semver": "^7.8.1", - "yaml": "1.10.3" - }, - "engines": { - "node": ">= 20.0.0" - }, - "peerDependencies": { - "constructs": "^10.5.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.5", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.0" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.28.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { - "version": "1.0.2", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/aws-cdk-lib/node_modules/balanced-match": { - "version": "4.0.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/case": { - "version": "1.6.3", - "inBundle": true, - "license": "(MIT OR GPL-3.0-or-later)", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.3.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/aws-cdk-lib/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.3.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-db": { - "version": "1.52.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-types": { - "version": "2.1.35", - "inBundle": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "10.2.5", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.8.1", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.3", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/constructs": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", - "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", - "license": "Apache-2.0" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - } - } -} diff --git a/infra/cdk/package.json b/infra/cdk/package.json deleted file mode 100644 index 4529259f..00000000 --- a/infra/cdk/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "shoc-frontend-infra", - "version": "0.1.0", - "private": true, - "description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.", - "bin": { - "app": "bin/app.ts" - }, - "engines": { - "node": ">=22.22.1" - }, - "scripts": { - "build": "tsc", - "test": "npm run build && node --test test/*.test.mjs", - "synth": "cdk synth", - "synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true", - "diff": "cdk diff", - "deploy": "cdk deploy" - }, - "devDependencies": { - "@types/node": "^24.13.3", - "aws-cdk": "^2.1130.0", - "ts-node": "^10.9.2", - "typescript": "~6.0.3" - }, - "dependencies": { - "aws-cdk-lib": "^2.261.0", - "constructs": "^10.4.2" - }, - "packageManager": "npm@11.16.0" -} diff --git a/infra/cdk/test/frontend-stack.test.mjs b/infra/cdk/test/frontend-stack.test.mjs deleted file mode 100644 index 8bcd607c..00000000 --- a/infra/cdk/test/frontend-stack.test.mjs +++ /dev/null @@ -1,232 +0,0 @@ -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { test } from "node:test"; - -const require = createRequire(import.meta.url); -const { App } = require("aws-cdk-lib"); -const { Template } = require("aws-cdk-lib/assertions"); -const { FrontendStack } = require("../lib/frontend-stack.js"); - -const account = "396287094661"; -const region = "us-east-1"; -const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev"; -const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1"; -const CONDITION = "ManageSiteInfrastructureCondition"; - -const RETAINED_TYPES = new Set([ - "AWS::S3::Bucket", - "AWS::S3::BucketPolicy", - "AWS::CloudFront::Distribution", - "AWS::CloudFront::Function", - "AWS::CloudFront::OriginAccessControl", - "AWS::Route53::RecordSet", - "Custom::S3AutoDeleteObjects", -]); - -function devTemplate(retainForTerraformAdoption, overrides = {}) { - const app = new App(); - const stack = new FrontendStack(app, "shoc-frontend-dev", { - envName: "dev", - githubRepo: "Sea-Haven-Industries/shoc-frontend-new", - deployBranch: "dev", - domainNames: ["dev.seahaven.com"], - certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`, - hostedZoneId: "Z07671212N75U4YLPWZR8", - hostedZoneName: "dev.seahaven.com", - retainForTerraformAdoption, - env: { account, region }, - ...overrides, - }); - return Template.fromStack(stack).toJSON(); -} - -function entriesByType(template, type) { - return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type); -} - -function isTransferred(logicalId, resource) { - const isDeployRoleResource = - (resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) || - (resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole")); - return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource; -} - -test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => { - const template = devTemplate(true); - assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1); - assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1); - assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1); - assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1); - assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1); - assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2); - assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1); - const transferred = Object.entries(template.Resources).filter(([id, resource]) => - isTransferred(id, resource), - ); - // Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline - // policy = 9 CloudFormation resources (Terraform splits the bucket into 6 - // addresses) plus the retained custom resource. - assert.equal(transferred.length, 10); -}); - -test("adoption mode preserves the live dev identifiers", () => { - const template = devTemplate(true); - const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; - assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev"); - assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled"); - assert.ok( - bucket.Properties.Tags.some( - (tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true", - ), - ); - - const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1]; - assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID); - assert.equal( - distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId, - DEV_ORIGIN_ID, - ); - - const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( - ([, resource]) => resource.Properties.RoleName === DEV_ROLE, - ); - assert.equal( - deployRole.Properties.PermissionsBoundary, - `arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`, - ); - assert.ok( - deployRole.Properties.Tags.some( - (tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev", - ), - ); - const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; - assert.equal( - condition.StringEquals["token.actions.githubusercontent.com:sub"], - "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", - ); - assert.equal(condition.StringLike, undefined); - - // Legacy inline policy stays byte-compatible with the live document. - const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) => - id.startsWith("GithubDeployRole"), - ); - const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid); - assert.deepEqual(sids, [ - "AssumeCdkBootstrapRoles", - "DescribeStack", - undefined, - "InvalidateDistribution", - ]); - - for (const output of [ - "TerraformWorkspaceTag", - "TerraformDeployBoundaryArn", - "TerraformImportBucket", - "TerraformImportBucketPolicy", - "TerraformImportDistribution", - "TerraformImportOriginAccessControl", - "TerraformOriginAccessControlName", - "TerraformOriginAccessControlDescription", - "TerraformDistributionOriginId", - "TerraformImportSpaRewriteFunction", - "TerraformImportAliasA", - "TerraformImportAliasAAAA", - "TerraformImportDeployRole", - "TerraformImportDeployRolePolicy", - "TerraformDeployInlinePolicyName", - "TerraformBucketAutoDeleteHelperRoleArn", - "TerraformRetainedAutoDeleteCustomResource", - ]) { - assert.ok(template.Outputs[output], `missing output ${output}`); - } - assert.equal( - template.Outputs.TerraformImportAliasA.Value, - "Z07671212N75U4YLPWZR8_dev.seahaven.com_A", - ); - assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID); -}); - -test("adoption mode retains exactly the transferred resources", () => { - const template = devTemplate(true); - for (const [logicalId, resource] of Object.entries(template.Resources)) { - if (isTransferred(logicalId, resource)) { - assert.equal(resource.DeletionPolicy, "Retain", logicalId); - assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId); - } else { - assert.notEqual(resource.DeletionPolicy, "Retain", logicalId); - assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId); - } - } - // The auto-delete provider Lambda, role, and log group stay unretained. - for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) { - for (const [, resource] of entriesByType(template, type)) { - assert.notEqual(resource.DeletionPolicy, "Retain"); - } - } - const providerRoles = entriesByType(template, "AWS::IAM::Role").filter( - ([, resource]) => resource.Properties.RoleName !== DEV_ROLE, - ); - assert.equal(providerRoles.length, 1); - assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain"); -}); - -test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => { - const template = devTemplate(true); - const parameter = template.Parameters.ManageSiteInfrastructure; - assert.ok(parameter); - assert.equal(parameter.Type, "String"); - assert.deepEqual(parameter.AllowedValues, ["true", "false"]); - assert.equal(parameter.Default, undefined); - assert.ok(template.Conditions[CONDITION]); - - for (const [logicalId, resource] of Object.entries(template.Resources)) { - if (isTransferred(logicalId, resource)) { - assert.equal(resource.Condition, CONDITION, logicalId); - } else { - assert.notEqual(resource.Condition, CONDITION, logicalId); - } - } - for (const [outputName, output] of Object.entries(template.Outputs)) { - assert.equal(output.Condition, CONDITION, outputName); - } - - const [, autoDeleteHandler] = entriesByType(template, "AWS::Lambda::Function")[0]; - assert.equal( - autoDeleteHandler.Properties.Description, - "Lambda function for auto-deleting objects in the site S3 bucket.", - ); - assert.equal(typeof autoDeleteHandler.Properties.Description, "string"); -}); - -test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => { - const template = devTemplate(false); - const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; - assert.equal(bucket.DeletionPolicy, "Delete"); - assert.equal(bucket.UpdateReplacePolicy, "Delete"); - const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1]; - assert.notEqual(customResource.DeletionPolicy, "Retain"); - - const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( - ([, resource]) => resource.Properties.RoleName === DEV_ROLE, - ); - assert.equal(deployRole.Properties.PermissionsBoundary, undefined); - assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace")); - const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; - assert.equal( - condition.StringLike["token.actions.githubusercontent.com:sub"], - "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", - ); - assert.equal(template.Outputs.TerraformWorkspaceTag, undefined); - assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined); - assert.equal(template.Conditions?.[CONDITION], undefined); - for (const resource of Object.values(template.Resources)) { - assert.equal(resource.Condition, undefined); - } -}); - -test("adoption mode refuses an environment without a verified origin ID", () => { - assert.throws( - () => devTemplate(true, { envName: "staging" }), - /No verified Terraform adoption origin ID exists for staging/, - ); -}); diff --git a/infra/cdk/tsconfig.json b/infra/cdk/tsconfig.json deleted file mode 100644 index 37092ab0..00000000 --- a/infra/cdk/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "declaration": true, - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "types": ["node"] - }, - "include": ["bin/**/*.ts", "lib/**/*.ts"], - "exclude": ["node_modules", "cdk.out"] -} diff --git a/package.json b/package.json index 50346ac3..c1a28231 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "test:e2e:visual": "playwright test --config playwright.visual.config.ts", "test:e2e:ui": "playwright test --ui", "test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py", + "test:terraform-release-plan": "python3 scripts/test-terraform-release-plan-check.py", "test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs", "test:terraform": "node scripts/terraform-validate.mjs", - "test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption", + "test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py", + "test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh", + "test:github-workflows": "bash scripts/check-github-workflows.sh", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", diff --git a/scripts/check-github-workflows.sh b/scripts/check-github-workflows.sh new file mode 100755 index 00000000..7a27c1ac --- /dev/null +++ b/scripts/check-github-workflows.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# bash -n every shell script and every workflow `run:` block. actionlint when present. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${ROOT}" + +for script in scripts/*.sh; do + bash -n "${script}" +done + +python3 - "${ROOT}" << 'PY' +import pathlib, re, subprocess, sys, tempfile +root = pathlib.Path(sys.argv[1]) +failures = 0 +workflow_count = 0 +block_count = 0 +for workflow in sorted((root / ".github/workflows").glob("*.yml")) + sorted( + (root / ".github/workflows").glob("*.yaml") +): + workflow_count += 1 + text = workflow.read_text(encoding="utf-8") + blocks = [] + for match in re.finditer(r"^(\s+)run:\s*\|[^\n]*\n((?:\1 .*\n)+)", text, re.M): + indent = len(match.group(1)) + 2 + body = [] + for line in match.group(2).splitlines(): + body.append(line[indent:] if len(line) >= indent else line.lstrip()) + blocks.append("\n".join(body) + "\n") + block_count += len(blocks) + for index, block in enumerate(blocks, start=1): + with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle: + handle.write(block) + name = handle.name + result = subprocess.run(["bash", "-n", name], capture_output=True, text=True) + pathlib.Path(name).unlink() + if result.returncode != 0: + failures += 1 + sys.stderr.write(f"{workflow.relative_to(root)} run block {index}: {result.stderr}") +if failures: + raise SystemExit(1) +print( + f"bash -n passed for scripts and {block_count} run blocks in {workflow_count} workflows" +) +PY + +if command -v actionlint >/dev/null 2>&1; then + actionlint -color +else + echo "actionlint not installed; skipped (CI installs it)" +fi diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py old mode 100644 new mode 100755 diff --git a/scripts/check-terraform-isolation.mjs b/scripts/check-terraform-isolation.mjs index 484b6a50..6386456b 100644 --- a/scripts/check-terraform-isolation.mjs +++ b/scripts/check-terraform-isolation.mjs @@ -45,6 +45,14 @@ export function mayAccompanyTerraform(file) { if (file.endsWith(".md")) return true; if (file.startsWith("docs/")) return true; if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true; + if ( + /^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test( + file, + ) + ) { + return true; + } + if (file.startsWith("scripts/testdata/terraform-")) return true; return false; } diff --git a/scripts/check-terraform-isolation.test.mjs b/scripts/check-terraform-isolation.test.mjs index 45c18fc8..c2913a2f 100644 --- a/scripts/check-terraform-isolation.test.mjs +++ b/scripts/check-terraform-isolation.test.mjs @@ -37,12 +37,21 @@ test("terraform tree, docs, and terraform tooling may accompany a Terraform chan "scripts/test-terraform-import-plan-check.py", "scripts/terraform-validate.mjs", "scripts/check-terraform-isolation.mjs", + "scripts/check-terraform-release-plan.py", + "scripts/hcp-run-guard.py", + "scripts/test-hcp-run-guard.py", + "scripts/verify-cloudfront-release.sh", + "scripts/test-verify-cloudfront-release.sh", + "scripts/summarize-cloudfront-live-state.sh", + "scripts/check-github-workflows.sh", + "scripts/read-release-pointer.py", + "scripts/testdata/terraform-release-plans/version-only.json", ]) { assert.equal(mayAccompanyTerraform(file), true, file); } }); -test("application, workflow, CDK, and dependency files count as application changes", () => { +test("application, workflow, and dependency files count as application changes", () => { for (const file of [ "src/App.tsx", "public/favicon.ico", @@ -52,7 +61,6 @@ test("application, workflow, CDK, and dependency files count as application chan ".env.production", "vite.config.ts", ".github/workflows/deploy.yml", - "infra/cdk/lib/frontend-stack.ts", "scripts/deploy-web.sh", "scripts/governance-check.mjs", "e2e/login.spec.ts", diff --git a/scripts/check-terraform-release-plan.py b/scripts/check-terraform-release-plan.py new file mode 100755 index 00000000..1d713891 --- /dev/null +++ b/scripts/check-terraform-release-plan.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +"""Reject HCP Terraform plans that are not a frontend content-release update. + +Accepts exactly: + - an update of the release pointer (content, plus computed etag/version_id) + - an update of the distribution with only origin[*].origin_path changed + - exactly one action invocation for the CloudFront invalidation + +after origin_path values must match the expected labels. before origin_path +values must match the pointer's prior current/previous. This script may read a +local plan JSON file or download plan JSON from the documented HashiCorp +endpoint: + + GET https://app.terraform.io/api/v2/plans/:id/json-output + +The download follows exactly one redirect, and only to archivist.terraform.io. +It does not create, apply, discard, or poll runs. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import ssl +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + + +POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer" +DISTRIBUTION_ADDRESS = "module.environment_owned.aws_cloudfront_distribution.site" +ACTION_ADDRESS = ( + "module.environment_owned.action.aws_cloudfront_create_invalidation.release" +) +API_HOST = "app.terraform.io" +ARCHIVE_HOST = "archivist.terraform.io" +PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$") +VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$") +IGNORED_ACTIONS = {"no-op", "read"} +UNSAFE_ACTIONS = {"create", "delete"} +POINTER_UNKNOWN_ATTRIBUTES = frozenset({"etag", "version_id"}) +DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset( + { + "etag", + "last_modified_time", + "status", + "in_progress_validation_batches", + } +) +REDIRECT_STATUSES = {301, 302, 303, 307, 308} + +UrlOpen = Callable[..., Any] + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Return the redirect response instead of following it.""" + + def http_error_301(self, req, fp, code, msg, headers): + return self._capture(req, fp, code, headers) + + http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301 + + @staticmethod + def _capture(req, fp, code, headers): + response = urllib.response.addinfourl(fp, headers, req.full_url, code=code) + response.msg = "Redirect" + return response + + +def _urlopen_without_redirects( + *handlers: urllib.request.BaseHandler, +) -> UrlOpen: + context = ssl.create_default_context() + opener = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=context), + _NoRedirectHandler, + *handlers, + ) + return opener.open + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument( + "plan_json", + type=Path, + nargs="?", + help="Local Terraform plan JSON. Mutually exclusive with --plan-id.", + ) + source.add_argument( + "--plan-id", + help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.", + ) + parser.add_argument( + "--expected-version-label", + required=True, + help="Immutable current release the plan must apply. Empty string is the legacy root.", + ) + parser.add_argument( + "--expected-previous-version-label", + default="", + help="Previous release label the origin group must fail over to.", + ) + parser.add_argument( + "--evidence-out", + type=Path, + help="Write machine-readable proof after every assertion passes.", + ) + return parser.parse_args() + + +def download_plan_json( + plan_id: str, + token: str, + *, + urlopen: UrlOpen | None = None, + handlers: tuple[urllib.request.BaseHandler, ...] = (), +) -> dict[str, Any]: + if not PLAN_ID_RE.fullmatch(plan_id): + raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id") + if not token: + raise ValueError("TF_API_TOKEN is required to download plan JSON") + + opener = urlopen or _urlopen_without_redirects(*handlers) + api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output" + request = urllib.request.Request( + api_url, + method="GET", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + "Accept": "application/json", + }, + ) + first = _open_pinned(opener, request, allowed_host=API_HOST) + try: + if first.status == 204: + raise ValueError( + "plan JSON is not ready; refusing to poll the plans endpoint" + ) + if first.status not in REDIRECT_STATUSES: + raise ValueError( + f"expected a redirect from {API_HOST}, got HTTP {first.status}" + ) + location = first.headers.get("Location") + if not location: + raise ValueError(f"{API_HOST} redirect is missing a Location header") + archive = urlparse(location) + if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST: + raise ValueError( + "refusing redirect that is not https://" + f"{ARCHIVE_HOST}/" + ) + archive_request = urllib.request.Request(location, method="GET") + second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST) + try: + if second.status in REDIRECT_STATUSES: + raise ValueError( + f"refusing a second redirect from {ARCHIVE_HOST}" + ) + if second.status != 200: + raise ValueError( + f"plan JSON download from {ARCHIVE_HOST} returned " + f"HTTP {second.status}" + ) + payload = second.read() + finally: + second.close() + finally: + first.close() + + plan = json.loads(payload.decode("utf-8")) + if not isinstance(plan, dict): + raise ValueError("plan JSON must be an object") + return plan + + +def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str): + parsed = urlparse(request.full_url) + if parsed.scheme != "https" or parsed.hostname != allowed_host: + raise ValueError( + f"refusing to contact {parsed.scheme}://{parsed.hostname} " + f"(pinned host is {allowed_host})" + ) + context = ssl.create_default_context() + try: + return urlopen(request, context=context, timeout=30) + except TypeError: + return urlopen(request, timeout=30) + + +def _is_nested_unknown(value: Any) -> bool: + if isinstance(value, dict): + return any(item is True or _is_nested_unknown(item) for item in value.values()) + if isinstance(value, list): + return any(item is True or _is_nested_unknown(item) for item in value) + return False + + +def changed_attributes( + change: dict[str, Any], + *, + computed_unknown: frozenset[str], +) -> set[str]: + before = change.get("before") or {} + after = change.get("after") or {} + unknown = change.get("after_unknown") or {} + keys = set(before) | set(after) | set(unknown) + changed: set[str] = set() + for key in keys: + unknown_value = unknown.get(key) + if unknown_value is True: + if key in computed_unknown: + continue + changed.add(key) + continue + if _is_nested_unknown(unknown_value): + changed.add(key) + continue + if before.get(key) != after.get(key): + changed.add(key) + return changed + + +def _label_ok(label: str) -> bool: + return label == "" or bool(VERSION_LABEL_RE.fullmatch(label)) + + +def origin_path_for_label(label: str) -> str: + return "" if label == "" else f"/releases/{label}" + + +def _origin_map(origins: Any) -> dict[str, dict[str, Any]]: + if not isinstance(origins, list): + return {} + mapped: dict[str, dict[str, Any]] = {} + for origin in origins: + if not isinstance(origin, dict): + continue + origin_id = origin.get("origin_id") + if not isinstance(origin_id, str) or not origin_id: + continue + mapped[origin_id] = origin + return mapped + + +def _origin_paths(origins: Any) -> dict[str, str]: + return { + origin_id: origin.get("origin_path") or "" + for origin_id, origin in _origin_map(origins).items() + } + + +def _decode_pointer(content: Any) -> dict[str, str]: + if not isinstance(content, str) or not content: + return {} + try: + payload = json.loads(content) + except json.JSONDecodeError: + return {} + if not isinstance(payload, dict): + return {} + return { + "current": payload.get("current") or "", + "previous": payload.get("previous") or "", + } + + +def _validate_pointer( + resource: dict[str, Any], + expected_current: str, + expected_previous: str, +) -> list[str]: + violations: list[str] = [] + change = resource.get("change") or {} + changed = changed_attributes(change, computed_unknown=POINTER_UNKNOWN_ATTRIBUTES) + if changed != {"content"}: + violations.append( + f"{POINTER_ADDRESS}: expected only content to change, found " + f"{sorted(changed) if changed else 'no attribute changes'}" + ) + after = _decode_pointer((change.get("after") or {}).get("content")) + if after.get("current") != expected_current: + violations.append( + f"{POINTER_ADDRESS}: after current {after.get('current')!r} does not match " + f"{expected_current!r}" + ) + if after.get("previous") != expected_previous: + violations.append( + f"{POINTER_ADDRESS}: after previous {after.get('previous')!r} does not match " + f"{expected_previous!r}" + ) + unknown = change.get("after_unknown") or {} + if unknown.get("content") is True: + violations.append(f"{POINTER_ADDRESS}: content after value is unknown") + return violations + + +def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool: + before_rest = {key: value for key, value in before.items() if key != "origin_path"} + after_rest = {key: value for key, value in after.items() if key != "origin_path"} + return before_rest != after_rest + + +def _validate_distribution( + resource: dict[str, Any], + pointer_before: dict[str, str], + expected_current: str, + expected_previous: str, +) -> list[str]: + violations: list[str] = [] + change = resource.get("change") or {} + changed = changed_attributes( + change, computed_unknown=DISTRIBUTION_UNKNOWN_ATTRIBUTES + ) + if changed != {"origin"}: + violations.append( + f"{DISTRIBUTION_ADDRESS}: expected only origin to change, found " + f"{sorted(changed) if changed else 'no attribute changes'}" + ) + return violations + + before_origins = _origin_map((change.get("before") or {}).get("origin")) + after_origins = _origin_map((change.get("after") or {}).get("origin")) + if set(before_origins) != set(after_origins): + violations.append( + f"{DISTRIBUTION_ADDRESS}: origin IDs changed " + f"from {sorted(before_origins)} to {sorted(after_origins)}" + ) + return violations + + for origin_id, before_origin in before_origins.items(): + if _origin_non_path_fields_changed(before_origin, after_origins[origin_id]): + violations.append( + f"{DISTRIBUTION_ADDRESS}: origin {origin_id!r} changed a field other than origin_path" + ) + + after_paths = sorted(_origin_paths((change.get("after") or {}).get("origin")).values()) + expected_after = sorted( + [ + origin_path_for_label(expected_current), + origin_path_for_label(expected_previous), + ] + ) + if after_paths != expected_after: + violations.append( + f"{DISTRIBUTION_ADDRESS}: after origin_path {after_paths} does not match " + f"{expected_after}" + ) + + before_paths = sorted(_origin_paths((change.get("before") or {}).get("origin")).values()) + expected_before = sorted( + [ + origin_path_for_label(pointer_before.get("current", "")), + origin_path_for_label(pointer_before.get("previous", "")), + ] + ) + if before_paths != expected_before: + violations.append( + f"{DISTRIBUTION_ADDRESS}: before origin_path {before_paths} does not match " + f"pointer prior values {expected_before}" + ) + return violations + + +def _validate_actions(plan: dict[str, Any]) -> list[str]: + invocations = plan.get("action_invocations") + if invocations is None: + return ["plan is missing action_invocations"] + if not isinstance(invocations, list): + return ["action_invocations must be a list"] + addresses = [ + item.get("address") + for item in invocations + if isinstance(item, dict) + ] + if addresses != [ACTION_ADDRESS]: + return [ + "expected exactly one action_invocations entry " + f"{ACTION_ADDRESS}, found {addresses}" + ] + return [] + + +def validate_plan( + plan: dict[str, Any], + expected_current: str, + expected_previous: str, +) -> list[str]: + violations: list[str] = [] + if not _label_ok(expected_current): + violations.append( + "expected version label must be empty or --" + ) + return violations + if not _label_ok(expected_previous): + violations.append( + "expected previous version label must be empty or --" + ) + return violations + + updates: dict[str, dict[str, Any]] = {} + for resource in plan.get("resource_changes", []): + if resource.get("mode", "managed") != "managed": + continue + address = resource.get("address", "") + change = resource.get("change") or {} + actions = list(change.get("actions") or []) + action_set = set(actions) + if action_set <= IGNORED_ACTIONS: + continue + + if change.get("importing"): + violations.append(f"{address}: import actions are not allowed") + + unsafe = sorted(action_set & UNSAFE_ACTIONS) + if unsafe: + violations.append(f"{address}: unsafe actions {unsafe}") + if "replace" in action_set or actions in ( + ["delete", "create"], + ["create", "delete"], + ): + violations.append(f"{address}: replacement is not allowed") + + if "update" in action_set: + updates[address] = resource + if action_set != {"update"}: + violations.append( + f"{address}: update must be the only action, got {actions}" + ) + + if address not in {POINTER_ADDRESS, DISTRIBUTION_ADDRESS} and ( + action_set - IGNORED_ACTIONS + ): + violations.append( + f"{address}: managed address is outside the content-release update" + ) + + if set(updates) != {POINTER_ADDRESS, DISTRIBUTION_ADDRESS}: + violations.append( + "expected exactly the pointer and distribution updates, found " + f"{sorted(updates)}" + ) + violations.extend(_validate_actions(plan)) + return violations + + pointer_change = updates[POINTER_ADDRESS].get("change") or {} + pointer_before = _decode_pointer((pointer_change.get("before") or {}).get("content")) + violations.extend( + _validate_pointer(updates[POINTER_ADDRESS], expected_current, expected_previous) + ) + violations.extend( + _validate_distribution( + updates[DISTRIBUTION_ADDRESS], + pointer_before, + expected_current, + expected_previous, + ) + ) + violations.extend(_validate_actions(plan)) + return violations + + +def main() -> int: + args = parse_args() + if args.plan_id: + try: + plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", "")) + except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc: + print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr) + return 1 + else: + if args.plan_json is None: + print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr) + return 1 + plan = json.loads(args.plan_json.read_text(encoding="utf-8")) + + violations = validate_plan( + plan, + args.expected_version_label, + args.expected_previous_version_label, + ) + if violations: + print("FAIL: Terraform plan is not a content-release update", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + + if args.evidence_out: + evidence = { + "pointer_address": POINTER_ADDRESS, + "distribution_address": DISTRIBUTION_ADDRESS, + "action_address": ACTION_ADDRESS, + "expected_version_label": args.expected_version_label, + "expected_previous_version_label": args.expected_previous_version_label, + "managed_updates": 2, + "action_invocations": 1, + "creates": 0, + "deletes": 0, + "replacements": 0, + } + args.evidence_out.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print( + "PASS: content-release plan updates " + f"{POINTER_ADDRESS} and {DISTRIBUTION_ADDRESS} to " + f"{args.expected_version_label} (previous {args.expected_previous_version_label!r})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index f777ce09..96209314 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -21,9 +21,12 @@ const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/; // script so it can also be run on its own. const REPOSITORY_GATES = [ ["Terraform import-plan contract", "test:terraform-import-plan"], + ["Terraform release-plan contract", "test:terraform-release-plan"], ["Terraform isolation gate", "test:terraform-isolation"], ["Terraform formatting and validation", "test:terraform"], - ["CDK build, tests, and synth", "test:infra"], + ["HCP run guard", "test:hcp-run-guard"], + ["CloudFront release verify", "test:cloudfront-release-verify"], + ["GitHub workflow shell", "test:github-workflows"], ]; function isGoverned(relativePath) { diff --git a/scripts/hcp-run-guard.py b/scripts/hcp-run-guard.py new file mode 100755 index 00000000..15c0f318 --- /dev/null +++ b/scripts/hcp-run-guard.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Guard HCP Terraform runs used by GitHub content CD. + +Subcommands: + check-and-discard Refuse unsafe workspace settings. Discard a blocking + non-speculative VCS run so GitHub CD can create-run. + reconcile-apply Treat an HCP run whose status is already ``applied`` as + success when the GitHub apply-run step reported failure. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Any, Callable + +API = "https://app.terraform.io/api/v2" +DEFAULT_WORKSPACE = "shoc-frontend-new-dev" +EXPECTED_TRIGGER_PATTERNS = [ + "terraform/live/dev/**", + "terraform/live/modules/**", +] +DISCARDABLE = { + "pending", + "planned", + "cost_estimated", + "policy_checked", + "policy_override", +} +APPLYING = {"applying", "apply_queued"} + +HttpGet = Callable[[str], dict[str, Any]] +HttpPost = Callable[[str, dict[str, Any]], int] + + +class GuardError(Exception): + """Refused to continue.""" + + +def _headers(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + } + + +def default_get(token: str) -> HttpGet: + def get(url: str) -> dict[str, Any]: + request = urllib.request.Request(url, headers=_headers(token)) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + return get + + +def default_post(token: str) -> HttpPost: + def post(url: str, payload: dict[str, Any]) -> int: + data = json.dumps(payload).encode() + request = urllib.request.Request( + url, data=data, method="POST", headers=_headers(token) + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return int(response.status) + except urllib.error.HTTPError as exc: + if exc.code in (409, 404): + body = exc.read().decode("utf-8", "replace") + print(f"discard returned HTTP {exc.code}: {body}") + return exc.code + raise + + return post + + +def require_token(token: str) -> str: + if not token: + raise GuardError("TF_API_TOKEN is required") + return token + + +def check_invariants(attrs: dict[str, Any], workspace: str) -> None: + if attrs.get("auto-apply") is True: + raise GuardError(f"{workspace} auto-apply is on; refuse to continue") + if not attrs.get("speculative-enabled"): + raise GuardError("speculative plans are off; refuse to continue") + if (attrs.get("vcs-repo") or {}).get("tags-regex"): + raise GuardError("tag-based VCS triggering is set; refuse to continue") + if attrs.get("trigger-patterns") != EXPECTED_TRIGGER_PATTERNS: + raise GuardError( + "trigger-patterns must be " + f"{EXPECTED_TRIGGER_PATTERNS}; got {attrs.get('trigger-patterns')}" + ) + + +def check_and_discard( + *, + workspace: str, + token: str, + get: HttpGet | None = None, + post: HttpPost | None = None, +) -> int: + token = require_token(token) + get = get or default_get(token) + post = post or default_post(token) + workspace_payload = get( + f"{API}/organizations/seahaven/workspaces/{workspace}" + )["data"] + attrs = workspace_payload["attributes"] + check_invariants(attrs, workspace) + if not attrs.get("locked"): + print("workspace is unlocked") + return 0 + + current = ( + workspace_payload.get("relationships", {}) + .get("current-run", {}) + .get("data") + ) + if not current: + raise GuardError("workspace is locked without a current run") + run_id = current["id"] + run = get(f"{API}/runs/{run_id}")["data"] + run_attrs = run["attributes"] + status = run_attrs.get("status") + plan_only = run_attrs.get("plan-only") + print(f"current run {run_id} status={status} plan-only={plan_only}") + if plan_only: + print("speculative run does not block GitHub CD") + return 0 + if status in APPLYING: + raise GuardError(f"{run_id} is {status}; wait, do not discard an apply") + if status not in DISCARDABLE: + raise GuardError(f"{run_id} status {status} is not discardable") + code = post( + f"{API}/runs/{run_id}/actions/discard", + { + "comment": ( + "Discarded so GitHub CD can create the content-release applyable run" + ) + }, + ) + print(f"discarded {run_id} http={code}") + return 0 + + +def reconcile_apply( + *, + run_id: str, + apply_outcome: str, + token: str, + get: HttpGet | None = None, +) -> int: + token = require_token(token) + if not run_id: + raise GuardError("run id is required") + if apply_outcome == "success": + print("Apply succeeded.") + return 0 + get = get or default_get(token) + status = get(f"{API}/runs/{run_id}")["data"]["attributes"]["status"] + print(f"HCP run {run_id} status={status}") + if status == "applied": + return 0 + raise GuardError( + f"Apply failed: GitHub outcome={apply_outcome} HCP status={status}" + ) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + + check = sub.add_parser("check-and-discard") + check.add_argument("--workspace", default=DEFAULT_WORKSPACE) + check.add_argument("--token", default=os.environ.get("TF_API_TOKEN", "")) + + reconcile = sub.add_parser("reconcile-apply") + reconcile.add_argument("--run-id", required=True) + reconcile.add_argument( + "--apply-outcome", + default=os.environ.get("APPLY_OUTCOME", ""), + ) + reconcile.add_argument("--token", default=os.environ.get("TF_API_TOKEN", "")) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + if args.command == "check-and-discard": + return check_and_discard(workspace=args.workspace, token=args.token) + return reconcile_apply( + run_id=args.run_id, + apply_outcome=args.apply_outcome, + token=args.token, + ) + except GuardError as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/read-release-pointer.py b/scripts/read-release-pointer.py new file mode 100755 index 00000000..d570310f --- /dev/null +++ b/scripts/read-release-pointer.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Read .release/current JSON from stdin and write GitHub Actions outputs.""" +from __future__ import annotations + +import json +import os +import sys + + +def main() -> int: + raw = sys.stdin.read().strip() + data = json.loads(raw) if raw else {} + current = data.get("current") or "" + previous = data.get("previous") or "" + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as handle: + handle.write(f"live_current={current}\n") + handle.write(f"live_previous={previous}\n") + print( + "Pointer live current=" + + (current or "") + + " previous=" + + (previous or "") + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/summarize-cloudfront-live-state.sh b/scripts/summarize-cloudfront-live-state.sh new file mode 100755 index 00000000..4389ec3e --- /dev/null +++ b/scripts/summarize-cloudfront-live-state.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Print pointer body, origin paths, distribution status, and served index hash. +# Used by deploy.yml's always() summary. Never fails the job on a missing pointer. +set -u +DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}" +SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}" +SITE_URL="${SITE_URL:-https://dev.seahaven.com}" +echo "=== CloudFront live state ===" +echo "pointer:" +aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)" +echo +aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c ' +import json, sys +payload = json.load(sys.stdin) +dist = payload.get("Distribution") or {} +config = dist.get("DistributionConfig") or {} +print("status:", dist.get("Status")) +for origin in ((config.get("Origins") or {}).get("Items") or []): + print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or "")) +' +echo +echo -n "served index sha256: " +curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable" diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py old mode 100644 new mode 100755 diff --git a/scripts/test-hcp-run-guard.py b/scripts/test-hcp-run-guard.py new file mode 100755 index 00000000..0e97e6d8 --- /dev/null +++ b/scripts/test-hcp-run-guard.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Tests for every hcp-run-guard refusal, exit-0, discard, and reconcile case.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + +SCRIPT = Path(__file__).with_name("hcp-run-guard.py") +WORKSPACE = "shoc-frontend-new-dev" +PATTERNS = [ + "terraform/live/dev/**", + "terraform/live/modules/**", +] + + +def load_module(): + spec = importlib.util.spec_from_file_location("hcp_run_guard", SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def workspace_payload( + *, + auto_apply: bool = False, + speculative: bool = True, + tags_regex: str | None = None, + trigger_patterns: list[str] | None = None, + locked: bool = False, + current_run: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "data": { + "attributes": { + "auto-apply": auto_apply, + "speculative-enabled": speculative, + "vcs-repo": {"tags-regex": tags_regex}, + "trigger-patterns": PATTERNS if trigger_patterns is None else trigger_patterns, + "locked": locked, + }, + "relationships": { + "current-run": {"data": current_run}, + }, + } + } + + +def run_payload(*, status: str, plan_only: bool = False) -> dict[str, Any]: + return {"data": {"attributes": {"status": status, "plan-only": plan_only}}} + + +def check(module, payloads: dict[str, Any], posts: list | None = None): + calls: list[str] = [] + + def get(url: str) -> dict[str, Any]: + calls.append(url) + if url not in payloads: + raise AssertionError(f"unexpected GET {url}") + return payloads[url] + + recorded: list[tuple[str, dict[str, Any]]] = [] + + def post(url: str, payload: dict[str, Any]) -> int: + recorded.append((url, payload)) + if posts: + return posts.pop(0) + return 202 + + try: + code = module.check_and_discard( + workspace=WORKSPACE, + token="test-token", + get=get, + post=post, + ) + return code, None, calls, recorded + except module.GuardError as exc: + return 1, str(exc), calls, recorded + + +def reconcile(module, outcome: str, payloads: dict[str, Any], run_id: str = "run-1"): + def get(url: str) -> dict[str, Any]: + if url not in payloads: + raise AssertionError(f"unexpected GET {url}") + return payloads[url] + + try: + code = module.reconcile_apply( + run_id=run_id, + apply_outcome=outcome, + token="test-token", + get=get, + ) + return code, None + except module.GuardError as exc: + return 1, str(exc) + + +def main() -> int: + module = load_module() + ws = f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{WORKSPACE}" + run_url = "https://app.terraform.io/api/v2/runs/run-1" + discard_url = f"{run_url}/actions/discard" + failures: list[str] = [] + + def expect_refuse(name: str, payloads: dict[str, Any], fragment: str) -> None: + code, error, _, recorded = check(module, payloads) + if code != 1 or not error or fragment not in error: + failures.append(f"{name}: expected refuse containing {fragment!r}, got {code} {error}") + if recorded: + failures.append(f"{name}: discard was posted on a refusal") + + expect_refuse( + "auto-apply", + {ws: workspace_payload(auto_apply=True)}, + "auto-apply is on", + ) + expect_refuse( + "speculative-off", + {ws: workspace_payload(speculative=False)}, + "speculative plans are off", + ) + expect_refuse( + "tags-regex", + {ws: workspace_payload(tags_regex="^v")}, + "tag-based VCS triggering", + ) + expect_refuse( + "wrong-patterns", + {ws: workspace_payload(trigger_patterns=["terraform/**"])}, + "trigger-patterns must be", + ) + expect_refuse( + "locked-without-run", + {ws: workspace_payload(locked=True, current_run=None)}, + "locked without a current run", + ) + expect_refuse( + "applying", + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="applying"), + }, + "wait, do not discard an apply", + ) + expect_refuse( + "not-discardable", + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="errored"), + }, + "is not discardable", + ) + + code, error, _, recorded = check(module, {ws: workspace_payload(locked=False)}) + if code != 0 or error is not None or recorded: + failures.append(f"unlocked: expected exit 0, got {code} {error} {recorded}") + + code, error, _, recorded = check( + module, + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="planned", plan_only=True), + }, + ) + if code != 0 or recorded: + failures.append(f"plan-only: expected exit 0 without discard, got {code} {recorded}") + + code, error, _, recorded = check( + module, + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="planned"), + }, + ) + if code != 0 or error is not None: + failures.append(f"discard: expected exit 0, got {code} {error}") + if not recorded or recorded[0][0] != discard_url: + failures.append(f"discard: posted {recorded}") + + code, error, _, recorded = check( + module, + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="policy_checked"), + }, + posts=[409], + ) + if code != 0: + failures.append(f"discard-409: expected exit 0, got {code} {error}") + + try: + module.check_and_discard(workspace=WORKSPACE, token="", get=lambda _url: {}) + failures.append("missing-token: accepted empty token") + except module.GuardError: + pass + + code, error = reconcile(module, "success", {}) + if code != 0: + failures.append(f"reconcile-success: expected 0, got {code} {error}") + + code, error = reconcile( + module, + "failure", + {run_url: run_payload(status="applied")}, + ) + if code != 0: + failures.append(f"reconcile-applied: expected 0, got {code} {error}") + + code, error = reconcile( + module, + "failure", + {run_url: run_payload(status="errored")}, + ) + if code != 1 or not error or "errored" not in error: + failures.append(f"reconcile-errored: expected refuse, got {code} {error}") + + try: + module.reconcile_apply(run_id="", apply_outcome="failure", token="test-token") + failures.append("reconcile-missing-run: accepted empty run id") + except module.GuardError: + pass + + try: + module.reconcile_apply(run_id="run-1", apply_outcome="failure", token="") + failures.append("reconcile-missing-token: accepted empty token") + except module.GuardError: + pass + + if failures: + print("FAIL: hcp-run-guard cases failed", file=__import__("sys").stderr) + for item in failures: + print(f" - {item}", file=__import__("sys").stderr) + return 1 + print("PASS: HCP run guard checks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py old mode 100644 new mode 100755 index c122ae19..f0c8c2e4 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -452,15 +452,23 @@ class ImportPlanCheckerTests(unittest.TestCase): with self.subTest(mutation=mutation): self.assert_fails(plan, "dev", BUCKET_POLICY) - def test_github_deploy_policy_stays_byte_identical(self) -> None: + def test_github_deploy_policy_is_release_prefix_only(self) -> None: source = ( REPOSITORY / "terraform/live/modules/environment-owned/main.tf" ).read_text(encoding="utf-8") document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1] document = document.split("resource ", 1)[0] self.assertNotIn("var.adoption_complete", document) - self.assertIn("AssumeCdkBootstrapRoles", document) - self.assertIn("DescribeStack", document) + self.assertIn("ListReleasePrefixes", document) + self.assertIn("PublishReleasePrefix", document) + self.assertIn("ReadReleasePointer", document) + self.assertIn("ReadDistribution", document) + self.assertIn("cloudfront:GetDistribution", document) + self.assertIn("cloudfront:GetDistributionConfig", document) + self.assertIn("releases/*", document) + self.assertNotIn("AssumeCdkBootstrapRoles", document) + self.assertNotIn("DescribeStack", document) + self.assertNotIn("CreateInvalidation", document) self.assertNotIn("ReadDeploymentBucket", document) self.assertNotIn("PublishAndRollbackSiteObjects", document) self.assertNotIn( diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py new file mode 100755 index 00000000..70e436b7 --- /dev/null +++ b/scripts/test-terraform-release-plan-check.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Deterministic tests for check-terraform-release-plan.py.""" + +from __future__ import annotations + +import importlib.util +import io +import subprocess +import sys +import urllib.request +from email.message import EmailMessage +from pathlib import Path +from urllib.request import Request + +SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py") +FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans" +EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" +EXPECTED_PREVIOUS = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" +PLAN_ID = "plan-8F5JFydVYAmtTjET" +POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer" + + +def run_case( + fixture_name: str, + *, + expected_label: str = EXPECTED_LABEL, + expected_previous: str = EXPECTED_PREVIOUS, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(FIXTURES / fixture_name), + "--expected-version-label", + expected_label, + "--expected-previous-version-label", + expected_previous, + ], + check=False, + capture_output=True, + text=True, + ) + + +class FakeResponse: + def __init__( + self, + *, + url: str, + status: int, + headers: dict[str, str] | None = None, + body: bytes = b"", + ) -> None: + self.url = url + self.status = status + self.headers = headers or {} + self._body = body + + def read(self) -> bytes: + return self._body + + def close(self) -> None: + return None + + +def load_check_module(): + spec = importlib.util.spec_from_file_location( + "check_terraform_release_plan", SCRIPT + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_download_pinning() -> list[str]: + module = load_check_module() + fixture = (FIXTURES / "version-only.json").read_bytes() + archive_url = "https://archivist.terraform.io/v1/object/example" + calls: list[str] = [] + + def fake_urlopen(request: Request, **_kwargs): + url = request.full_url + calls.append(url) + host = request.host if hasattr(request, "host") else "" + if url.startswith("https://app.terraform.io/api/v2/plans/"): + if request.get_header("Authorization") != "Bearer test-token": + raise AssertionError("API request is missing the bearer token") + if "/runs" in url or "/apply" in url or "/discard" in url: + raise AssertionError(f"download contacted a run-control path: {url}") + return FakeResponse( + url=url, + status=307, + headers={"Location": archive_url}, + ) + if url == archive_url: + if request.get_header("Authorization"): + raise AssertionError("archivist request must not send TF_API_TOKEN") + return FakeResponse(url=url, status=200, body=fixture) + raise AssertionError(f"unexpected URL {url} host={host}") + + plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen) + failures: list[str] = [] + addresses = [item["address"] for item in plan["resource_changes"]] + if POINTER_ADDRESS not in addresses: + failures.append("download did not return the version-only fixture") + if calls != [ + f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output", + archive_url, + ]: + failures.append(f"download URLs were {calls}") + + try: + module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen) + failures.append("invalid plan id was accepted") + except ValueError: + pass + + def redirect_elsewhere(request: Request, **_kwargs): + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": "https://evil.example/plan.json"}, + ) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere) + failures.append("redirect to a non-archivist host was accepted") + except ValueError: + pass + + def double_redirect(request: Request, **_kwargs): + if request.full_url.startswith("https://app.terraform.io/"): + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": archive_url}, + ) + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": "https://archivist.terraform.io/v1/object/other"}, + ) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect) + failures.append("second archivist redirect was accepted") + except ValueError: + pass + + def not_ready(request: Request, **_kwargs): + return FakeResponse(url=request.full_url, status=204) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready) + failures.append("HTTP 204 was polled or accepted") + except ValueError as exc: + if "poll" not in str(exc): + failures.append(f"HTTP 204 error was {exc}") + + source = SCRIPT.read_text(encoding="utf-8") + for banned in ("/apply", "/discard", "/runs"): + if banned in source: + failures.append(f"download client contains run-control path {banned}") + + return failures + + +def _scripted_https_handler(fixture: bytes, archive_url: str): + calls: list[str] = [] + api_prefix = "https://app.terraform.io/api/v2/plans/" + + class ScriptedHTTPSHandler(urllib.request.BaseHandler): + handler_order = 100 + + def https_open(self, req: Request): + url = req.full_url + calls.append(url) + headers = EmailMessage() + if url.startswith(api_prefix): + headers["Location"] = archive_url + body = b"" + status = 307 + msg = "Temporary Redirect" + elif url == archive_url: + body = fixture + status = 200 + msg = "OK" + else: + raise AssertionError(f"unexpected URL {url}") + response = urllib.response.addinfourl( + io.BytesIO(body), + headers, + url, + code=status, + ) + response.msg = msg + return response + + return ScriptedHTTPSHandler(), calls + + +def test_download_standard_opener_redirect() -> list[str]: + """urllib follows the HCP 307; the guard must still inspect that first hop.""" + module = load_check_module() + fixture = (FIXTURES / "version-only.json").read_bytes() + archive_url = "https://archivist.terraform.io/v1/object/example" + api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output" + failures: list[str] = [] + + following_handler, following_calls = _scripted_https_handler(fixture, archive_url) + followed = urllib.request.build_opener(following_handler).open(api_url) + try: + if followed.status != 200: + failures.append( + f"standard opener first status was {followed.status}, not 200" + ) + if following_calls != [api_url, archive_url]: + failures.append(f"standard opener URLs were {following_calls}") + finally: + followed.close() + + guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url) + try: + plan = module.download_plan_json( + PLAN_ID, + "test-token", + handlers=(guard_handler,), + ) + except ValueError as exc: + failures.append(f"no-redirect download failed: {exc}") + return failures + + addresses = [item["address"] for item in plan["resource_changes"]] + if POINTER_ADDRESS not in addresses: + failures.append("no-redirect download did not return the version-only fixture") + if guard_calls != [api_url, archive_url]: + failures.append(f"no-redirect download URLs were {guard_calls}") + + following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url) + following_urlopen = urllib.request.build_opener(following_urlopen_handler).open + try: + module.download_plan_json( + PLAN_ID, + "test-token", + urlopen=following_urlopen, + ) + failures.append("redirect-following urlopen was accepted as the first hop") + except ValueError as exc: + if "expected a redirect" not in str(exc): + failures.append(f"following urlopen error was {exc}") + + return failures + + +def test_deploy_workflow_uses_script_flags() -> list[str]: + workflow = ( + Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml" + ).read_text(encoding="utf-8") + failures: list[str] = [] + if workflow.count("--expected-version-label") < 2: + failures.append( + "deploy.yml must pass --expected-version-label on release and rollback" + ) + if workflow.count("--expected-previous-version-label") < 2: + failures.append( + "deploy.yml must pass --expected-previous-version-label on release and rollback" + ) + for forbidden in ( + "--expected-current-label", + "--expected-previous-label", + "--before-current-label", + "--before-previous-label", + "--current-origin-id", + "--previous-origin-id", + "CURRENT_ORIGIN_ID", + ): + if forbidden in workflow: + failures.append(f"deploy.yml still passes unknown flag {forbidden}") + return failures + + +def main() -> int: + cases = [ + ("version-only", run_case("version-only.json"), 0), + ("wrong-label", run_case("wrong-label.json"), 1), + ("wrong-before", run_case("wrong-before.json"), 1), + ("extra-origin-change", run_case("extra-origin-change.json"), 1), + ("iam-update", run_case("iam-update.json"), 1), + ("dns-update", run_case("dns-update.json"), 1), + ("create", run_case("create.json"), 1), + ("delete", run_case("delete.json"), 1), + ("replace", run_case("replace.json"), 1), + ("multiple-updates", run_case("multiple-updates.json"), 1), + ("nested-unknown", run_case("nested-unknown.json"), 1), + ("unknown-only", run_case("unknown-only.json"), 1), + ("empty", run_case("empty.json"), 1), + ("missing-action", run_case("missing-action.json"), 1), + ("extra-action", run_case("extra-action.json"), 1), + ] + failures = [ + (name, result, expected) + for name, result, expected in cases + if result.returncode != expected + ] + download_failures = test_download_pinning() + redirect_failures = test_download_standard_opener_redirect() + download_failures.extend(redirect_failures) + download_failures.extend(test_deploy_workflow_uses_script_flags()) + if failures or download_failures: + if failures: + print( + "FAIL: release plan-check cases failed: " + + ", ".join(name for name, _, _ in failures), + file=sys.stderr, + ) + for name, result, expected in failures: + print( + f"{name}: expected {expected}, got {result.returncode}\n" + f"{result.stdout}{result.stderr}", + file=sys.stderr, + ) + for item in download_failures: + print(f"FAIL: {item}", file=sys.stderr) + return 1 + print("PASS: Terraform release plan safety checks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-verify-cloudfront-release.sh b/scripts/test-verify-cloudfront-release.sh new file mode 100755 index 00000000..8e192eae --- /dev/null +++ b/scripts/test-verify-cloudfront-release.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# Stubbed aws/curl tests for scripts/verify-cloudfront-release.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh" +CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" +PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" +NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111" +OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000" +INDEX_HTML='api.dev.seahaven.com' +INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" + +failures=0 +assert_exit() { + local name="$1" expected="$2" got="$3" log="$4" + if [[ "${got}" != "${expected}" ]]; then + echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + else + echo "PASS: ${name}" + fi +} + +make_stubs() { + local bin="$1" + mkdir -p "${bin}" + cat > "${bin}/aws" << 'AWS' +#!/usr/bin/env bash +set -euo pipefail +state_dir="${STUB_STATE}" +if [[ "${1:-}" == "s3" ]]; then + cat "${state_dir}/pointer.json" + exit 0 +fi +cat "${state_dir}/distribution.json" +AWS + cat > "${bin}/curl" << 'CURL' +#!/usr/bin/env bash +set -euo pipefail +state_dir="${STUB_STATE}" +method="GET" +url="" +dump="" +output="" +write_out="" +args=("$@") +i=0 +while [[ $i -lt ${#args[@]} ]]; do + arg="${args[$i]}" + case "${arg}" in + -X) i=$((i + 1)); method="${args[$i]}" ;; + -D) i=$((i + 1)); dump="${args[$i]}" ;; + -o) i=$((i + 1)); output="${args[$i]}" ;; + -w) i=$((i + 1)); write_out="${args[$i]}" ;; + -H|--max-time|-s|-S|-f|-fsS|-sS) ;; + http*) url="${arg}" ;; + esac + i=$((i + 1)) +done +if [[ "${method}" == "OPTIONS" ]]; then + [[ -n "${dump}" ]] && printf 'HTTP/1.1 204 No Content\nAccess-Control-Allow-Origin: https://dev.seahaven.com\n\n' > "${dump}" + [[ -n "${write_out}" ]] && printf '204' + exit 0 +fi +if [[ "${url}" == *"/assets/"* ]]; then + [[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: public,max-age=31536000,immutable\n\n' > "${dump}" + [[ -n "${output}" ]] && printf 'asset' > "${output}" + [[ -z "${output}" ]] && printf 'asset' + exit 0 +fi +body="$(cat "${state_dir}/index.html")" +[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}" +if [[ -n "${output}" ]]; then + printf '%s' "${body}" > "${output}" +else + printf '%s' "${body}" +fi +exit 0 +CURL + chmod +x "${bin}/aws" "${bin}/curl" +} + +dist_json() { + local status="$1" current_path="$2" + python3 -c 'import json,sys +status, path = sys.argv[1], sys.argv[2] +print(json.dumps({ + "Distribution": { + "Status": status, + "DistributionConfig": { + "Origins": {"Items": [ + {"Id": "current", "OriginPath": path}, + {"Id": "previous", "OriginPath": ""}, + ]} + } + } +}))' "${status}" "${current_path}" +} + +pointer_json() { + python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2" +} + +run_case() { + local name="$1" + local dir + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" + export PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" + export EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" + export PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" + export SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=3 + export INTERVAL=0 + local log="${dir}/log.txt" + set +e + bash "${VERIFY}" > "${log}" 2>&1 + local code=$? + set -e + assert_exit "${name}" "$2" "${code}" "${log}" + rm -rf "${dir}" +} + +# 1. Right config, then propagates (InProgress -> Deployed, hash already matches). +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + printf 'InProgress\n' > "${dir}/status" + cat > "${dir}/bin/aws" << AWS +#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "s3" ]]; then + cat "${dir}/pointer.json" + exit 0 +fi +status="\$(cat "${dir}/status")" +python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}" +echo Deployed > "${dir}/status" +AWS + chmod +x "${dir}/bin/aws" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=5 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt" + rm -rf "${dir}" +} + +# 2. Right config never propagates (Deployed, stale hash). +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf 'stale' > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt" + grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 3. Wrong origin path fails fast. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt" + grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 4. Wrong pointer fails fast. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt" + grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 5. Never Deployed. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "never-deployed" 1 "${code}" "${dir}/log.txt" + grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: never-deployed missing last observed state" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +if [[ "${failures}" -ne 0 ]]; then + echo "FAIL: ${failures} verify-cloudfront-release cases failed" >&2 + exit 1 +fi +echo "PASS: CloudFront release verify checks" diff --git a/scripts/testdata/terraform-release-plans/create.json b/scripts/testdata/terraform-release-plans/create.json new file mode 100644 index 00000000..7933f203 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/create.json @@ -0,0 +1,94 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["create"], + "before": null, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}" + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/delete.json b/scripts/testdata/terraform-release-plans/delete.json new file mode 100644 index 00000000..4c056b51 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/delete.json @@ -0,0 +1,94 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["delete"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}" + }, + "after": null + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/dns-update.json b/scripts/testdata/terraform-release-plans/dns-update.json new file mode 100644 index 00000000..940a2da3 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/dns-update.json @@ -0,0 +1,116 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + }, + { + "address": "module.environment_owned.aws_route53_record.site_a", + "mode": "managed", + "type": "aws_route53_record", + "change": { + "actions": ["update"], + "before": { + "ttl": 60 + }, + "after": { + "ttl": 300 + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/empty.json b/scripts/testdata/terraform-release-plans/empty.json new file mode 100644 index 00000000..49edaf62 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/empty.json @@ -0,0 +1,9 @@ +{ + "resource_changes": [], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/extra-action.json b/scripts/testdata/terraform-release-plans/extra-action.json new file mode 100644 index 00000000..758347e8 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/extra-action.json @@ -0,0 +1,106 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + }, + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release_extra", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/extra-origin-change.json b/scripts/testdata/terraform-release-plans/extra-origin-change.json new file mode 100644 index 00000000..734aed73 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/extra-origin-change.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 20, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/iam-update.json b/scripts/testdata/terraform-release-plans/iam-update.json new file mode 100644 index 00000000..e4a1019c --- /dev/null +++ b/scripts/testdata/terraform-release-plans/iam-update.json @@ -0,0 +1,116 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + }, + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["update"], + "before": { + "policy": "{}" + }, + "after": { + "policy": "{\"Version\":\"2012-10-17\"}" + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/missing-action.json b/scripts/testdata/terraform-release-plans/missing-action.json new file mode 100644 index 00000000..be48195f --- /dev/null +++ b/scripts/testdata/terraform-release-plans/missing-action.json @@ -0,0 +1,97 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [] +} diff --git a/scripts/testdata/terraform-release-plans/multiple-updates.json b/scripts/testdata/terraform-release-plans/multiple-updates.json new file mode 100644 index 00000000..a4b5e869 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/multiple-updates.json @@ -0,0 +1,130 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + }, + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["update"], + "before": { + "policy": "{}" + }, + "after": { + "policy": "{\"Version\":\"2012-10-17\"}" + } + } + }, + { + "address": "module.environment_owned.aws_route53_record.site_a", + "mode": "managed", + "type": "aws_route53_record", + "change": { + "actions": ["update"], + "before": { + "ttl": 60 + }, + "after": { + "ttl": 300 + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/nested-unknown.json b/scripts/testdata/terraform-release-plans/nested-unknown.json new file mode 100644 index 00000000..2c8a4400 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/nested-unknown.json @@ -0,0 +1,105 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true, + "tags": { + "Environment": true + } + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/replace.json b/scripts/testdata/terraform-release-plans/replace.json new file mode 100644 index 00000000..c37fb1aa --- /dev/null +++ b/scripts/testdata/terraform-release-plans/replace.json @@ -0,0 +1,58 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["delete", "create"], + "before": { + "origin": [] + }, + "after": { + "origin": [] + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/unknown-only.json b/scripts/testdata/terraform-release-plans/unknown-only.json new file mode 100644 index 00000000..8ef737df --- /dev/null +++ b/scripts/testdata/terraform-release-plans/unknown-only.json @@ -0,0 +1,103 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true, + "comment": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/version-only.json b/scripts/testdata/terraform-release-plans/version-only.json new file mode 100644 index 00000000..f9b5a86c --- /dev/null +++ b/scripts/testdata/terraform-release-plans/version-only.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/wrong-before.json b/scripts/testdata/terraform-release-plans/wrong-before.json new file mode 100644 index 00000000..478c7f6f --- /dev/null +++ b/scripts/testdata/terraform-release-plans/wrong-before.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/wrong-label.json b/scripts/testdata/terraform-release-plans/wrong-label.json new file mode 100644 index 00000000..c823a2e8 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/wrong-label.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"cccccccccccccccccccccccccccccccccccccccc-3-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/cccccccccccccccccccccccccccccccccccccccc-3-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/upload-sourcemaps.sh b/scripts/upload-sourcemaps.sh index c557b6c5..a5a91781 100755 --- a/scripts/upload-sourcemaps.sh +++ b/scripts/upload-sourcemaps.sh @@ -6,15 +6,19 @@ set -euo pipefail SENTRY_ORG="${SENTRY_ORG:-seahaven}" SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}" COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" +RELEASE_LABEL="${SENTRY_RELEASE:-${RELEASE_LABEL:-}}" -if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2 - exit 1 +if [[ -n "${RELEASE_LABEL}" ]]; then + RELEASE="${RELEASE_LABEL}" +else + if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2 + exit 1 + fi + COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')" + RELEASE="shoc-frontend@${COMMIT_SHA}" fi -COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')" -RELEASE="shoc-frontend@${COMMIT_SHA}" - npm exec --no -- sentry-cli sourcemaps upload \ --org "${SENTRY_ORG}" \ --project "${SENTRY_PROJECT}" \ diff --git a/scripts/verify-cloudfront-release.sh b/scripts/verify-cloudfront-release.sh new file mode 100755 index 00000000..e46ee221 --- /dev/null +++ b/scripts/verify-cloudfront-release.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Verify a CloudFront content release or rollback. +# +# Fail fast when origin_path or .release/current is the wrong label. +# Poll while the distribution is InProgress or the served index.html hash +# still matches the previous release. On timeout, print last observed state. +set -euo pipefail + +DISTRIBUTION_ID="${DISTRIBUTION_ID:-}" +EXPECTED_LABEL="${EXPECTED_LABEL:-}" +EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}" +SITE_URL="${SITE_URL:-}" +SITE_BUCKET="${SITE_BUCKET:-}" +PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}" +API_URL="${API_URL:-https://api.dev.seahaven.com/api}" +BUDGET="${BUDGET:-40}" +INTERVAL="${INTERVAL:-15}" + +if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then + echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2 + exit 2 +fi + +SITE_URL="${SITE_URL%/}" +if [[ -n "${EXPECTED_LABEL}" ]]; then + EXPECTED_PATH="/releases/${EXPECTED_LABEL}" +else + EXPECTED_PATH="" +fi + +sha256_of() { + python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" +} + +read_pointer() { + aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true +} + +read_distribution_json() { + aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json +} + +parse_distribution() { + python3 -c ' +import json, os, sys +payload = json.load(sys.stdin) +dist = payload.get("Distribution") or payload +status = dist.get("Status") or "Unknown" +config = dist.get("DistributionConfig") or {} +origins = ((config.get("Origins") or {}).get("Items")) or [] +paths = [origin.get("OriginPath") or "" for origin in origins] +expected = os.environ["EXPECTED_PATH"] +print(status) +print("\x1f".join(paths)) +print("yes" if expected in paths else "no") +' +} + +pointer_current() { + POINTER_BODY="$1" python3 -c ' +import json, os +raw = os.environ.get("POINTER_BODY", "").strip() +if not raw: + print("") + raise SystemExit +print(json.loads(raw).get("current") or "") +' +} + +last_status="Unknown" +last_paths="Unknown" +last_pointer="Unknown" +last_hash="Unknown" +last_path_ok="no" + +observe() { + last_pointer="$(read_pointer)" + local parsed + parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)" + last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')" + last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')" + last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')" + local body + body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)" + if [[ -n "${body}" ]]; then + last_hash="$(printf '%s' "${body}" | sha256_of)" + else + last_hash="unreachable" + fi +} + +report_state() { + echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}" +} + +fail_fast_if_misconfigured() { + local current + current="$(pointer_current "${last_pointer}")" + if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then + echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2 + report_state >&2 + exit 1 + fi + if [[ "${last_path_ok}" != "yes" ]]; then + echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2 + report_state >&2 + exit 1 + fi +} + +observe +fail_fast_if_misconfigured + +attempt=0 +while [[ "${attempt}" -lt "${BUDGET}" ]]; do + attempt=$((attempt + 1)) + echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}" + fail_fast_if_misconfigured + if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then + break + fi + sleep "${INTERVAL}" + observe +done + +if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA256}" ]]; then + echo "FAIL: release did not converge within the budget." >&2 + report_state >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers" +curl -fsS --max-time 30 "${SITE_URL}/login" -o "${tmp}/login.html" +curl -fsS --max-time 30 "${SITE_URL}/work-orders" -o "${tmp}/route.html" +if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then + echo "FAIL: HTML Cache-Control is missing no-store." >&2 + exit 1 +fi +for forbidden in api.staging.seahaven.com localhost:5141; do + if grep -Fq "${forbidden}" "${tmp}/index.html"; then + echo "FAIL: served index contains forbidden URL ${forbidden}." >&2 + exit 1 + fi +done +if ! grep -Fq "api.dev.seahaven.com" "${tmp}/index.html"; then + echo "FAIL: served index is missing the dev API URL." >&2 + exit 1 +fi + +asset_path="$(python3 -c 'import re,sys; html=open(sys.argv[1],encoding="utf-8").read(); m=re.search(r"(/assets/[^\"'\'']+)", html); print(m.group(1) if m else "")' "${tmp}/index.html")" +if [[ -z "${asset_path}" ]]; then + echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2 + exit 1 +fi +curl -fsS --max-time 30 "${SITE_URL}${asset_path}" -o /dev/null -D "${tmp}/asset.headers" +if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then + echo "FAIL: hashed asset is missing Cache-Control immutable." >&2 + exit 1 +fi + +cors_code="$(curl -sS --max-time 30 -o /dev/null -D "${tmp}/cors.headers" -w '%{http_code}' -X OPTIONS "${API_URL}" \ + -H "Origin: ${SITE_URL}" \ + -H "Access-Control-Request-Method: GET")" +if [[ "${cors_code}" != "200" && "${cors_code}" != "204" ]]; then + echo "FAIL: CORS preflight returned HTTP ${cors_code}." >&2 + exit 1 +fi +if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then + echo "FAIL: CORS preflight is missing Access-Control-Allow-Origin." >&2 + exit 1 +fi + +echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean." +report_state diff --git a/terraform/README.md b/terraform/README.md index 190dd53b..f466e8d6 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -4,11 +4,11 @@ This tree adopts the existing Sea Haven SHOC frontend dev hosting resources into HCP Terraform without recreating them. It mirrors the backend adoption (`shoc-backend` #94, #98, #99, #102) and lands in three PRs: -| PR | Branch | Change | -| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| A | `feature/frontend-terraform-adoption` | Merged. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. | -| B | `feature/terraform-dev-adoption` | This PR. `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. | -| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. | +| PR | Branch | Change | +| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. | +| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. | +| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, rollback. | Creating these files, formatting them, initializing with `-backend=false`, and validating them does not authorize an AWS, HCP Terraform, GitHub, @@ -42,7 +42,7 @@ before the first release after any Terraform merge: `terraform/live/dev/**` and `terraform/live/modules/**`. No trigger prefixes, no tags regex. Do not switch to tag-based triggering. - Execution mode remote, Terraform `1.16.x` (`versions.tf` requires - `>= 1.9.0, < 2.0.0`; CI validates with `1.16.0`). + `>= 1.14.0, < 2.0.0`; CI validates with `1.16.0`). - Dynamic AWS credentials only: environment variables `TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and `TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan` @@ -54,7 +54,8 @@ before the first release after any Terraform merge: ## Ownership boundary -`live/modules/environment-owned` owns exactly these 13 addresses: +`live/modules/environment-owned` owns these 14 addresses (13 imported hosting +resources plus the release pointer created in Phase 3): 1. `module.environment_owned.aws_s3_bucket.site` 2. `module.environment_owned.aws_s3_bucket_public_access_block.site` @@ -69,7 +70,10 @@ before the first release after any Terraform merge: 11. `module.environment_owned.aws_route53_record.site_aaaa` 12. `module.environment_owned.aws_iam_role.github_deploy` 13. `module.environment_owned.aws_iam_role_policy.github_deploy` +14. `module.environment_owned.aws_s3_object.release_pointer` +The CloudFront invalidation is a Terraform action +(`action.aws_cloudfront_create_invalidation.release`), not a managed resource. Every managed resource has `prevent_destroy = true`. `live/modules/environment-inventory` is data-only. It resolves and checks the @@ -135,15 +139,8 @@ Each step is gated. State the impact, get the go, act, read back, record. 1. **Workspace invariants.** Set the invariants above on `shoc-frontend-new-dev`. Read back the workspace and record the JSON in the PR. -2. **CDK retain deploy.** From the reviewed PR head, with administrator - credentials: - - ```bash - cd infra/cdk && npm ci - npx cdk deploy shoc-frontend-dev \ - -c retainForTerraformAdoption=true \ - --parameters ManageSiteInfrastructure=true - ``` +2. **CDK retain deploy.** Completed from the reviewed PR A head. The CDK app + is no longer in this repository. Expected: an update-only change set (no create, no delete, no replace) that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the @@ -175,7 +172,7 @@ Each step is gated. State the impact, get the go, act, read back, record. After Phase 1 CloudFormation still owns every resource. Terraform holds state for them and nothing else. -## Phase 2: controlled ownership transfer (this PR) +## Phase 2: controlled ownership transfer (merged #178) PR B pins `adoption_complete = true`. The controlled apply may update only: @@ -201,23 +198,58 @@ python3 scripts/check-terraform-import-plan.py plan.json --environment dev \ --allow-update-address module.environment_owned.aws_iam_role.github_deploy ``` -After the apply and a no-op plan, deploy the same reviewed CDK SHA with -`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the -13 transferred resources and the custom resource. Never deploy with -`ManageSiteInfrastructure=true` again after that. See -[`infra/cdk/README.md`](../infra/cdk/README.md). +After the apply and a no-op plan, the CDK stack was relinquished with +`ManageSiteInfrastructure=false`. Never deploy that stack with +`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C. -Confirm `dev.seahaven.com` still serves and that a manual `workflow_dispatch` -of `deploy.yml` can still upload with the unchanged GitHub content policy. +Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual +`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged +GitHub content policy. PR C replaces that policy with the release-prefix +document during bootstrap. -## Phase 3: content CD through Terraform (PR C) +## Phase 3: content CD through Terraform (this PR) -Summary only; PR C carries the full design. GitHub builds and uploads to an -immutable `releases/--/` prefix. Terraform owns the -`.release/current` pointer, both origin paths of a CloudFront origin group, -and the invalidation action. Rollback is one guarded Terraform run swapping -the labels. Push-to-`dev` releases return behind the repository variable -`TERRAFORM_CONTENT_CD_ENABLED`. +GitHub builds the SPA and uploads only `releases/--/`. +The GitHub role may `GetObject` on `.release/current` and read the exact +distribution (`GetDistribution` / `GetDistributionConfig`) so verify and +live-state summary can observe origin paths. It cannot invalidate or write +the pointer. Terraform owns `.release/current`, both origin paths of the +CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one +guarded Terraform run that swaps the labels. Push-to-`dev` stays off until +`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls +`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287). + +Release vars `release_version_label` and `previous_release_version_label` are +nullable, default null, and must not be set on the workspace or in tfvars. +Null VCS plans read the pointer back from S3. Empty string is the legacy root +layout. + +Per GitHub content release after bootstrap: exactly two managed updates plus +one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py` +accepts a plan that updates only the pointer `content` and +`origin[*].origin_path`, with `after` equal to the expected labels, `before` +equal to the pointer's prior values, and exactly one invalidation +`action_invocations` entry. + +The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates +`.release/current` (legacy empty labels), adds the previous origin and origin +group, switches the default behavior to the group, replaces the GitHub inline +policy with the release-prefix document, and invokes invalidation. A human +confirms that apply. GitHub CD starts only after bootstrap is applied. + +Activation (each step gated; do not run without an explicit go): + +1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard + the HCP VCS run. Apply bootstrap as a human-confirmed controlled update. +2. Re-read workspace invariants (auto-apply off, speculative on, trigger + patterns only, no prefixes, no tags-regex). +3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation, + smoke, and rollback readiness from the live-state summary. +4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner + approval. +5. Confirm the first push-to-`dev` run. Close SH-300 on that proof. + +A red job does not mean the site is down. Read the live-state summary first. ## Operational rules @@ -240,9 +272,9 @@ the labels. Push-to-`dev` releases return behind the repository variable workspace. - **Re-read the workspace invariants** before the first release after any Terraform merge or workspace settings change. -- **A red job does not mean the site is down.** Read the live state first - (served `index.html`, distribution status, pointer body once PR C lands), - then triage. +- **A red job does not mean the site is down.** Read the live-state summary + first (served `index.html` hash, distribution status, pointer body, both + origin paths), then triage. - **Exact-head evidence.** Every live step records the run URL, the SHA, and a machine-readable read-back on the PR or SH-300. @@ -254,8 +286,11 @@ From the repository root (also run by `npm run verify` through ```bash npm run test:terraform # fmt -check, init -backend=false, validate npm run test:terraform-import-plan # checker unit tests against synthetic plans +npm run test:terraform-release-plan # content-release plan guard npm run test:terraform-isolation # isolation gate unit tests -npm run test:infra # CDK build, template tests, synth in both modes +npm run test:hcp-run-guard # workspace invariant and apply reconcile +npm run test:cloudfront-release-verify +npm run test:github-workflows # bash -n and actionlint ``` `terraform init -backend=false -lockfile=readonly` may download the provider diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf index f11f75d4..f926ffd1 100644 --- a/terraform/live/dev/main.tf +++ b/terraform/live/dev/main.tf @@ -90,4 +90,6 @@ module "environment_owned" { ownership_tags = local.terraform_tags pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag) post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag) + release_version_label = var.release_version_label + previous_release_version_label = var.previous_release_version_label } diff --git a/terraform/live/dev/outputs.tf b/terraform/live/dev/outputs.tf index 726ee1e8..8b9e94c5 100644 --- a/terraform/live/dev/outputs.tf +++ b/terraform/live/dev/outputs.tf @@ -9,3 +9,11 @@ output "distribution_id" { output "deploy_role_arn" { value = module.environment_owned.deploy_role_arn } + +output "current_origin_id" { + value = module.environment_owned.current_origin_id +} + +output "previous_origin_id" { + value = module.environment_owned.previous_origin_id +} diff --git a/terraform/live/dev/variables.tf b/terraform/live/dev/variables.tf new file mode 100644 index 00000000..b280e421 --- /dev/null +++ b/terraform/live/dev/variables.tf @@ -0,0 +1,33 @@ +variable "release_version_label" { + type = string + default = null + nullable = true + + description = "Immutable content release label. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.release_version_label == null || + var.release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label)) + ) + error_message = "release_version_label must be empty or --." + } +} + +variable "previous_release_version_label" { + type = string + default = null + nullable = true + + description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.previous_release_version_label == null || + var.previous_release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label)) + ) + error_message = "previous_release_version_label must be empty or --." + } +} diff --git a/terraform/live/dev/versions.tf b/terraform/live/dev/versions.tf index 9e341837..b8a94b0c 100644 --- a/terraform/live/dev/versions.tf +++ b/terraform/live/dev/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.9.0, < 2.0.0" + required_version = ">= 1.14.0, < 2.0.0" cloud { organization = "seahaven" diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index 6a0e2a61..1bf36612 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -5,6 +5,24 @@ locals { bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags github_subject_operator = var.pre_adoption_github_subject_operator + previous_origin_id = "${var.origin_id}-previous" + origin_group_id = "${var.origin_id}-group" + pointer_key = ".release/current" + pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {}) + # coalesce() skips empty strings, so a null var plus a missing pointer + # would error. Empty string is the legacy root layout and must be valid. + current_label = ( + var.release_version_label != null + ? var.release_version_label + : try(local.pointer_body.current, "") + ) + previous_label = ( + var.previous_release_version_label != null + ? var.previous_release_version_label + : try(local.pointer_body.previous, "") + ) + current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}" + previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}" spa_rewrite_code = join("\n", [ "function handler(event) {", @@ -19,6 +37,17 @@ locals { ]) } +data "aws_s3_objects" "release_prefix" { + bucket = aws_s3_bucket.site.bucket + prefix = ".release/" +} + +data "aws_s3_object" "release_pointer" { + count = contains(coalesce(data.aws_s3_objects.release_prefix.keys, []), local.pointer_key) ? 1 : 0 + bucket = aws_s3_bucket.site.bucket + key = local.pointer_key +} + data "aws_iam_policy_document" "site_bucket" { dynamic "statement" { for_each = var.adoption_complete ? [] : [1] @@ -109,53 +138,48 @@ data "aws_iam_policy_document" "github_deploy_assume" { } data "aws_iam_policy_document" "github_deploy" { - # Byte-identical to the live GitHub content policy through Phase 2 so - # aws_iam_role_policy.github_deploy stays no-op. Phase 3 replaces this - # with the release-prefix policy. - dynamic "statement" { - for_each = var.environment == "dev" ? [1] : [] + statement { + sid = "ListReleasePrefixes" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = [local.bucket_arn] - content { - sid = "AssumeCdkBootstrapRoles" - effect = "Allow" - actions = ["sts:AssumeRole"] - resources = ["arn:aws:iam::${var.aws_account_id}:role/cdk-hnb659fds-*"] + condition { + test = "StringLike" + variable = "s3:prefix" + values = [ + "releases/", + "releases/*", + ] } } statement { - sid = "DescribeStack" - effect = "Allow" - actions = ["cloudformation:DescribeStacks"] - resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"] - } - - statement { + sid = "PublishReleasePrefix" effect = "Allow" actions = [ - "s3:Abort*", - "s3:DeleteObject*", - "s3:GetBucket*", - "s3:GetObject*", - "s3:List*", + "s3:GetObject", "s3:PutObject", - "s3:PutObjectLegalHold", - "s3:PutObjectRetention", - "s3:PutObjectTagging", - "s3:PutObjectVersionTagging", - ] - resources = [ - local.bucket_arn, - "${local.bucket_arn}/*", ] + resources = ["${local.bucket_arn}/releases/*"] } statement { - sid = "InvalidateDistribution" + sid = "ReadReleasePointer" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["${local.bucket_arn}/${local.pointer_key}"] + } + + statement { + sid = "ReadDistribution" effect = "Allow" actions = [ - "cloudfront:CreateInvalidation", - "cloudfront:GetInvalidation", + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", ] resources = [local.distribution_arn] } @@ -233,6 +257,20 @@ resource "aws_s3_bucket_policy" "site" { } } +resource "aws_s3_object" "release_pointer" { + bucket = aws_s3_bucket.site.bucket + key = local.pointer_key + content_type = "application/json" + content = jsonencode({ + current = local.current_label + previous = local.previous_label + }) + + lifecycle { + prevent_destroy = true + } +} + resource "aws_cloudfront_origin_access_control" "site" { name = var.origin_access_control_name description = var.origin_access_control_description @@ -275,6 +313,32 @@ resource "aws_cloudfront_distribution" "site" { domain_name = aws_s3_bucket.site.bucket_regional_domain_name origin_access_control_id = aws_cloudfront_origin_access_control.site.id origin_id = var.origin_id + origin_path = local.current_origin_path + } + + origin { + connection_attempts = 3 + connection_timeout = 10 + domain_name = aws_s3_bucket.site.bucket_regional_domain_name + origin_access_control_id = aws_cloudfront_origin_access_control.site.id + origin_id = local.previous_origin_id + origin_path = local.previous_origin_path + } + + origin_group { + origin_id = local.origin_group_id + + failover_criteria { + status_codes = [403, 404] + } + + member { + origin_id = var.origin_id + } + + member { + origin_id = local.previous_origin_id + } } default_cache_behavior { @@ -282,7 +346,7 @@ resource "aws_cloudfront_distribution" "site" { cache_policy_id = var.cache_policy_id cached_methods = ["GET", "HEAD"] compress = true - target_origin_id = var.origin_id + target_origin_id = local.origin_group_id viewer_protocol_policy = "redirect-to-https" function_association { @@ -305,6 +369,18 @@ resource "aws_cloudfront_distribution" "site" { lifecycle { prevent_destroy = true + + action_trigger { + events = [after_update] + actions = [action.aws_cloudfront_create_invalidation.release] + } + } +} + +action "aws_cloudfront_create_invalidation" "release" { + config { + distribution_id = aws_cloudfront_distribution.site.id + paths = ["/*"] } } diff --git a/terraform/live/modules/environment-owned/outputs.tf b/terraform/live/modules/environment-owned/outputs.tf index 44f519a7..ef7ebee9 100644 --- a/terraform/live/modules/environment-owned/outputs.tf +++ b/terraform/live/modules/environment-owned/outputs.tf @@ -12,3 +12,33 @@ output "deploy_role_arn" { value = aws_iam_role.github_deploy.arn description = "Imported GitHub deployment role ARN." } + +output "current_release_label" { + value = local.current_label + description = "Pointer current release label. Empty string is the legacy root layout." +} + +output "previous_release_label" { + value = local.previous_label + description = "Pointer previous release label. Empty string is the legacy root layout." +} + +output "current_origin_path" { + value = local.current_origin_path + description = "CloudFront origin_path for the current member of the origin group." +} + +output "previous_origin_path" { + value = local.previous_origin_path + description = "CloudFront origin_path for the previous member of the origin group." +} + +output "current_origin_id" { + value = var.origin_id + description = "CloudFront origin ID for the current release." +} + +output "previous_origin_id" { + value = local.previous_origin_id + description = "CloudFront origin ID for the previous release." +} diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index 69434ebd..05d6b7a9 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -10,10 +10,44 @@ variable "environment" { variable "adoption_complete" { type = bool - description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy. The GitHub deploy inline policy stays byte-identical to live until the content-CD PR." + description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy." default = false } +variable "release_version_label" { + type = string + default = null + nullable = true + + description = "Immutable content release label. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.release_version_label == null || + var.release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label)) + ) + error_message = "release_version_label must be empty or --." + } +} + +variable "previous_release_version_label" { + type = string + default = null + nullable = true + + description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.previous_release_version_label == null || + var.previous_release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label)) + ) + error_message = "previous_release_version_label must be empty or --." + } +} + variable "aws_account_id" { type = string description = "AWS account containing the resources."