diff --git a/.env.example b/.env.example index 1bd84acf..1b59d08b 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,13 @@ # Production: absolute URLs MUST include the /api path segment VITE_API_URL=https://api.seahavenindustries.com/api +# Sentry browser DSN. This value is public configuration and is baked into the bundle. +# Leave blank to disable telemetry for that build. +VITE_SENTRY_DSN= + +# Sentry environment label (development, staging, production). Blank falls back +# to the Vite build mode. +#VITE_SENTRY_ENVIRONMENT= + # Development proxy target (used by vite.config.ts) #VITE_API_TARGET=http://localhost:5141 diff --git a/.env.production b/.env.production index 1183d018..6ca55486 100644 --- a/.env.production +++ b/.env.production @@ -2,3 +2,9 @@ # NOTE: baked into the build at `vite build`, so this is the DEV value. Staging # and prod builds must override VITE_API_URL per environment (api.staging..., etc.). VITE_API_URL=https://api.dev.seahaven.com/api + +# Public browser configuration; the DSN is public by design and baked into the +# bundle. Blank disables telemetry. These are the DEV deploy values: staging +# and future prod jobs override them per environment. +VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752 +VITE_SENTRY_ENVIRONMENT=development 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-staging.yml b/.github/workflows/deploy-staging.yml index eed6460e..c00f749e 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -32,6 +32,8 @@ jobs: environment: staging env: VITE_API_URL: https://api.staging.seahaven.com/api + VITE_SENTRY_ENVIRONMENT: staging + VITE_APP_COMMIT_SHA: ${{ github.sha }} AWS_REGION: us-east-1 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -68,15 +70,21 @@ jobs: role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging aws-region: us-east-1 - # Builds the SPA with the staging VITE_API_URL (process env overrides the - # dev value committed in .env.production), syncs to the staging bucket, - # and invalidates CloudFront. + # Builds the SPA with the staging VITE_API_URL and Sentry environment + # label (process env overrides the dev values committed in + # .env.production), syncs to the staging bucket, and invalidates + # CloudFront. - name: Build and publish SPA run: bash scripts/deploy-web.sh env: STACK_NAME: shoc-frontend-staging WAIT_FOR_INVALIDATION: "true" + - name: Upload private source maps + run: bash scripts/upload-sourcemaps.sh + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: Verify deployment run: | set -euo pipefail diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aa72c01d..a388a043 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,38 +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 }} + 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 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Build SPA + run: | + set -euo pipefail + npm ci + npm run build + if grep -Rq "api.staging.seahaven.com" dist/; then + echo "::error::Built assets contain the staging API URL." >&2 + exit 1 + fi + if grep -Rq "localhost:5141" dist/; then + echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 + exit 1 + fi + grep -Rq "api.dev.seahaven.com" dist/ + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev + aws-region: us-east-1 + audience: sts.amazonaws.com + + - 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/config/sentry-release.test.ts b/config/sentry-release.test.ts new file mode 100644 index 00000000..15f8cc5c --- /dev/null +++ b/config/sentry-release.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release"; + +describe("resolveSentryRelease", () => { + it("maps the exact commit to the frontend release", () => { + const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; + + expect(resolveSentryRelease(sha, true)).toEqual({ + commitSha: sha.toLowerCase(), + release: `shoc-frontend@${sha.toLowerCase()}`, + }); + }); + + it("fails CI when the commit identity is missing or malformed", () => { + expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/); + expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/); + }); + + it("labels non-CI builds without a commit as local development", () => { + expect(resolveSentryRelease(undefined, false)).toEqual({ + commitSha: LOCAL_DEVELOPMENT_RELEASE, + release: LOCAL_DEVELOPMENT_RELEASE, + }); + }); +}); diff --git a/config/sentry-release.ts b/config/sentry-release.ts new file mode 100644 index 00000000..8bf0c90a --- /dev/null +++ b/config/sentry-release.ts @@ -0,0 +1,32 @@ +export const SENTRY_SERVICE_NAME = "shoc-frontend"; +export const LOCAL_DEVELOPMENT_RELEASE = "local-development"; + +const COMMIT_SHA = /^[0-9a-f]{40}$/i; + +export function resolveSentryRelease( + candidate: string | undefined, + ci: boolean, +): { + commitSha: string; + release: string; +} { + const commitSha = candidate?.trim(); + + if (commitSha && COMMIT_SHA.test(commitSha)) { + return { + commitSha: commitSha.toLowerCase(), + release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`, + }; + } + + if (ci) { + throw new Error( + "CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA", + ); + } + + return { + commitSha: LOCAL_DEVELOPMENT_RELEASE, + release: LOCAL_DEVELOPMENT_RELEASE, + }; +} diff --git a/docs/adr/0002-sentry-observability.md b/docs/adr/0002-sentry-observability.md new file mode 100644 index 00000000..3aeee86c --- /dev/null +++ b/docs/adr/0002-sentry-observability.md @@ -0,0 +1,94 @@ +# 0002. Browser error and transaction telemetry with Sentry + +## Status + +Accepted + +## Context + +The SeaHaven admin SPA has no centralized browser error reporting or distributed +transaction tracing. Operational failures therefore have to be reconstructed +from user reports and isolated backend logs. We need browser navigation and API +request transactions to join the backend trace while preserving the existing UI, +routing, authentication, and server-state behavior. Every emitted transaction +must also identify its exact build, trace, transaction, operation, function, and +opaque actor without sending credentials, request bodies, query values, or +personal data. + +## Decision + +Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application +router is created, and wrap `createBrowserRouter` with Sentry's current React +Router compatibility API. Configure the browser tracing integration with a 100% +transaction sample rate, and propagate trace headers only to the SPA origin and +the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the +`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of +`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught, +and recoverable component errors as Sentry events. + +All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor +document `XMLHttpRequest` calls run inside explicit `http.client` transactions. +They use normalized route templates and attach `code.function`, `operation.type`, +HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions +receive the same searchable trace identifiers in `beforeSendTransaction`. +Authenticated sessions set only the application's opaque numeric/string user ID; +logout and session expiry clear it. + +The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public +browser configuration because every `VITE_` value is embedded in the built +JavaScript. `.env.production` commits the public DSN of the real +`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing +or blank DSN in any other build leaves telemetry inactive without preventing +the application from starting. Default personally identifiable information +collection remains disabled. + +The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and +non-blank, falling back to the Vite build mode otherwise. The committed +`.env.production` labels dev deploys `development`, the staging deploy job +overrides the label to `staging`, and a future production pipeline can set +`production` without code changes. + +Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry +release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot +resolve that identity. Vite creates hidden source maps, and the dev/staging +deployment workflows upload them to the private `shoc-frontend` Sentry project +using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly +excludes `*.map`; the upload credential is never available to the browser build. + +Before-send processors reduce request data to normalized URL plus HTTP method, +reduce users to opaque ID only, attach native trace/transaction identifiers as +searchable tags, and reduce breadcrumbs to safe network method/status/normalized +URL metadata. Root and child span attributes use the same allowlist, retaining +function, operation, actor type, normalized route, method/status, service host, +timing, and native IDs while removing raw URLs and query values. Bodies, headers, +cookies, query strings/values, free-form breadcrumb messages, usernames, email +addresses, IP addresses, tokens, and filenames are not intentionally sent. +`sendDefaultPii` remains disabled. + +### Alternatives considered + +- **Manual error and timing calls** — rejected because they would miss route + transitions and distributed request context, and would require every feature + to maintain its own instrumentation. +- **Propagate tracing headers to every request** — rejected because third-party + requests must not receive SeaHaven trace metadata and broad propagation can + introduce cross-origin request failures. +- **Publish source maps with the SPA** — rejected because source code belongs in + Sentry's private artifact store, not the public application bucket. + +## Consequences + +- Browser navigations and API requests are recorded as connected transactions + when the deployment supplies a valid DSN. +- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev + deploys, `staging` on the staging job), falling back to the Vite build mode + when unset. +- Every transaction is sampled. This meets the current requirement but increases + event volume and should be reviewed against Sentry quota and retention after + real traffic is measured. +- Cross-origin API tracing depends on the backend accepting `sentry-trace` and + `baggage` request headers; the current API CORS policy allows request headers. +- Deployed stack traces resolve against the exact commit's private source maps + once the scoped upload secret is present and the deployment workflow runs. +- Rollback is isolated: remove `@sentry/react`, the initialization module and + import, restore the unwrapped router factory, and remove the Sentry env entries. 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-lock.json b/package-lock.json index 33da7afe..f6fe736b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", + "@sentry/react": "10.73.0", "@tanstack/query-broadcast-client-experimental": "5.101.2", "@tanstack/react-query": "5.101.2", "@tanstack/react-query-devtools": "5.101.2", @@ -45,6 +46,7 @@ "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", + "@sentry/cli": "3.7.0", "@tailwindcss/vite": "^4.3.2", "@types/node": "^24.13.3", "@types/react": "^19.2.17", @@ -1974,6 +1976,295 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@sentry/browser": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz", + "integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0", + "@sentry/feedback": "10.73.0", + "@sentry/replay": "10.73.0", + "@sentry/replay-canvas": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz", + "integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", + "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.7.0", + "@sentry/cli-linux-arm": "3.7.0", + "@sentry/cli-linux-arm64": "3.7.0", + "@sentry/cli-linux-i686": "3.7.0", + "@sentry/cli-linux-x64": "3.7.0", + "@sentry/cli-win32-arm64": "3.7.0", + "@sentry/cli-win32-i686": "3.7.0", + "@sentry/cli-win32-x64": "3.7.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", + "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", + "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", + "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", + "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", + "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", + "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", + "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", + "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz", + "integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz", + "integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz", + "integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/replay": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz", + "integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz", + "integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0", + "@sentry/replay": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", @@ -5736,6 +6027,16 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5753,6 +6054,13 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index fc91c435..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 .", @@ -55,6 +62,7 @@ "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", + "@sentry/react": "10.73.0", "@tanstack/query-broadcast-client-experimental": "5.101.2", "@tanstack/react-query": "5.101.2", "@tanstack/react-query-devtools": "5.101.2", @@ -79,6 +87,7 @@ "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", + "@sentry/cli": "3.7.0", "@tailwindcss/vite": "^4.3.2", "@types/node": "^24.13.3", "@types/react": "^19.2.17", 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 59b302ef..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}" @@ -16,24 +18,35 @@ REGION="${AWS_REGION:-us-east-1}" WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}" echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..." +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}..." @@ -41,6 +54,7 @@ echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." aws s3 sync dist/ "s3://${BUCKET}/" \ --delete \ --exclude "index.html" \ + --exclude "*.map" \ --cache-control "public,max-age=31536000,immutable" echo "Uploading index.html (never cached)..." 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..fcbc6c2d --- /dev/null +++ b/scripts/test-verify-cloudfront-release.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# Stubbed aws/curl tests for scripts/verify-cloudfront-release.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh" +CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" +PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" +NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111" +OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000" +INDEX_HTML='' +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 +body="$(cat "${state_dir}/index.html")" +[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}" +if [[ -n "${output}" ]]; then + printf '%s' "${body}" > "${output}" +else + printf '%s' "${body}" +fi +exit 0 +CURL + chmod +x "${bin}/aws" "${bin}/curl" +} + +dist_json() { + local status="$1" current_path="$2" + python3 -c 'import json,sys +status, path = sys.argv[1], sys.argv[2] +print(json.dumps({ + "Distribution": { + "Status": status, + "DistributionConfig": { + "Origins": {"Items": [ + {"Id": "current", "OriginPath": path}, + {"Id": "previous", "OriginPath": ""}, + ]} + } + } +}))' "${status}" "${current_path}" +} + +pointer_json() { + python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2" +} + +run_case() { + local name="$1" + local dir + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" + export PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" + export EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" + export PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" + export SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=3 + export INTERVAL=0 + local log="${dir}/log.txt" + set +e + bash "${VERIFY}" > "${log}" 2>&1 + local code=$? + set -e + assert_exit "${name}" "$2" "${code}" "${log}" + rm -rf "${dir}" +} + +# 1. Right config, then propagates (InProgress -> Deployed, hash already matches). +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + printf 'InProgress\n' > "${dir}/status" + cat > "${dir}/bin/aws" << AWS +#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "s3" ]]; then + cat "${dir}/pointer.json" + exit 0 +fi +status="\$(cat "${dir}/status")" +python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}" +echo Deployed > "${dir}/status" +AWS + chmod +x "${dir}/bin/aws" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=5 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt" + rm -rf "${dir}" +} + +# 2. Right config never propagates (Deployed, stale hash). +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf 'stale' > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt" + grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 3. Wrong origin path fails fast. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt" + grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 4. Wrong pointer fails fast. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt" + grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 5. Never Deployed. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "never-deployed" 1 "${code}" "${dir}/log.txt" + grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: never-deployed missing last observed state" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 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 new file mode 100755 index 00000000..a5a91781 --- /dev/null +++ b/scripts/upload-sourcemaps.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is required for source-map upload}" + +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 [[ -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 + +npm exec --no -- sentry-cli sourcemaps upload \ + --org "${SENTRY_ORG}" \ + --project "${SENTRY_PROJECT}" \ + --release "${RELEASE}" \ + --url-prefix "~/" \ + --validate \ + dist diff --git a/scripts/verify-cloudfront-release.sh b/scripts/verify-cloudfront-release.sh new file mode 100755 index 00000000..2382c37c --- /dev/null +++ b/scripts/verify-cloudfront-release.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Verify a CloudFront content release or rollback. +# +# Fail fast when origin_path or .release/current is the wrong label. +# Poll while the distribution is InProgress or the served index.html hash +# still matches the previous release. On timeout, print last observed state. +set -euo pipefail + +DISTRIBUTION_ID="${DISTRIBUTION_ID:-}" +EXPECTED_LABEL="${EXPECTED_LABEL:-}" +EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}" +SITE_URL="${SITE_URL:-}" +SITE_BUCKET="${SITE_BUCKET:-}" +PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}" +API_URL="${API_URL:-https://api.dev.seahaven.com/api}" +BUDGET="${BUDGET:-40}" +INTERVAL="${INTERVAL:-15}" + +if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then + echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2 + exit 2 +fi + +SITE_URL="${SITE_URL%/}" +if [[ -n "${EXPECTED_LABEL}" ]]; then + EXPECTED_PATH="/releases/${EXPECTED_LABEL}" +else + EXPECTED_PATH="" +fi + +sha256_of() { + python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" +} + +read_pointer() { + aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true +} + +read_distribution_json() { + aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json +} + +parse_distribution() { + python3 -c ' +import json, os, sys +payload = json.load(sys.stdin) +dist = payload.get("Distribution") or payload +status = dist.get("Status") or "Unknown" +config = dist.get("DistributionConfig") or {} +origins = ((config.get("Origins") or {}).get("Items")) or [] +paths = [origin.get("OriginPath") or "" for origin in origins] +expected = os.environ["EXPECTED_PATH"] +print(status) +print("\x1f".join(paths)) +print("yes" if expected in paths else "no") +' +} + +pointer_current() { + POINTER_BODY="$1" python3 -c ' +import json, os +raw = os.environ.get("POINTER_BODY", "").strip() +if not raw: + print("") + raise SystemExit +print(json.loads(raw).get("current") or "") +' +} + +last_status="Unknown" +last_paths="Unknown" +last_pointer="Unknown" +last_hash="Unknown" +last_path_ok="no" + +observe() { + last_pointer="$(read_pointer)" + local parsed + parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)" + last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')" + last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')" + last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')" + local body + body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)" + if [[ -n "${body}" ]]; then + last_hash="$(printf '%s' "${body}" | sha256_of)" + else + last_hash="unreachable" + fi +} + +report_state() { + echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}" +} + +fail_fast_if_misconfigured() { + local current + current="$(pointer_current "${last_pointer}")" + if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then + echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2 + report_state >&2 + exit 1 + fi + if [[ "${last_path_ok}" != "yes" ]]; then + echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2 + report_state >&2 + exit 1 + fi +} + +observe +fail_fast_if_misconfigured + +attempt=0 +while [[ "${attempt}" -lt "${BUDGET}" ]]; do + attempt=$((attempt + 1)) + echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}" + fail_fast_if_misconfigured + if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then + break + fi + sleep "${INTERVAL}" + observe +done + +if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA256}" ]]; then + echo "FAIL: release did not converge within the budget." >&2 + report_state >&2 + exit 1 +fi + +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/src/api/api-paths.ts b/src/api/api-paths.ts index d175e6c5..37db2b85 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -37,6 +37,8 @@ export const API_PATHS = { media: (id: string | number) => `workorders/${id}/media`, mediaItem: (id: string | number, mediaId: string | number) => `workorders/${id}/media/${mediaId}`, + mediaContent: (id: string | number, mediaId: string | number) => + `workorders/${id}/media/${mediaId}/content`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, uplifts: (id: string | number) => `workorders/${id}/uplifts`, upliftCancel: (workOrderId: string | number, upliftId: string | number) => diff --git a/src/api/api.ts b/src/api/api.ts index 51897b61..147c0571 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,18 +3,9 @@ import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; -import queryClient from "@/lib/query/query-client"; -import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; -import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; - -function clearSessionAndRedirectToLogin(): void { - clearAuth(); - stopQueryBroadcast(queryClient); - - if (typeof window !== "undefined" && window.location.pathname !== "/login") { - window.location.replace("/login"); - } -} +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; +import { traceHttpOperation } from "@/observability/http-transaction"; function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); @@ -51,35 +42,99 @@ export const api: KyInstance = ky.create({ return response; } - clearSessionAndRedirectToLogin(); + expireSessionAndRedirectToLogin(); return response; }, ], }, }); +export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete"; + +export async function apiRequestRaw( + method: ApiRawMethod, + url: string, + options?: Options, + entryPoint?: string, +): Promise { + return traceHttpOperation( + { + method: method.toUpperCase(), + url, + entryPoint: entryPoint ?? `api.${method}`, + }, + async ({ reportStatus }) => { + const response = await api[method](url, options); + reportStatus(response.status); + return response; + }, + ); +} + export async function apiGet(url: string, options?: Options): Promise { - return api.get(url, options).json(); + return traceHttpOperation( + { method: "GET", url, entryPoint: "api.apiGet" }, + async ({ reportStatus }) => { + const response = await api.get(url, options); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPost(url: string, body?: unknown, options?: Options): Promise { - return api.post(url, { ...options, json: body }).json(); + return traceHttpOperation( + { method: "POST", url, entryPoint: "api.apiPost" }, + async ({ reportStatus }) => { + const response = await api.post(url, { ...options, json: body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPostForm(url: string, body: FormData, options?: Options): Promise { - return api.post(url, { ...options, body }).json(); + return traceHttpOperation( + { method: "POST", url, entryPoint: "api.apiPostForm" }, + async ({ reportStatus }) => { + const response = await api.post(url, { ...options, body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPut(url: string, body?: unknown, options?: Options): Promise { - return api.put(url, { ...options, json: body }).json(); + return traceHttpOperation( + { method: "PUT", url, entryPoint: "api.apiPut" }, + async ({ reportStatus }) => { + const response = await api.put(url, { ...options, json: body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPatch(url: string, body?: unknown, options?: Options): Promise { - return api.patch(url, { ...options, json: body }).json(); + return traceHttpOperation( + { method: "PATCH", url, entryPoint: "api.apiPatch" }, + async ({ reportStatus }) => { + const response = await api.patch(url, { ...options, json: body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiDelete(url: string, options?: Options): Promise { - return api.delete(url, options).json(); + return traceHttpOperation( + { method: "DELETE", url, entryPoint: "api.apiDelete" }, + async ({ reportStatus }) => { + const response = await api.delete(url, options); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPostNoContent( @@ -87,31 +142,43 @@ export async function apiPostNoContent( body?: unknown, options?: Options, ): Promise { - const response = await api.post(url, { ...options, json: body }); - if (response.status === 204) { - return; - } + return traceHttpOperation( + { method: "POST", url, entryPoint: "api.apiPostNoContent" }, + async ({ reportStatus }) => { + const response = await api.post(url, { ...options, json: body }); + reportStatus(response.status); + if (response.status === 204) { + return; + } - const text = await response.text(); - if (!text.trim()) { - return; - } + const text = await response.text(); + if (!text.trim()) { + return; + } - JSON.parse(text); + JSON.parse(text); + }, + ); } export async function apiDeleteNoContent(url: string, options?: Options): Promise { - const response = await api.delete(url, options); - if (response.status === 204) { - return; - } + return traceHttpOperation( + { method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" }, + async ({ reportStatus }) => { + const response = await api.delete(url, options); + reportStatus(response.status); + if (response.status === 204) { + return; + } - const text = await response.text(); - if (!text.trim()) { - return; - } + const text = await response.text(); + if (!text.trim()) { + return; + } - JSON.parse(text); + JSON.parse(text); + }, + ); } export { mapHttpStatusToMessage }; diff --git a/src/api/upload-form-with-progress.ts b/src/api/upload-form-with-progress.ts new file mode 100644 index 00000000..90a2a254 --- /dev/null +++ b/src/api/upload-form-with-progress.ts @@ -0,0 +1,103 @@ +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; +import { env } from "@/lib/env"; +import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; + +export interface UploadFormResult { + ok: boolean; + status: number; + data: unknown; +} + +export interface UploadFormOptions { + signal?: AbortSignal; + onProgress?: (percent: number) => void; +} + +function parseJsonBody(text: string): unknown { + if (!text.trim()) { + return undefined; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function createAbortError(): DOMException { + return new DOMException("The upload was aborted.", "AbortError"); +} + +/** + * hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept + * streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies + * as a network error before reaching the API. XHR reports progress on every transport. + */ +export function uploadFormWithProgress( + path: string, + body: FormData, + options?: UploadFormOptions, +): Promise { + return new Promise((resolve, reject) => { + if (options?.signal?.aborted) { + reject(createAbortError()); + return; + } + + const request = new XMLHttpRequest(); + request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`); + request.setRequestHeader("Accept", "application/json"); + + const token = getAccessToken(); + if (token) { + request.setRequestHeader("Authorization", token); + } + + const abortRequest = () => { + request.abort(); + }; + options?.signal?.addEventListener("abort", abortRequest); + + const detachSignal = () => { + options?.signal?.removeEventListener("abort", abortRequest); + }; + + const { onProgress } = options ?? {}; + if (onProgress) { + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(Math.round((event.loaded / event.total) * 100)); + } + }); + } + + request.addEventListener("load", () => { + detachSignal(); + const { status } = request; + + if (status === 401) { + expireSessionAndRedirectToLogin(); + } + + resolve({ + ok: status >= 200 && status < 300, + status, + data: parseJsonBody(request.responseText), + }); + }); + + request.addEventListener("error", () => { + detachSignal(); + reject(new Error("Upload failed. Check your connection and try again.")); + }); + + request.addEventListener("abort", () => { + detachSignal(); + reject(createAbortError()); + }); + + request.send(body); + }); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index a6df25e5..eee8b3ec 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { toast } from "react-toastify"; import { Text } from "@/components/ui/text"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; import { @@ -20,9 +21,9 @@ import { import { canDeleteWorkOrderMedia, formatMediaSizeLimit, + isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, - isWorkOrderMediaTypeAllowed, - workOrderMediaAcceptAttribute, + workOrderExtraDocsAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; const FILETYPE_ICON: Record = { @@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = { isReadOnly: boolean; pending?: boolean; avetaRequired?: boolean; + workOrderId?: string | number; onUpload: (files: File[]) => void; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; +async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { + const tab = window.open("about:blank", "_blank"); + if (!tab) { + toast.error("Unable to open this document. Please allow pop-ups and try again."); + return; + } + + try { + const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId); + const objectUrl = URL.createObjectURL(blob); + tab.opener = null; + tab.location.href = objectUrl; + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + } catch { + tab.close(); + toast.error("Unable to open this document."); + } +} + function ExtraDocRow({ file, isReadOnly, pending, + workOrderId, onRemove, onCategorize, }: { file: MediaFile; isReadOnly: boolean; pending: boolean; + workOrderId?: string | number; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }) { @@ -75,15 +98,20 @@ function ExtraDocRow({ className="shrink-0" style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }} /> - { + if (workOrderId == null) { + return; + } + void openExtraDocContent(workOrderId, file.id); + }} + className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50" style={{ fontSize: 12, color: "var(--foreground)" }} > {file.name} - + {showCategory ? ( diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 1bc70fae..c5955f15 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -39,6 +39,9 @@ function SlideOverScheduleOnField({ draft: WorkOrderTableRow; onDraftChange: (patch: Partial) => void; }) { + const hasSchedule = + Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); + return ( + onDraftChange({ + scheduledOn: "", + scheduleWeekOnly: false, + targetWeek: "", + scheduleEnd: "", + schedMode: undefined, + isPastDue: false, + ...deriveDayFields(""), + }) + : undefined + } /> ); } diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 94b60262..346ecb57 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "react-toastify"; import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; @@ -9,9 +9,13 @@ import type { useDeleteWorkOrderMedia, useUpdateWorkOrderMediaCategory, useUploadWorkOrderMedia, + UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; - -const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]); +import { + isCompletionSurfaceMedia, + isExtrasSurfaceMedia, + type MediaSurfaceOwnership, +} from "@/domain/work-orders/utils/partition-slide-over-media"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = { categorizeMediaMutation: ReturnType; }; +function addOwnedId(previous: Set, id: string | undefined): Set { + if (!id) return previous; + const next = new Set(previous); + next.add(id); + return next; +} + +function dropOwnedId(previous: Set, id: string): Set { + if (!previous.has(id)) return previous; + const next = new Set(previous); + next.delete(id); + return next; +} + +const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:"; + +function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership { + if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() }; + try { + const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`); + if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() }; + const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown }; + return { + completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []), + extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []), + }; + } catch { + return { completionOwnedIds: new Set(), extraOwnedIds: new Set() }; + } +} + +function saveOwnership( + workOrderId: string | number | undefined, + ownership: MediaSurfaceOwnership, +): void { + if (workOrderId == null) return; + try { + sessionStorage.setItem( + `${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`, + JSON.stringify({ + completion: [...ownership.completionOwnedIds], + extra: [...ownership.extraOwnedIds], + }), + ); + } catch { + return; + } +} + export function useSlideOverMediaState({ detailForRow, uploadMediaMutation, deleteMediaMutation, categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { + const workOrderId = detailForRow?.info.id; const [mediaBatchPending, setMediaBatchPending] = useState(false); + const [ownership, setOwnershipState] = useState(() => + loadOwnership(workOrderId), + ); + + useEffect(() => { + setOwnershipState(loadOwnership(workOrderId)); + }, [workOrderId]); + + const updateOwnership = useCallback( + (update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => { + setOwnershipState((current) => { + const next = update(current); + saveOwnership(workOrderId, next); + return next; + }); + }, + [workOrderId], + ); + + const persistCompletionUpload = useCallback( + async (input: UploadWorkOrderMediaInput) => { + const item = await uploadMediaMutation.mutateAsync(input); + updateOwnership((current) => ({ + completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id), + extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id), + })); + return item; + }, + [updateOwnership, uploadMediaMutation], + ); + const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = - useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input)); + useMediaUploadSession(detailForRow, persistCompletionUpload); const completionMedia = useMemo( - () => - uploads.filter( - (item) => - COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") || - item.uploadStatus === "uploading" || - item.uploadStatus === "failed", - ), - [uploads], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), + [uploads, ownership], ); const extrasUploads = useMemo( () => - boardMediaItemsToMediaFiles( - (detailForRow?.media ?? []).filter( - (item) => item.category === "Extra" || item.category === "Aveta", - ), + boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => + isExtrasSurfaceMedia(item, ownership), ), - [detailForRow?.media], + [detailForRow?.media, ownership], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); @@ -68,7 +145,11 @@ export function useSlideOverMediaState({ try { for (const file of files) { // Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH. - await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + updateOwnership((current) => ({ + completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id), + extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id), + })); } } finally { setMediaBatchPending(false); @@ -81,7 +162,20 @@ export function useSlideOverMediaState({ toast.error("Missing work order version. Refresh and try again."); return; } - categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion }); + categorizeMediaMutation.mutate( + { mediaId, category, workOrderVersion }, + { + onSuccess: () => { + updateOwnership((current) => ({ + completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId), + extraOwnedIds: + category === "Extra" + ? addOwnedId(new Set(current.extraOwnedIds), mediaId) + : dropOwnedId(new Set(current.extraOwnedIds), mediaId), + })); + }, + }, + ); }; const removeMedia = (id: string) => { diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index ebd41392..d43d94c1 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({ > e.stopPropagation()} slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} > + {Boolean(onClear) && ( +
+ +
+ )} )} - {Boolean(onClear) && ( -
- -
- )} ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts new file mode 100644 index 00000000..cf3624c9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from "react"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; +import { + buildCompanyOptions, + buildTechnicianOptions, + phoneForOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import { + mergeVendorDropdownItems, + resolveVendorCompanyAnchor, +} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor"; +import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key"; +import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save"; +import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit"; +import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; + +type UseVendorDialogFormArgs = { + open: boolean; + onOpenChange: (open: boolean) => void; + vendorId: string | number; + company: string; + tech: string; + techPhone: string; + vendors: VendorDropdownItem[]; + onSave: (patch: VendorPatch) => void; +}; + +export function useVendorDialogForm({ + open, + onOpenChange, + vendorId, + company, + tech, + techPhone, + vendors, + onSave, +}: UseVendorDialogFormArgs) { + const createVendor = useCreateVendor(); + const [selectedCompany, setSelectedCompany] = useState(company); + const [selectedKey, setSelectedKey] = useState(() => + resolveTechnicianKey(vendorId, tech, company, vendors), + ); + const [t, setT] = useState(tech); + const [phone, setPhone] = useState(techPhone); + const [notes, setNotes] = useState(""); + const [addedVendors, setAddedVendors] = useState([]); + const [pendingCreates, setPendingCreates] = useState>({}); + const [saveError, setSaveError] = useState(null); + + const allVendors = useMemo( + () => mergeVendorDropdownItems(vendors, addedVendors), + [vendors, addedVendors], + ); + + const companyOptions = useMemo( + () => buildCompanyOptions(allVendors, company), + [allVendors, company], + ); + + const technicianOptions = useMemo( + () => buildTechnicianOptions(allVendors, selectedCompany, tech, company), + [allVendors, selectedCompany, tech, company], + ); + + useEffect(() => { + if (open) { + const key = resolveTechnicianKey(vendorId, tech, company, vendors); + const initialOptions = buildTechnicianOptions(vendors, company, tech, company); + setSelectedCompany(company); + setSelectedKey(key); + setT(tech); + setPhone(techPhone || phoneForOption(tech, initialOptions)); + setNotes(""); + setAddedVendors([]); + setPendingCreates({}); + setSaveError(null); + } + // invariant: rebuilding options after Add must not clear staged technician drafts. + }, [open, tech, techPhone, vendors, company, vendorId]); + + const selected = technicianOptions.find((option) => option.key === selectedKey); + const resolvedCompany = selectedCompany; + const selectedVendor = + selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany); + + const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({ + company: selectedCompany, + resolvedCompany, + selectedVendor, + vendors: allVendors, + }); + + const canSubmit = computeVendorDialogCanSubmit({ + current: { + company: selectedCompany, + selectedKey, + phone, + notes, + pendingCreateCount: Object.keys(pendingCreates).length, + }, + baseline: { + company, + selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors), + phone: + techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)), + notes: "", + pendingCreateCount: 0, + }, + }); + + const { handleSave } = useVendorDialogSave({ + allVendors, + pendingCreates, + selectedKey, + selectedVendor, + resolvedCompany, + anchorCompanyName, + tech: selected?.name ?? t, + phone, + createVendor, + onSave, + onOpenChange, + setSaveError, + canSubmit, + }); + + return { + companyOptions, + technicianOptions, + selected, + t, + phone, + notes, + resolvedCompany, + selectedVendor, + selectedCompany, + selectedKey, + anchorCompanyName, + companyVendor, + saveError, + canSubmit, + isPending: createVendor.isPending, + handleSave, + handleCompanyPick: (nextCompany: string) => { + const selectedOption = technicianOptions.find((option) => option.key === selectedKey); + setSelectedCompany(nextCompany); + if (selectedOption?.company !== nextCompany) { + setAddedVendors([]); + setPendingCreates({}); + setSelectedKey(""); + setT(""); + setPhone(""); + } + }, + handlePick: (key: string) => { + const option = technicianOptions.find((o) => o.key === key); + setSelectedKey(key); + setT(option?.name ?? key); + setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions)); + }, + handleTechnicianCreated: ( + created: VendorDropdownItem, + createdPhone: string, + payload: VendorFormValues, + ) => { + setAddedVendors((current) => [...current, created]); + setPendingCreates((current) => ({ ...current, [String(created.id)]: payload })); + setSelectedKey(String(created.id)); + setT(created.contactName); + setPhone(createdPhone); + }, + setPhone, + setNotes, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts index 502714c6..619bc6c9 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = { onSave: (patch: VendorPatch) => void; onOpenChange: (open: boolean) => void; setSaveError: (error: string | null) => void; + canSubmit: boolean; }; export function useVendorDialogSave({ @@ -33,6 +34,7 @@ export function useVendorDialogSave({ onSave, onOpenChange, setSaveError, + canSubmit, }: UseVendorDialogSaveArgs) { const resolveSaveVendor = () => allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? @@ -44,6 +46,7 @@ export function useVendorDialogSave({ selectedVendor; const handleSave = async () => { + if (!canSubmit) return; setSaveError(null); let saveVendor = resolveSaveVendor(); const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined; @@ -65,8 +68,8 @@ export function useVendorDialogSave({ onSave({ vendorId: saveVendor ? String(saveVendor.id) : "", company: resolvedCompany || anchorCompanyName, - tech: saveVendor?.contactName ?? tech, - techPhone: phone, + tech: selectedKey ? (saveVendor?.contactName ?? tech) : "", + techPhone: selectedKey ? phone : "", }); onOpenChange(false); }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx index 5b58de5e..928bfe51 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -1,4 +1,4 @@ -import { Wrench } from "lucide-react"; +import { Building2, Wrench } from "lucide-react"; import { DLG_INPUT_CLS, DLG_INPUT_STYLE, @@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/ import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician"; import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import type { + CompanyOption, + TechnicianOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; type VendorDialogBodyProps = { + selectedCompany: string; + companyOptions: CompanyOption[]; selectedKey: string; technicianOptions: TechnicianOption[]; anchorCompanyName: string; @@ -23,6 +28,7 @@ type VendorDialogBodyProps = { resolvedCompany: string; selectedVendor: VendorDropdownItem | undefined; saveError: string | null; + onCompanyPick: (company: string) => void; onPick: (key: string) => void; onPhoneChange: (phone: string) => void; onNotesChange: (notes: string) => void; @@ -34,6 +40,8 @@ type VendorDialogBodyProps = { }; export function VendorDialogBody({ + selectedCompany, + companyOptions, selectedKey, technicianOptions, anchorCompanyName, @@ -44,6 +52,7 @@ export function VendorDialogBody({ resolvedCompany, selectedVendor, saveError, + onCompanyPick, onPick, onPhoneChange, onNotesChange, @@ -53,17 +62,46 @@ export function VendorDialogBody({ <>
- Technician + Company + + } + searchPlaceholder="Search company…" + options={companyOptions.map((option) => ({ + key: option.key, + label: option.label, + }))} + onPick={onCompanyPick} + /> +
+ + {Boolean(resolvedCompany) && ( +
+ Company info + +
+ )} + +
+ Technician (optional) } searchPlaceholder="Search technician…" options={technicianOptions.map((option) => ({ key: option.key, - label: option.label, + label: option.name, }))} onPick={onPick} /> @@ -80,7 +118,7 @@ export function VendorDialogBody({ {Boolean(tech) && (
- Technician Phone + Technician Phone (optional) )} - {Boolean(resolvedCompany) && ( -
- Company - -
- )} -
- Notes + Notes (optional)