diff --git a/.github/renovate.json b/.github/renovate.json index af6c5ee0..38bd081a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "enabledManagers": ["npm", "custom.regex"], + "enabledManagers": ["npm", "custom.regex", "terraform"], "minimumReleaseAge": "3 days", "internalChecksFilter": "strict", "customManagers": [ @@ -17,6 +17,12 @@ } ], "packageRules": [ + { + "description": ["Group non-major Terraform provider updates"], + "matchManagers": ["terraform"], + "matchUpdateTypes": ["minor", "patch"], + "groupName": "terraform minor and patch" + }, { "description": ["Do not open major or replacement PRs until approved on the dashboard"], "matchUpdateTypes": ["major", "replacement"], diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2222ef4f..8a49b667 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,9 +23,11 @@ jobs: # repository, independent of (and in addition to) the reusable workflow. # `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). If the reusable workflow is later confirmed to run - # every gate, this job can be slimmed to `npm run governance`. + # checks in scripts/governance-check.mjs (godfile ratchet, changed-file + # 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/) @@ -53,10 +55,28 @@ jobs: base="origin/dev" fi printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - name: Set up Terraform + # Same minor as the HCP workspace (1.16.x) so fmt/validate see what + # the remote run will see. + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 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 6ad9bde2..a388a043 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,60 +1,299 @@ -name: Deploy +name: Deploy dev content -# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`. +# 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. # -# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs -# `cdk deploy` (provisioning the infra in infra/cdk) and then the -# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates -# CloudFront. Both run as the OIDC deploy role created by the stack. -# -# When staging/prod accounts exist, add jobs keyed to their branches and their -# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow. +# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not +# re-run those gates on pull requests, pushes, or workflow_dispatch. on: push: branches: [dev] + paths-ignore: + - "terraform/**" workflow_dispatch: {} -# OIDC needs id-token: write — it is never in the default token set and cannot -# be granted to the reusable workflow unless the caller has it. permissions: - id-token: write contents: read -concurrency: - group: deploy-dev - cancel-in-progress: false - jobs: - deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 - with: - node-version: "24" - region: us-east-1 - cdk-dir: infra/cdk - stack-name: shoc-frontend-dev - post-deploy-script: scripts/deploy-web.sh - secrets: - deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} - - upload-sourcemaps: - name: Upload private source maps - needs: deploy - if: github.ref == 'refs/heads/dev' + 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') 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 - with: - fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: npm - - name: Build exact deployed release - run: npm ci && npm run build - - name: Upload source maps to Sentry + + - 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 + + - 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: Read previous release pointer + id: pointer + run: | + set -euo pipefail + body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)" + printf '%s' "${body}" | python3 scripts/read-release-pointer.py + + - 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 s3api head-object \ + --bucket "${SITE_BUCKET}" \ + --key "${prefix}/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 + + - 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/.github/workflows/terraform-isolation.yaml b/.github/workflows/terraform-isolation.yaml new file mode 100644 index 00000000..92cc9d38 --- /dev/null +++ b/.github/workflows/terraform-isolation.yaml @@ -0,0 +1,43 @@ +name: Terraform isolation + +# Own workflow so labeled/unlabeled re-evaluate this gate without starting a +# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events +# would report required checks as success and could merge a failing SHA. + +on: + pull_request: + branches: [main, dev, staging] + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + +permissions: + contents: read + +jobs: + terraform-isolation: + # Fails a pull request that changes Terraform infrastructure together with + # deployable application code (scripts/check-terraform-isolation.mjs). A + # merge that does both queues an HCP VCS run and a content release at the + # same time, and the two race for the workspace lock. The + # `terraform-isolation-override` label is the reviewed exception. This + # job is unconditional so adding or removing that label always reads the + # current label set; a previous green check does not survive removal. + name: Terraform and application changes are isolated + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Check changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }} + run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}" diff --git a/.gitignore b/.gitignore index aabcecf0..9536adc7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,12 +38,14 @@ 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 +*.tfstate.* +*.tfplan +*.tfvars +*.tfvars.json + +# python +__pycache__/ +*.py[cod] diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index b28babd0..61fd26f6 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -7,22 +7,33 @@ npm run verify ``` `verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) → -`test` (`vitest run`) → `governance`. A task is not done until this is green. +`test` (`vitest run`) → `governance`. Governance also runs the repository +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 | +| 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 @@ -36,6 +47,18 @@ npm run verify - **Changed-file maintainability fails closed without a valid base** — in CI the base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before` (push). An absent or unresolvable base is a failure, not a pass. +- **Terraform gates never touch live state** — `terraform init -backend=false +-lockfile=readonly` and `validate` run offline; the plan checker is tested + against synthetic plan JSON. Real import and controlled-update plans from HCP + are migration evidence reviewed by a human before an approved apply + (`terraform/README.md`). +- **The isolation gate re-evaluates on label changes** — the + `terraform-isolation-override` label is the only way to merge a mixed + Terraform/application PR. `.github/workflows/terraform-isolation.yaml` + runs `terraform-isolation` on `labeled` and `unlabeled` as well as the + default pull-request types, so adding or removing the label re-checks + the current labels without starting a new Frontend checks run. Removing + the label fails a mixed PR that had previously passed with the override. ## Where the gates run @@ -44,11 +67,17 @@ npm run verify - **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs format/lint/build/tests, **and** a repo-owned `governance` job runs - `npm run verify` so the maintainability ratchets are guaranteed from this - repository regardless of the reusable workflow. + `npm run verify` (with Terraform 1.16.0 installed) so the maintainability + ratchets and repository gates are guaranteed from this repository regardless + of the reusable workflow. +- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):** + on pull requests, fails when Terraform infrastructure and application code + change together. Label add/remove re-runs only this workflow. ## Toolchain pin 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`. +`package-lock.json` v3; install with `npm ci`. Governance also needs +`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 51fc21d2..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,21 +18,25 @@ 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 push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev] - ROLE -->|cdk deploy + s3 sync + invalidation| S3 + GH[GitHub Actions] -->|OIDC upload releases/*| S3 + TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF ``` +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, Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the @@ -40,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 | | ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | @@ -57,12 +61,13 @@ No Lambdas, queues, or databases — this stack is static hosting only. ### Secrets -No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions -repo secret**: +No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role +ARNs are deterministic and pinned in the workflows. The one **GitHub Actions +repo secret** is: -| Secret | Purpose | -| --------------------- | ----------------------------------------------------------------------------------- | -| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow | +| Secret | Purpose | +| ------------------- | ------------------------------------------------------------------ | +| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy | ### Environment variables (build-time, `VITE_*`) @@ -77,8 +82,8 @@ repo secret**: 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 CI 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 @@ -95,17 +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` | Frontend governance checks (godfile + maintainability) | -| `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 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 @@ -123,66 +131,74 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or a green CI run and an approving review from a code owner (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals. Merged branches are deleted automatically. -- Promotion flow: `feature/* → dev` (auto-deployed and verified on - `dev.seahaven.com`) `→ main` (production promotion — no prod environment - exists yet). +- 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 + Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`) + `→ main` (production promotion — no prod environment exists yet). ## Deployment -CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only): +No stored AWS keys — OIDC only. Infrastructure and content deploy separately: - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push - and PRs to `main`/`dev`, calls + and PRs to `main`/`dev`/`staging`, calls `Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24): 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)) is - guaranteed from this repository. Conventions and gates are documented under + ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs), + 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 [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). -- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on - push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which - runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`) - and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, - `aws s3 sync dist/` (hashed assets immutable, `index.html` never cached), - CloudFront invalidation. Both run as the OIDC deploy role. +- **Terraform isolation** + ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)) + — 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`, 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 HCP Terraform workspace + `shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)). + Staging hosting stays on the existing CloudFormation stack until SH-287. -One-time provisioning (OIDC provider, CDK bootstrap, first local deploy, -setting `AWS_DEPLOY_ROLE_ARN`) is documented in -[`infra/cdk/README.md`](infra/cdk/README.md). - -Manual deploy (emergency/reference only — needs credentials for the -external-dev AWS account; the normal path is push to `dev`): - -```bash -(cd infra/cdk && npx cdk deploy) -STACK_NAME=shoc-frontend-dev 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 -- **Verify:** open after a green **Deploy** run in - the Actions tab; confirm a deep link (e.g. a work-orders route) loads - directly and API calls succeed. +- **Verify:** open after a green **Deploy dev + content** run in the Actions tab; confirm a deep link (e.g. a work-orders + route) loads directly and API calls succeed. - **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There 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. - - _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev` - on this repo; deploys from other branches/repos are rejected by design. + - _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. - _Broken API requests after a build_ — `VITE_API_URL` missing the `/api` 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`. -- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still - deploys (matches the org's push-time-CD model; gating deploy on CI is known - follow-up work). +- **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 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 364780af..00000000 --- a/infra/cdk/README.md +++ /dev/null @@ -1,221 +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, -deployed through the org's **reusable** GitHub Actions workflow. - -- **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 the CI `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) -- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's - `Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy` - (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). -- **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`. -- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and - `staging` (push to `staging`, via the standalone `deploy-staging.yml`). - -``` -infra/cdk/ - bin/app.ts entry point (reads -c context) - lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role -scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation -.github/workflows/ - ci.yaml quality gates (lint / build / test / e2e) - deploy.yml caller of the org reusable cd-cdk.yaml (push to 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 whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on -`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's -pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`), -and `cloudfront:CreateInvalidation` (cache bust). 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. - ---- - -## 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 push the first content (or just push to -`dev` and let CI do everything from here on): - -```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. Set the one GitHub secret - -`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable): - -```bash -REPO=Sea-Haven-Industries/shoc-frontend-new -gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \ - --body "arn:aws:iam:::role/githubdeploy-shoc-frontend-new-dev" -``` - -(Or **Settings → Secrets and variables → Actions → Secrets**.) - -### 7. From now on: push to `dev` - -```bash -git push origin dev -``` - -`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs -`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open -the `SiteUrl` output. - -> First-run verification: this first push is what actually exercises the role's -> permissions and the OIDC trust through the reusable workflow (the local -> bootstrap used admin creds and tested none of that). Watch for -> credential/OIDC errors and a green post-deploy step. - ---- - -## Staging environment (same account, exact OIDC subject) - -Staging lives in the same AWS account (396287094661) but deploys through its -own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org -reusable `cd-cdk.yaml`: - -- **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. -- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging - differs only in that its CD workflow also runs `npm run verify` itself - before deploying); a red-CI commit still deploys on `dev` (matches the - org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part - of enabling CICD. -- **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 876463fe..00000000 --- a/infra/cdk/bin/app.ts +++ /dev/null @@ -1,51 +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") ?? ""; - -// 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, - 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 dda5f67e..00000000 --- a/infra/cdk/lib/frontend-stack.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } 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"; - -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; -} - -/** - * 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. The org's reusable - * `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to - * build the SPA, sync it to this bucket, and invalidate CloudFront — so this - * stack only owns the infrastructure, and the deploy role carries the - * permissions those post-deploy steps need. - */ -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, - } = props; - - 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. - 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), - 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. Without one (dev): keep - // the branch-ref trust, where StringLike scopes `sub` to pushes on the - // deploy branch (reusable-workflow runs still carry the caller-based sub). - const oidcConditions = githubEnvironment - ? { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, - }, - } - : { - 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 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), - }); - - // Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is - // intentionally narrower: its recurring promotion workflow only publishes - // application assets to this stack's bucket/distribution. Infrastructure - // changes remain an administrator-run CDK operation, so the staging OIDC - // role cannot inherit the bootstrap roles' account-wide deployment power. - 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. - 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]; - - new route53.ARecord(this, "AliasA", { zone, recordName, target }); - new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target }); - } - - // --- Outputs ----------------------------------------------------------- - // scripts/deploy-web.sh reads BucketName + DistributionId from these. - new CfnOutput(this, "SiteUrl", { - value: hasCustomDomain - ? `https://${domainNames[0]}` - : `https://${distribution.distributionDomainName}`, - description: "Public URL of the deployed SPA", - }); - new CfnOutput(this, "DistributionDomainName", { - value: distribution.distributionDomainName, - description: "CloudFront domain — point the custom-domain DNS record here", - }); - new CfnOutput(this, "BucketName", { - value: bucket.bucketName, - }); - new CfnOutput(this, "DistributionId", { - value: distribution.distributionId, - }); - new CfnOutput(this, "DeployRoleArn", { - value: deployRole.roleArn, - description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN", - }); - } -} 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 b490e706..00000000 --- a/infra/cdk/package.json +++ /dev/null @@ -1,29 +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", - "synth": "cdk synth", - "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/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 ed95210d..c1a28231 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,13 @@ "test:e2e": "playwright test", "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: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 new file mode 100755 index 00000000..1e65685f --- /dev/null +++ b/scripts/check-terraform-import-plan.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +"""Reject plans that violate the frontend Terraform adoption boundary.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from terraform_import_plan_resources import ( + CONTROLLED_UPDATE_ADDRESSES, + ENVIRONMENT_CONFIG, + REQUIRED_IMPORT_IDS, + REQUIRED_RESOURCES, +) + +BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site" +BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site" +DISTRIBUTION_ADDRESS = ( + "module.environment_owned.aws_cloudfront_distribution.site" +) +ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy" +TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS} +OWNERSHIP_TAGS = { + "Environment": None, + "ManagedBy": "terraform", + "Ownership": "terraform", + "Project": "shoc-frontend", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("plan_json", type=Path) + parser.add_argument( + "--environment", + required=True, + choices=sorted(REQUIRED_RESOURCES), + help="Exact environment ownership boundary expected in the plan.", + ) + modes = parser.add_mutually_exclusive_group() + modes.add_argument( + "--post-import-no-op", + action="store_true", + help=( + "Require all managed resources to be no-op after import and forbid " + "import metadata." + ), + ) + modes.add_argument( + "--allow-update-address", + action="append", + default=[], + metavar="ADDRESS", + help=( + "Enter controlled-update mode and allow one exact reviewed address. " + "Repeat for every expected update." + ), + ) + return parser.parse_args() + + +def _load_plan(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("plan JSON root must be an object") + if not isinstance(value.get("resource_changes"), list): + raise ValueError("plan JSON must contain a resource_changes array") + return value + + +def _validate_import_metadata( + *, + address: str, + change: dict[str, Any], + environment: str, +) -> list[str]: + importing = change.get("importing") + if not isinstance(importing, dict) or set(importing) != {"id"}: + return [f"{address}: import metadata must be exactly {{'id': }}"] + + import_id = importing.get("id") + if not isinstance(import_id, str) or not import_id.strip(): + return [f"{address}: import ID must be a non-empty string"] + if import_id.startswith("REPLACE_WITH_"): + return [f"{address}: import ID is still a placeholder"] + + expected = REQUIRED_IMPORT_IDS[environment][address] + if expected is not None and import_id != expected: + return [f"{address}: expected import ID {expected!r}, got {import_id!r}"] + + other_environment_ids = { + imports[address] + for name, imports in REQUIRED_IMPORT_IDS.items() + if name != environment and imports[address] is not None + } + if import_id in other_environment_ids: + return [f"{address}: import ID belongs to another environment"] + return [] + + +def _contains_unknown(value: Any) -> bool: + if value is True: + return True + if isinstance(value, dict): + return any(_contains_unknown(item) for item in value.values()) + if isinstance(value, list): + return any(_contains_unknown(item) for item in value) + return False + + +def _changed_leaf_paths( + before: Any, + after: Any, + path: tuple[str, ...] = (), +) -> set[tuple[str, ...]]: + if isinstance(before, dict) and isinstance(after, dict): + result: set[tuple[str, ...]] = set() + for key in set(before) | set(after): + result.update( + _changed_leaf_paths( + before.get(key), + after.get(key), + (*path, str(key)), + ) + ) + return result + if before != after: + return {path} + return set() + + +def _canonical(value: Any) -> Any: + if isinstance(value, dict): + return {key: _canonical(value[key]) for key in sorted(value)} + if isinstance(value, list): + items = [_canonical(item) for item in value] + return sorted(items, key=lambda item: json.dumps(item, sort_keys=True)) + return value + + +def _parse_policy(value: Any, address: str, side: str) -> tuple[Any, list[str]]: + if not isinstance(value, str): + return None, [f"{address}: {side} policy must be a JSON string"] + try: + document = json.loads(value) + except json.JSONDecodeError: + return None, [f"{address}: {side} policy is not valid JSON"] + if not isinstance(document, dict): + return None, [f"{address}: {side} policy must be a JSON object"] + return _canonical(document), [] + + +def _distribution_id( + plan: dict[str, Any], + environment: str, +) -> str | None: + configured = ENVIRONMENT_CONFIG[environment]["distribution_id"] + if isinstance(configured, str): + return configured + for resource in plan["resource_changes"]: + if not isinstance(resource, dict) or resource.get("address") != DISTRIBUTION_ADDRESS: + continue + after = resource.get("change", {}).get("after") + if isinstance(after, dict): + identifier = after.get("id") + if isinstance(identifier, str) and identifier.strip(): + return identifier + return None + + +def _expected_pre_adoption_bucket_policy( + environment: str, + distribution_id: str, +) -> dict[str, Any]: + config = ENVIRONMENT_CONFIG[environment] + bucket_arn = f"arn:aws:s3:::{config['bucket_name']}" + distribution_arn = ( + f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}" + ) + return _canonical( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": config["bucket_auto_delete_helper_role_arn"] + }, + "Action": [ + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:List*", + "s3:PutBucketPolicy", + ], + "Resource": [bucket_arn, f"{bucket_arn}/*"], + }, + { + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": "s3:GetObject", + "Resource": f"{bucket_arn}/*", + "Condition": { + "StringEquals": {"AWS:SourceArn": distribution_arn} + }, + }, + { + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Action": "s3:*", + "Resource": [bucket_arn, f"{bucket_arn}/*"], + "Condition": {"Bool": {"aws:SecureTransport": "false"}}, + }, + ], + } + ) + + +def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]: + bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] + bucket_arn = f"arn:aws:s3:::{bucket}" + distribution_arn = ( + f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}" + ) + return _canonical( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": "s3:GetObject", + "Resource": f"{bucket_arn}/*", + "Condition": { + "StringEquals": {"AWS:SourceArn": distribution_arn} + }, + }, + { + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Action": "s3:*", + "Resource": [bucket_arn, f"{bucket_arn}/*"], + "Condition": {"Bool": {"aws:SecureTransport": "false"}}, + }, + ], + } + ) + + +def _validate_tag_update( + address: str, + before: dict[str, Any], + after: dict[str, Any], + environment: str, +) -> list[str]: + changed = _changed_leaf_paths(before, after) + invalid = { + path + for path in changed + if len(path) != 2 or path[0] not in {"tags", "tags_all"} + } + violations = [ + f"{address}: controlled tag update changes forbidden path {'.'.join(path)}" + for path in sorted(invalid) + ] + expected = {**OWNERSHIP_TAGS, "Environment": environment} + if address == ROLE_ADDRESS: + expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][ + "workspace_name" + ] + if address == BUCKET_ADDRESS: + expected["aws-cdk:auto-delete-objects"] = None + expected_after = { + key: value for key, value in expected.items() if value is not None + } + for tag_attribute in ("tags", "tags_all"): + if after.get(tag_attribute) != expected_after: + violations.append( + f"{address}: {tag_attribute} must exactly match adopted ownership tags" + ) + for path in sorted(changed - invalid): + key = path[1] + if key not in expected: + violations.append(f"{address}: tag {key!r} is not an ownership tag") + elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}): + violations.append( + f"{address}: legacy auto-delete ownership tag was not removed" + ) + elif after.get(path[0], {}).get(key) != expected[key]: + violations.append( + f"{address}: tag {key!r} does not have its expected adopted value" + ) + if not changed: + violations.append(f"{address}: update has no changed leaf values") + return violations + + +def _validate_policy_update( + address: str, + before: dict[str, Any], + after: dict[str, Any], + environment: str, + distribution_id: str | None, +) -> list[str]: + changed = _changed_leaf_paths(before, after) + if changed != {("policy",)}: + return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"] + before_policy, violations = _parse_policy(before.get("policy"), address, "before") + after_policy, after_violations = _parse_policy( + after.get("policy"), address, "after" + ) + violations.extend(after_violations) + if before_policy == after_policy: + violations.append(f"{address}: policy semantics did not change") + if distribution_id is None: + violations.append( + f"{address}: cannot verify policy without the pinned distribution ID" + ) + return violations + expected_before = _expected_pre_adoption_bucket_policy( + environment, distribution_id + ) + expected_after = _expected_bucket_policy(environment, distribution_id) + if before_policy is not None and before_policy != expected_before: + violations.append(f"{address}: pre-adoption policy semantics are not exact") + if after_policy is not None and after_policy != expected_after: + violations.append(f"{address}: post-adoption policy semantics are not exact") + return violations + + +def _validate_controlled_update( + address: str, + change: dict[str, Any], + environment: str, + distribution_id: str | None, +) -> list[str]: + violations: list[str] = [] + replace_paths = change.get("replace_paths", []) + if replace_paths not in (None, []): + violations.append(f"{address}: replace_paths must be empty") + if _contains_unknown(change.get("after_unknown", {})): + violations.append(f"{address}: controlled update contains unknown values") + before = change.get("before") + after = change.get("after") + if not isinstance(before, dict) or not isinstance(after, dict): + return [*violations, f"{address}: controlled update requires before/after objects"] + if address in TAG_UPDATE_ADDRESSES: + violations.extend(_validate_tag_update(address, before, after, environment)) + elif address == BUCKET_POLICY_ADDRESS: + violations.extend( + _validate_policy_update( + address, + before, + after, + environment, + distribution_id, + ) + ) + return violations + + +def check_plan( + plan: dict[str, Any], + *, + environment: str, + mode: str, + allowed_updates: set[str], +) -> list[str]: + violations: list[str] = [] + invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES + for address in sorted(invalid_allowed): + violations.append( + f"{address}: address is not eligible for the controlled adoption update" + ) + + distribution_id = _distribution_id(plan, environment) + seen_addresses: set[str] = set() + seen_updates: set[str] = set() + required_resources = REQUIRED_RESOURCES[environment] + for resource in plan["resource_changes"]: + if not isinstance(resource, dict): + violations.append(": resource change must be an object") + continue + if resource.get("mode", "managed") != "managed": + continue + address = resource.get("address") + if not isinstance(address, str): + violations.append(": managed resource has no valid address") + continue + if address in seen_addresses: + violations.append(f"{address}: duplicate managed resource change") + seen_addresses.add(address) + + expected_type = required_resources.get(address) + if expected_type is None: + violations.append(f"{address}: managed address is outside the ownership boundary") + elif resource.get("type") != expected_type: + violations.append( + f"{address}: expected managed type {expected_type!r}, " + f"got {resource.get('type')!r}" + ) + + change = resource.get("change") + if not isinstance(change, dict): + violations.append(f"{address}: missing change object") + continue + actions = change.get("actions") + if not isinstance(actions, list) or not all( + isinstance(action, str) for action in actions + ): + violations.append(f"{address}: actions must be a string array") + continue + + if change.get("replace_paths") not in (None, []): + violations.append(f"{address}: replace_paths must be empty") + + if mode == "import": + if actions != ["no-op"]: + violations.append( + f"{address}: import mode requires no-op, got {actions!r}" + ) + if expected_type is not None: + violations.extend( + _validate_import_metadata( + address=address, + change=change, + environment=environment, + ) + ) + elif mode == "post-import": + if actions != ["no-op"]: + violations.append( + f"{address}: post-import mode requires no-op, got {actions!r}" + ) + if "importing" in change: + violations.append( + f"{address}: import metadata is forbidden in post-import mode" + ) + else: + if "importing" in change: + violations.append( + f"{address}: import metadata is forbidden in controlled-update mode" + ) + if actions == ["update"]: + seen_updates.add(address) + if address not in allowed_updates: + violations.append(f"{address}: update is not explicitly allowlisted") + else: + violations.extend( + _validate_controlled_update( + address, + change, + environment, + distribution_id, + ) + ) + elif actions != ["no-op"]: + violations.append(f"{address}: unsafe controlled actions {actions!r}") + + for missing in sorted(set(required_resources) - seen_addresses): + violations.append(f"{missing}: required managed resource is absent") + for unused in sorted(allowed_updates - seen_updates): + violations.append(f"{unused}: allowlisted update address is not updating") + return violations + + +def main() -> int: + args = parse_args() + try: + plan = _load_plan(args.plan_json) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr) + return 1 + + allowed_updates = set(args.allow_update_address or []) + if args.post_import_no_op: + mode = "post-import" + elif allowed_updates: + mode = "controlled" + else: + mode = "import" + violations = check_plan( + plan, + environment=args.environment, + mode=mode, + allowed_updates=allowed_updates, + ) + if violations: + print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + + label = { + "import": "zero-change import", + "post-import": "post-import no-op", + "controlled": "controlled update", + }[mode] + print( + f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} " + f"managed resources and {len(allowed_updates)} exact updates" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-terraform-isolation.mjs b/scripts/check-terraform-isolation.mjs new file mode 100644 index 00000000..6386456b --- /dev/null +++ b/scripts/check-terraform-isolation.mjs @@ -0,0 +1,137 @@ +// Terraform/application change isolation gate. +// +// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run +// on the workspace. If the same merge also changes deployable application +// code, the content release and the VCS run race for the workspace lock +// (backend incident, 2026-09-04). This gate fails a pull request that mixes the +// two, so Terraform changes ship in their own PR and their VCS run is confirmed +// or discarded by a human before the next content release. +// +// Files that may accompany a Terraform change without triggering a release: +// the Terraform tree itself, its plan-guard tooling, and documentation. +// +// Usage: +// node scripts/check-terraform-isolation.mjs --base --head +// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin +// +// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets +// it only when the PR carries the `terraform-isolation-override` label, which +// reviewers grant to the rare change that must introduce Terraform variables +// together with the workflow that consumes them. The checker has no memory of +// a previous pass: the same mixed diff fails again as soon as the override +// env is unset (label removal). +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const OVERRIDE_LABEL = "terraform-isolation-override"; + +export function isTerraformPath(file) { + return file.startsWith("terraform/"); +} + +// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers +// are terraform/live/dev/** and terraform/live/modules/**), so it is not a +// Terraform change for the mixed-PR check. +export function isTerraformInfrastructurePath(file) { + return isTerraformPath(file) && !file.endsWith(".md"); +} + +export function mayAccompanyTerraform(file) { + if (isTerraformPath(file)) return true; + 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; +} + +/** + * @param {string[]} files changed paths relative to the repository root + * @returns {{ terraform: string[], application: string[], mixed: boolean }} + */ +export function classifyChangedFiles(files) { + const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort(); + const terraform = unique.filter(isTerraformInfrastructurePath); + const application = unique.filter((file) => !mayAccompanyTerraform(file)); + return { + terraform, + application, + mixed: terraform.length > 0 && application.length > 0, + }; +} + +function changedFilesFromGit(base, head) { + const mergeBase = execFileSync("git", ["merge-base", base, head], { + cwd: ROOT, + encoding: "utf8", + }).trim(); + return execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head], + { cwd: ROOT, encoding: "utf8" }, + ) + .split("\n") + .filter(Boolean); +} + +function parseArgs(argv) { + const options = { base: null, head: "HEAD", stdin: false }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--base") options.base = argv[++index]; + else if (argument === "--head") options.head = argv[++index]; + else if (argument === "--stdin") options.stdin = true; + else throw new Error(`unknown argument: ${argument}`); + } + if (!options.stdin && !options.base) { + throw new Error("provide --base (and optionally --head ) or --stdin"); + } + return options; +} + +function main(argv) { + const options = parseArgs(argv); + const files = options.stdin + ? readFileSync(0, "utf8").split("\n") + : changedFilesFromGit(options.base, options.head); + const result = classifyChangedFiles(files); + const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true"; + + console.log("─".repeat(64)); + console.log( + `terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`, + ); + if (!result.mixed) { + console.log(" PASS: Terraform and application changes are not mixed"); + return 0; + } + console.log(" Terraform files:"); + for (const file of result.terraform) console.log(` ${file}`); + console.log(" Application files that cannot ship in the same PR:"); + for (const file of result.application) console.log(` ${file}`); + if (override) { + console.log( + ` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`, + ); + return 0; + } + console.log( + ` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`, + ); + return 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/check-terraform-isolation.test.mjs b/scripts/check-terraform-isolation.test.mjs new file mode 100644 index 00000000..c2913a2f --- /dev/null +++ b/scripts/check-terraform-isolation.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + OVERRIDE_LABEL, + classifyChangedFiles, + isTerraformInfrastructurePath, + mayAccompanyTerraform, +} from "./check-terraform-isolation.mjs"; + +const SCRIPT = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "check-terraform-isolation.mjs", +); + +function runGate(files, env = {}) { + return spawnSync(process.execPath, [SCRIPT, "--stdin"], { + input: `${files.join("\n")}\n`, + encoding: "utf8", + env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env }, + }); +} + +test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => { + for (const file of [ + "terraform/live/dev/main.tf", + "terraform/live/modules/environment-owned/main.tf", + "terraform/README.md", + "README.md", + "docs/adr/0003-terraform.md", + "scripts/check-terraform-import-plan.py", + "scripts/terraform_import_plan_resources.py", + "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, and dependency files count as application changes", () => { + for (const file of [ + "src/App.tsx", + "public/favicon.ico", + "index.html", + "package.json", + "package-lock.json", + ".env.production", + "vite.config.ts", + ".github/workflows/deploy.yml", + "scripts/deploy-web.sh", + "scripts/governance-check.mjs", + "e2e/login.spec.ts", + ]) { + assert.equal(mayAccompanyTerraform(file), false, file); + } +}); + +test("terraform-only and application-only changes are not mixed", () => { + assert.equal( + classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed, + false, + ); + assert.equal( + classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed, + false, + ); + assert.equal(classifyChangedFiles([]).mixed, false); +}); + +test("terraform documentation does not mix with application or workflow changes", () => { + assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false); + assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true); + assert.equal( + classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed, + false, + ); + const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]); + assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr); + assert.match(docsOnly.stdout, /PASS/); +}); + +test("terraform plus application is mixed and lists the offending files", () => { + const result = classifyChangedFiles([ + "terraform/live/dev/main.tf", + "src/App.tsx", + "README.md", + " ", + "src/App.tsx", + ]); + assert.equal(result.mixed, true); + assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]); + assert.deepEqual(result.application, ["src/App.tsx"]); +}); + +test("CLI exits 1 on a mixed change and 0 when isolated", () => { + const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]); + assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr); + assert.match(mixed.stdout, /FAIL/); + assert.match(mixed.stdout, /src\/App\.tsx/); + + const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]); + assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr); + assert.match(isolated.stdout, /PASS/); +}); + +test("CLI override downgrades a mixed change to a warning that names the label", () => { + const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], { + TERRAFORM_ISOLATION_OVERRIDE: "true", + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /WARNING/); + assert.match(result.stdout, new RegExp(OVERRIDE_LABEL)); + + const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], { + TERRAFORM_ISOLATION_OVERRIDE: "yes", + }); + assert.equal(notTrue.status, 1); +}); + +test("removing the override fails a mixed change that was previously green", () => { + const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"]; + const previouslyGreen = runGate(files, { + TERRAFORM_ISOLATION_OVERRIDE: "true", + }); + assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr); + assert.match(previouslyGreen.stdout, /WARNING/); + + // CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is + // the string "false" after the label is removed. A stale green check must + // not survive that. + const afterLabelRemoved = runGate(files, { + TERRAFORM_ISOLATION_OVERRIDE: "false", + }); + assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr); + assert.match(afterLabelRemoved.stdout, /FAIL/); + assert.match(afterLabelRemoved.stdout, /deploy\.yml/); +}); + +test("CLI refuses to run without a base ref or --stdin", () => { + const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" }); + assert.notEqual(result.status, 0); +}); + +test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => { + const workflows = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ".github/workflows", + ); + const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8"); + const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8"); + + for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) { + assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType); + } + + assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m); + assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m); + assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m); + assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/); + + assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m); + assert.match(isolationYaml, /name: Terraform and application changes are isolated/); + assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/); +}); diff --git a/scripts/check-terraform-release-plan.py b/scripts/check-terraform-release-plan.py new file mode 100755 index 00000000..03ae590f --- /dev/null +++ b/scripts/check-terraform-release-plan.py @@ -0,0 +1,533 @@ +#!/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 + (response_completion_timeout 0, null, and a missing key are equivalent) + - 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", + } +) +# Not an after_unknown allowlist. AWS returns 0 when the timeout is unset; +# the provider writes null on origin_path updates. Treat 0, null, and a +# missing key as the same. Any other value still fails closed. +ORIGIN_RESPONSE_COMPLETION_TIMEOUT = "response_completion_timeout" +ORIGIN_TIMEOUT_UNSET = frozenset({0, None}) +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_fields_for_compare(origin: dict[str, Any]) -> dict[str, Any]: + rest = {key: value for key, value in origin.items() if key != "origin_path"} + timeout = rest.get(ORIGIN_RESPONSE_COMPLETION_TIMEOUT) + if timeout in ORIGIN_TIMEOUT_UNSET: + rest.pop(ORIGIN_RESPONSE_COMPLETION_TIMEOUT, None) + return rest + + +def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool: + return _origin_fields_for_compare(before) != _origin_fields_for_compare(after) + + +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/deploy-web.sh b/scripts/deploy-web.sh index 2dfb66a2..ec64a742 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash # -# Post-deploy step for the org reusable workflow `cd-cdk.yaml` -# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`). +# Content publish step for the environment deploy workflows +# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`). # -# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub -# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with -# the right cache headers, and invalidates CloudFront. +# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the +# environment's S3 bucket with the right cache headers, and invalidates +# CloudFront. It never touches infrastructure. # -# Runs from the repo root. Reads the bucket + distribution from stack outputs, -# so it has no hardcoded resource IDs. +# Runs from the repo root. The target is resolved from, in order: +# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by +# dev, whose CloudFormation outputs disappear during Terraform adoption) +# 2. the BucketName/DistributionId outputs of STACK_NAME (staging) set -euo pipefail STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" @@ -20,21 +22,31 @@ export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" npm ci npm run build -echo "Reading stack outputs from ${STACK_NAME}..." -stack_output() { - aws cloudformation describe-stacks \ - --stack-name "${STACK_NAME}" \ - --region "${REGION}" \ - --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ - --output text -} +BUCKET="${SITE_BUCKET:-}" +DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}" -BUCKET="$(stack_output BucketName)" -DIST_ID="$(stack_output DistributionId)" - -if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then - echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 +if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then + echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}." +elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then + echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2 exit 1 +else + echo "Reading stack outputs from ${STACK_NAME}..." + stack_output() { + aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text + } + + BUCKET="$(stack_output BucketName)" + DIST_ID="$(stack_output DistributionId)" + + if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then + echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 + exit 1 + fi fi echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 8cabc70f..96209314 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -17,6 +17,17 @@ const MAINTAINABILITY_RULES = [ const GOVERNED_ROOTS = ["src/", "config/"]; const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//; const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/; +// Repository-level gates that run after the source gates. Each is an npm +// 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"], + ["HCP run guard", "test:hcp-run-guard"], + ["CloudFront release verify", "test:cloudfront-release-verify"], + ["GitHub workflow shell", "test:github-workflows"], +]; function isGoverned(relativePath) { return ( @@ -194,6 +205,20 @@ function plural(count, word) { return `${count} ${word}${count === 1 ? "" : "s"}`; } +function runRepositoryGate(label, script) { + // Reuse the npm that launched us when available (matches its version and + // config); fall back to PATH for direct `node scripts/governance-check.mjs`. + const npmCli = process.env.npm_execpath; + const executable = npmCli ? process.execPath : "npm"; + const args = npmCli ? [npmCli, "run", script] : ["run", script]; + const result = spawnSync(executable, args, { + cwd: ROOT, + encoding: "utf8", + stdio: "inherit", + }); + return { label, status: result.status, error: result.error }; +} + function main() { const failures = []; const baseRef = resolveBaseRef(); @@ -281,6 +306,17 @@ function main() { } } + for (const [label, script] of REPOSITORY_GATES) { + console.log("─".repeat(64)); + console.log(`${label}: npm run ${script}`); + const gate = runRepositoryGate(label, script); + if (gate.error) { + failures.push(`${label}: could not start: ${gate.error.message}`); + } else if (gate.status !== 0) { + failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`); + } + } + console.log("─".repeat(64)); if (failures.length > 0) { console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`); 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-validate.mjs b/scripts/terraform-validate.mjs new file mode 100644 index 00000000..59ee4140 --- /dev/null +++ b/scripts/terraform-validate.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const TERRAFORM = process.env.TERRAFORM_BIN || "terraform"; +// Only dev has a live root. Staging adoption (SH-287) adds its own root here. +const ENVIRONMENTS = ["dev"]; +const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment)); + +function run(args, cwd = ROOT) { + const result = spawnSync(TERRAFORM, args, { + cwd, + encoding: "utf8", + stdio: "inherit", + }); + if (result.error) { + throw new Error(`could not start Terraform: ${result.error.message}`, { + cause: result.error, + }); + } + if (result.status !== 0) { + throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`); + } +} + +run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]); +for (const root of ROOTS) { + // -backend=false never touches HCP state; -lockfile=readonly refuses to + // silently rewrite the committed provider lock. + run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root); + run(["validate", "-no-color"], root); +} + +console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`); diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py new file mode 100755 index 00000000..a5b6db08 --- /dev/null +++ b/scripts/terraform_import_plan_resources.py @@ -0,0 +1,130 @@ +"""Canonical frontend Terraform ownership and import-ID maps. + +Only ``dev`` has a Terraform root in this repository. The ``staging`` constants +are kept so the checker can prove that a dev plan carrying a staging identifier +is rejected; they do not authorize a staging import. +""" + +COMMON_RESOURCES = { + "module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket", + "module.environment_owned.aws_s3_bucket_public_access_block.site": ( + "aws_s3_bucket_public_access_block" + ), + "module.environment_owned.aws_s3_bucket_ownership_controls.site": ( + "aws_s3_bucket_ownership_controls" + ), + "module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": ( + "aws_s3_bucket_server_side_encryption_configuration" + ), + "module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning", + "module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy", + "module.environment_owned.aws_cloudfront_distribution.site": ( + "aws_cloudfront_distribution" + ), + "module.environment_owned.aws_cloudfront_origin_access_control.site": ( + "aws_cloudfront_origin_access_control" + ), + "module.environment_owned.aws_cloudfront_function.spa_rewrite": ( + "aws_cloudfront_function" + ), + "module.environment_owned.aws_route53_record.site_a": "aws_route53_record", + "module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record", + "module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role", + "module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy", +} + +REQUIRED_RESOURCES = { + environment: dict(COMMON_RESOURCES) + for environment in ("dev", "staging") +} + +CONTROLLED_UPDATE_ADDRESSES = frozenset( + { + "module.environment_owned.aws_s3_bucket.site", + "module.environment_owned.aws_s3_bucket_policy.site", + "module.environment_owned.aws_cloudfront_distribution.site", + "module.environment_owned.aws_cloudfront_function.spa_rewrite", + "module.environment_owned.aws_iam_role.github_deploy", + } +) + +ENVIRONMENT_CONFIG = { + "dev": { + "bucket_name": "seahaven-shoc-frontend-dev", + "bucket_auto_delete_helper_role_arn": ( + "arn:aws:iam::396287094661:role/" + "shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV" + ), + "cloudformation_stack_name": "shoc-frontend-dev", + "distribution_id": "E2CWLM1AFB964P", + "workspace_name": "shoc-frontend-new-dev", + }, + "staging": { + "bucket_name": "seahaven-shoc-frontend-staging", + "bucket_auto_delete_helper_role_arn": ( + "arn:aws:iam::396287094661:role/" + "shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3" + ), + "cloudformation_stack_name": "shoc-frontend-staging", + "distribution_id": "E2JDVEZ6EGD49J", + "workspace_name": "shoc-frontend-new-staging", + }, +} + + +def _bucket_imports(bucket_name: str) -> dict[str, str]: + return { + address: bucket_name + for address in COMMON_RESOURCES + if address.startswith("module.environment_owned.aws_s3_bucket") + } + + +REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = { + "dev": { + **_bucket_imports("seahaven-shoc-frontend-dev"), + "module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P", + "module.environment_owned.aws_cloudfront_origin_access_control.site": ( + "E30VSIK87N8H64" + ), + "module.environment_owned.aws_cloudfront_function.spa_rewrite": ( + "us-east-1shocfrontenddevSpaRewrite58674DB8" + ), + "module.environment_owned.aws_route53_record.site_a": ( + "Z07671212N75U4YLPWZR8_dev.seahaven.com_A" + ), + "module.environment_owned.aws_route53_record.site_aaaa": ( + "Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA" + ), + "module.environment_owned.aws_iam_role.github_deploy": ( + "githubdeploy-shoc-frontend-new-dev" + ), + "module.environment_owned.aws_iam_role_policy.github_deploy": ( + "githubdeploy-shoc-frontend-new-dev:" + "GithubDeployRoleDefaultPolicyE8F540D1" + ), + }, + "staging": { + **_bucket_imports("seahaven-shoc-frontend-staging"), + "module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J", + "module.environment_owned.aws_cloudfront_origin_access_control.site": ( + "E1PF5R6QQNBZAI" + ), + "module.environment_owned.aws_cloudfront_function.spa_rewrite": ( + "us-east-1shocfrontendstagingSpaRewriteE9C0CBDA" + ), + "module.environment_owned.aws_route53_record.site_a": ( + "Z02602739VQWBWCAGXP4_staging.seahaven.com_A" + ), + "module.environment_owned.aws_route53_record.site_aaaa": ( + "Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA" + ), + "module.environment_owned.aws_iam_role.github_deploy": ( + "githubdeploy-shoc-frontend-new-staging" + ), + "module.environment_owned.aws_iam_role_policy.github_deploy": ( + "githubdeploy-shoc-frontend-new-staging:" + "GithubDeployRoleDefaultPolicyE8F540D1" + ), + }, +} 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 new file mode 100755 index 00000000..f0c8c2e4 --- /dev/null +++ b/scripts/test-terraform-import-plan-check.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +"""Deterministic unit tests for the frontend Terraform plan checker.""" + +from __future__ import annotations + +import copy +import json +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +from terraform_import_plan_resources import ( + CONTROLLED_UPDATE_ADDRESSES, + ENVIRONMENT_CONFIG, + REQUIRED_IMPORT_IDS, + REQUIRED_RESOURCES, +) + +SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py") +REPOSITORY = SCRIPT.parent.parent +BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site" +BUCKET = "module.environment_owned.aws_s3_bucket.site" +DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy" +ROLE = "module.environment_owned.aws_iam_role.github_deploy" +DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site" +TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY} + + +def import_id(environment: str, address: str) -> str: + expected = REQUIRED_IMPORT_IDS[environment][address] + assert expected is not None, f"{environment} must pin an import ID for {address}" + return expected + + +def distribution_id(environment: str) -> str: + configured = ENVIRONMENT_CONFIG[environment]["distribution_id"] + assert isinstance(configured, str), f"{environment} must pin a distribution ID" + return configured + + +def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]: + config = ENVIRONMENT_CONFIG[environment] + bucket_arn = f"arn:aws:s3:::{config['bucket_name']}" + source = ( + "arn:aws:cloudfront::396287094661:distribution/" + f"{distribution_id(environment)}" + ) + return { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": config["bucket_auto_delete_helper_role_arn"] + }, + "Action": [ + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:List*", + "s3:PutBucketPolicy", + ], + "Resource": [bucket_arn, f"{bucket_arn}/*"], + }, + { + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": "s3:GetObject", + "Resource": f"{bucket_arn}/*", + "Condition": {"StringEquals": {"AWS:SourceArn": source}}, + }, + { + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Action": "s3:*", + "Resource": [bucket_arn, f"{bucket_arn}/*"], + "Condition": {"Bool": {"aws:SecureTransport": "false"}}, + }, + ], + } + + +def bucket_policy(environment: str) -> dict[str, Any]: + bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] + bucket_arn = f"arn:aws:s3:::{bucket}" + source = ( + "arn:aws:cloudfront::396287094661:distribution/" + f"{distribution_id(environment)}" + ) + return { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": "s3:GetObject", + "Resource": f"{bucket_arn}/*", + "Condition": {"StringEquals": {"AWS:SourceArn": source}}, + }, + { + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Action": "s3:*", + "Resource": [bucket_arn, f"{bucket_arn}/*"], + "Condition": {"Bool": {"aws:SecureTransport": "false"}}, + }, + ], + } + + +def tag_change(environment: str, address: str) -> dict[str, Any]: + manager = { + "HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"] + } + before_tags = { + "Environment": environment, + "ManagedBy": "cdk", + "Project": "shoc-frontend", + } + after_tags = { + "Environment": environment, + "ManagedBy": "terraform", + "Ownership": "terraform", + "Project": "shoc-frontend", + } + if address == ROLE: + before_tags.update(manager) + after_tags.update(manager) + if address == BUCKET: + before_tags["aws-cdk:auto-delete-objects"] = "true" + before: dict[str, Any] = { + "tags": before_tags, + "tags_all": before_tags, + } + after: dict[str, Any] = { + "tags": after_tags, + "tags_all": after_tags, + } + if address == DISTRIBUTION: + before["id"] = distribution_id(environment) + after["id"] = distribution_id(environment) + return {"actions": ["update"], "before": before, "after": after} + + +def policy_change(environment: str, address: str) -> dict[str, Any]: + if address != BUCKET_POLICY: + raise AssertionError(f"{address} is not a reviewed policy update") + return { + "actions": ["update"], + "before": {"policy": json.dumps(pre_adoption_bucket_policy(environment))}, + "after": {"policy": json.dumps(bucket_policy(environment))}, + } + + +def make_plan( + environment: str, + *, + mode: str = "import", + controlled_updates: set[str] | None = None, +) -> dict[str, Any]: + resources: list[dict[str, Any]] = [] + updates = controlled_updates or set() + for address, resource_type in REQUIRED_RESOURCES[environment].items(): + if mode == "import": + change: dict[str, Any] = { + "actions": ["no-op"], + "importing": {"id": import_id(environment, address)}, + } + elif mode == "post-import": + change = {"actions": ["no-op"]} + elif address in updates: + change = ( + tag_change(environment, address) + if address in TAG_ADDRESSES + else policy_change(environment, address) + ) + else: + change = {"actions": ["no-op"]} + if address == DISTRIBUTION: + change["after"] = {"id": distribution_id(environment)} + resources.append( + { + "address": address, + "mode": "managed", + "type": resource_type, + "change": change, + } + ) + return {"resource_changes": resources} + + +def resource(plan: dict[str, Any], address: str) -> dict[str, Any]: + return next( + item for item in plan["resource_changes"] if item["address"] == address + ) + + +def run_checker( + plan: dict[str, Any], + environment: str, + *allowed_updates: str, + post_import: bool = False, +) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "plan.json" + path.write_text(json.dumps(plan), encoding="utf-8") + command = [ + sys.executable, + str(SCRIPT), + str(path), + "--environment", + environment, + ] + if post_import: + command.append("--post-import-no-op") + for address in allowed_updates: + command.extend(["--allow-update-address", address]) + return subprocess.run( + command, + check=False, + capture_output=True, + text=True, + ) + + +class ImportPlanCheckerTests(unittest.TestCase): + def assert_passes( + self, + plan: dict[str, Any], + environment: str, + *allowed_updates: str, + post_import: bool = False, + ) -> None: + result = run_checker( + plan, + environment, + *allowed_updates, + post_import=post_import, + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def assert_fails( + self, + plan: dict[str, Any], + environment: str, + *allowed_updates: str, + post_import: bool = False, + ) -> None: + result = run_checker( + plan, + environment, + *allowed_updates, + post_import=post_import, + ) + self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) + + def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None: + source = ( + REPOSITORY + / "terraform/live/modules/environment-owned/main.tf" + ).read_text(encoding="utf-8") + expected = """ spa_rewrite_code = join("\\n", [ + "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;", + "}", + ])""" + self.assertIn(expected, source) + + def test_only_dev_has_a_live_root(self) -> None: + live_roots = sorted( + path.name + for path in (REPOSITORY / "terraform/live").iterdir() + if path.is_dir() and path.name != "modules" + ) + self.assertEqual(["dev"], live_roots) + + def test_dev_root_pins_adoption_complete_in_code(self) -> None: + source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8") + self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n") + self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete") + self.assertNotIn('variable "adoption_complete"', source) + for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"): + self.assertNotIn( + "variable ", + (REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"), + root_file, + ) + + def test_managed_modules_use_direct_pinned_inputs(self) -> None: + expected = { + "dev": ( + "local.hosted_zone_id", + "local.certificate_arn", + "local.github_oidc_arn", + "local.cache_policy_id", + ), + } + for environment, values in expected.items(): + source = ( + REPOSITORY / f"terraform/live/{environment}/main.tf" + ).read_text(encoding="utf-8") + for name, value in zip( + ( + "hosted_zone_id", + "certificate_arn", + "github_oidc_provider_arn", + "cache_policy_id", + ), + values, + strict=True, + ): + self.assertIn(f"{name}", source) + self.assertRegex(source, rf"{name}\s+= {re.escape(value)}") + self.assertNotRegex( + source, + r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory", + ) + + def test_exact_import_plan_passes_for_every_environment(self) -> None: + for environment in REQUIRED_RESOURCES: + with self.subTest(environment=environment): + self.assert_passes(make_plan(environment), environment) + + def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None: + for mutation in ("missing", "extra", "wrong-type", "cross-environment"): + plan = make_plan("dev") + if mutation == "missing": + plan["resource_changes"].pop() + elif mutation == "extra": + plan["resource_changes"].append( + { + "address": "module.inventory.aws_route53_zone.site", + "mode": "managed", + "type": "aws_route53_zone", + "change": { + "actions": ["no-op"], + "importing": {"id": "Z00000000000000000000"}, + }, + } + ) + elif mutation == "wrong-type": + plan["resource_changes"][0]["type"] = "aws_s3_object" + else: + resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = ( + REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION] + ) + with self.subTest(mutation=mutation): + self.assert_fails(plan, "dev") + + def test_import_rejects_mutation_and_invalid_metadata(self) -> None: + for actions in (["create"], ["update"], ["delete"], ["delete", "create"]): + plan = make_plan("dev") + plan["resource_changes"][0]["change"]["actions"] = actions + with self.subTest(actions=actions): + self.assert_fails(plan, "dev") + plan = make_plan("dev") + plan["resource_changes"][0]["change"]["importing"] = {"id": ""} + self.assert_fails(plan, "dev") + + def test_post_import_no_op_passes(self) -> None: + self.assert_passes( + make_plan("staging", mode="post-import"), + "staging", + post_import=True, + ) + + def test_post_import_rejects_import_metadata_and_update(self) -> None: + plan = make_plan("dev", mode="post-import") + plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"} + self.assert_fails(plan, "dev", post_import=True) + plan = make_plan("dev", mode="post-import") + plan["resource_changes"][0]["change"]["actions"] = ["update"] + self.assert_fails(plan, "dev", post_import=True) + + def test_every_allowed_controlled_diff_passes(self) -> None: + for environment in REQUIRED_RESOURCES: + for address in CONTROLLED_UPDATE_ADDRESSES: + with self.subTest(environment=environment, address=address): + self.assert_passes( + make_plan( + environment, + mode="controlled", + controlled_updates={address}, + ), + environment, + address, + ) + + def test_full_exact_controlled_allowlist_passes(self) -> None: + addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES)) + self.assert_passes( + make_plan( + "dev", + mode="controlled", + controlled_updates=set(addresses), + ), + "dev", + *addresses, + ) + + def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None: + plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) + resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}" + self.assert_fails(plan, "dev", ROLE) + plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) + resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker" + self.assert_fails(plan, "dev", ROLE) + + def test_tag_update_requires_complete_adopted_tag_sets(self) -> None: + plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET}) + del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"] + self.assert_fails(plan, "dev", BUCKET) + + def test_role_trust_change_is_rejected(self) -> None: + plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) + role = resource(plan, ROLE)["change"] + role["before"]["assume_role_policy"] = '{"Statement":[]}' + role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}' + self.assert_fails(plan, "dev", ROLE) + + def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None: + for mutation in ("principal", "extra"): + plan = make_plan( + "dev", + mode="controlled", + controlled_updates={BUCKET_POLICY}, + ) + policy = copy.deepcopy(bucket_policy("dev")) + if mutation == "principal": + policy["Statement"][0]["Principal"] = {"AWS": "*"} + else: + policy["Statement"].append( + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:*", + "Resource": "*", + } + ) + resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps( + policy + ) + with self.subTest(mutation=mutation): + self.assert_fails(plan, "dev", BUCKET_POLICY) + + 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("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( + "module.environment_owned.aws_iam_role_policy.github_deploy", + CONTROLLED_UPDATE_ADDRESSES, + ) + + def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None: + plan = make_plan("dev", mode="controlled", controlled_updates=set()) + self.assert_fails(plan, "dev", DEPLOY_POLICY) + plan = make_plan("dev", mode="controlled", controlled_updates=set()) + resource(plan, DEPLOY_POLICY)["change"] = { + "actions": ["update"], + "before": {"policy": "{}"}, + "after": {"policy": '{"Version":"2012-10-17"}'}, + } + self.assert_fails(plan, "dev", DEPLOY_POLICY) + + def test_policy_updates_require_exact_pre_adoption_state(self) -> None: + for environment in REQUIRED_RESOURCES: + plan = make_plan( + environment, + mode="controlled", + controlled_updates={BUCKET_POLICY}, + ) + change = resource(plan, BUCKET_POLICY)["change"] + before = json.loads(change["before"]["policy"]) + before["Statement"].append( + { + "Sid": "UnexpectedDrift", + "Effect": "Deny", + "Action": "*", + "Resource": "*", + } + ) + change["before"]["policy"] = json.dumps(before) + with self.subTest(environment=environment): + self.assert_fails(plan, environment, BUCKET_POLICY) + + def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None: + for field, value in ( + ("after_unknown", {"tags": {"ManagedBy": True}}), + ("replace_paths", [["tags"]]), + ): + plan = make_plan( + "dev", + mode="controlled", + controlled_updates={ROLE}, + ) + resource(plan, ROLE)["change"][field] = value + with self.subTest(field=field): + self.assert_fails(plan, "dev", ROLE) + + def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None: + plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) + self.assert_fails(plan, "dev", BUCKET_POLICY) + plan = make_plan("dev", mode="controlled", controlled_updates=set()) + self.assert_fails(plan, "dev", ROLE) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py new file mode 100755 index 00000000..d050701e --- /dev/null +++ b/scripts/test-terraform-release-plan-check.py @@ -0,0 +1,363 @@ +#!/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 test_deploy_workflow_confirms_prefix_with_head_object() -> list[str]: + workflow = ( + Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml" + ).read_text(encoding="utf-8") + failures: list[str] = [] + if "aws s3api head-object" not in workflow: + failures.append( + "deploy.yml must confirm the uploaded index.html with s3api head-object" + ) + if "aws s3 ls" in workflow: + failures.append("deploy.yml must not list the prefix with aws s3 ls") + if any( + line.lstrip().startswith("run:") and "npm run verify" in line + for line in workflow.splitlines() + ): + failures.append( + "deploy.yml must not re-run npm run verify; Frontend checks owns that gate" + ) + if any(line.lstrip().startswith("pull_request:") for line in workflow.splitlines()): + failures.append( + "deploy.yml must not run on pull_request; Frontend checks owns PR verify" + ) + return failures + + +def main() -> int: + cases = [ + ("version-only", run_case("version-only.json"), 0), + ( + "origin-timeout-normalization", + run_case("origin-timeout-normalization.json"), + 0, + ), + ("origin-timeout-change", run_case("origin-timeout-change.json"), 1), + ("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()) + download_failures.extend(test_deploy_workflow_confirms_prefix_with_head_object()) + 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..7cc5607b --- /dev/null +++ b/scripts/test-verify-cloudfront-release.sh @@ -0,0 +1,302 @@ +#!/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" +# Vite writes index.html with a trailing newline. Keep it in the fixture so +# the expected hash covers every served byte, exactly like dist/index.html. +INDEX_HTML=$'\n' +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}" + if [[ -f "${state_dir}/asset.js" ]]; then + body="$(cat "${state_dir}/asset.js")" + else + body='const api="https://api.dev.seahaven.com/api";' + fi + [[ -n "${output}" ]] && printf '%s' "${body}" > "${output}" + [[ -z "${output}" ]] && printf '%s' "${body}" + exit 0 +fi +# Serve index.html byte-for-byte, trailing newline included, like real curl. +[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}" +if [[ -n "${output}" ]]; then + cat "${state_dir}/index.html" > "${output}" +else + cat "${state_dir}/index.html" +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}" +} + +# 6. Hash-matched Deployed release whose JS assets omit the baked API URL. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + printf 'const x=1;' > "${dir}/asset.js" + 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=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt" + grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 7. Hash-matched Deployed release whose JS assets contain the staging API URL. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js" + 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=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "forbidden-staging-api-url" 1 "${code}" "${dir}/log.txt" + grep -q "forbidden URL api.staging.seahaven.com" "${dir}/log.txt" || { echo "FAIL: staging API URL did not name forbidden URL" >&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/origin-timeout-change.json b/scripts/testdata/terraform-release-plans/origin-timeout-change.json new file mode 100644 index 00000000..a5f42591 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/origin-timeout-change.json @@ -0,0 +1,104 @@ +{ + "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", + "response_completion_timeout": 10 + }, + { + "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", + "response_completion_timeout": 60 + }, + { + "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/origin-timeout-normalization.json b/scripts/testdata/terraform-release-plans/origin-timeout-normalization.json new file mode 100644 index 00000000..e70aa560 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/origin-timeout-normalization.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", + "response_completion_timeout": 0 + }, + { + "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", + "response_completion_timeout": 0 + } + ], + "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", + "response_completion_timeout": null + }, + { + "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/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..f0ed3b7c --- /dev/null +++ b/scripts/verify-cloudfront-release.sh @@ -0,0 +1,210 @@ +#!/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')" + # Hash the response stream directly. Capturing the body in "$(...)" strips + # trailing newlines, so the hash never matched dist/index.html. + local hash + if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then + last_hash="${hash}" + 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 + +write_asset_paths() { + python3 -c ' +import re, sys +html = open(sys.argv[1], encoding="utf-8").read() +seen = [] +for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html): + if path not in seen: + seen.append(path) + print(path) +' "$1" +} + +assert_baked_api_url() { + local tmp="$1" + if [[ ! -s "${tmp}/asset-paths.txt" ]]; then + echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2 + exit 1 + fi + : > "${tmp}/assets.txt" + local immutable_ok="no" + local asset_path + while IFS= read -r asset_path; do + curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \ + -o "${tmp}/asset-body" -D "${tmp}/asset.headers" + cat "${tmp}/asset-body" >> "${tmp}/assets.txt" + if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then + if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then + echo "FAIL: hashed asset is missing Cache-Control immutable." >&2 + exit 1 + fi + immutable_ok="yes" + fi + done < "${tmp}/asset-paths.txt" + if [[ "${immutable_ok}" != "yes" ]]; then + echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2 + exit 1 + fi + cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt" + local forbidden + for forbidden in api.staging.seahaven.com localhost:5141; do + if grep -Fq "${forbidden}" "${tmp}/served.txt"; then + echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2 + exit 1 + fi + done + if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then + echo "FAIL: served JS assets are missing the baked dev API URL." >&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 +write_asset_paths "${tmp}/index.html" > "${tmp}/asset-paths.txt" +assert_baked_api_url "${tmp}" + +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 new file mode 100644 index 00000000..f466e8d6 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,352 @@ +# Frontend Terraform adoption runbook (dev) + +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 (#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, +CloudFormation, DNS, or deployment mutation. Every live step below is gated on +an explicit go from the owner, with the production impact stated first. + +Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked +separately (SH-287) and adds its own root under `live/staging` when it starts. +The `staging` constants in `scripts/terraform_import_plan_resources.py` exist +only so the checker can prove a dev plan carrying a staging identifier fails. + +## Fixed targets + +- AWS account: `396287094661` +- AWS region: `us-east-1` +- HCP organization: `seahaven` +- HCP project: `seahaven-external-dev` +- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working + directory `terraform/live/dev` +- Site: `dev.seahaven.com` +- API build value: `https://api.dev.seahaven.com/api` + +## Workspace invariants + +Set before any Terraform lands on `dev`, read back after setting, and re-read +before the first release after any Terraform merge: + +- Auto-apply **off**. GitHub or a human applies every run. +- Automatic speculative plans **on** (PR plans are read-only evidence). +- Automatic run triggering: **patterns** + `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.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` + and `hcptf-shoc-frontend-new-dev`. No access keys. +- **No** `adoption_complete` workspace variable. The dev root pins it in code + (`local.adoption_complete`) so the value under review is the value that + applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable` + block reappears in the root. + +## Ownership boundary + +`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` +3. `module.environment_owned.aws_s3_bucket_ownership_controls.site` +4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site` +5. `module.environment_owned.aws_s3_bucket_versioning.site` +6. `module.environment_owned.aws_s3_bucket_policy.site` +7. `module.environment_owned.aws_cloudfront_distribution.site` +8. `module.environment_owned.aws_cloudfront_origin_access_control.site` +9. `module.environment_owned.aws_cloudfront_function.spa_rewrite` +10. `module.environment_owned.aws_route53_record.site_a` +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 +caller account, provider region, public hosted zone, ACM certificate, account +GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache +policy against pinned values, and fails the plan on any mismatch. + +The following remain outside state: + +- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate +- the account-global GitHub OIDC provider +- the AWS managed CloudFront cache policy +- `CDKToolkit` resources and CDK metadata +- the S3 auto-delete custom resource, its provider Lambda and role +- the HCP plan/apply roles and the deploy-role permissions boundary + +## Exact live inventory (dev) + +- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev` +- Distribution: `E2CWLM1AFB964P` +- OAC: `E30VSIK87N8H64`, name + `shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`, + description modeled as `""` +- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1` +- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8` +- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A` +- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA` +- Deploy role: `githubdeploy-shoc-frontend-new-dev` +- Inline policy import ID: + `githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1` +- Hosted zone: `Z07671212N75U4YLPWZR8` +- Certificate: + `arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00` +- Legacy stack: `shoc-frontend-dev` +- Auto-delete helper role: + `arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV` +- Permissions boundary: + `arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary` + +With `adoption_complete = false` the root declares the configuration observed +after the CDK retain deploy (Phase 1, step 2), not the configuration live +today: + +- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the + S3-only `aws-cdk:auto-delete-objects=true` tag +- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag +- the permissions boundary attached to the deploy role +- `StringEquals` on the OIDC subject + `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` +- the legacy bucket policy including the auto-delete helper grant +- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`, + bucket read/write, `InvalidateDistribution`) + +The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing. +If read-back after that deploy differs from the root in any other way, update +the root to the observed value and prove a zero-change import plan. Do not +approve drift through the controlled-update checker. + +## Phase 1: import-first adoption (merged) + +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.** 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 + 13 transferred resources and the `Custom::S3AutoDeleteObjects` resource, + attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows + the trust operator. Read back the role, bucket policy, and stack resources + as JSON and attach it to the PR. + +3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply + off). Download the plan JSON and run the guard: + + ```bash + python3 scripts/check-terraform-import-plan.py plan.json --environment dev + ``` + + Confirm the apply only when the plan is exactly 13 imports, 0 create, + 0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the + run and fix the root in a new PR. + +4. **Post-import no-op.** Queue a plan and require it to be no-op: + + ```bash + python3 scripts/check-terraform-import-plan.py post-import.json \ + --environment dev --post-import-no-op + ``` + + Post the run URLs and the guard output on SH-300. + +After Phase 1 CloudFormation still owns every resource. Terraform holds state +for them and nothing else. + +## Phase 2: controlled ownership transfer (merged #178) + +PR B pins `adoption_complete = true`. The controlled apply may update only: + +- `module.environment_owned.aws_s3_bucket.site` (tags) +- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the + auto-delete helper grant) +- `module.environment_owned.aws_cloudfront_distribution.site` (tags) +- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags) +- `module.environment_owned.aws_iam_role.github_deploy` (tags) + +The OAC, both Route 53 records, and the deploy inline policy must be no-op. +PR B keeps the GitHub deploy inline policy byte-identical to live so +`aws_iam_role_policy.github_deploy` does not appear in the plan. Run the +checker with one `--allow-update-address` per updating address; it rejects +unused allowlist entries, unknown values, and replacements: + +```bash +python3 scripts/check-terraform-import-plan.py plan.json --environment dev \ + --allow-update-address module.environment_owned.aws_s3_bucket.site \ + --allow-update-address module.environment_owned.aws_s3_bucket_policy.site \ + --allow-update-address module.environment_owned.aws_cloudfront_distribution.site \ + --allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \ + --allow-update-address module.environment_owned.aws_iam_role.github_deploy +``` + +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. 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 (this PR) + +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 + +- **Terraform-only PRs.** A PR that changes `terraform/**` may not change + deployable application code. The `terraform-isolation` job in + `.github/workflows/terraform-isolation.yaml` enforces this; documentation and the + `scripts/*terraform*` tooling are allowed alongside. A reviewer may add the + `terraform-isolation-override` label for the rare change that must introduce + Terraform variables together with the workflow that consumes them (PR C). + Adding or removing that label re-runs only that workflow against the labels + currently on the PR; Frontend checks does not start a new run. Removing the + label fails a mixed PR that had previously passed with the override, so a + stale green check cannot merge. Markdown under `terraform/` does not count as a Terraform + change for this gate; it does not match the workspace trigger patterns. + The label is the approval record. The override is temporary: + a follow-up PR after PR C removes the label path from the checker and + workflow so the gate has no exception. +- **Every Terraform merge produces a VCS run.** A human confirms or discards + it before the next content release. Do not leave a pending run on the + 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 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. + +## Local validation + +From the repository root (also run by `npm run verify` through +`scripts/governance-check.mjs`): + +```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: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 +but never contacts HCP state or plans against AWS. Only HCP runs plan against +the account. + +The lock file must carry `h1:` hashes for every platform that runs the gate +(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the +provider version, refresh them with: + +```bash +terraform -chdir=terraform/live/dev providers lock \ + -platform=linux_amd64 -platform=linux_arm64 \ + -platform=darwin_amd64 -platform=darwin_arm64 +``` + +## Import plan safety + +Import mode requires exactly the canonical 13 addresses and AWS types, valid +import metadata for every resource, the exact dev import IDs (a staging ID in a +dev plan fails), and zero create, update, delete, or replace actions. + +Post-import mode requires all 13 resources to be no-op and rejects any +remaining import metadata. + +Controlled mode permits only in-place updates to the addresses explicitly +listed with `--allow-update-address`, verifies `before` against the exact +pre-adoption policies and tags and `after` against the exact adopted values, +and rejects create, delete, replace, import metadata, unknown values, +unapproved addresses, and unused allowlist entries. + +## Rollback + +- Before import apply: discard the run and correct the root. +- After import, before the controlled update (end of Phase 1): remove only the + 13 imported addresses from state under a separately reviewed state + operation. CloudFormation remains authoritative; a + `ManageSiteInfrastructure=true` stack is unchanged by this. +- After the controlled update, before detachment: either complete the reviewed + detachment or restore the exact pre-adoption policy and tags under a + separate approval. Do not remove state or redeploy CloudFormation blindly. +- After detachment: Terraform is authoritative. Restore content from the + versioned bucket. Re-establishing CloudFormation ownership requires a + reviewed `IMPORT` change set, never an ordinary update. + +Any replacement, destroy, cross-environment ID, missing import, broad policy +change, or failed smoke check is a hard stop. + +## Evidence per phase + +- HCP run URL and the workspace settings read-back +- plan JSON and checker output +- `terraform state list` showing exactly the 13 addresses +- read-only inventory before and after each mutation +- synthesized CloudFormation template, change set, and stack events +- deploy, invalidation, and smoke output +- the post-action no-op plan +- phase close-out on SH-300: completed work, validation, risks, deviations, + remaining work diff --git a/terraform/live/dev/.terraform.lock.hcl b/terraform/live/dev/.terraform.lock.hcl new file mode 100644 index 00000000..7f171232 --- /dev/null +++ b/terraform/live/dev/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.62.0" + constraints = "~> 6.57" + hashes = [ + "h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=", + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=", + "h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=", + "h1:yOSEz5G8b/n5uhFCZ0gbEsKkAQATtVuhXJEXR3OM5qs=", + "zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5", + "zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd", + "zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010", + "zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3", + "zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df", + "zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844", + "zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090", + "zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2", + "zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7", + "zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f", + "zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba", + "zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913", + "zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14", + "zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02", + ] +} diff --git a/terraform/live/dev/imports.tf b/terraform/live/dev/imports.tf new file mode 100644 index 00000000..8f5e3e3f --- /dev/null +++ b/terraform/live/dev/imports.tf @@ -0,0 +1,64 @@ +import { + to = module.environment_owned.aws_s3_bucket.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_public_access_block.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_ownership_controls.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_versioning.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_policy.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_cloudfront_distribution.site + id = local.distribution_id +} + +import { + to = module.environment_owned.aws_cloudfront_origin_access_control.site + id = local.oac_id +} + +import { + to = module.environment_owned.aws_cloudfront_function.spa_rewrite + id = local.function_name +} + +import { + to = module.environment_owned.aws_route53_record.site_a + id = "${local.hosted_zone_id}_${local.domain_name}_A" +} + +import { + to = module.environment_owned.aws_route53_record.site_aaaa + id = "${local.hosted_zone_id}_${local.domain_name}_AAAA" +} + +import { + to = module.environment_owned.aws_iam_role.github_deploy + id = local.deploy_role_name +} + +import { + to = module.environment_owned.aws_iam_role_policy.github_deploy + id = "${local.deploy_role_name}:${local.inline_policy}" +} diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf new file mode 100644 index 00000000..f926ffd1 --- /dev/null +++ b/terraform/live/dev/main.tf @@ -0,0 +1,95 @@ +locals { + # Controlled ownership transfer. Pinned in code, never a workspace variable. + adoption_complete = true + + environment = "dev" + workspace_name = "shoc-frontend-new-dev" + aws_account_id = "396287094661" + aws_region = "us-east-1" + bucket_name = "seahaven-shoc-frontend-dev" + distribution_id = "E2CWLM1AFB964P" + oac_id = "E30VSIK87N8H64" + oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620" + origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1" + function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8" + domain_name = "dev.seahaven.com" + hosted_zone_id = "Z07671212N75U4YLPWZR8" + certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00" + github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com" + deploy_role_name = "githubdeploy-shoc-frontend-new-dev" + inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1" + stack_name = "shoc-frontend-dev" + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + permissions_boundary_arn = ( + "arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary" + ) + bucket_auto_delete_helper_role_arn = ( + "arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV" + ) + legacy_tags = { + Environment = "dev" + ManagedBy = "cdk" + Project = "shoc-frontend" + } + legacy_bucket_tags = merge(local.legacy_tags, { + "aws-cdk:auto-delete-objects" = "true" + }) + terraform_tags = { + Environment = "dev" + ManagedBy = "terraform" + Ownership = "terraform" + Project = "shoc-frontend" + } + manager_tag = { + HcpTerraformWorkspace = local.workspace_name + } +} + +module "inventory" { + source = "../modules/environment-inventory" + + aws_account_id = local.aws_account_id + aws_region = local.aws_region + hosted_zone_name = local.domain_name + expected_hosted_zone_id = local.hosted_zone_id + certificate_domain = "*.seahaven.com" + expected_certificate_arn = local.certificate_arn + expected_github_oidc_provider_arn = local.github_oidc_arn + expected_cache_policy_id = local.cache_policy_id +} + +module "environment_owned" { + source = "../modules/environment-owned" + + environment = local.environment + adoption_complete = local.adoption_complete + aws_account_id = local.aws_account_id + aws_region = local.aws_region + bucket_name = local.bucket_name + distribution_id = local.distribution_id + origin_access_control_name = local.oac_name + origin_access_control_description = "" + origin_id = local.origin_id + function_name = local.function_name + domain_name = local.domain_name + hosted_zone_id = local.hosted_zone_id + certificate_arn = local.certificate_arn + cache_policy_id = local.cache_policy_id + github_oidc_provider_arn = local.github_oidc_arn + github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev" + pre_adoption_github_subject_operator = "StringEquals" + post_adoption_github_subject_operator = "StringEquals" + deploy_branch = "dev" + deploy_role_name = local.deploy_role_name + deploy_inline_policy_name = local.inline_policy + deploy_permissions_boundary_arn = local.permissions_boundary_arn + cloudformation_stack_name = local.stack_name + bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn + pre_adoption_tags = local.legacy_tags + pre_adoption_bucket_tags = local.legacy_bucket_tags + 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 new file mode 100644 index 00000000..8b9e94c5 --- /dev/null +++ b/terraform/live/dev/outputs.tf @@ -0,0 +1,19 @@ +output "bucket_name" { + value = module.environment_owned.bucket_name +} + +output "distribution_id" { + value = module.environment_owned.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/providers.tf b/terraform/live/dev/providers.tf new file mode 100644 index 00000000..b6c81d54 --- /dev/null +++ b/terraform/live/dev/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = local.aws_region +} 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 new file mode 100644 index 00000000..b8a94b0c --- /dev/null +++ b/terraform/live/dev/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.14.0, < 2.0.0" + + cloud { + organization = "seahaven" + + workspaces { + project = "seahaven-external-dev" + name = "shoc-frontend-new-dev" + } + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + } +} diff --git a/terraform/live/modules/environment-inventory/main.tf b/terraform/live/modules/environment-inventory/main.tf new file mode 100644 index 00000000..d0639b77 --- /dev/null +++ b/terraform/live/modules/environment-inventory/main.tf @@ -0,0 +1,65 @@ +data "aws_caller_identity" "current" { + lifecycle { + postcondition { + condition = self.account_id == var.aws_account_id + error_message = "Refusing to inspect resources outside the expected AWS account." + } + } +} + +data "aws_region" "current" { + lifecycle { + postcondition { + condition = self.region == var.aws_region + error_message = "Refusing to inspect resources outside the expected AWS region." + } + } +} + +data "aws_route53_zone" "site" { + name = "${trimsuffix(var.hosted_zone_name, ".")}." + private_zone = false + + lifecycle { + postcondition { + condition = self.zone_id == var.expected_hosted_zone_id + error_message = "The resolved Route 53 zone does not match the pinned hosted zone." + } + } +} + +data "aws_acm_certificate" "shared" { + domain = var.certificate_domain + statuses = ["ISSUED"] + types = ["AMAZON_ISSUED"] + most_recent = true + + lifecycle { + postcondition { + condition = self.arn == var.expected_certificate_arn + error_message = "The resolved ACM certificate does not match the pinned certificate." + } + } +} + +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" + + lifecycle { + postcondition { + condition = self.arn == var.expected_github_oidc_provider_arn + error_message = "The GitHub OIDC provider does not match the pinned account provider." + } + } +} + +data "aws_cloudfront_cache_policy" "managed" { + name = var.cache_policy_name + + lifecycle { + postcondition { + condition = self.id == var.expected_cache_policy_id + error_message = "The AWS managed CloudFront cache policy does not match the pinned ID." + } + } +} diff --git a/terraform/live/modules/environment-inventory/outputs.tf b/terraform/live/modules/environment-inventory/outputs.tf new file mode 100644 index 00000000..3223842d --- /dev/null +++ b/terraform/live/modules/environment-inventory/outputs.tf @@ -0,0 +1,19 @@ +output "hosted_zone_id" { + value = data.aws_route53_zone.site.zone_id + description = "Verified hosted zone ID." +} + +output "certificate_arn" { + value = data.aws_acm_certificate.shared.arn + description = "Verified ACM certificate ARN." +} + +output "github_oidc_provider_arn" { + value = data.aws_iam_openid_connect_provider.github.arn + description = "Verified GitHub OIDC provider ARN." +} + +output "cache_policy_id" { + value = data.aws_cloudfront_cache_policy.managed.id + description = "Verified AWS managed cache policy ID." +} diff --git a/terraform/live/modules/environment-inventory/variables.tf b/terraform/live/modules/environment-inventory/variables.tf new file mode 100644 index 00000000..e76ae6dd --- /dev/null +++ b/terraform/live/modules/environment-inventory/variables.tf @@ -0,0 +1,46 @@ +variable "aws_account_id" { + type = string + description = "Expected AWS account ID." +} + +variable "aws_region" { + type = string + description = "Expected AWS provider region." +} + +variable "hosted_zone_name" { + type = string + description = "Public hosted zone DNS name." +} + +variable "expected_hosted_zone_id" { + type = string + description = "Pinned hosted zone ID." +} + +variable "certificate_domain" { + type = string + description = "Domain used to resolve the expected certificate." +} + +variable "expected_certificate_arn" { + type = string + description = "Pinned ACM certificate ARN." +} + +variable "expected_github_oidc_provider_arn" { + type = string + description = "Pinned account-global GitHub OIDC provider ARN." +} + +variable "cache_policy_name" { + type = string + description = "AWS managed CloudFront cache policy name." + default = "Managed-CachingOptimized" +} + +variable "expected_cache_policy_id" { + type = string + description = "Pinned AWS managed CloudFront cache policy ID." + default = "658327ea-f89d-4fab-a63d-7e88639e58f6" +} diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf new file mode 100644 index 00000000..1bf36612 --- /dev/null +++ b/terraform/live/modules/environment-owned/main.tf @@ -0,0 +1,441 @@ +locals { + bucket_arn = "arn:aws:s3:::${var.bucket_name}" + distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}" + resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags + 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) {", + " 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;", + "}", + ]) +} + +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] + + content { + effect = "Allow" + + principals { + type = "AWS" + identifiers = [var.bucket_auto_delete_helper_role_arn] + } + + actions = [ + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:List*", + "s3:PutBucketPolicy", + ] + resources = [ + local.bucket_arn, + "${local.bucket_arn}/*", + ] + } + } + + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = ["s3:GetObject"] + resources = ["${local.bucket_arn}/*"] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [local.distribution_arn] + } + } + + statement { + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + local.bucket_arn, + "${local.bucket_arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +data "aws_iam_policy_document" "github_deploy_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [var.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = local.github_subject_operator + variable = "token.actions.githubusercontent.com:sub" + values = [var.github_subject] + } + } +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + sid = "ListReleasePrefixes" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = [local.bucket_arn] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = [ + "releases/", + "releases/*", + ] + } + } + + statement { + sid = "PublishReleasePrefix" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + ] + resources = ["${local.bucket_arn}/releases/*"] + } + + statement { + sid = "ReadReleasePointer" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["${local.bucket_arn}/${local.pointer_key}"] + } + + statement { + sid = "ReadDistribution" + effect = "Allow" + actions = [ + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + ] + resources = [local.distribution_arn] + } +} + +resource "aws_s3_bucket" "site" { + bucket = var.bucket_name + force_destroy = false + tags = local.bucket_tags + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "site" { + bucket = aws_s3_bucket.site.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_ownership_controls" "site" { + bucket = aws_s3_bucket.site.id + + rule { + object_ownership = "BucketOwnerEnforced" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "site" { + bucket = aws_s3_bucket.site.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = false + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_versioning" "site" { + bucket = aws_s3_bucket.site.id + + versioning_configuration { + status = "Enabled" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_policy" "site" { + bucket = aws_s3_bucket.site.id + policy = data.aws_iam_policy_document.site_bucket.json + + lifecycle { + prevent_destroy = true + } +} + +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 + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_cloudfront_function" "spa_rewrite" { + name = var.function_name + runtime = "cloudfront-js-1.0" + comment = "SPA routing: rewrite extensionless paths to /index.html" + publish = true + code = local.spa_rewrite_code + tags = local.resource_tags + + lifecycle { + prevent_destroy = true + ignore_changes = [publish] + } +} + +resource "aws_cloudfront_distribution" "site" { + aliases = [var.domain_name] + comment = "SeaHaven SHOC frontend (${var.environment})" + default_root_object = "index.html" + enabled = true + http_version = "http2and3" + is_ipv6_enabled = true + price_class = "PriceClass_100" + tags = local.resource_tags + + 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 = 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 { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cache_policy_id = var.cache_policy_id + cached_methods = ["GET", "HEAD"] + compress = true + target_origin_id = local.origin_group_id + viewer_protocol_policy = "redirect-to-https" + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.spa_rewrite.arn + } + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + acm_certificate_arn = var.certificate_arn + minimum_protocol_version = "TLSv1.2_2021" + ssl_support_method = "sni-only" + } + + 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 = ["/*"] + } +} + +resource "aws_route53_record" "site_a" { + zone_id = var.hosted_zone_id + name = var.domain_name + type = "A" + + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_route53_record" "site_aaaa" { + zone_id = var.hosted_zone_id + name = var.domain_name + type = "AAAA" + + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role" "github_deploy" { + name = var.deploy_role_name + path = "/" + description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}" + assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json + max_session_duration = 3600 + permissions_boundary = var.deploy_permissions_boundary_arn + tags = local.deploy_role_tags + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = var.deploy_inline_policy_name + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.github_deploy.json + + lifecycle { + prevent_destroy = true + } +} diff --git a/terraform/live/modules/environment-owned/outputs.tf b/terraform/live/modules/environment-owned/outputs.tf new file mode 100644 index 00000000..ef7ebee9 --- /dev/null +++ b/terraform/live/modules/environment-owned/outputs.tf @@ -0,0 +1,44 @@ +output "bucket_name" { + value = aws_s3_bucket.site.id + description = "Imported site bucket name." +} + +output "distribution_id" { + value = aws_cloudfront_distribution.site.id + description = "Imported CloudFront distribution ID." +} + +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 new file mode 100644 index 00000000..05d6b7a9 --- /dev/null +++ b/terraform/live/modules/environment-owned/variables.tf @@ -0,0 +1,194 @@ +variable "environment" { + type = string + description = "Environment name." + + validation { + condition = contains(["dev", "staging"], var.environment) + error_message = "environment must be dev or staging." + } +} + +variable "adoption_complete" { + type = bool + 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." +} + +variable "aws_region" { + type = string + description = "AWS region used by the environment." +} + +variable "bucket_name" { + type = string + description = "Existing private S3 origin bucket." +} + +variable "distribution_id" { + type = string + description = "Existing CloudFront distribution ID." +} + +variable "origin_access_control_name" { + type = string + description = "Exact existing CloudFront OAC name." +} + +variable "origin_access_control_description" { + type = string + description = "Exact existing CloudFront OAC description." +} + +variable "origin_id" { + type = string + description = "Exact origin ID in the existing distribution." +} + +variable "function_name" { + type = string + description = "Existing CloudFront Function name." +} + +variable "domain_name" { + type = string + description = "Site hostname." +} + +variable "hosted_zone_id" { + type = string + description = "Inventory-verified hosted zone ID." +} + +variable "certificate_arn" { + type = string + description = "Inventory-verified ACM certificate ARN." +} + +variable "cache_policy_id" { + type = string + description = "Inventory-verified AWS managed cache policy ID." +} + +variable "github_oidc_provider_arn" { + type = string + description = "Inventory-verified GitHub OIDC provider ARN." +} + +variable "github_subject" { + type = string + description = "Exact GitHub OIDC subject in the existing role." +} + +variable "pre_adoption_github_subject_operator" { + type = string + description = "Condition operator used by the role before adoption." + + validation { + condition = contains(["StringEquals", "StringLike"], var.pre_adoption_github_subject_operator) + error_message = "pre_adoption_github_subject_operator must be StringEquals or StringLike." + } +} + +variable "post_adoption_github_subject_operator" { + type = string + description = "Condition operator used by the role after adoption." + + validation { + condition = contains(["StringEquals", "StringLike"], var.post_adoption_github_subject_operator) + error_message = "post_adoption_github_subject_operator must be StringEquals or StringLike." + } +} + +variable "deploy_branch" { + type = string + description = "Branch or environment named in the existing role description." +} + +variable "deploy_role_name" { + type = string + description = "Existing GitHub deployment role name." +} + +variable "deploy_inline_policy_name" { + type = string + description = "Existing generated inline policy name." +} + +variable "deploy_permissions_boundary_arn" { + type = string + description = "Exact permissions boundary attached before import." +} + +variable "cloudformation_stack_name" { + type = string + description = "Legacy CloudFormation stack used by the pre-adoption policy." +} + +variable "bucket_auto_delete_helper_role_arn" { + type = string + description = "Exact legacy S3 auto-delete helper role ARN." +} + +variable "pre_adoption_tags" { + type = map(string) + description = "Exact tags present while CloudFormation still owns the resources." +} + +variable "pre_adoption_bucket_tags" { + type = map(string) + description = "Exact pre-adoption S3 tags, including the CDK auto-delete marker." +} + +variable "ownership_tags" { + type = map(string) + description = "Tags applied by the controlled ownership transfer." +} + +variable "pre_adoption_deploy_role_tags" { + type = map(string) + description = "Exact pre-adoption deploy-role tags, including its HCP manager tag." +} + +variable "post_adoption_deploy_role_tags" { + type = map(string) + description = "Exact post-adoption deploy-role tags, preserving its HCP manager tag." +}