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..53262ad1 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 guard + # tests, Terraform isolation gate tests, CDK build/test/synth). If the + # reusable workflow is later confirmed to run every gate, this job can be + # slimmed to `npm run governance`. # # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) @@ -53,6 +55,13 @@ 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" @@ -62,6 +71,30 @@ jobs: env: GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} + terraform-isolation: + # Fails a pull request that changes `terraform/**` 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; it is + # read when the job runs, so re-run this workflow after labeling. + name: Terraform and application changes are isolated + if: github.event_name == 'pull_request' + 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}" + visual-regression: name: Visual regression runs-on: ubuntu-latest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6ad9bde2..099b55de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,22 +1,22 @@ -name: Deploy +name: Deploy dev content -# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`. +# Manual dev content deployment during the Terraform adoption (SH-300). # -# 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. +# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are +# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are +# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the +# resources, and move to HCP Terraform (`terraform/README.md`) as adoption +# completes. Automatic push-to-`dev` releases return with the Terraform +# content-CD change, gated on a repository variable. # -# When staging/prod accounts exist, add jobs keyed to their branches and their -# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow. +# This workflow publishes only content: verify, build, `aws s3 sync`, and a +# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC +# deploy role. The bucket and distribution are pinned here so a content deploy +# keeps working after CloudFormation relinquishes the stack outputs. on: - push: - branches: [dev] 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 @@ -27,22 +27,13 @@ concurrency: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 - with: - node-version: "24" - region: us-east-1 - cdk-dir: infra/cdk - stack-name: shoc-frontend-dev - post-deploy-script: scripts/deploy-web.sh - secrets: - deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} - - upload-sourcemaps: - name: Upload private source maps - needs: deploy + name: Publish content to dev + # Deploy only the exact dev branch ref: workflow_dispatch can be invoked + # from arbitrary refs, and the deploy role trusts only refs/heads/dev. if: github.ref == 'refs/heads/dev' runs-on: ubuntu-latest env: + AWS_REGION: us-east-1 VITE_APP_COMMIT_SHA: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -52,9 +43,66 @@ jobs: with: node-version: "24" cache: npm - - name: Build exact deployed release - run: npm ci && npm run build - - name: Upload source maps to Sentry + - name: Set up Terraform + # Required by `npm run verify` (governance runs terraform fmt/validate). + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false + - name: Quality gates (full verify before any deploy) + run: npm ci && npm run verify + env: + GOVERNANCE_BASE: origin/dev + + - name: Assume dev deploy role (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 + + # Builds with the dev values committed in .env.production (VITE_API_URL, + # Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront. + - name: Build and publish SPA + run: bash scripts/deploy-web.sh + env: + SITE_BUCKET: seahaven-shoc-frontend-dev + CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P + WAIT_FOR_INVALIDATION: "true" + + - name: 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 + SITE_URL="https://dev.seahaven.com" + if grep -Rq "api.staging.seahaven.com" dist/; then + echo "::error::Built assets contain the staging API URL." >&2 + exit 1 + fi + grep -Rq "api.dev.seahaven.com" dist/ + echo "Built assets reference the dev API URL." + + # The invalidation has completed, but give edges a short window to + # converge before calling the served index.html wrong. + remote_dir="$(mktemp -d)" + trap 'rm -rf "${remote_dir}"' EXIT + matched=false + for i in 1 2 3 4 5 6; do + if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \ + && cmp -s dist/index.html "${remote_dir}/index.html"; then + matched=true + break + fi + echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..." + sleep 20 + done + if [[ "${matched}" != "true" ]]; then + echo "::error::Served index.html does not match the build just published." >&2 + exit 1 + fi + echo "Served index.html matches the published build." + curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login" + echo "Extensionless SPA route serves." diff --git a/.gitignore b/.gitignore index aabcecf0..5e4ce209 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,15 @@ 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..60ed9970 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -7,7 +7,10 @@ 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: the Terraform import-plan checker tests, the Terraform isolation gate +tests, Terraform formatting and validation, and the CDK build, template tests, +and synthesis. A task is not done until this is green. ## Gate matrix @@ -23,6 +26,11 @@ npm run verify | Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | | Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | | Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | +| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | +| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier | +| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` | +| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes | +| Terraform/app change isolation | `ci.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | ## No-false-pass guarantees @@ -36,6 +44,15 @@ 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 reads the override when it runs** — the + `terraform-isolation-override` label is the only way to merge a mixed + Terraform/application PR, the gate logs the override on the run, and the + workflow must be re-run after the label is added or removed. ## Where the gates run @@ -44,11 +61,16 @@ 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. On pull requests the same workflow's + `terraform-isolation` job fails when `terraform/**` and application code + change together. ## 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.9.0, < 2.0.0`) and +`python3` (3.10+) on `PATH`. diff --git a/README.md b/README.md index 51fc21d2..9a4bc04a 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,15 @@ graph LR CF -->|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: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev] + ROLE -->|s3 sync + invalidation| S3 + TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3 ``` +Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see +[`terraform/README.md`](terraform/README.md) for the phase runbook and the +current ownership state. + 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 @@ -57,12 +62,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_*`) @@ -78,7 +84,8 @@ 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. +[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs +`cdk deploy` with no flags. ## Local Development @@ -95,17 +102,22 @@ 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, CDK) | +| `npm run verify` | **All gates**: format + lint + build + test + governance | + +`npm run governance` needs `terraform` and `python3` on `PATH` for the +Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`, +`npm run test:terraform-isolation`) and installs `infra/cdk` for +`npm run test:infra`. Husky + lint-staged run ESLint and Prettier on staged files at commit; commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or @@ -123,66 +135,81 @@ 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 the + **Deploy dev content** workflow while the Terraform adoption is in progress) + `→ 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 CDK template tests) 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`, +- **Terraform isolation** (the `terraform-isolation` job in + [`.github/workflows/ci.yaml`](.github/workflows/ci.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` only while the Terraform adoption is in + progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`, + and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, `aws s3 sync dist/` (hashed assets immutable, `index.html` never cached), - CloudFront invalidation. Both run as the OIDC deploy role. + CloudFront invalidation, then uploads source maps and checks the served + `index.html` matches the build. Push-to-`dev` releases return with the + Terraform content-CD change. +- **Staging content** + ([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml)) + — on push to `staging`, unchanged. +- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence + and the HCP Terraform workspace `shoc-frontend-new-dev` + ([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy` + ([`infra/cdk/README.md`](infra/cdk/README.md)). -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`): +Manual content deploy (emergency/reference only — needs credentials for the +external-dev AWS account): ```bash -(cd infra/cdk && npx cdk deploy) -STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh +SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \ + AWS_REGION=us-east-1 bash scripts/deploy-web.sh ``` ## 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. + - _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). +- **Dev has no push-triggered deploy during the adoption.** Merging to `dev` + runs CI only; publish through the **Deploy dev content** workflow. Merging a + `terraform/**` change also queues an HCP Terraform run that a human confirms + or discards (see the operational rules in `terraform/README.md`). ## Documentation - Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md) +- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md) - Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md); design system and UI docs under [`docs/`](docs/) diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 364780af..cf7d466f 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -1,34 +1,44 @@ # 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. +AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo. +Infrastructure deploys are administrator-run; GitHub Actions publishes content +only. + +> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack +> `shoc-frontend-dev` is in the retain/transfer sequence described under +> [Terraform adoption mode](#terraform-adoption-mode) and in +> [`terraform/README.md`](../terraform/README.md). Do not run a plain +> `cdk deploy` against dev while that sequence is in progress. Staging is +> unaffected and stays on this CDK path (SH-287 tracks its cutover). - **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias). - **API:** the SPA calls the backend **directly** over HTTPS at `https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend allows CORS). CloudFront serves static content only — no `/api` proxy. -- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy` - picks them up with no flags. `VITE_API_URL` is baked into the build, so it's +- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks + them up with no flags. `VITE_API_URL` is baked into the build, so it's per-environment (see the note under "Adding staging / prod"). - **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys) -- **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). +- **Content workflows:** `.github/workflows/deploy.yml` (dev, + `workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to + `staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy + role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was + retired with the adoption PR. - **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is created by this stack, not added to the central `oidc-deploy-roles.yaml`. -- **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 + bin/app.ts entry point (reads -c context) + lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role + lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition) + test/frontend-stack.test.mjs template assertions for both modes +scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation .github/workflows/ - ci.yaml quality gates (lint / build / test / e2e) - deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) - deploy-staging.yml standalone staging deploy (push to staging) + ci.yaml quality gates (lint / build / test / governance / terraform isolation) + deploy.yml dev content publish (workflow_dispatch on dev) + deploy-staging.yml standalone staging deploy (push to staging) ``` ## What the stack creates @@ -40,12 +50,67 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation | 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. +The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants: +`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`, +read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It +is left byte-identical on purpose so the Terraform import is a no-op; the +Terraform content-CD change narrows it. The OIDC **provider** is a singleton +account resource — the stack only _imports_ it (created in step 2), so +`cdk destroy` can't delete a resource shared by other roles. + +## Terraform adoption mode + +`-c retainForTerraformAdoption=true` switches the stack into the safety mode +used only while HCP Terraform adopts the dev resources. It is off by default +and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts +both). In adoption mode the stack: + +- pins the origin ID CloudFormation generated for the live distribution + (`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is + metadata-only; environments without a verified value fail synthesis +- attaches the `seahaven-org-baseline` permissions boundary + `shoc-frontend-new-dev-deploy-boundary` and the + `HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role +- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the + same exact value +- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13 + transferred resources (bucket, bucket policy, distribution, OAC, SPA + function, A and AAAA records, deploy role, inline policy) and to + `SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider + Lambda and role stay unretained +- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no + default) and conditions those same resources and every output on it +- emits `TerraformImport*` outputs carrying the exact import IDs + +`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must +state the ownership phase: + +```bash +cd infra/cdk && npm ci + +# Phase 1, before the Terraform import: keep the resources in the stack and +# install Retain on them. Update-only change set. +npx cdk deploy shoc-frontend-dev \ + -c retainForTerraformAdoption=true \ + --parameters ManageSiteInfrastructure=true + +# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish +# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource. +npx cdk deploy shoc-frontend-dev \ + -c retainForTerraformAdoption=true \ + --parameters ManageSiteInfrastructure=false +``` + +Both deploys must use the same reviewed SHA. Review the change set before +confirming: Phase 1 must show no create, delete, or replace. After the +`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used +again. If the `true` deploy rolls back, inspect the stack resources and the +live bucket before retrying; retained resources can outlive a failed update and +must not be cleaned up automatically. Never delete the auto-delete custom +resource while its handler can still empty the versioned bucket. + +Local checks (`npm run test:infra` from the repo root) build the app, run the +template assertions, and synthesize both modes. --- @@ -102,48 +167,31 @@ 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): +Note the `DeployRoleArn` output. Then publish the first content manually: ```bash # from repo root, optional manual first content publish: STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh ``` -### 6. Set the one GitHub secret +### 6. Content deploys -`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. +The deploy role ARN is deterministic and pinned in +`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the +Terraform adoption, dev content deploys run only through **Actions → Deploy dev +content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes +`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the +pinned bucket and distribution, uploads source maps, and verifies the served +`index.html` matches the build. Automatic push-to-`dev` releases return with the +Terraform content-CD change. --- ## Staging environment (same account, exact OIDC subject) -Staging lives in the same AWS account (396287094661) but deploys through its -own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org -reusable `cd-cdk.yaml`: +Staging lives in the same AWS account (396287094661) and deploys through its +own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to +`staging`: - **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub @@ -212,10 +260,11 @@ for prod. - **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. + Never run it against dev during or after the Terraform adoption: the + adoption-mode stack retains the transferred resources, and after Phase 2 + Terraform owns them. +- **CI and staging CD both fire on push to `staging`** in parallel; the + staging CD workflow runs `npm run verify` itself before deploying. Dev has + no push-triggered deploy during the adoption. - **npm is pinned to v11.16.0**; the committed `package-lock.json` uses lockfileVersion 3, matching the Node 24 / npm 11 CI environment. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 876463fe..afb51ff9 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -25,6 +25,12 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; +// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required +// ManageSiteInfrastructure parameter and Retain policies on the transferred +// resources. Off by default so ordinary synthesis is unchanged. +const retainForTerraformAdoption = + String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true"; + // Staging and beyond protect their stacks from accidental deletion; dev // stays teardown-friendly (its artifacts are reproducible). CDK applies this // at deploy time — it is not part of the synthesized template. @@ -40,6 +46,7 @@ const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { certificateArn, hostedZoneId, hostedZoneName, + retainForTerraformAdoption, env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index dda5f67e..bce19e98 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -1,4 +1,16 @@ -import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib"; +import { + Aspects, + CfnCondition, + CfnOutput, + CfnParameter, + CfnResource, + Duration, + Fn, + RemovalPolicy, + Stack, + StackProps, + Tags, +} from "aws-cdk-lib"; import { Construct } from "constructs"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; @@ -7,6 +19,7 @@ import * as iam from "aws-cdk-lib/aws-iam"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as targets from "aws-cdk-lib/aws-route53-targets"; +import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption"; export interface FrontendStackProps extends StackProps { /** Environment label, e.g. "dev". Used in names/tags. */ @@ -42,6 +55,11 @@ export interface FrontendStackProps extends StackProps { readonly hostedZoneId: string; /** Name of the hosted zone above, e.g. "dev.seahaven.com". */ readonly hostedZoneName: string; + /** + * Opt-in safety mode used only during the reviewed Terraform adoption. + * Normal dev/staging synthesis remains unchanged when false. + */ + readonly retainForTerraformAdoption?: boolean; } /** @@ -50,11 +68,10 @@ export interface FrontendStackProps extends StackProps { * - 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. + * Content (the built `dist/`) is NOT uploaded here. Manual environment + * workflows run `scripts/deploy-web.sh` independently of infrastructure + * changes, so this stack only owns infrastructure and the deploy role carries + * content-publication permissions. */ export class FrontendStack extends Stack { constructor(scope: Construct, id: string, props: FrontendStackProps) { @@ -69,8 +86,23 @@ export class FrontendStack extends Stack { certificateArn, hostedZoneId, hostedZoneName, + retainForTerraformAdoption = false, } = props; + const manageSiteInfrastructureCondition = retainForTerraformAdoption + ? new CfnCondition(this, "ManageSiteInfrastructureCondition", { + expression: Fn.conditionEquals( + new CfnParameter(this, "ManageSiteInfrastructure", { + type: "String", + allowedValues: ["true", "false"], + description: + "Set true only before Terraform adoption. After ownership transfer, always reuse false.", + }).valueAsString, + "true", + ), + }) + : undefined; + const hasCustomDomain = domainNames.length > 0; if (hasCustomDomain && !certificateArn) { throw new Error( @@ -114,6 +146,16 @@ export class FrontendStack extends Stack { // --- CloudFront: serves the static SPA from S3 ------------------------- // The SPA calls the backend directly at its absolute HTTPS URL // (VITE_API_URL, cross-origin), so CloudFront hosts only static content. + // Adoption mode pins the origin ID CloudFormation generated for the live + // distribution so the retention deploy is a metadata-only update. Only + // environments with a read-back-verified value may enter adoption mode. + const adoptionOriginIds: Record = { + dev: "shocfrontenddevDistributionOrigin10CCD0EE1", + }; + const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined; + if (retainForTerraformAdoption && !originId) { + throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`); + } const distribution = new cloudfront.Distribution(this, "Distribution", { comment: `SeaHaven SHOC frontend (${envName})`, defaultRootObject: "index.html", @@ -130,7 +172,9 @@ export class FrontendStack extends Stack { : undefined, defaultBehavior: { // withOriginAccessControl wires up OAC + the bucket policy automatically. - origin: origins.S3BucketOrigin.withOriginAccessControl(bucket), + origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, { + originId, + }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, @@ -158,9 +202,9 @@ export class FrontendStack extends Stack { // 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). + // runs in that environment can assume the role. Normal dev synthesis keeps + // the current branch-ref StringLike trust. The adoption prerequisite + // narrows that already-exact value to StringEquals before Terraform import. const oidcConditions = githubEnvironment ? { StringEquals: { @@ -168,30 +212,48 @@ export class FrontendStack extends Stack { "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}`, - }, - }; + : retainForTerraformAdoption + ? { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + } + : { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + // Tightly scoped: only pushes to this repo's deploy branch. For a + // reusable-workflow run the OIDC `sub` is still caller-based, so this + // matches even though the deploy job lives in the `.github` repo. + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + }; + + const deployPermissionsBoundary = retainForTerraformAdoption + ? iam.ManagedPolicy.fromManagedPolicyArn( + this, + "GithubDeployPermissionsBoundary", + `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, + ) + : undefined; const deployRole = new iam.Role(this, "GithubDeployRole", { roleName: `githubdeploy-shoc-frontend-new-${envName}`, description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, maxSessionDuration: Duration.hours(1), assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions), + permissionsBoundary: deployPermissionsBoundary, }); + if (retainForTerraformAdoption) { + Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`); + } - // 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. + // Preserve dev's legacy CDK capability until the reviewed adoption update + // replaces this inline policy. Staging is intentionally narrower: its + // content role only publishes application assets to this stack's + // bucket/distribution. Infrastructure changes remain administrator-run. if (!githubEnvironment) { deployRole.addToPolicy( new iam.PolicyStatement({ @@ -224,6 +286,8 @@ export class FrontendStack extends Stack { // --- DNS: point the custom domain at CloudFront ------------------------ // Only when a hosted zone is supplied (it must be in THIS account). Creates // A + AAAA aliases; for the zone apex, recordName is the zone itself. + let aliasA: route53.ARecord | undefined; + let aliasAaaa: route53.AaaaRecord | undefined; if (hostedZoneId && hasCustomDomain) { const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", { hostedZoneId, @@ -233,31 +297,169 @@ export class FrontendStack extends Stack { // 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 }); + aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target }); + aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", { + zone, + recordName, + target, + }); } + const gateOutput = (output: CfnOutput): CfnOutput => { + if (manageSiteInfrastructureCondition) { + output.condition = manageSiteInfrastructureCondition; + } + return output; + }; + // --- Outputs ----------------------------------------------------------- // scripts/deploy-web.sh reads BucketName + DistributionId from these. - 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", - }); + gateOutput( + new CfnOutput(this, "SiteUrl", { + value: hasCustomDomain + ? `https://${domainNames[0]}` + : `https://${distribution.distributionDomainName}`, + description: "Public URL of the deployed SPA", + }), + ); + gateOutput( + new CfnOutput(this, "DistributionDomainName", { + value: distribution.distributionDomainName, + description: "CloudFront domain — point the custom-domain DNS record here", + }), + ); + gateOutput( + new CfnOutput(this, "BucketName", { + value: bucket.bucketName, + }), + ); + gateOutput( + new CfnOutput(this, "DistributionId", { + value: distribution.distributionId, + }), + ); + gateOutput( + new CfnOutput(this, "DeployRoleArn", { + value: deployRole.roleArn, + description: "Pinned GitHub OIDC content-deployment role", + }), + ); + + if (retainForTerraformAdoption) { + const originAccessControl = distribution.node + .findAll() + .find( + (node): node is cloudfront.CfnOriginAccessControl => + node instanceof cloudfront.CfnOriginAccessControl, + ); + if (!originAccessControl || !aliasA || !aliasAaaa) { + throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records."); + } + const originAccessControlConfig = + originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty; + + const rolePolicy = deployRole.node + .findAll() + .find((node): node is iam.Policy => node instanceof iam.Policy); + const autoDeleteProviderRole = this.node + .findAll() + .find( + (node): node is CfnResource => + node instanceof CfnResource && + node.cfnResourceType === "AWS::IAM::Role" && + node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"), + ); + if (!rolePolicy || !autoDeleteProviderRole) { + throw new Error("Terraform adoption outputs require deploy and auto-delete roles."); + } + + const recordName = domainNames[0]; + gateOutput( + new CfnOutput(this, "TerraformWorkspaceTag", { + value: `shoc-frontend-new-${envName}`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDeployBoundaryArn", { + value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, + }), + ); + gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName })); + gateOutput( + new CfnOutput(this, "TerraformImportBucketPolicy", { + value: bucket.bucketName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDistribution", { + value: distribution.distributionId, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportOriginAccessControl", { + value: originAccessControl.attrId, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformOriginAccessControlName", { + value: originAccessControlConfig.name, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformOriginAccessControlDescription", { + value: "EMPTY_STRING", + description: "Use an empty Terraform string because the generated OAC has no description", + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDistributionOriginId", { + value: originId!, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportSpaRewriteFunction", { + value: spaRewrite.functionName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportAliasA", { + value: `${hostedZoneId}_${recordName}_A`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportAliasAAAA", { + value: `${hostedZoneId}_${recordName}_AAAA`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDeployRole", { + value: deployRole.roleName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDeployRolePolicy", { + value: `${deployRole.roleName}:${rolePolicy.policyName}`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDeployInlinePolicyName", { + value: rolePolicy.policyName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", { + value: autoDeleteProviderRole.getAtt("Arn").toString(), + }), + ); + gateOutput( + new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", { + value: "SiteBucket/AutoDeleteObjectsCustomResource", + description: + "CloudFormation custom resource retained to prevent bucket emptying during detachment", + }), + ); + + Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition)); + } } } diff --git a/infra/cdk/lib/retain-for-terraform-adoption.ts b/infra/cdk/lib/retain-for-terraform-adoption.ts new file mode 100644 index 00000000..c02aa3e9 --- /dev/null +++ b/infra/cdk/lib/retain-for-terraform-adoption.ts @@ -0,0 +1,63 @@ +import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib"; +import { IConstruct } from "constructs"; + +const TRANSFERRED_RESOURCE_TYPES = new Set([ + "AWS::S3::Bucket", + "AWS::S3::BucketPolicy", + "AWS::CloudFront::Distribution", + "AWS::CloudFront::Function", + "AWS::CloudFront::OriginAccessControl", + "AWS::Route53::RecordSet", +]); + +function isTransferredResource(resource: CfnResource): boolean { + if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) { + return true; + } + + if ( + resource.cfnResourceType === "Custom::S3AutoDeleteObjects" && + resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource") + ) { + return true; + } + + return ( + (resource.cfnResourceType === "AWS::IAM::Role" || + resource.cfnResourceType === "AWS::IAM::Policy") && + resource.node.path.includes("/GithubDeployRole") + ); +} + +/** + * Retains only the resources in the approved Terraform transfer set. + * + * The bucket auto-delete custom resource is intentionally retained while the + * generated provider Lambda, role, log group, and CDK metadata remain excluded. + * When a management condition is supplied, those same resources share it so + * CloudFormation can later relinquish them without deleting them. + */ +export class RetainForTerraformAdoption implements IAspect { + constructor(private readonly manageCondition?: CfnCondition) {} + + public visit(node: IConstruct): void { + if (!(node instanceof CfnResource) || !isTransferredResource(node)) { + return; + } + + // Keep the L2 bucket's configured DESTROY policy visible to its + // AutoDeleteObjects validator while overriding the emitted CloudFormation + // resource. This preserves the custom resource and retains both together. + if (node.cfnResourceType === "AWS::S3::Bucket") { + node.addOverride("DeletionPolicy", "Retain"); + node.addOverride("UpdateReplacePolicy", "Retain"); + } else { + node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN; + node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN; + } + + if (this.manageCondition) { + node.cfnOptions.condition = this.manageCondition; + } + } +} diff --git a/infra/cdk/package.json b/infra/cdk/package.json index b490e706..4529259f 100644 --- a/infra/cdk/package.json +++ b/infra/cdk/package.json @@ -11,7 +11,9 @@ }, "scripts": { "build": "tsc", + "test": "npm run build && node --test test/*.test.mjs", "synth": "cdk synth", + "synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true", "diff": "cdk diff", "deploy": "cdk deploy" }, diff --git a/infra/cdk/test/frontend-stack.test.mjs b/infra/cdk/test/frontend-stack.test.mjs new file mode 100644 index 00000000..f285557d --- /dev/null +++ b/infra/cdk/test/frontend-stack.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; + +const require = createRequire(import.meta.url); +const { App } = require("aws-cdk-lib"); +const { Template } = require("aws-cdk-lib/assertions"); +const { FrontendStack } = require("../lib/frontend-stack.js"); + +const account = "396287094661"; +const region = "us-east-1"; +const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev"; +const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1"; +const CONDITION = "ManageSiteInfrastructureCondition"; + +const RETAINED_TYPES = new Set([ + "AWS::S3::Bucket", + "AWS::S3::BucketPolicy", + "AWS::CloudFront::Distribution", + "AWS::CloudFront::Function", + "AWS::CloudFront::OriginAccessControl", + "AWS::Route53::RecordSet", + "Custom::S3AutoDeleteObjects", +]); + +function devTemplate(retainForTerraformAdoption, overrides = {}) { + const app = new App(); + const stack = new FrontendStack(app, "shoc-frontend-dev", { + envName: "dev", + githubRepo: "Sea-Haven-Industries/shoc-frontend-new", + deployBranch: "dev", + domainNames: ["dev.seahaven.com"], + certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`, + hostedZoneId: "Z07671212N75U4YLPWZR8", + hostedZoneName: "dev.seahaven.com", + retainForTerraformAdoption, + env: { account, region }, + ...overrides, + }); + return Template.fromStack(stack).toJSON(); +} + +function entriesByType(template, type) { + return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type); +} + +function isTransferred(logicalId, resource) { + const isDeployRoleResource = + (resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) || + (resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole")); + return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource; +} + +test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => { + const template = devTemplate(true); + assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1); + assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1); + assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2); + assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1); + const transferred = Object.entries(template.Resources).filter(([id, resource]) => + isTransferred(id, resource), + ); + // Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline + // policy = 9 CloudFormation resources (Terraform splits the bucket into 6 + // addresses) plus the retained custom resource. + assert.equal(transferred.length, 10); +}); + +test("adoption mode preserves the live dev identifiers", () => { + const template = devTemplate(true); + const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; + assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev"); + assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled"); + assert.ok( + bucket.Properties.Tags.some( + (tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true", + ), + ); + + const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1]; + assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID); + assert.equal( + distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId, + DEV_ORIGIN_ID, + ); + + const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( + ([, resource]) => resource.Properties.RoleName === DEV_ROLE, + ); + assert.equal( + deployRole.Properties.PermissionsBoundary, + `arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`, + ); + assert.ok( + deployRole.Properties.Tags.some( + (tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev", + ), + ); + const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; + assert.equal( + condition.StringEquals["token.actions.githubusercontent.com:sub"], + "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", + ); + assert.equal(condition.StringLike, undefined); + + // Legacy inline policy stays byte-compatible with the live document. + const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) => + id.startsWith("GithubDeployRole"), + ); + const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid); + assert.deepEqual(sids, [ + "AssumeCdkBootstrapRoles", + "DescribeStack", + undefined, + "InvalidateDistribution", + ]); + + for (const output of [ + "TerraformWorkspaceTag", + "TerraformDeployBoundaryArn", + "TerraformImportBucket", + "TerraformImportBucketPolicy", + "TerraformImportDistribution", + "TerraformImportOriginAccessControl", + "TerraformOriginAccessControlName", + "TerraformOriginAccessControlDescription", + "TerraformDistributionOriginId", + "TerraformImportSpaRewriteFunction", + "TerraformImportAliasA", + "TerraformImportAliasAAAA", + "TerraformImportDeployRole", + "TerraformImportDeployRolePolicy", + "TerraformDeployInlinePolicyName", + "TerraformBucketAutoDeleteHelperRoleArn", + "TerraformRetainedAutoDeleteCustomResource", + ]) { + assert.ok(template.Outputs[output], `missing output ${output}`); + } + assert.equal( + template.Outputs.TerraformImportAliasA.Value, + "Z07671212N75U4YLPWZR8_dev.seahaven.com_A", + ); + assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID); +}); + +test("adoption mode retains exactly the transferred resources", () => { + const template = devTemplate(true); + for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (isTransferred(logicalId, resource)) { + assert.equal(resource.DeletionPolicy, "Retain", logicalId); + assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId); + } else { + assert.notEqual(resource.DeletionPolicy, "Retain", logicalId); + assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId); + } + } + // The auto-delete provider Lambda, role, and log group stay unretained. + for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) { + for (const [, resource] of entriesByType(template, type)) { + assert.notEqual(resource.DeletionPolicy, "Retain"); + } + } + const providerRoles = entriesByType(template, "AWS::IAM::Role").filter( + ([, resource]) => resource.Properties.RoleName !== DEV_ROLE, + ); + assert.equal(providerRoles.length, 1); + assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain"); +}); + +test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => { + const template = devTemplate(true); + const parameter = template.Parameters.ManageSiteInfrastructure; + assert.ok(parameter); + assert.equal(parameter.Type, "String"); + assert.deepEqual(parameter.AllowedValues, ["true", "false"]); + assert.equal(parameter.Default, undefined); + assert.ok(template.Conditions[CONDITION]); + + for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (isTransferred(logicalId, resource)) { + assert.equal(resource.Condition, CONDITION, logicalId); + } else { + assert.notEqual(resource.Condition, CONDITION, logicalId); + } + } + for (const [outputName, output] of Object.entries(template.Outputs)) { + assert.equal(output.Condition, CONDITION, outputName); + } +}); + +test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => { + const template = devTemplate(false); + const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; + assert.equal(bucket.DeletionPolicy, "Delete"); + assert.equal(bucket.UpdateReplacePolicy, "Delete"); + const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1]; + assert.notEqual(customResource.DeletionPolicy, "Retain"); + + const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( + ([, resource]) => resource.Properties.RoleName === DEV_ROLE, + ); + assert.equal(deployRole.Properties.PermissionsBoundary, undefined); + assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace")); + const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; + assert.equal( + condition.StringLike["token.actions.githubusercontent.com:sub"], + "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", + ); + assert.equal(template.Outputs.TerraformWorkspaceTag, undefined); + assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined); + assert.equal(template.Conditions?.[CONDITION], undefined); + for (const resource of Object.values(template.Resources)) { + assert.equal(resource.Condition, undefined); + } +}); + +test("adoption mode refuses an environment without a verified origin ID", () => { + assert.throws( + () => devTemplate(true, { envName: "staging" }), + /No verified Terraform adoption origin ID exists for staging/, + ); +}); diff --git a/package.json b/package.json index ed95210d..50346ac3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "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-isolation": "node --test scripts/check-terraform-isolation.test.mjs", + "test:terraform": "node scripts/terraform-validate.mjs", + "test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py new file mode 100644 index 00000000..28bd0dce --- /dev/null +++ b/scripts/check-terraform-import-plan.py @@ -0,0 +1,628 @@ +#!/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" +DEPLOY_POLICY_ADDRESS = ( + "module.environment_owned.aws_iam_role_policy.github_deploy" +) +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, + DEPLOY_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 _expected_pre_adoption_deploy_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}" + ) + statements: list[dict[str, Any]] = [] + if environment == "dev": + statements.append( + { + "Sid": "AssumeCdkBootstrapRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", + } + ) + statements.extend( + [ + { + "Sid": "DescribeStack", + "Effect": "Allow", + "Action": "cloudformation:DescribeStacks", + "Resource": ( + "arn:aws:cloudformation:us-east-1:396287094661:stack/" + f"{config['cloudformation_stack_name']}/*" + ), + }, + { + "Effect": "Allow", + "Action": [ + "s3:Abort*", + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + ], + "Resource": [bucket_arn, f"{bucket_arn}/*"], + }, + { + "Sid": "InvalidateDistribution", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + ], + "Resource": distribution_arn, + }, + ] + ) + return _canonical({"Version": "2012-10-17", "Statement": statements}) + + +def _expected_deploy_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": [ + { + "Sid": "ReadDeploymentBucket", + "Effect": "Allow", + "Action": [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:ListBucket", + "s3:ListBucketVersions", + ], + "Resource": bucket_arn, + }, + { + "Sid": "PublishAndRollbackSiteObjects", + "Effect": "Allow", + "Action": [ + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + ], + "Resource": f"{bucket_arn}/*", + }, + { + "Sid": "InvalidateDistribution", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + ], + "Resource": distribution_arn, + }, + ], + } + ) + + +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) + if address == BUCKET_POLICY_ADDRESS + else _expected_pre_adoption_deploy_policy(environment, distribution_id) + ) + expected_after = ( + _expected_bucket_policy(environment, distribution_id) + if address == BUCKET_POLICY_ADDRESS + else _expected_deploy_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 in {BUCKET_POLICY_ADDRESS, DEPLOY_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..7b28c149 --- /dev/null +++ b/scripts/check-terraform-isolation.mjs @@ -0,0 +1,120 @@ +// 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. +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/"); +} + +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; + 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(isTerraformPath); + 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..24c6c87d --- /dev/null +++ b/scripts/check-terraform-isolation.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + OVERRIDE_LABEL, + classifyChangedFiles, + 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", + ]) { + assert.equal(mayAccompanyTerraform(file), true, file); + } +}); + +test("application, workflow, CDK, 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", + "infra/cdk/lib/frontend-stack.ts", + "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 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("CLI refuses to run without a base ref or --stdin", () => { + const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" }); + assert.notEqual(result.status, 0); +}); diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 2dfb66a2..ec64a742 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash # -# Post-deploy step for the org reusable workflow `cd-cdk.yaml` -# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`). +# Content publish step for the environment deploy workflows +# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`). # -# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub -# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with -# the right cache headers, and invalidates CloudFront. +# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the +# environment's S3 bucket with the right cache headers, and invalidates +# CloudFront. It never touches infrastructure. # -# Runs from the repo root. Reads the bucket + distribution from stack outputs, -# so it has no hardcoded resource IDs. +# Runs from the repo root. The target is resolved from, in order: +# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by +# dev, whose CloudFormation outputs disappear during Terraform adoption) +# 2. the BucketName/DistributionId outputs of STACK_NAME (staging) set -euo pipefail STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" @@ -20,21 +22,31 @@ export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" npm ci npm run build -echo "Reading stack outputs from ${STACK_NAME}..." -stack_output() { - aws cloudformation describe-stacks \ - --stack-name "${STACK_NAME}" \ - --region "${REGION}" \ - --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ - --output text -} +BUCKET="${SITE_BUCKET:-}" +DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}" -BUCKET="$(stack_output BucketName)" -DIST_ID="$(stack_output DistributionId)" - -if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then - echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 +if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then + echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}." +elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then + echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2 exit 1 +else + echo "Reading stack outputs from ${STACK_NAME}..." + stack_output() { + aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text + } + + BUCKET="$(stack_output BucketName)" + DIST_ID="$(stack_output DistributionId)" + + if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then + echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 + exit 1 + fi fi echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 8cabc70f..f777ce09 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -17,6 +17,14 @@ 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 isolation gate", "test:terraform-isolation"], + ["Terraform formatting and validation", "test:terraform"], + ["CDK build, tests, and synth", "test:infra"], +]; function isGoverned(relativePath) { return ( @@ -194,6 +202,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 +303,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/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 100644 index 00000000..5d558b02 --- /dev/null +++ b/scripts/terraform_import_plan_resources.py @@ -0,0 +1,131 @@ +"""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", + "module.environment_owned.aws_iam_role_policy.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-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py new file mode 100644 index 00000000..5046ccad --- /dev/null +++ b/scripts/test-terraform-import-plan-check.py @@ -0,0 +1,638 @@ +#!/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, DEPLOY_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 pre_adoption_deploy_policy(environment: str) -> dict[str, Any]: + config = ENVIRONMENT_CONFIG[environment] + bucket_arn = f"arn:aws:s3:::{config['bucket_name']}" + distribution_arn = ( + "arn:aws:cloudfront::396287094661:distribution/" + f"{distribution_id(environment)}" + ) + statements: list[dict[str, Any]] = [] + if environment == "dev": + statements.append( + { + "Sid": "AssumeCdkBootstrapRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", + } + ) + statements.extend( + [ + { + "Sid": "DescribeStack", + "Effect": "Allow", + "Action": "cloudformation:DescribeStacks", + "Resource": ( + "arn:aws:cloudformation:us-east-1:396287094661:stack/" + f"{config['cloudformation_stack_name']}/*" + ), + }, + { + "Effect": "Allow", + "Action": [ + "s3:Abort*", + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + ], + "Resource": [bucket_arn, f"{bucket_arn}/*"], + }, + { + "Sid": "InvalidateDistribution", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + ], + "Resource": distribution_arn, + }, + ] + ) + return {"Version": "2012-10-17", "Statement": statements} + + +def deploy_policy(environment: str) -> dict[str, Any]: + bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] + bucket_arn = f"arn:aws:s3:::{bucket}" + distribution_arn = ( + "arn:aws:cloudfront::396287094661:distribution/" + f"{distribution_id(environment)}" + ) + return { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadDeploymentBucket", + "Effect": "Allow", + "Action": [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:ListBucket", + "s3:ListBucketVersions", + ], + "Resource": bucket_arn, + }, + { + "Sid": "PublishAndRollbackSiteObjects", + "Effect": "Allow", + "Action": [ + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + ], + "Resource": f"{bucket_arn}/*", + }, + { + "Sid": "InvalidateDistribution", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + ], + "Resource": distribution_arn, + }, + ], + } + + +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]: + before_policy = ( + pre_adoption_bucket_policy(environment) + if address == BUCKET_POLICY + else pre_adoption_deploy_policy(environment) + ) + after_policy = ( + bucket_policy(environment) + if address == BUCKET_POLICY + else deploy_policy(environment) + ) + return { + "actions": ["update"], + "before": {"policy": json.dumps(before_policy)}, + "after": {"policy": json.dumps(after_policy)}, + } + + +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_import_phase_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+= false\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_deploy_policy_rejects_resource_action_and_extra_statement(self) -> None: + for mutation in ("resource", "action", "extra"): + plan = make_plan( + "staging", + mode="controlled", + controlled_updates={DEPLOY_POLICY}, + ) + policy = copy.deepcopy(deploy_policy("staging")) + if mutation == "resource": + policy["Statement"][0]["Resource"] = "*" + elif mutation == "action": + policy["Statement"][0]["Action"].append("iam:PassRole") + else: + policy["Statement"].append( + { + "Sid": "Extra", + "Effect": "Allow", + "Action": "s3:*", + "Resource": "*", + } + ) + resource(plan, DEPLOY_POLICY)["change"]["after"]["policy"] = json.dumps( + policy + ) + with self.subTest(mutation=mutation): + self.assert_fails(plan, "staging", DEPLOY_POLICY) + + def test_policy_updates_require_exact_pre_adoption_state(self) -> None: + for environment in REQUIRED_RESOURCES: + for address in (BUCKET_POLICY, DEPLOY_POLICY): + plan = make_plan( + environment, + mode="controlled", + controlled_updates={address}, + ) + change = resource(plan, address)["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, address=address): + self.assert_fails(plan, environment, address) + + 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/terraform/README.md b/terraform/README.md new file mode 100644 index 00000000..a20e97cc --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,308 @@ +# Frontend Terraform adoption runbook (dev) + +This tree adopts the existing Sea Haven SHOC frontend dev hosting resources +into HCP Terraform without recreating them. It mirrors the backend adoption +(`shoc-backend` #94, #98, #99, #102) and lands in three PRs: + +| PR | Branch | Change | +| --- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| A | `feature/frontend-terraform-adoption` | This PR. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. | +| B | `feature/terraform-dev-adoption` | `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant, CloudFormation detaches. | +| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. | + +Creating these files, formatting them, initializing with `-backend=false`, and +validating them does not authorize an AWS, HCP Terraform, GitHub, +CloudFormation, DNS, or deployment mutation. Every live step below is gated on +an explicit go from the owner, with the production impact stated first. + +Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked +separately (SH-287) and adds its own root under `live/staging` when it starts. +The `staging` constants in `scripts/terraform_import_plan_resources.py` exist +only so the checker can prove a dev plan carrying a staging identifier fails. + +## Fixed targets + +- AWS account: `396287094661` +- AWS region: `us-east-1` +- HCP organization: `seahaven` +- HCP project: `seahaven-external-dev` +- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working + directory `terraform/live/dev` +- Site: `dev.seahaven.com` +- API build value: `https://api.dev.seahaven.com/api` + +## Workspace invariants + +Set before any Terraform lands on `dev`, read back after setting, and re-read +before the first release after any Terraform merge: + +- Auto-apply **off**. GitHub or a human applies every run. +- Automatic speculative plans **on** (PR plans are read-only evidence). +- Automatic run triggering: **patterns** + `terraform/live/dev/**` and `terraform/live/modules/**`. No trigger + prefixes, no tags regex. Do not switch to tag-based triggering. +- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires + `>= 1.9.0, < 2.0.0`; CI validates with `1.16.0`). +- Dynamic AWS credentials only: environment variables + `TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and + `TFC_AWS_APPLY_ROLE_ARN` pointing at the `seahaven-org-baseline` roles + `hcptf-shoc-frontend-new-dev-plan` and `hcptf-shoc-frontend-new-dev`. No + access keys. +- **No** `adoption_complete` workspace variable. The dev root pins it in code + (`local.adoption_complete`) so the value under review is the value that + applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable` + block reappears in the root. + +## Ownership boundary + +`live/modules/environment-owned` owns exactly these 13 addresses: + +1. `module.environment_owned.aws_s3_bucket.site` +2. `module.environment_owned.aws_s3_bucket_public_access_block.site` +3. `module.environment_owned.aws_s3_bucket_ownership_controls.site` +4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site` +5. `module.environment_owned.aws_s3_bucket_versioning.site` +6. `module.environment_owned.aws_s3_bucket_policy.site` +7. `module.environment_owned.aws_cloudfront_distribution.site` +8. `module.environment_owned.aws_cloudfront_origin_access_control.site` +9. `module.environment_owned.aws_cloudfront_function.spa_rewrite` +10. `module.environment_owned.aws_route53_record.site_a` +11. `module.environment_owned.aws_route53_record.site_aaaa` +12. `module.environment_owned.aws_iam_role.github_deploy` +13. `module.environment_owned.aws_iam_role_policy.github_deploy` + +Every managed resource has `prevent_destroy = true`. + +`live/modules/environment-inventory` is data-only. It resolves and checks the +caller account, provider region, public hosted zone, ACM certificate, account +GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache +policy against pinned values, and fails the plan on any mismatch. + +The following remain outside state: + +- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate +- the account-global GitHub OIDC provider +- the AWS managed CloudFront cache policy +- `CDKToolkit` resources and CDK metadata +- the S3 auto-delete custom resource, its provider Lambda and role +- the HCP plan/apply roles and the deploy-role permissions boundary + (`seahaven-org-baseline` owns them) + +## Exact live inventory (dev) + +- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev` +- Distribution: `E2CWLM1AFB964P` +- OAC: `E30VSIK87N8H64`, name + `shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`, + description modeled as `""` +- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1` +- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8` +- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A` +- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA` +- Deploy role: `githubdeploy-shoc-frontend-new-dev` +- Inline policy import ID: + `githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1` +- Hosted zone: `Z07671212N75U4YLPWZR8` +- Certificate: + `arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00` +- Legacy stack: `shoc-frontend-dev` +- Auto-delete helper role: + `arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV` +- Permissions boundary: + `arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary` + +With `adoption_complete = false` the root declares the configuration observed +after the CDK retain deploy (Phase 1, step 2), not the configuration live +today: + +- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the + S3-only `aws-cdk:auto-delete-objects=true` tag +- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag +- the permissions boundary attached to the deploy role +- `StringEquals` on the OIDC subject + `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` +- the legacy bucket policy including the auto-delete helper grant +- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`, + bucket read/write, `InvalidateDistribution`) + +The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing. +If read-back after that deploy differs from the root in any other way, update +the root to the observed value and prove a zero-change import plan. Do not +approve drift through the controlled-update checker. + +## Phase 1: import-first adoption (this PR) + +Each step is gated. State the impact, get the go, act, read back, record. + +1. **Workspace invariants.** Set the invariants above on + `shoc-frontend-new-dev`. Read back the workspace and record the JSON in the + PR. +2. **CDK retain deploy.** From the reviewed PR head, with administrator + credentials: + + ```bash + cd infra/cdk && npm ci + npx cdk deploy shoc-frontend-dev \ + -c retainForTerraformAdoption=true \ + --parameters ManageSiteInfrastructure=true + ``` + + Expected: an update-only change set (no create, no delete, no replace) + that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the + 13 transferred resources and the `Custom::S3AutoDeleteObjects` resource, + attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows + the trust operator. Read back the role, bucket policy, and stack resources + as JSON and attach it to the PR. + +3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply + off). Download the plan JSON and run the guard: + + ```bash + python3 scripts/check-terraform-import-plan.py plan.json --environment dev + ``` + + Confirm the apply only when the plan is exactly 13 imports, 0 create, + 0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the + run and fix the root in a new PR. + +4. **Post-import no-op.** Queue a plan and require it to be no-op: + + ```bash + python3 scripts/check-terraform-import-plan.py post-import.json \ + --environment dev --post-import-no-op + ``` + + Post the run URLs and the guard output on SH-300. + +After Phase 1 CloudFormation still owns every resource. Terraform holds state +for them and nothing else. + +## Phase 2: controlled ownership transfer (PR B) + +PR B pins `adoption_complete = true`. The controlled apply may update only: + +- `module.environment_owned.aws_s3_bucket.site` (tags) +- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the + auto-delete helper grant) +- `module.environment_owned.aws_cloudfront_distribution.site` (tags) +- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags) +- `module.environment_owned.aws_iam_role.github_deploy` (tags) + +The OAC, both Route 53 records, and the deploy inline policy must be no-op. +PR B keeps the post-adoption inline policy byte-identical to live so the +policy address does not appear in the plan. Run the checker with one +`--allow-update-address` per updating address; it rejects unused allowlist +entries, unknown values, and replacements. + +Dependency: `hcptf-shoc-frontend-new-dev` currently lacks +`cloudfront:UpdateDistribution` and `cloudfront:UpdateFunction`. Codify the +expansion in `seahaven-org-baseline` (cross-family plus security review) and +deploy it before the controlled apply. + +After the apply and a no-op plan, deploy the same reviewed CDK SHA with +`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the +13 transferred resources and the custom resource. Never deploy with +`ManageSiteInfrastructure=true` again after that. See +[`infra/cdk/README.md`](../infra/cdk/README.md). + +## Phase 3: content CD through Terraform (PR C) + +Summary only; PR C carries the full design. GitHub builds and uploads to an +immutable `releases/--/` prefix. Terraform owns the +`.release/current` pointer, both origin paths of a CloudFront origin group, +and the invalidation action. Rollback is one guarded Terraform run swapping +the labels. Push-to-`dev` releases return behind the repository variable +`TERRAFORM_CONTENT_CD_ENABLED`. + +## Operational rules + +- **Terraform-only PRs.** A PR that changes `terraform/**` may not change + deployable application code. The `terraform-isolation` job in + `.github/workflows/ci.yaml` enforces this; documentation and the + `scripts/*terraform*` tooling are allowed alongside. A reviewer may add the + `terraform-isolation-override` label for the rare change that must introduce + Terraform variables together with the workflow that consumes them (PR A and + PR C), then re-run the workflow so the job reads the label. The label is the + approval record. The override is temporary: a follow-up PR after PR C + removes the label path from the checker and workflow so the gate has no + exception. +- **Every Terraform merge produces a VCS run.** A human confirms or discards + it before the next content release. Do not leave a pending run on the + workspace. +- **Re-read the workspace invariants** before the first release after any + Terraform merge or workspace settings change. +- **A red job does not mean the site is down.** Read the live state first + (served `index.html`, distribution status, pointer body once PR C lands), + then triage. +- **Exact-head evidence.** Every live step records the run URL, the SHA, and a + machine-readable read-back on the PR or SH-300. + +## Local validation + +From the repository root (also run by `npm run verify` through +`scripts/governance-check.mjs`): + +```bash +npm run test:terraform # fmt -check, init -backend=false, validate +npm run test:terraform-import-plan # checker unit tests against synthetic plans +npm run test:terraform-isolation # isolation gate unit tests +npm run test:infra # CDK build, template tests, synth in both modes +``` + +`terraform init -backend=false -lockfile=readonly` may download the provider +but never contacts HCP state or plans against AWS. Only HCP runs plan against +the account. + +The lock file must carry `h1:` hashes for every platform that runs the gate +(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the +provider version, refresh them with: + +```bash +terraform -chdir=terraform/live/dev providers lock \ + -platform=linux_amd64 -platform=linux_arm64 \ + -platform=darwin_amd64 -platform=darwin_arm64 +``` + +## Import plan safety + +Import mode requires exactly the canonical 13 addresses and AWS types, valid +import metadata for every resource, the exact dev import IDs (a staging ID in a +dev plan fails), and zero create, update, delete, or replace actions. + +Post-import mode requires all 13 resources to be no-op and rejects any +remaining import metadata. + +Controlled mode permits only in-place updates to the addresses explicitly +listed with `--allow-update-address`, verifies `before` against the exact +pre-adoption policies and tags and `after` against the exact adopted values, +and rejects create, delete, replace, import metadata, unknown values, +unapproved addresses, and unused allowlist entries. + +## Rollback + +- Before import apply: discard the run and correct the root. +- After import, before the controlled update (end of Phase 1): remove only the + 13 imported addresses from state under a separately reviewed state + operation. CloudFormation remains authoritative; a + `ManageSiteInfrastructure=true` stack is unchanged by this. +- After the controlled update, before detachment: either complete the reviewed + detachment or restore the exact pre-adoption policy and tags under a + separate approval. Do not remove state or redeploy CloudFormation blindly. +- After detachment: Terraform is authoritative. Restore content from the + versioned bucket. Re-establishing CloudFormation ownership requires a + reviewed `IMPORT` change set, never an ordinary update. + +Any replacement, destroy, cross-environment ID, missing import, broad policy +change, or failed smoke check is a hard stop. + +## Evidence per phase + +- HCP run URL and the workspace settings read-back +- plan JSON and checker output +- `terraform state list` showing exactly the 13 addresses +- read-only inventory before and after each mutation +- synthesized CloudFormation template, change set, and stack events +- deploy, invalidation, and smoke output +- the post-action no-op plan +- phase close-out on SH-300: completed work, validation, risks, deviations, + remaining work diff --git a/terraform/live/dev/.terraform.lock.hcl b/terraform/live/dev/.terraform.lock.hcl new file mode 100644 index 00000000..7f171232 --- /dev/null +++ b/terraform/live/dev/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.62.0" + constraints = "~> 6.57" + hashes = [ + "h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=", + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=", + "h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=", + "h1:yOSEz5G8b/n5uhFCZ0gbEsKkAQATtVuhXJEXR3OM5qs=", + "zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5", + "zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd", + "zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010", + "zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3", + "zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df", + "zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844", + "zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090", + "zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2", + "zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7", + "zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f", + "zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba", + "zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913", + "zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14", + "zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02", + ] +} diff --git a/terraform/live/dev/imports.tf b/terraform/live/dev/imports.tf new file mode 100644 index 00000000..8f5e3e3f --- /dev/null +++ b/terraform/live/dev/imports.tf @@ -0,0 +1,64 @@ +import { + to = module.environment_owned.aws_s3_bucket.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_public_access_block.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_ownership_controls.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_versioning.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_s3_bucket_policy.site + id = local.bucket_name +} + +import { + to = module.environment_owned.aws_cloudfront_distribution.site + id = local.distribution_id +} + +import { + to = module.environment_owned.aws_cloudfront_origin_access_control.site + id = local.oac_id +} + +import { + to = module.environment_owned.aws_cloudfront_function.spa_rewrite + id = local.function_name +} + +import { + to = module.environment_owned.aws_route53_record.site_a + id = "${local.hosted_zone_id}_${local.domain_name}_A" +} + +import { + to = module.environment_owned.aws_route53_record.site_aaaa + id = "${local.hosted_zone_id}_${local.domain_name}_AAAA" +} + +import { + to = module.environment_owned.aws_iam_role.github_deploy + id = local.deploy_role_name +} + +import { + to = module.environment_owned.aws_iam_role_policy.github_deploy + id = "${local.deploy_role_name}:${local.inline_policy}" +} diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf new file mode 100644 index 00000000..37ed5472 --- /dev/null +++ b/terraform/live/dev/main.tf @@ -0,0 +1,94 @@ +locals { + # Import-first phase. Pinned in code, never a workspace variable: the + # controlled ownership transfer flips this to true in its own reviewed PR. + adoption_complete = false + + environment = "dev" + workspace_name = "shoc-frontend-new-dev" + aws_account_id = "396287094661" + aws_region = "us-east-1" + bucket_name = "seahaven-shoc-frontend-dev" + distribution_id = "E2CWLM1AFB964P" + oac_id = "E30VSIK87N8H64" + oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620" + origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1" + function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8" + domain_name = "dev.seahaven.com" + hosted_zone_id = "Z07671212N75U4YLPWZR8" + certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00" + github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com" + deploy_role_name = "githubdeploy-shoc-frontend-new-dev" + inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1" + stack_name = "shoc-frontend-dev" + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + permissions_boundary_arn = ( + "arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary" + ) + bucket_auto_delete_helper_role_arn = ( + "arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV" + ) + legacy_tags = { + Environment = "dev" + ManagedBy = "cdk" + Project = "shoc-frontend" + } + legacy_bucket_tags = merge(local.legacy_tags, { + "aws-cdk:auto-delete-objects" = "true" + }) + terraform_tags = { + Environment = "dev" + ManagedBy = "terraform" + Ownership = "terraform" + Project = "shoc-frontend" + } + manager_tag = { + HcpTerraformWorkspace = local.workspace_name + } +} + +module "inventory" { + source = "../modules/environment-inventory" + + aws_account_id = local.aws_account_id + aws_region = local.aws_region + hosted_zone_name = local.domain_name + expected_hosted_zone_id = local.hosted_zone_id + certificate_domain = "*.seahaven.com" + expected_certificate_arn = local.certificate_arn + expected_github_oidc_provider_arn = local.github_oidc_arn + expected_cache_policy_id = local.cache_policy_id +} + +module "environment_owned" { + source = "../modules/environment-owned" + + environment = local.environment + adoption_complete = local.adoption_complete + aws_account_id = local.aws_account_id + aws_region = local.aws_region + bucket_name = local.bucket_name + distribution_id = local.distribution_id + origin_access_control_name = local.oac_name + origin_access_control_description = "" + origin_id = local.origin_id + function_name = local.function_name + domain_name = local.domain_name + hosted_zone_id = local.hosted_zone_id + certificate_arn = local.certificate_arn + cache_policy_id = local.cache_policy_id + github_oidc_provider_arn = local.github_oidc_arn + github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev" + pre_adoption_github_subject_operator = "StringEquals" + post_adoption_github_subject_operator = "StringEquals" + deploy_branch = "dev" + deploy_role_name = local.deploy_role_name + deploy_inline_policy_name = local.inline_policy + deploy_permissions_boundary_arn = local.permissions_boundary_arn + cloudformation_stack_name = local.stack_name + bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn + pre_adoption_tags = local.legacy_tags + pre_adoption_bucket_tags = local.legacy_bucket_tags + ownership_tags = local.terraform_tags + pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag) + post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag) +} diff --git a/terraform/live/dev/outputs.tf b/terraform/live/dev/outputs.tf new file mode 100644 index 00000000..726ee1e8 --- /dev/null +++ b/terraform/live/dev/outputs.tf @@ -0,0 +1,11 @@ +output "bucket_name" { + value = module.environment_owned.bucket_name +} + +output "distribution_id" { + value = module.environment_owned.distribution_id +} + +output "deploy_role_arn" { + value = module.environment_owned.deploy_role_arn +} diff --git a/terraform/live/dev/providers.tf b/terraform/live/dev/providers.tf new file mode 100644 index 00000000..b6c81d54 --- /dev/null +++ b/terraform/live/dev/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = local.aws_region +} diff --git a/terraform/live/dev/versions.tf b/terraform/live/dev/versions.tf new file mode 100644 index 00000000..9e341837 --- /dev/null +++ b/terraform/live/dev/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.9.0, < 2.0.0" + + cloud { + organization = "seahaven" + + workspaces { + project = "seahaven-external-dev" + name = "shoc-frontend-new-dev" + } + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + } +} diff --git a/terraform/live/modules/environment-inventory/main.tf b/terraform/live/modules/environment-inventory/main.tf new file mode 100644 index 00000000..d0639b77 --- /dev/null +++ b/terraform/live/modules/environment-inventory/main.tf @@ -0,0 +1,65 @@ +data "aws_caller_identity" "current" { + lifecycle { + postcondition { + condition = self.account_id == var.aws_account_id + error_message = "Refusing to inspect resources outside the expected AWS account." + } + } +} + +data "aws_region" "current" { + lifecycle { + postcondition { + condition = self.region == var.aws_region + error_message = "Refusing to inspect resources outside the expected AWS region." + } + } +} + +data "aws_route53_zone" "site" { + name = "${trimsuffix(var.hosted_zone_name, ".")}." + private_zone = false + + lifecycle { + postcondition { + condition = self.zone_id == var.expected_hosted_zone_id + error_message = "The resolved Route 53 zone does not match the pinned hosted zone." + } + } +} + +data "aws_acm_certificate" "shared" { + domain = var.certificate_domain + statuses = ["ISSUED"] + types = ["AMAZON_ISSUED"] + most_recent = true + + lifecycle { + postcondition { + condition = self.arn == var.expected_certificate_arn + error_message = "The resolved ACM certificate does not match the pinned certificate." + } + } +} + +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" + + lifecycle { + postcondition { + condition = self.arn == var.expected_github_oidc_provider_arn + error_message = "The GitHub OIDC provider does not match the pinned account provider." + } + } +} + +data "aws_cloudfront_cache_policy" "managed" { + name = var.cache_policy_name + + lifecycle { + postcondition { + condition = self.id == var.expected_cache_policy_id + error_message = "The AWS managed CloudFront cache policy does not match the pinned ID." + } + } +} diff --git a/terraform/live/modules/environment-inventory/outputs.tf b/terraform/live/modules/environment-inventory/outputs.tf new file mode 100644 index 00000000..3223842d --- /dev/null +++ b/terraform/live/modules/environment-inventory/outputs.tf @@ -0,0 +1,19 @@ +output "hosted_zone_id" { + value = data.aws_route53_zone.site.zone_id + description = "Verified hosted zone ID." +} + +output "certificate_arn" { + value = data.aws_acm_certificate.shared.arn + description = "Verified ACM certificate ARN." +} + +output "github_oidc_provider_arn" { + value = data.aws_iam_openid_connect_provider.github.arn + description = "Verified GitHub OIDC provider ARN." +} + +output "cache_policy_id" { + value = data.aws_cloudfront_cache_policy.managed.id + description = "Verified AWS managed cache policy ID." +} diff --git a/terraform/live/modules/environment-inventory/variables.tf b/terraform/live/modules/environment-inventory/variables.tf new file mode 100644 index 00000000..e76ae6dd --- /dev/null +++ b/terraform/live/modules/environment-inventory/variables.tf @@ -0,0 +1,46 @@ +variable "aws_account_id" { + type = string + description = "Expected AWS account ID." +} + +variable "aws_region" { + type = string + description = "Expected AWS provider region." +} + +variable "hosted_zone_name" { + type = string + description = "Public hosted zone DNS name." +} + +variable "expected_hosted_zone_id" { + type = string + description = "Pinned hosted zone ID." +} + +variable "certificate_domain" { + type = string + description = "Domain used to resolve the expected certificate." +} + +variable "expected_certificate_arn" { + type = string + description = "Pinned ACM certificate ARN." +} + +variable "expected_github_oidc_provider_arn" { + type = string + description = "Pinned account-global GitHub OIDC provider ARN." +} + +variable "cache_policy_name" { + type = string + description = "AWS managed CloudFront cache policy name." + default = "Managed-CachingOptimized" +} + +variable "expected_cache_policy_id" { + type = string + description = "Pinned AWS managed CloudFront cache policy ID." + default = "658327ea-f89d-4fab-a63d-7e88639e58f6" +} diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf new file mode 100644 index 00000000..3ddb5fdb --- /dev/null +++ b/terraform/live/modules/environment-owned/main.tf @@ -0,0 +1,403 @@ +locals { + bucket_arn = "arn:aws:s3:::${var.bucket_name}" + distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}" + resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags + bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags + deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags + github_subject_operator = var.pre_adoption_github_subject_operator + + spa_rewrite_code = join("\n", [ + "function handler(event) {", + " var request = event.request;", + " var uri = request.uri;", + " // No file extension after the last slash -> a client-side route.", + " if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {", + " request.uri = '/index.html';", + " }", + " return request;", + "}", + ]) +} + +data "aws_iam_policy_document" "site_bucket" { + dynamic "statement" { + for_each = var.adoption_complete ? [] : [1] + + content { + effect = "Allow" + + principals { + type = "AWS" + identifiers = [var.bucket_auto_delete_helper_role_arn] + } + + actions = [ + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:List*", + "s3:PutBucketPolicy", + ] + resources = [ + local.bucket_arn, + "${local.bucket_arn}/*", + ] + } + } + + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = ["s3:GetObject"] + resources = ["${local.bucket_arn}/*"] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [local.distribution_arn] + } + } + + statement { + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + local.bucket_arn, + "${local.bucket_arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +data "aws_iam_policy_document" "github_deploy_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [var.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = local.github_subject_operator + variable = "token.actions.githubusercontent.com:sub" + values = [var.github_subject] + } + } +} + +data "aws_iam_policy_document" "github_deploy" { + dynamic "statement" { + for_each = !var.adoption_complete && var.environment == "dev" ? [1] : [] + + content { + sid = "AssumeCdkBootstrapRoles" + effect = "Allow" + actions = ["sts:AssumeRole"] + resources = ["arn:aws:iam::${var.aws_account_id}:role/cdk-hnb659fds-*"] + } + } + + dynamic "statement" { + for_each = var.adoption_complete ? [] : [1] + + content { + sid = "DescribeStack" + effect = "Allow" + actions = ["cloudformation:DescribeStacks"] + resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"] + } + } + + dynamic "statement" { + for_each = var.adoption_complete ? [] : [1] + + content { + effect = "Allow" + actions = [ + "s3:Abort*", + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + ] + resources = [ + local.bucket_arn, + "${local.bucket_arn}/*", + ] + } + } + + dynamic "statement" { + for_each = var.adoption_complete ? [1] : [] + + content { + sid = "ReadDeploymentBucket" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:ListBucket", + "s3:ListBucketVersions", + ] + resources = [local.bucket_arn] + } + } + + dynamic "statement" { + for_each = var.adoption_complete ? [1] : [] + + content { + sid = "PublishAndRollbackSiteObjects" + effect = "Allow" + actions = [ + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + ] + resources = ["${local.bucket_arn}/*"] + } + } + + statement { + sid = "InvalidateDistribution" + effect = "Allow" + actions = [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + ] + resources = [local.distribution_arn] + } +} + +resource "aws_s3_bucket" "site" { + bucket = var.bucket_name + force_destroy = false + tags = local.bucket_tags + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "site" { + bucket = aws_s3_bucket.site.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_ownership_controls" "site" { + bucket = aws_s3_bucket.site.id + + rule { + object_ownership = "BucketOwnerEnforced" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "site" { + bucket = aws_s3_bucket.site.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = false + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_versioning" "site" { + bucket = aws_s3_bucket.site.id + + versioning_configuration { + status = "Enabled" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_policy" "site" { + bucket = aws_s3_bucket.site.id + policy = data.aws_iam_policy_document.site_bucket.json + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_cloudfront_origin_access_control" "site" { + name = var.origin_access_control_name + description = var.origin_access_control_description + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_cloudfront_function" "spa_rewrite" { + name = var.function_name + runtime = "cloudfront-js-1.0" + comment = "SPA routing: rewrite extensionless paths to /index.html" + publish = true + code = local.spa_rewrite_code + tags = local.resource_tags + + lifecycle { + prevent_destroy = true + ignore_changes = [publish] + } +} + +resource "aws_cloudfront_distribution" "site" { + aliases = [var.domain_name] + comment = "SeaHaven SHOC frontend (${var.environment})" + default_root_object = "index.html" + enabled = true + http_version = "http2and3" + is_ipv6_enabled = true + price_class = "PriceClass_100" + tags = local.resource_tags + + origin { + connection_attempts = 3 + connection_timeout = 10 + domain_name = aws_s3_bucket.site.bucket_regional_domain_name + origin_access_control_id = aws_cloudfront_origin_access_control.site.id + origin_id = var.origin_id + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cache_policy_id = var.cache_policy_id + cached_methods = ["GET", "HEAD"] + compress = true + target_origin_id = var.origin_id + viewer_protocol_policy = "redirect-to-https" + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.spa_rewrite.arn + } + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + acm_certificate_arn = var.certificate_arn + minimum_protocol_version = "TLSv1.2_2021" + ssl_support_method = "sni-only" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_route53_record" "site_a" { + zone_id = var.hosted_zone_id + name = var.domain_name + type = "A" + + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_route53_record" "site_aaaa" { + zone_id = var.hosted_zone_id + name = var.domain_name + type = "AAAA" + + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role" "github_deploy" { + name = var.deploy_role_name + path = "/" + description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}" + assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json + max_session_duration = 3600 + permissions_boundary = var.deploy_permissions_boundary_arn + tags = local.deploy_role_tags + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = var.deploy_inline_policy_name + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.github_deploy.json + + lifecycle { + prevent_destroy = true + } +} diff --git a/terraform/live/modules/environment-owned/outputs.tf b/terraform/live/modules/environment-owned/outputs.tf new file mode 100644 index 00000000..44f519a7 --- /dev/null +++ b/terraform/live/modules/environment-owned/outputs.tf @@ -0,0 +1,14 @@ +output "bucket_name" { + value = aws_s3_bucket.site.id + description = "Imported site bucket name." +} + +output "distribution_id" { + value = aws_cloudfront_distribution.site.id + description = "Imported CloudFront distribution ID." +} + +output "deploy_role_arn" { + value = aws_iam_role.github_deploy.arn + description = "Imported GitHub deployment role ARN." +} diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf new file mode 100644 index 00000000..6a135679 --- /dev/null +++ b/terraform/live/modules/environment-owned/variables.tf @@ -0,0 +1,160 @@ +variable "environment" { + type = string + description = "Environment name." + + validation { + condition = contains(["dev", "staging"], var.environment) + error_message = "environment must be dev or staging." + } +} + +variable "adoption_complete" { + type = bool + description = "Switches only ownership tags and the deploy policy to their adopted values." + default = false +} + +variable "aws_account_id" { + type = string + description = "AWS account containing the resources." +} + +variable "aws_region" { + type = string + description = "AWS region used by the environment." +} + +variable "bucket_name" { + type = string + description = "Existing private S3 origin bucket." +} + +variable "distribution_id" { + type = string + description = "Existing CloudFront distribution ID." +} + +variable "origin_access_control_name" { + type = string + description = "Exact existing CloudFront OAC name." +} + +variable "origin_access_control_description" { + type = string + description = "Exact existing CloudFront OAC description." +} + +variable "origin_id" { + type = string + description = "Exact origin ID in the existing distribution." +} + +variable "function_name" { + type = string + description = "Existing CloudFront Function name." +} + +variable "domain_name" { + type = string + description = "Site hostname." +} + +variable "hosted_zone_id" { + type = string + description = "Inventory-verified hosted zone ID." +} + +variable "certificate_arn" { + type = string + description = "Inventory-verified ACM certificate ARN." +} + +variable "cache_policy_id" { + type = string + description = "Inventory-verified AWS managed cache policy ID." +} + +variable "github_oidc_provider_arn" { + type = string + description = "Inventory-verified GitHub OIDC provider ARN." +} + +variable "github_subject" { + type = string + description = "Exact GitHub OIDC subject in the existing role." +} + +variable "pre_adoption_github_subject_operator" { + type = string + description = "Condition operator used by the role before adoption." + + validation { + condition = contains(["StringEquals", "StringLike"], var.pre_adoption_github_subject_operator) + error_message = "pre_adoption_github_subject_operator must be StringEquals or StringLike." + } +} + +variable "post_adoption_github_subject_operator" { + type = string + description = "Condition operator used by the role after adoption." + + validation { + condition = contains(["StringEquals", "StringLike"], var.post_adoption_github_subject_operator) + error_message = "post_adoption_github_subject_operator must be StringEquals or StringLike." + } +} + +variable "deploy_branch" { + type = string + description = "Branch or environment named in the existing role description." +} + +variable "deploy_role_name" { + type = string + description = "Existing GitHub deployment role name." +} + +variable "deploy_inline_policy_name" { + type = string + description = "Existing generated inline policy name." +} + +variable "deploy_permissions_boundary_arn" { + type = string + description = "Exact permissions boundary attached before import." +} + +variable "cloudformation_stack_name" { + type = string + description = "Legacy CloudFormation stack used by the pre-adoption policy." +} + +variable "bucket_auto_delete_helper_role_arn" { + type = string + description = "Exact legacy S3 auto-delete helper role ARN." +} + +variable "pre_adoption_tags" { + type = map(string) + description = "Exact tags present while CloudFormation still owns the resources." +} + +variable "pre_adoption_bucket_tags" { + type = map(string) + description = "Exact pre-adoption S3 tags, including the CDK auto-delete marker." +} + +variable "ownership_tags" { + type = map(string) + description = "Tags applied by the controlled ownership transfer." +} + +variable "pre_adoption_deploy_role_tags" { + type = map(string) + description = "Exact pre-adoption deploy-role tags, including its HCP manager tag." +} + +variable "post_adoption_deploy_role_tags" { + type = map(string) + description = "Exact post-adoption deploy-role tags, preserving its HCP manager tag." +}