From 838054891780ce950e22aab753bc1ec18ebde24f Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sun, 30 Aug 2026 22:34:33 -0400 Subject: [PATCH] feat(terraform): adopt live deployment roles safely --- .github/workflows/ci.yaml | 4 + .github/workflows/deploy-staging.yml | 93 +--- .github/workflows/deploy-tf-poc.yml | 53 ++ .github/workflows/deploy.yml | 56 +- .gitignore | 13 + QUALITY_GATES.md | 12 +- README.md | 98 ++-- infra/cdk/README.md | 287 ++++------ infra/cdk/bin/app.ts | 133 +++-- infra/cdk/cdk.json | 3 +- infra/cdk/lib/frontend-stack.ts | 193 ++++++- .../cdk/lib/retain-for-terraform-adoption.ts | 56 ++ infra/cdk/lib/tf-poc-shared-stack.ts | 75 +++ infra/cdk/package.json | 3 + infra/cdk/test/frontend-stack.test.mjs | 204 +++++++ package.json | 4 + scripts/check-terraform-import-plan.py | 514 ++++++++++++++++++ scripts/deploy-web.sh | 477 ++++++++++++++-- scripts/deploy-web.test.mjs | 100 ++++ scripts/governance-check.mjs | 33 ++ scripts/terraform-validate.mjs | 33 ++ scripts/terraform_import_plan_resources.py | 133 +++++ scripts/test-terraform-import-plan-check.py | 505 +++++++++++++++++ terraform/README.md | 367 +++++++++++++ terraform/live/dev/.terraform.lock.hcl | 26 + terraform/live/dev/imports.tf | 64 +++ terraform/live/dev/main.tf | 96 ++++ terraform/live/dev/outputs.tf | 11 + terraform/live/dev/providers.tf | 3 + terraform/live/dev/versions.tf | 19 + .../modules/environment-inventory/main.tf | 65 +++ .../modules/environment-inventory/outputs.tf | 19 + .../environment-inventory/variables.tf | 46 ++ .../live/modules/environment-owned/main.tf | 403 ++++++++++++++ .../live/modules/environment-owned/outputs.tf | 14 + .../modules/environment-owned/variables.tf | 160 ++++++ terraform/live/staging/.terraform.lock.hcl | 26 + terraform/live/staging/imports.tf | 64 +++ terraform/live/staging/main.tf | 96 ++++ terraform/live/staging/outputs.tf | 11 + terraform/live/staging/providers.tf | 3 + terraform/live/staging/versions.tf | 19 + terraform/live/tf-poc/.terraform.lock.hcl | 26 + terraform/live/tf-poc/imports.tf | 64 +++ terraform/live/tf-poc/main.tf | 152 ++++++ terraform/live/tf-poc/outputs.tf | 15 + terraform/live/tf-poc/providers.tf | 3 + .../live/tf-poc/terraform.tfvars.example | 12 + terraform/live/tf-poc/versions.tf | 19 + 49 files changed, 4422 insertions(+), 463 deletions(-) create mode 100644 .github/workflows/deploy-tf-poc.yml create mode 100644 infra/cdk/lib/retain-for-terraform-adoption.ts create mode 100644 infra/cdk/lib/tf-poc-shared-stack.ts create mode 100644 infra/cdk/test/frontend-stack.test.mjs create mode 100644 scripts/check-terraform-import-plan.py create mode 100644 scripts/deploy-web.test.mjs create mode 100644 scripts/terraform-validate.mjs create mode 100644 scripts/terraform_import_plan_resources.py create mode 100644 scripts/test-terraform-import-plan-check.py create mode 100644 terraform/README.md create mode 100644 terraform/live/dev/.terraform.lock.hcl create mode 100644 terraform/live/dev/imports.tf create mode 100644 terraform/live/dev/main.tf create mode 100644 terraform/live/dev/outputs.tf create mode 100644 terraform/live/dev/providers.tf create mode 100644 terraform/live/dev/versions.tf create mode 100644 terraform/live/modules/environment-inventory/main.tf create mode 100644 terraform/live/modules/environment-inventory/outputs.tf create mode 100644 terraform/live/modules/environment-inventory/variables.tf create mode 100644 terraform/live/modules/environment-owned/main.tf create mode 100644 terraform/live/modules/environment-owned/outputs.tf create mode 100644 terraform/live/modules/environment-owned/variables.tf create mode 100644 terraform/live/staging/.terraform.lock.hcl create mode 100644 terraform/live/staging/imports.tf create mode 100644 terraform/live/staging/main.tf create mode 100644 terraform/live/staging/outputs.tf create mode 100644 terraform/live/staging/providers.tf create mode 100644 terraform/live/staging/versions.tf create mode 100644 terraform/live/tf-poc/.terraform.lock.hcl create mode 100644 terraform/live/tf-poc/imports.tf create mode 100644 terraform/live/tf-poc/main.tf create mode 100644 terraform/live/tf-poc/outputs.tf create mode 100644 terraform/live/tf-poc/providers.tf create mode 100644 terraform/live/tf-poc/terraform.tfvars.example create mode 100644 terraform/live/tf-poc/versions.tf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2222ef4f..245183a1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,6 +53,10 @@ jobs: base="origin/dev" fi printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index eed6460e..d2cf4552 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -1,17 +1,6 @@ name: Deploy staging -# Standalone staging deployment (push to `staging` / manual dispatch), NOT a -# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging -# trusts the exact GitHub-environment OIDC subject, which requires the deploy -# job to declare `environment: staging` and run in this repo, with the -# non-secret role ARN pinned below (created by the staging stack itself). -# -# Order is fixed: full `npm run verify` gates run BEFORE any deploy step. -# No secrets are used — OIDC + the static role ARN are the only credentials. - on: - push: - branches: [staging] workflow_dispatch: {} permissions: @@ -32,6 +21,14 @@ jobs: environment: staging env: VITE_API_URL: https://api.staging.seahaven.com/api + EXPECTED_API_URL: https://api.staging.seahaven.com/api + FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.tf-poc.seahaven.com/api + SITE_URL: https://staging.seahaven.com + API_SMOKE_URL: https://api.staging.seahaven.com/swagger/v1/swagger.json + SITE_BUCKET: seahaven-shoc-frontend-staging + EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-staging + CLOUDFRONT_DISTRIBUTION_ID: E2JDVEZ6EGD49J + DEPLOY_RELEASE_ID: ${{ github.sha }} AWS_REGION: us-east-1 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -51,6 +48,10 @@ jobs: base="origin/dev" fi printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" @@ -68,73 +69,5 @@ jobs: role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging aws-region: us-east-1 - # Builds the SPA with the staging VITE_API_URL (process env overrides the - # dev value committed in .env.production), syncs to the staging bucket, - # and invalidates CloudFront. - - name: Build and publish SPA + - name: Build, publish, and verify SPA run: bash scripts/deploy-web.sh - env: - STACK_NAME: shoc-frontend-staging - WAIT_FOR_INVALIDATION: "true" - - - name: Verify deployment - run: | - set -euo pipefail - stack_output() { - aws cloudformation describe-stacks \ - --stack-name shoc-frontend-staging \ - --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ - --output text - } - BUCKET="$(stack_output BucketName)" - DIST_ID="$(stack_output DistributionId)" - DIST_DOMAIN="$(stack_output DistributionDomainName)" - SITE_URL="$(stack_output SiteUrl)" - if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then - echo "::error::Could not resolve bucket/distribution from stack outputs." >&2 - exit 1 - fi - echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}" - - aws s3api head-bucket --bucket "${BUCKET}" - echo "Bucket exists." - # The distribution is proven to exist and serve by the HTTPS check - # below: the custom domain is an alias to this distribution, and the - # deploy role deliberately carries no cloudfront:GetDistribution - # (least privilege; the dev template is shared and must not drift). - - if grep -Rq "api.dev.seahaven.com" dist/; then - echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2 - grep -Rl "api.dev.seahaven.com" dist/ >&2 || true - exit 1 - fi - echo "Built assets carry no dev API URL." - grep -Rq "api.staging.seahaven.com" dist/ - echo "Built assets reference the staging API URL." - - # Verify the actual post-invalidation HTML and its referenced assets, - # not only the local build or a generic endpoint response. - remote_dir="$(mktemp -d)" - trap 'rm -rf "${remote_dir}"' EXIT - for i in 1 2 3 4 5 6; do - if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then - break - fi - echo "Endpoint not ready (attempt ${i}); retrying in 20s..." - sleep 20 - done - test -s "${remote_dir}/index.html" - grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \ - | sed -E 's/^(src|href)="([^"]+)"$/\2/' \ - | sort -u > "${remote_dir}/asset-paths.txt" - test -s "${remote_dir}/asset-paths.txt" - while IFS= read -r asset_path; do - curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \ - >> "${remote_dir}/assets.txt" - done < "${remote_dir}/asset-paths.txt" - if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then - echo "::error::Deployed assets contain the dev API URL." >&2 - exit 1 - fi - grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt" - echo "Deployed staging assets reference only the staging API URL." diff --git a/.github/workflows/deploy-tf-poc.yml b/.github/workflows/deploy-tf-poc.yml new file mode 100644 index 00000000..33bc3a6b --- /dev/null +++ b/.github/workflows/deploy-tf-poc.yml @@ -0,0 +1,53 @@ +name: Deploy Terraform POC + +on: + workflow_dispatch: {} + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy-tf-poc + cancel-in-progress: false + +jobs: + deploy: + name: Deploy to tf-poc + if: github.ref == 'refs/heads/feature/terraform-cd-poc' + runs-on: ubuntu-latest + environment: tf-poc + env: + AWS_REGION: us-east-1 + VITE_API_URL: https://api.tf-poc.seahaven.com/api + EXPECTED_API_URL: https://api.tf-poc.seahaven.com/api + FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.staging.seahaven.com/api + SITE_URL: https://frontend-tf-poc.seahaven.com + SITE_BUCKET: seahaven-shoc-frontend-tf-poc + EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-tf-poc + CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} + API_SMOKE_URL: https://api.tf-poc.seahaven.com/swagger/v1/swagger.json + DEPLOY_RELEASE_ID: ${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - name: Quality gates + run: npm ci && npm run verify + env: + GOVERNANCE_BASE: origin/dev + - name: Assume tf-poc 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-tf-poc + aws-region: us-east-1 + - name: Build, publish, and verify SPA + run: bash scripts/deploy-web.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aa72c01d..bd0eceb6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,22 +1,8 @@ name: Deploy -# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`. -# -# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs -# `cdk deploy` (provisioning the infra in infra/cdk) and then the -# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates -# CloudFront. Both run as the OIDC deploy role created by the stack. -# -# When staging/prod accounts exist, add jobs keyed to their branches and their -# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow. - 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,12 +13,36 @@ 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 }} + name: Deploy to dev + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + env: + AWS_REGION: us-east-1 + VITE_API_URL: https://api.dev.seahaven.com/api + EXPECTED_API_URL: https://api.dev.seahaven.com/api + FORBIDDEN_API_URLS: https://api.staging.seahaven.com/api,https://api.tf-poc.seahaven.com/api + SITE_URL: https://dev.seahaven.com + API_SMOKE_URL: https://api.dev.seahaven.com/swagger/v1/swagger.json + SITE_BUCKET: seahaven-shoc-frontend-dev + EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-dev + CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P + DEPLOY_RELEASE_ID: ${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - name: Quality gates + run: npm ci && npm run verify + - 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 + - name: Build, publish, and verify SPA + run: bash scripts/deploy-web.sh diff --git a/.gitignore b/.gitignore index aabcecf0..13d92762 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,16 @@ infra/cdk/bin/*.d.ts infra/cdk/bin/*.js infra/cdk/lib/*.d.ts infra/cdk/lib/*.js + +# terraform +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +*.tfvars.json +!*.tfvars.example + +# python +__pycache__/ +*.py[cod] diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index b28babd0..77d7581a 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -7,7 +7,9 @@ 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 Terraform +import-plan contract, Terraform formatting and validation, the web deployment +contract, and CDK build/synth. A task is not done until this is green. ## Gate matrix @@ -23,6 +25,10 @@ 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 plan-checker contract | `npm run test:terraform-import-plan` | `governance` + CI | Synthetic plan JSON + canonical maps | +| Terraform formatting/validation | `npm run test:terraform` | `governance` + CI | tf-poc, dev, and staging roots | +| Web deployment/rollback contract | `npm run test:deploy-web` | `governance` + CI | `scripts/deploy-web.sh` | +| CDK compile and synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**` | ## No-false-pass guarantees @@ -36,6 +42,10 @@ 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. +- **Real import and controlled-update plans remain migration evidence** — CI + tests the checker and validates configuration, but it cannot evaluate live + AWS/HCP state. Each environment requires a saved `terraform show -json` plan + and checker output before an approved apply. ## Where the gates run diff --git a/README.md b/README.md index 51fc21d2..5fb701ff 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,17 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md). - **GitHub:** `Sea-Haven-Industries/shoc-frontend-new` -- **Hosted at:** (dev environment; the only environment today) -- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend` +- **Hosted at:** and +- **Backend APIs:** matching `api..seahaven.com/api` endpoints, + called directly from the browser ## Architecture -Static SPA hosting on AWS, provisioned by a CDK app local to this repo -([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/` -from a private S3 bucket; the SPA calls the backend directly over HTTPS at -`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS). +Static SPA hosting on AWS. CloudFront serves the built `dist/` from a private, +versioned S3 bucket; the SPA calls the backend directly over HTTPS at +`VITE_API_URL`. The live stacks remain CDK/CloudFormation-owned while the +import-first Terraform transfer is rehearsed and reviewed. See +[`terraform/README.md`](terraform/README.md). ```mermaid graph LR @@ -29,8 +31,8 @@ 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[Manual GitHub deployment] -->|OIDC| ROLE[Environment deploy role] + ROLE -->|content publish + invalidation| S3 ``` Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack @@ -40,16 +42,17 @@ architecture plan for the keep/discard migration matrix). ## AWS Resources -Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region -`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts). +Stacks **`shoc-frontend-dev`** and **`shoc-frontend-staging`** are currently +CDK-owned in account `396287094661`, region `us-east-1`. They are defined in +[`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts). -| Resource | Name | Purpose | -| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) | -| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` | -| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) | -| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | -| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront | +| Resource | Name | Purpose | +| ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------- | +| S3 bucket | `seahaven-shoc-frontend-{dev,staging}` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) | +| CloudFront distribution | `E2CWLM1AFB964P` / `E2JDVEZ6EGD49J` | HTTPS static hosting on the matching environment domain | +| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) | +| IAM role | `githubdeploy-shoc-frontend-new-{dev,staging}` | Environment-scoped GitHub OIDC content deploy role | +| Route 53 records | A/AAAA aliases in the dev and staging hosted zones | Point each custom domain at its CloudFront distribution | No Lambdas, queues, or databases — this stack is static hosting only. @@ -57,12 +60,9 @@ 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**: - -| Secret | Purpose | -| --------------------- | ----------------------------------------------------------------------------------- | -| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow | +No Secrets Manager, SSM parameters, AWS access keys, or deploy-role repo secret +are used. Content workflows assume their pinned environment role through +GitHub OIDC. ### Environment variables (build-time, `VITE_*`) @@ -77,8 +77,9 @@ repo secret**: build otherwise. See [`.env.example`](.env.example), [`.env.development`](.env.development), and [`.env.production`](.env.production). -CDK context (domain, certificate ARN, hosted zone) lives in -[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags. +CDK context for normal dev synthesis lives in +[`infra/cdk/cdk.json`](infra/cdk/cdk.json). CDK deployment is no longer part of +recurring content releases during the ownership transfer. ## Local Development @@ -114,7 +115,7 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or ## Contributing - Branch from `dev` with a kebab-case description and a prefix matching the - work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/` + work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/` (e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`). - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org) — commitlint @@ -123,13 +124,14 @@ 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). +- Promotion flow during migration: `feature/* → dev`, then an explicitly + approved manual dev deployment and verification on `dev.seahaven.com`. + Staging promotion and deployment are separate approvals. No production + environment exists yet. ## Deployment -CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only): +CI/CD uses OIDC and stores no AWS access keys: - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push and PRs to `main`/`dev`, calls @@ -141,24 +143,22 @@ CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only): [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). -- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on - push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which - runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`) - and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, - `aws s3 sync dist/` (hashed assets immutable, `index.html` never cached), - CloudFront invalidation. Both run as the OIDC deploy role. +- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml), + [`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml), + and [`.github/workflows/deploy-tf-poc.yml`](.github/workflows/deploy-tf-poc.yml)) + is manual-only during migration. [`scripts/deploy-web.sh`](scripts/deploy-web.sh) + publishes to pinned targets, verifies cache/API/routing behavior, retains two + release manifests, and restores the prior versioned index on verification + failure. -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). +The isolated rehearsal, retention mechanism, and deployment prerequisites are +documented in [`infra/cdk/README.md`](infra/cdk/README.md). Terraform ownership, +HCP configuration, import gates, evidence, and rollback are documented in +[`terraform/README.md`](terraform/README.md). -Manual deploy (emergency/reference only — needs credentials for the -external-dev AWS account; the normal path is push to `dev`): - -```bash -(cd infra/cdk && npx cdk deploy) -STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh -``` +Infrastructure changes and ownership transfer remain separate reviewed +administrator actions. Content workflows never run `cdk deploy` or Terraform +apply. ## Operations @@ -177,9 +177,9 @@ STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh 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). +- **Deploy workflow is unavailable on an arbitrary ref** — each manual workflow + checks its exact branch or protected GitHub environment before assuming AWS + credentials. ## Documentation diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 364780af..e3913761 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -1,221 +1,136 @@ -# Infrastructure & CI/CD — Sea Haven SHOC frontend +# Frontend infrastructure and migration rehearsal -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. +This CDK app describes the existing Sea Haven SHOC SPA hosting and an isolated, +production-shaped Terraform adoption rehearsal. It performs no content upload. +Content-only deployment is handled by `scripts/deploy-web.sh`. -- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom - domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias). -- **API:** the SPA calls the backend **directly** over HTTPS at - `https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend - allows CORS). CloudFront serves static content only — no `/api` proxy. -- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy` - picks them up with no flags. `VITE_API_URL` is baked into the build, so it's - per-environment (see the note under "Adding staging / prod"). -- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys) -- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's - `Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy` - (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). -- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is - created by this stack, not added to the central `oidc-deploy-roles.yaml`. -- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and - `staging` (push to `staging`, via the standalone `deploy-staging.yml`). +## Existing environments -``` -infra/cdk/ - bin/app.ts entry point (reads -c context) - lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role -scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation -.github/workflows/ - ci.yaml quality gates (lint / build / test / e2e) - deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) - deploy-staging.yml standalone staging deploy (push to staging) -``` +Normal synthesis remains unchanged when the adoption flag is off: -## What the stack creates +- private, versioned S3 bucket with CDK auto-delete cleanup +- CloudFront distribution and origin access control +- viewer-request function that rewrites extensionless SPA routes +- optional Route 53 A and AAAA aliases +- GitHub Actions OIDC deploy role -| Resource | Purpose | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) | -| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) | -| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) | -| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | +Dev remains the default context in `cdk.json`. Staging uses explicit context +arguments. During migration, both content workflows are manual-only and use +fixed environment configuration rather than discovering deployment targets +from CloudFormation. -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. +## Terraform POC ---- +The POC is isolated in account `396287094661`, region `us-east-1`, and is +created only with `-c tfPoc=true` plus an explicit `tfPocPhase`. It uses three +ownership scopes: -## One-time setup (run by a human with admin AWS creds) +1. `shoc-frontend-tf-poc-shared`: a dedicated public hosted zone for + `frontend-tf-poc.seahaven.com`. +2. `shoc-frontend-tf-poc-certificate`: the DNS-validated ACM certificate. +3. `shoc-frontend-tf-poc`: the private versioned bucket, CloudFront OAC, + distribution, SPA function, A/AAAA aliases, and GitHub OIDC content deploy + role. -### 1. Authenticate to the AWS account +Fixed application values: -```bash -aws configure # or: aws sso login --profile -aws sts get-caller-identity # confirm the right account + region (us-east-1) -``` +- bucket: `seahaven-shoc-frontend-tf-poc` +- role: `githubdeploy-shoc-frontend-new-tf-poc` +- GitHub environment: `tf-poc` +- site: `https://frontend-tf-poc.seahaven.com` +- API: `https://api.tf-poc.seahaven.com/api` -### 2. Ensure the GitHub OIDC provider exists (once per account) - -```bash -aws iam list-open-id-connect-providers -# If none ends in token.actions.githubusercontent.com, create it (thumbprint is -# no longer required — AWS validates GitHub against its own trust store): -aws iam create-open-id-connect-provider \ - --url https://token.actions.githubusercontent.com \ - --client-id-list sts.amazonaws.com -``` - -### 3. CDK bootstrap (once per account/region) +The shared stack is intentionally staged. The zone must exist and be delegated +before ACM can validate a certificate inside it: ```bash cd infra/cdk npm ci -npx cdk bootstrap aws:///us-east-1 +npm test +npm run synth:tf-poc-zone +npm run synth:tf-poc-environment ``` -### 4. Domain, cert, and API URL (already wired for dev) +After approval, deploy only `shoc-frontend-tf-poc-shared` with +`tfPocPhase=zone`. Its outputs provide the child name servers. Create the +parent NS record as a separate approved change and verify public delegation. +Only then use `tfPocPhase=environment` to deploy the separate certificate and +site stacks. The zone stack never contains the certificate, so re-running the +zone phase cannot remove a certificate created by the environment phase. +Omitting `tfPocPhase` fails closed. -Domain/cert/zone are set in `cdk.json` context (account `396287094661`): +The stacks output the hosted zone ID, certificate ARN, delegation evidence, +workspace tag, boundary ARN, and import IDs for the bucket, bucket policy, +distribution, OAC, SPA function, A/AAAA records, deploy role, and inline role +policy. They also emit the generated OAC name/description, deterministic origin +ID, and inline policy name required by the tf-poc Terraform configuration. -| Context key | Value | -| --------------------------------- | ------------------------------------------------------------ | -| `domainNames` | `dev.seahaven.com` | -| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) | -| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` | +## Adoption retention -The stack creates the apex A/AAAA alias in the hosted zone (in this account, -delegated from the parent `seahaven.com` zone). The **API URL is not infra** — -it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`), -baked into the build. Per-environment; override for staging/prod. +`-c retainForTerraformAdoption=true` is deliberately opt-in. Keep it enabled +from the reviewed retention deployment through CloudFormation ownership +detachment. -### 5. First deploy (locally, with admin creds) +The emitted template applies both `DeletionPolicy: Retain` and +`UpdateReplacePolicy: Retain` to: -The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it -locally. This provisions infra + the role: +- site bucket and bucket policy +- distribution, OAC, and SPA rewrite function +- A and AAAA records +- GitHub deploy role and its inline policy +- `SiteBucket/AutoDeleteObjectsCustomResource` -```bash -cd infra/cdk -npx cdk deploy -``` +The bucket remains configured with `autoDeleteObjects: true`. The emitted +bucket and its matching custom resource are both retained, so deleting the +stack cannot invoke that custom resource to empty the versioned bucket. +Generated provider Lambda resources, provider IAM resources, provider logs, +and CDK metadata are intentionally excluded. Template tests enforce this exact +boundary. -Note the `DeployRoleArn` output. Then push the first content (or just push to -`dev` and let CI do everything from here on): +In adoption mode, the deploy role also receives: -```bash -# from repo root, optional manual first content publish: -STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh -``` +- tag `HcpTerraformWorkspace=shoc-frontend-new-{env}` +- permissions boundary + `arn:aws:iam:::policy/shoc-frontend-new-{env}-deploy-boundary` +- exact `StringEquals` OIDC subject matching; for dev this narrows the current + no-wildcard `StringLike` subject before Terraform import -### 6. Set the one GitHub secret +These changes are absent when the flag is off. -`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable): +## Content deployment safeguards -```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" -``` +`scripts/deploy-web.sh` requires explicit target and expectation variables: -(Or **Settings → Secrets and variables → Actions → Secrets**.) +- `SITE_BUCKET` and matching `EXPECTED_SITE_BUCKET` +- `CLOUDFRONT_DISTRIBUTION_ID` +- `SITE_URL` +- `VITE_API_URL` and matching `EXPECTED_API_URL` +- `DEPLOY_RELEASE_ID` or `GITHUB_SHA` +- optional comma-separated `FORBIDDEN_API_URLS` +- optional `API_SMOKE_URL` and `API_CORS_ORIGIN` -### 7. From now on: push to `dev` +The script verifies bucket versioning, builds the app, publishes immutable +assets and a no-cache index, records a release manifest, invalidates and waits, +then checks `/`, `/login`, an extensionless route, asset references, API URLs, +and cache headers. Optional API preflight checks verify CORS. -```bash -git push origin dev -``` +If verification fails after publishing the index, the previous index version +is restored and invalidated. Pruning starts only after successful remote +verification. Current versions needed by the latest two release manifests are +kept; unreferenced object versions are deleted. -`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. +## Manual workflows -> 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. +- `.github/workflows/deploy.yml`: dev content deployment +- `.github/workflows/deploy-staging.yml`: staging content deployment +- `.github/workflows/deploy-tf-poc.yml`: isolated POC content deployment ---- +All are `workflow_dispatch` only. Staging and tf-poc retain their GitHub +environment protection and exact environment-scoped OIDC trust. Each dev and +staging distribution ID is pinned in its workflow. The tf-poc environment +must define its generated `CLOUDFRONT_DISTRIBUTION_ID` as a protected variable. +Each workflow pins its environment's Swagger URL for API CORS/preflight checks. -## Staging environment (same account, exact OIDC subject) - -Staging lives in the same AWS account (396287094661) but deploys through its -own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org -reusable `cd-cdk.yaml`: - -- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role - (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub - environment subject - `repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging` - (`StringEquals` on both `aud` and `sub`). The workflow declares - `environment: staging`, so only runs in that environment can assume the role. - Without `githubEnvironment`, the dev stack keeps its branch-ref trust - unchanged. -- **No secret:** the role ARN is static (the role name is deterministic), so - the workflow pins - `arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging` - directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set. -- **Gates first:** the workflow runs the full `npm run verify` before assuming - the staging role, then runs `scripts/deploy-web.sh` with - `STACK_NAME=shoc-frontend-staging`, - `VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the - CloudFront invalidation to complete. -- **Application-only role:** the recurring staging workflow can describe only - its exact stack, publish only to its exact bucket, and invalidate only its - exact distribution. It cannot assume the shared CDK bootstrap roles or - modify infrastructure. Staging infrastructure changes use the Administrator - command below. -- **Post-deploy checks:** bucket + distribution existence, HTTPS on - `https://staging.seahaven.com`, and the actual post-invalidation remote assets - contain the staging API URL and no dev API URL. (Not browser QA.) - -### One-time setup (run by a human with admin AWS creds + GitHub Admin) - -1. **GitHub Admin — create the `staging` environment** (Settings → - Environments → New environment → `staging`). Add protection rules as - appropriate (e.g. required reviewers, restrict to the `staging` branch). If - the environment does not exist, GitHub creates it unprotected on first use. -2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the - OIDC provider and bootstrap already exist in this account): - - ```bash - cd infra/cdk - npx cdk deploy shoc-frontend-staging \ - -c envName=staging \ - -c deployBranch=staging \ - -c githubEnvironment=staging \ - -c domainNames=staging.seahaven.com \ - -c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \ - -c hostedZoneId=Z02602739VQWBWCAGXP4 \ - -c hostedZoneName=staging.seahaven.com - ``` - - The `DeployRoleArn` output must match the ARN pinned in - `deploy-staging.yml` (it will — the role name is deterministic). - -3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must - allow the `https://staging.seahaven.com` origin. -4. Push to `staging` — `ci.yaml` runs the quality gates and - `deploy-staging.yml` deploys. - -### Adding prod later - -Same pattern: a prod account/stack with its own contexts and, ideally, its own -`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked -into each environment's build, and the bucket's `RemovalPolicy.DESTROY` + -`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited -for prod. - -## Notes - -- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + - `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. -- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging - differs only in that its CD workflow also runs `npm run verify` itself - before deploying); a red-CI commit still deploys on `dev` (matches the - org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part - of enabling CICD. -- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses - lockfileVersion 3, matching the Node 24 / npm 11 CI environment. +Infrastructure creation, parent-zone delegation, Terraform imports, and +ownership detachment remain separate administrator actions. None of these +workflows performs them. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 876463fe..e6314c92 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -1,51 +1,102 @@ #!/usr/bin/env node -import { App, Tags } from "aws-cdk-lib"; +import { App, Stack, Tags } from "aws-cdk-lib"; import { FrontendStack } from "../lib/frontend-stack"; +import { TfPocCertificateStack, TfPocZoneStack } from "../lib/tf-poc-shared-stack"; const app = new App(); +const tfPoc = String(app.node.tryGetContext("tfPoc") ?? "false").toLowerCase() === "true"; -// Defaults match the dev setup; override via `-c key=value` on the CLI. -const envName = app.node.tryGetContext("envName") ?? "dev"; -const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; -const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; -// When set (e.g. "staging"), the deploy role trusts the exact GitHub -// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style -// branch-ref trust. -const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? ""; +if (tfPoc) { + const pocEnv = { account: "396287094661", region: "us-east-1" }; + const tfPocPhase = String(app.node.tryGetContext("tfPocPhase") ?? "").toLowerCase(); + if (!["zone", "environment"].includes(tfPocPhase)) { + throw new Error("tfPocPhase must be set explicitly to 'zone' or 'environment'."); + } + const createEnvironment = tfPocPhase === "environment"; + const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", { + env: pocEnv, + terminationProtection: true, + }); -// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com -// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. -const domainNames = (app.node.tryGetContext("domainNames") ?? "") - .split(",") - .map((d: string) => d.trim()) - .filter((d: string) => d.length > 0); -const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; + const stacks: Stack[] = [zoneStack]; + if (createEnvironment) { + const certificateStack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", { + env: pocEnv, + terminationProtection: true, + hostedZone: zoneStack.hostedZone, + }); + const environmentStack = new FrontendStack(app, "shoc-frontend-tf-poc", { + envName: "tf-poc", + githubRepo: "Sea-Haven-Industries/shoc-frontend-new", + deployBranch: "tf-poc", + githubEnvironment: "tf-poc", + terminationProtection: true, + domainNames: [zoneStack.hostedZoneName], + certificateArn: certificateStack.certificateArn, + hostedZoneId: zoneStack.hostedZoneId, + hostedZoneName: zoneStack.hostedZoneName, + retainForTerraformAdoption: true, + env: pocEnv, + }); + certificateStack.addDependency(zoneStack); + environmentStack.addDependency(certificateStack); + stacks.push(certificateStack, environmentStack); + } -// Route 53 hosted zone (this account) for the custom-domain alias record. -const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; -const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; + for (const stack of stacks) { + Tags.of(stack).add("Project", "shoc-frontend"); + Tags.of(stack).add("Environment", "tf-poc"); + Tags.of(stack).add("ManagedBy", "cdk"); + } +} else { + // Defaults match the dev setup; override via `-c key=value` on the CLI. + const envName = app.node.tryGetContext("envName") ?? "dev"; + const githubRepo = + app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; + const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; + // When set (e.g. "staging"), the deploy role trusts the exact GitHub + // environment OIDC subject instead of a deploy-branch ref. Empty = dev-style + // branch-ref trust. + const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? ""; -// Staging and beyond protect their stacks from accidental deletion; dev -// stays teardown-friendly (its artifacts are reproducible). CDK applies this -// at deploy time — it is not part of the synthesized template. -const terminationProtection = envName !== "dev"; + // Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com + // The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. + const domainNames = (app.node.tryGetContext("domainNames") ?? "") + .split(",") + .map((d: string) => d.trim()) + .filter((d: string) => d.length > 0); + const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; -const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { - envName, - githubRepo, - deployBranch, - githubEnvironment, - terminationProtection, - domainNames, - certificateArn, - hostedZoneId, - hostedZoneName, - env: { - account: process.env.CDK_DEFAULT_ACCOUNT, - region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", - }, -}); + // Route 53 hosted zone (this account) for the custom-domain alias record. + const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; + const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; + const retainForTerraformAdoption = + String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === + "true"; -Tags.of(stack).add("Project", "shoc-frontend"); -Tags.of(stack).add("Environment", envName); -Tags.of(stack).add("ManagedBy", "cdk"); + // Staging and beyond protect their stacks from accidental deletion; dev + // stays teardown-friendly (its artifacts are reproducible). CDK applies this + // at deploy time — it is not part of the synthesized template. + const terminationProtection = envName !== "dev"; + + const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { + envName, + githubRepo, + deployBranch, + githubEnvironment, + terminationProtection, + domainNames, + certificateArn, + hostedZoneId, + hostedZoneName, + retainForTerraformAdoption, + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", + }, + }); + + Tags.of(stack).add("Project", "shoc-frontend"); + Tags.of(stack).add("Environment", envName); + Tags.of(stack).add("ManagedBy", "cdk"); +} diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json index aaecf396..fa68eef7 100644 --- a/infra/cdk/cdk.json +++ b/infra/cdk/cdk.json @@ -7,10 +7,11 @@ "context": { "@aws-cdk/aws-iam:minimizePolicies": true, "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:defaultCrossStackReferences": "strong", "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, "@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true, - "//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.", + "//": "Dev synthesis defaults for account 396287094661. Infrastructure deployment is administrator-run.", "domainNames": "dev.seahaven.com", "certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00", "hostedZoneId": "Z07671212N75U4YLPWZR8", diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index dda5f67e..b0a06f35 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -1,4 +1,13 @@ -import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib"; +import { + Aspects, + CfnOutput, + CfnResource, + Duration, + 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 +16,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 +52,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 +65,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,6 +83,7 @@ export class FrontendStack extends Stack { certificateArn, hostedZoneId, hostedZoneName, + retainForTerraformAdoption = false, } = props; const hasCustomDomain = domainNames.length > 0; @@ -114,6 +129,15 @@ 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. + const adoptionOriginIds: Record = { + dev: "shocfrontenddevDistributionOrigin10CCD0EE1", + staging: "shocfrontendstagingDistributionOrigin16E4628FC", + "tf-poc": "shoc-frontend-tf-poc-origin", + }; + 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 +154,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 +184,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 +194,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 +268,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,8 +279,12 @@ 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, + }); } // --- Outputs ----------------------------------------------------------- @@ -257,7 +307,92 @@ export class FrontendStack extends Stack { }); new CfnOutput(this, "DeployRoleArn", { value: deployRole.roleArn, - description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN", + 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]; + new CfnOutput(this, "TerraformWorkspaceTag", { + value: `shoc-frontend-new-${envName}`, + }); + new CfnOutput(this, "TerraformDeployBoundaryArn", { + value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, + }); + new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }); + new CfnOutput(this, "TerraformImportBucketPolicy", { + value: bucket.bucketName, + }); + new CfnOutput(this, "TerraformImportDistribution", { + value: distribution.distributionId, + }); + new CfnOutput(this, "TerraformImportOriginAccessControl", { + value: originAccessControl.attrId, + }); + new CfnOutput(this, "TerraformOriginAccessControlName", { + value: originAccessControlConfig.name, + }); + new CfnOutput(this, "TerraformOriginAccessControlDescription", { + value: "EMPTY_STRING", + description: "Use an empty Terraform string because the generated OAC has no description", + }); + new CfnOutput(this, "TerraformDistributionOriginId", { + value: originId!, + }); + new CfnOutput(this, "TerraformImportSpaRewriteFunction", { + value: spaRewrite.functionName, + }); + new CfnOutput(this, "TerraformImportAliasA", { + value: `${hostedZoneId}_${recordName}_A`, + }); + new CfnOutput(this, "TerraformImportAliasAAAA", { + value: `${hostedZoneId}_${recordName}_AAAA`, + }); + new CfnOutput(this, "TerraformImportDeployRole", { + value: deployRole.roleName, + }); + new CfnOutput(this, "TerraformImportDeployRolePolicy", { + value: `${deployRole.roleName}:${rolePolicy.policyName}`, + }); + new CfnOutput(this, "TerraformDeployInlinePolicyName", { + value: rolePolicy.policyName, + }); + new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", { + value: autoDeleteProviderRole.getAtt("Arn").toString(), + }); + new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", { + value: "SiteBucket/AutoDeleteObjectsCustomResource", + description: + "CloudFormation custom resource retained to prevent bucket emptying during detachment", + }); + + Aspects.of(this).add(new RetainForTerraformAdoption()); + } } } 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..889986ce --- /dev/null +++ b/infra/cdk/lib/retain-for-terraform-adoption.ts @@ -0,0 +1,56 @@ +import { 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. + */ +export class RetainForTerraformAdoption implements IAspect { + 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"); + return; + } + + node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN; + node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN; + } +} diff --git a/infra/cdk/lib/tf-poc-shared-stack.ts b/infra/cdk/lib/tf-poc-shared-stack.ts new file mode 100644 index 00000000..4d992a3f --- /dev/null +++ b/infra/cdk/lib/tf-poc-shared-stack.ts @@ -0,0 +1,75 @@ +import { CfnOutput, Fn, Stack, StackProps } from "aws-cdk-lib"; +import * as acm from "aws-cdk-lib/aws-certificatemanager"; +import * as route53 from "aws-cdk-lib/aws-route53"; +import { Construct } from "constructs"; + +const TF_POC_DOMAIN = "frontend-tf-poc.seahaven.com"; + +export interface TfPocCertificateStackProps extends StackProps { + readonly hostedZone: route53.IHostedZone; +} + +/** + * Temporary, isolated DNS zone for the production-shaped Terraform POC. + * Parent-zone delegation is deliberately excluded from this stack. + */ +export class TfPocZoneStack extends Stack { + public readonly hostedZone: route53.IHostedZone; + public readonly hostedZoneId: string; + public readonly hostedZoneName = TF_POC_DOMAIN; + + public constructor(scope: Construct, id: string, props: StackProps) { + super(scope, id, props); + + this.hostedZone = new route53.PublicHostedZone(this, "HostedZone", { + zoneName: TF_POC_DOMAIN, + comment: "Temporary isolated hosted zone for frontend Terraform adoption rehearsal", + }); + this.hostedZoneId = this.hostedZone.hostedZoneId; + + const nameServers = this.hostedZone.hostedZoneNameServers; + if (!nameServers) { + throw new Error("Public hosted zone must expose delegation name servers."); + } + + new CfnOutput(this, "HostedZoneId", { + value: this.hostedZone.hostedZoneId, + description: "Terraform aws_route53_zone import ID", + }); + new CfnOutput(this, "HostedZoneName", { value: TF_POC_DOMAIN }); + new CfnOutput(this, "DelegationNameServers", { + value: Fn.join(",", nameServers), + description: + "Evidence only. Add these NS values to the seahaven.com parent zone in a separately approved change.", + }); + new CfnOutput(this, "DelegationRecordName", { + value: TF_POC_DOMAIN, + }); + new CfnOutput(this, "DelegationRequiredAction", { + value: + "SEPARATE APPROVAL REQUIRED: create an NS record for frontend-tf-poc.seahaven.com in the parent seahaven.com zone", + }); + } +} + +/** + * Certificate is isolated so re-running the zone phase cannot remove it. + */ +export class TfPocCertificateStack extends Stack { + public readonly certificateArn: string; + + public constructor(scope: Construct, id: string, props: TfPocCertificateStackProps) { + super(scope, id, props); + + const certificate = new acm.Certificate(this, "Certificate", { + domainName: TF_POC_DOMAIN, + validation: acm.CertificateValidation.fromDns(props.hostedZone), + }); + this.certificateArn = certificate.certificateArn; + + new CfnOutput(this, "CertificateArn", { + value: certificate.certificateArn, + description: "Inventory-only certificate ARN for the frontend tf-poc root", + }); + } +} diff --git a/infra/cdk/package.json b/infra/cdk/package.json index b490e706..f9e7b67c 100644 --- a/infra/cdk/package.json +++ b/infra/cdk/package.json @@ -11,7 +11,10 @@ }, "scripts": { "build": "tsc", + "test": "npm run build && node --test test/*.test.mjs", "synth": "cdk synth", + "synth:tf-poc-zone": "cdk synth -c tfPoc=true -c tfPocPhase=zone", + "synth:tf-poc-environment": "cdk synth -c tfPoc=true -c tfPocPhase=environment", "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..bc04d41d --- /dev/null +++ b/infra/cdk/test/frontend-stack.test.mjs @@ -0,0 +1,204 @@ +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 { TfPocCertificateStack, TfPocZoneStack } = require("../lib/tf-poc-shared-stack.js"); + +const account = "396287094661"; +const region = "us-east-1"; + +function frontendTemplate(retainForTerraformAdoption) { + const app = new App(); + const stack = new FrontendStack(app, "shoc-frontend-tf-poc", { + envName: "tf-poc", + githubRepo: "Sea-Haven-Industries/shoc-frontend-new", + deployBranch: "tf-poc", + githubEnvironment: "tf-poc", + domainNames: ["frontend-tf-poc.seahaven.com"], + certificateArn: `arn:aws:acm:${region}:${account}:certificate/test`, + hostedZoneId: "ZTESTPOC", + hostedZoneName: "frontend-tf-poc.seahaven.com", + retainForTerraformAdoption, + env: { account, region }, + }); + return Template.fromStack(stack).toJSON(); +} + +function entriesByType(template, type) { + return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type); +} + +test("tf-poc has fixed production-shaped resources and adoption metadata", () => { + const template = frontendTemplate(true); + const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; + assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-tf-poc"); + 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 [deployRole] = entriesByType(template, "AWS::IAM::Role").filter( + ([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc", + ); + assert.ok(deployRole); + assert.equal( + deployRole[1].Properties.PermissionsBoundary, + `arn:aws:iam::${account}:policy/shoc-frontend-new-tf-poc-deploy-boundary`, + ); + assert.ok( + deployRole[1].Properties.Tags.some( + (tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-tf-poc", + ), + ); + + assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1); + assert.equal( + entriesByType(template, "AWS::CloudFront::Distribution")[0][1].Properties.DistributionConfig + .Origins[0].Id, + "shoc-frontend-tf-poc-origin", + ); + 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); + + 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}`); + } +}); + +test("adoption mode retains exactly the transferred resources", () => { + const template = frontendTemplate(true); + const retainedTypes = new Set([ + "AWS::S3::Bucket", + "AWS::S3::BucketPolicy", + "AWS::CloudFront::Distribution", + "AWS::CloudFront::Function", + "AWS::CloudFront::OriginAccessControl", + "AWS::Route53::RecordSet", + "Custom::S3AutoDeleteObjects", + ]); + + for (const [logicalId, resource] of Object.entries(template.Resources)) { + const isDeployRoleResource = + (resource.Type === "AWS::IAM::Role" && + resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc") || + (resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole")); + const shouldRetain = retainedTypes.has(resource.Type) || isDeployRoleResource; + if (shouldRetain) { + 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); + } + } + + const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1]; + assert.equal(customResource.DeletionPolicy, "Retain"); + for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) { + for (const [, resource] of entriesByType(template, type)) { + assert.notEqual(resource.DeletionPolicy, "Retain"); + } + } +}); + +test("normal mode preserves destructive cleanup and has no adoption boundary or tag", () => { + const template = frontendTemplate(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 === "githubdeploy-shoc-frontend-new-tf-poc", + )[1]; + assert.equal(deployRole.Properties.PermissionsBoundary, undefined); + assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace")); + assert.equal(template.Outputs.TerraformWorkspaceTag, undefined); +}); + +test("dev adoption prerequisite preserves origin ID and narrows exact trust", () => { + 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/test`, + hostedZoneId: "Z07671212N75U4YLPWZR8", + hostedZoneName: "dev.seahaven.com", + retainForTerraformAdoption: true, + env: { account, region }, + }); + const template = Template.fromStack(stack).toJSON(); + const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1]; + assert.equal( + distribution.Properties.DistributionConfig.Origins[0].Id, + "shocfrontenddevDistributionOrigin10CCD0EE1", + ); + + const deployRole = entriesByType(template, "AWS::IAM::Role").find( + ([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-dev", + )[1]; + 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); +}); + +test("zone stack never owns a certificate or parent delegation", () => { + const app = new App(); + const stack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", { + env: { account, region }, + }); + const template = Template.fromStack(stack).toJSON(); + assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 1); + assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 0); + assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 0); + assert.ok(template.Outputs.DelegationNameServers); + assert.equal(template.Outputs.CertificateArn, undefined); + assert.match(template.Outputs.DelegationRequiredAction.Value, /SEPARATE APPROVAL REQUIRED/); +}); + +test("environment phase creates its certificate in a separate stack", () => { + const app = new App(); + const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", { + env: { account, region }, + }); + const stack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", { + env: { account, region }, + hostedZone: zoneStack.hostedZone, + }); + const template = Template.fromStack(stack).toJSON(); + assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 0); + assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 1); + assert.ok(template.Outputs.CertificateArn); +}); diff --git a/package.json b/package.json index fc91c435..a3285b5c 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:deploy-web": "node scripts/deploy-web.test.mjs", + "test:terraform-import-plan": "python scripts/test-terraform-import-plan-check.py", + "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:tf-poc-zone && npm --prefix infra/cdk run synth:tf-poc-environment", "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..ef04b258 --- /dev/null +++ b/scripts/check-terraform-import-plan.py @@ -0,0 +1,514 @@ +#!/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_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_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 = ( + _expected_bucket_policy(environment, distribution_id) + if address == BUCKET_POLICY_ADDRESS + else _expected_deploy_policy(environment, distribution_id) + ) + if after_policy is not None and after_policy != expected: + 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/deploy-web.sh b/scripts/deploy-web.sh index 59b302ef..64799b0e 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -1,65 +1,436 @@ #!/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`). -# -# 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 from the repo root. Reads the bucket + distribution from stack outputs, -# so it has no hardcoded resource IDs. -set -euo pipefail +set -Eeuo pipefail -STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" -REGION="${AWS_REGION:-us-east-1}" -WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}" +required_vars=( + SITE_BUCKET + EXPECTED_SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID + SITE_URL + EXPECTED_API_URL +) +for name in "${required_vars[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "::error::${name} must be set explicitly." >&2 + exit 1 + fi +done -echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..." -npm ci -npm run build +DEPLOY_RELEASE_ID="${DEPLOY_RELEASE_ID:-${GITHUB_SHA:-}}" +EXTENSIONLESS_SMOKE_PATH="${EXTENSIONLESS_SMOKE_PATH:-/deployment-smoke}" +FORBIDDEN_API_URLS="${FORBIDDEN_API_URLS:-}" +API_SMOKE_URL="${API_SMOKE_URL:-}" +API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}" -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 +if [[ "${SITE_BUCKET}" != "${EXPECTED_SITE_BUCKET}" ]]; then + echo "::error::SITE_BUCKET does not match EXPECTED_SITE_BUCKET." >&2 + exit 1 +fi +if [[ "${VITE_API_URL:-}" != "${EXPECTED_API_URL}" ]]; then + echo "::error::VITE_API_URL must exactly match EXPECTED_API_URL." >&2 + exit 1 +fi +if [[ ! "${DEPLOY_RELEASE_ID}" =~ ^[A-Za-z0-9._-]{7,128}$ ]]; then + echo "::error::DEPLOY_RELEASE_ID is missing or unsafe." >&2 + exit 1 +fi +if [[ ! "${SITE_URL}" =~ ^https://[^/]+/?$ || ! "${EXPECTED_API_URL}" =~ ^https:// ]]; then + echo "::error::SITE_URL and EXPECTED_API_URL must be HTTPS URLs." >&2 + exit 1 +fi +if [[ "${EXTENSIONLESS_SMOKE_PATH}" != /* || "${EXTENSIONLESS_SMOKE_PATH}" == *.* ]]; then + echo "::error::EXTENSIONLESS_SMOKE_PATH must be an extensionless absolute path." >&2 exit 1 fi -echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." -# Everything except index.html: long-lived + immutable, prune stale objects. -aws s3 sync dist/ "s3://${BUCKET}/" \ - --delete \ +SITE_URL="${SITE_URL%/}" +work_dir="$(mktemp -d)" +published_index_version="" +prior_index_version="" +deployment_verified="false" + +invalidate_and_wait() { + local invalidation_id + invalidation_id="$( + aws cloudfront create-invalidation \ + --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \ + --paths "/*" \ + --query "Invalidation.Id" \ + --output text + )" + test -n "${invalidation_id}" + aws cloudfront wait invalidation-completed \ + --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \ + --id "${invalidation_id}" +} + +rollback_index() { + [[ -n "${published_index_version}" ]] || return 0 + echo "::warning::Verification failed. Restoring the prior index version." >&2 + if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then + aws s3api copy-object \ + --bucket "${SITE_BUCKET}" \ + --key index.html \ + --copy-source "${SITE_BUCKET}/index.html?versionId=${prior_index_version}" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" \ + --metadata-directive REPLACE >/dev/null + else + aws s3api delete-object \ + --bucket "${SITE_BUCKET}" \ + --key index.html \ + --version-id "${published_index_version}" >/dev/null + fi + invalidate_and_wait || true +} + +cleanup() { + local status=$? + if [[ "${status}" -ne 0 && "${deployment_verified}" != "true" ]]; then + rollback_index + fi + rm -rf "${work_dir}" + exit "${status}" +} +trap cleanup EXIT + +versioning_status="$( + aws s3api get-bucket-versioning \ + --bucket "${SITE_BUCKET}" \ + --query Status \ + --output text +)" +if [[ "${versioning_status}" != "Enabled" ]]; then + echo "::error::The target bucket must have versioning enabled." >&2 + exit 1 +fi + +if ! prior_index_version="$( + aws s3api head-object \ + --bucket "${SITE_BUCKET}" \ + --key index.html \ + --query VersionId \ + --output text 2>"${work_dir}/prior-index.error" +)"; then + if grep -Eqi "(404|Not Found|NoSuchKey)" "${work_dir}/prior-index.error"; then + prior_index_version="" + else + cat "${work_dir}/prior-index.error" >&2 + echo "::error::Could not inspect the current index version." >&2 + exit 1 + fi +fi + +: >"${work_dir}/prior-asset-versions.tsv" +if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then + prior_manifest_key="$( + aws s3api list-objects-v2 \ + --bucket "${SITE_BUCKET}" \ + --prefix ".deploy/releases/" \ + --query "reverse(sort_by(Contents,&LastModified))[0].Key" \ + --output text + )" + if [[ -n "${prior_manifest_key}" && "${prior_manifest_key}" != "None" ]]; then + aws s3 cp "s3://${SITE_BUCKET}/${prior_manifest_key}" \ + "${work_dir}/prior-manifest.json" --quiet + node - "${work_dir}/prior-manifest.json" \ + >"${work_dir}/prior-asset-versions.tsv" <<'NODE' +const manifest = require(process.argv[2]); +for (const asset of manifest.assets ?? []) { + if (typeof asset === "object" && asset.key && asset.versionId) { + console.log(`${asset.key}\t${asset.versionId}`); + } +} +NODE + else + aws s3api list-objects-v2 \ + --bucket "${SITE_BUCKET}" \ + --query "Contents[].Key" \ + --output text | tr "\t" "\n" \ + | awk '$0 != "index.html" && $0 !~ /^\.deploy\// && $0 != "None"' \ + | sort -u >"${work_dir}/prior-asset-keys.txt" + while IFS= read -r prior_asset_key; do + [[ -n "${prior_asset_key}" ]] || continue + prior_asset_version="$( + aws s3api head-object \ + --bucket "${SITE_BUCKET}" \ + --key "${prior_asset_key}" \ + --query VersionId \ + --output text + )" + if [[ -z "${prior_asset_version}" || "${prior_asset_version}" == "None" ]]; then + echo "::error::Prior asset ${prior_asset_key} did not resolve to a version ID." >&2 + exit 1 + fi + printf "%s\t%s\n" "${prior_asset_key}" "${prior_asset_version}" \ + >>"${work_dir}/prior-asset-versions.tsv" + done <"${work_dir}/prior-asset-keys.txt" + fi +fi + +echo "Building SPA for ${EXPECTED_API_URL}..." +npm ci +npm run build +test -s dist/index.html +if ! grep -RqsF -- "${EXPECTED_API_URL}" dist; then + echo "::error::Built output does not contain EXPECTED_API_URL." >&2 + exit 1 +fi +for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do + if [[ -n "${forbidden_url}" ]] && grep -RqsF -- "${forbidden_url}" dist; then + echo "::error::Built output contains forbidden API URL ${forbidden_url}." >&2 + exit 1 + fi +done + +echo "Publishing immutable release assets..." +aws s3 sync dist/ "s3://${SITE_BUCKET}/" \ --exclude "index.html" \ --cache-control "public,max-age=31536000,immutable" -echo "Uploading index.html (never cached)..." -aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \ - --cache-control "no-cache,no-store,must-revalidate" \ - --content-type "text/html" - -echo "Invalidating CloudFront ${DIST_ID}..." -INVALIDATION_ID="$(aws cloudfront create-invalidation \ - --distribution-id "${DIST_ID}" \ - --paths "/*" \ - --query 'Invalidation.Id' \ - --output text)" - -if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then - echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..." - aws cloudfront wait invalidation-completed \ - --distribution-id "${DIST_ID}" \ - --id "${INVALIDATION_ID}" +published_index_version="$( + aws s3api put-object \ + --bucket "${SITE_BUCKET}" \ + --key index.html \ + --body dist/index.html \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" \ + --query VersionId \ + --output text +)" +if [[ -z "${published_index_version}" || "${published_index_version}" == "None" ]]; then + echo "::error::Index upload did not return a version ID." >&2 + exit 1 fi -echo "Web deploy complete." +node - >"${work_dir}/asset-keys.txt" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +function files(directory, prefix = "") { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const relative = path.posix.join(prefix, entry.name); + return entry.isDirectory() + ? files(path.join(directory, entry.name), relative) + : [relative]; + }); +} +for (const file of files("dist").filter((entry) => entry !== "index.html").sort()) { + console.log(file); +} +NODE +: >"${work_dir}/asset-versions.tsv" +while IFS= read -r asset_key; do + asset_version="$( + aws s3api head-object \ + --bucket "${SITE_BUCKET}" \ + --key "${asset_key}" \ + --query VersionId \ + --output text + )" + if [[ -z "${asset_version}" || "${asset_version}" == "None" ]]; then + echo "::error::Asset ${asset_key} did not resolve to a version ID." >&2 + exit 1 + fi + printf "%s\t%s\n" "${asset_key}" "${asset_version}" >>"${work_dir}/asset-versions.tsv" +done <"${work_dir}/asset-keys.txt" + +node - "${DEPLOY_RELEASE_ID}" "${published_index_version}" "${prior_index_version}" \ + "${work_dir}/asset-versions.tsv" "${work_dir}/prior-asset-versions.tsv" \ + >"${work_dir}/manifest.json" <<'NODE' +const fs = require("node:fs"); +const [release, indexVersion, priorIndexVersion, versionsPath, priorVersionsPath] = + process.argv.slice(2); +function readVersions(path) { + return fs + .readFileSync(path, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [key, versionId] = line.split("\t"); + return { key, versionId }; + }); +} +process.stdout.write( + `${JSON.stringify( + { + release, + indexVersion, + priorIndexVersion, + assets: readVersions(versionsPath), + priorAssets: readVersions(priorVersionsPath), + }, + null, + 2, + )}\n`, +); +NODE +manifest_key=".deploy/releases/${DEPLOY_RELEASE_ID}.json" + +echo "Invalidating CloudFront and waiting for propagation..." +invalidate_and_wait + +fetch_route() { + local route="$1" + local slug="$2" + curl -fsS --max-time 30 \ + -D "${work_dir}/${slug}.headers" \ + -o "${work_dir}/${slug}.body" \ + "${SITE_URL}${route}" + grep -qi "^content-type:.*text/html" "${work_dir}/${slug}.headers" + grep -qi "^cache-control:.*no-cache" "${work_dir}/${slug}.headers" + grep -qi "^cache-control:.*no-store" "${work_dir}/${slug}.headers" + grep -qi "^cache-control:.*must-revalidate" "${work_dir}/${slug}.headers" + cmp -s "${work_dir}/${slug}.body" "${work_dir}/root.body" +} + +curl -fsS --max-time 30 \ + -D "${work_dir}/root.headers" \ + -o "${work_dir}/root.body" \ + "${SITE_URL}/" +grep -qi "^content-type:.*text/html" "${work_dir}/root.headers" +grep -qi "^cache-control:.*no-cache" "${work_dir}/root.headers" +grep -qi "^cache-control:.*no-store" "${work_dir}/root.headers" +grep -qi "^cache-control:.*must-revalidate" "${work_dir}/root.headers" +fetch_route "/login" "login" +fetch_route "${EXTENSIONLESS_SMOKE_PATH}" "extensionless" + +grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${work_dir}/root.body" \ + | awk -F'"' '{ print $2 }' \ + | sort -u >"${work_dir}/asset-paths.txt" +test -s "${work_dir}/asset-paths.txt" +: >"${work_dir}/asset-content.txt" +while IFS= read -r asset_path; do + asset_slug="$(printf "%s" "${asset_path}" | tr "/." "__")" + curl -fsS --max-time 30 \ + -D "${work_dir}/${asset_slug}.headers" \ + -o "${work_dir}/${asset_slug}.body" \ + "${SITE_URL}${asset_path}" + grep -qi "^cache-control:.*max-age=31536000" "${work_dir}/${asset_slug}.headers" + grep -qi "^cache-control:.*immutable" "${work_dir}/${asset_slug}.headers" + cat "${work_dir}/${asset_slug}.body" >>"${work_dir}/asset-content.txt" +done <"${work_dir}/asset-paths.txt" + +grep -qsF -- "${EXPECTED_API_URL}" "${work_dir}/asset-content.txt" +for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do + if [[ -n "${forbidden_url}" ]] && + grep -qsF -- "${forbidden_url}" "${work_dir}/asset-content.txt"; then + echo "::error::Deployed assets contain forbidden API URL ${forbidden_url}." >&2 + exit 1 + fi +done + +if [[ -n "${API_SMOKE_URL}" ]]; then + api_status="$( + curl -sS --max-time 30 \ + -H "Origin: ${API_CORS_ORIGIN}" \ + -D "${work_dir}/api.headers" \ + -o "${work_dir}/api.body" \ + -w "%{http_code}" \ + "${API_SMOKE_URL}" + )" + if [[ "${api_status}" == "000" || "${api_status}" -ge 500 ]]; then + echo "::error::API smoke request failed with HTTP ${api_status}." >&2 + exit 1 + fi + grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/api.headers" + + curl -fsS --max-time 30 \ + -X OPTIONS \ + -H "Origin: ${API_CORS_ORIGIN}" \ + -H "Access-Control-Request-Method: GET" \ + -D "${work_dir}/cors.headers" \ + -o /dev/null \ + "${API_SMOKE_URL}" + grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/cors.headers" + grep -qi "^access-control-allow-methods:.*GET" "${work_dir}/cors.headers" +fi + +aws s3 cp "${work_dir}/manifest.json" "s3://${SITE_BUCKET}/${manifest_key}" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "application/json" + +# Once the manifest is durable, this release and its rollback target are both +# protected from pruning. A later cleanup failure must not roll back a release +# whose prior assets may already have been pruned. +deployment_verified="true" + +# Keep exactly the current and immediately prior release manifests and every +# object version they reference. Prune only unreferenced versions, after all +# remote checks pass. +aws s3api list-objects-v2 \ + --bucket "${SITE_BUCKET}" \ + --prefix ".deploy/releases/" \ + --query "reverse(sort_by(Contents,&LastModified))[].Key" \ + --output text | tr "\t" "\n" >"${work_dir}/manifest-keys.txt" +printf "%s\n" "${manifest_key}" >"${work_dir}/kept-manifests.txt" +awk -v current="${manifest_key}" '$0 != current { print; exit }' \ + "${work_dir}/manifest-keys.txt" >>"${work_dir}/kept-manifests.txt" +: >"${work_dir}/retained-versions.tsv" +while IFS= read -r kept_manifest; do + [[ -n "${kept_manifest}" ]] || continue + aws s3 cp "s3://${SITE_BUCKET}/${kept_manifest}" "${work_dir}/kept.json" --quiet + kept_manifest_version="$( + aws s3api head-object \ + --bucket "${SITE_BUCKET}" \ + --key "${kept_manifest}" \ + --query VersionId \ + --output text + )" + printf "%s\t%s\n" "${kept_manifest}" "${kept_manifest_version}" \ + >>"${work_dir}/retained-versions.tsv" + is_current_manifest="false" + if [[ "${kept_manifest}" == "${manifest_key}" ]]; then + is_current_manifest="true" + fi + node - "${work_dir}/kept.json" "${is_current_manifest}" \ + >>"${work_dir}/retained-versions.tsv" <<'NODE' +const manifest = require(process.argv[2]); +const isCurrentManifest = process.argv[3] === "true"; +if (manifest.indexVersion && manifest.indexVersion !== "None") { + console.log(`index.html\t${manifest.indexVersion}`); +} +if (manifest.priorIndexVersion && manifest.priorIndexVersion !== "None") { + console.log(`index.html\t${manifest.priorIndexVersion}`); +} +for (const asset of manifest.assets) { + if (typeof asset === "object" && asset.key && asset.versionId) { + console.log(`${asset.key}\t${asset.versionId}`); + } +} +if (isCurrentManifest) { + for (const asset of manifest.priorAssets ?? []) { + if (typeof asset === "object" && asset.key && asset.versionId) { + console.log(`${asset.key}\t${asset.versionId}`); + } + } +} +NODE +done <"${work_dir}/kept-manifests.txt" +sort -u -o "${work_dir}/retained-versions.tsv" "${work_dir}/retained-versions.tsv" + +aws s3api list-object-versions \ + --bucket "${SITE_BUCKET}" \ + --output json >"${work_dir}/object-versions.json" +node - "${work_dir}/object-versions.json" >"${work_dir}/prune-candidates.tsv" <<'NODE' +const listing = require(process.argv[2]); +for (const version of listing.Versions ?? []) { + console.log(`version\t${version.Key}\t${version.VersionId}`); +} +for (const marker of listing.DeleteMarkers ?? []) { + console.log(`marker\t${marker.Key}\t${marker.VersionId}`); +} +NODE + +while IFS=$'\t' read -r kind object_key version_id; do + [[ -n "${object_key}" && -n "${version_id}" ]] || continue + if [[ "${kind}" == "version" ]] && + grep -qxF -- "${object_key}"$'\t'"${version_id}" "${work_dir}/retained-versions.tsv"; then + continue + fi + aws s3api delete-object \ + --bucket "${SITE_BUCKET}" \ + --key "${object_key}" \ + --version-id "${version_id}" >/dev/null +done <"${work_dir}/prune-candidates.tsv" + +echo "Web release ${DEPLOY_RELEASE_ID} deployed and verified." diff --git a/scripts/deploy-web.test.mjs b/scripts/deploy-web.test.mjs new file mode 100644 index 00000000..dac8bd23 --- /dev/null +++ b/scripts/deploy-web.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptPath = new URL("./deploy-web.sh", import.meta.url); +const script = readFileSync(scriptPath, "utf8"); +const windowsGitBash = `${process.env.ProgramFiles ?? "C:\\Program Files"}\\Git\\bin\\bash.exe`; +const bash = process.platform === "win32" ? windowsGitBash : "bash"; +const hasBash = process.platform !== "win32" || existsSync(windowsGitBash); +const nativeScriptPath = fileURLToPath(scriptPath); +const bashScriptPath = + process.platform === "win32" + ? nativeScriptPath + .replace(/^([A-Za-z]):\\/, (_, drive) => `/${drive.toLowerCase()}/`) + .replaceAll("\\", "/") + : nativeScriptPath; + +test("deploy script has valid bash syntax", { skip: !hasBash }, () => { + const result = spawnSync(bash, ["-n", bashScriptPath], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); +}); + +test("deploy script fails closed before running tools", { skip: !hasBash }, () => { + const result = spawnSync(bash, [bashScriptPath], { + encoding: "utf8", + env: { PATH: process.env.PATH }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /SITE_BUCKET must be set explicitly/); +}); + +test("target bucket mismatch fails before publishing", { skip: !hasBash }, () => { + const result = spawnSync(bash, [bashScriptPath], { + encoding: "utf8", + env: { + ...process.env, + SITE_BUCKET: "wrong-bucket", + EXPECTED_SITE_BUCKET: "expected-bucket", + CLOUDFRONT_DISTRIBUTION_ID: "DIST123", + SITE_URL: "https://example.test", + EXPECTED_API_URL: "https://api.example.test/api", + VITE_API_URL: "https://api.example.test/api", + DEPLOY_RELEASE_ID: "1234567", + }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /SITE_BUCKET does not match EXPECTED_SITE_BUCKET/); +}); + +test("content safety contract is present and ordered", () => { + for (const required of [ + "get-bucket-versioning", + "head-object", + "list-object-versions", + "asset-versions.tsv", + "prior-asset-versions.tsv", + "prior-manifest.json", + "priorAssets", + "isCurrentManifest", + "--version-id", + "public,max-age=31536000,immutable", + "no-cache,no-store,must-revalidate", + "cloudfront wait invalidation-completed", + 'fetch_route "/login"', + 'fetch_route "${EXTENSIONLESS_SMOKE_PATH}"', + "Access-Control-Request-Method: GET", + ".deploy/releases/${DEPLOY_RELEASE_ID}.json", + ]) { + assert.ok(script.includes(required), `missing contract: ${required}`); + } + + assert.ok( + script.indexOf('deployment_verified="true"') < script.indexOf("aws s3api list-object-versions"), + "pruning must occur only after remote verification", + ); + assert.ok( + script.indexOf('grep -qi "^access-control-allow-methods:.*GET"') < + script.indexOf('aws s3 cp "${work_dir}/manifest.json"'), + "failed remote verification must not publish a retention manifest", + ); + assert.ok( + script.indexOf('aws s3 cp "${work_dir}/manifest.json"') < + script.indexOf('deployment_verified="true"'), + "manifest publication failures must roll back the new index", + ); + assert.ok( + script.indexOf('>"${work_dir}/prior-asset-keys.txt"') < + script.indexOf('aws s3 sync dist/ "s3://${SITE_BUCKET}/"'), + "the pre-manifest release must be inventoried before new assets publish", + ); + assert.match( + script, + /if \(isCurrentManifest\) \{[\s\S]*manifest\.priorAssets/, + "only the current manifest may retain its pre-script rollback assets", + ); + assert.match(script, /Could not inspect the current index version/); + assert.doesNotMatch(script, /s3 sync[\s\S]{0,250}--delete/); +}); diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 8cabc70f..a01aaf35 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -17,6 +17,12 @@ 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$/; +const REPOSITORY_GATES = [ + ["Terraform import-plan contract", "test:terraform-import-plan"], + ["Terraform formatting and validation", "test:terraform"], + ["Web deployment contract", "test:deploy-web"], + ["CDK build and synth", "test:infra"], +]; function isGoverned(relativePath) { return ( @@ -194,6 +200,22 @@ function plural(count, word) { return `${count} ${word}${count === 1 ? "" : "s"}`; } +function runRepositoryGate(label, script) { + 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..68055d8b --- /dev/null +++ b/scripts/terraform-validate.mjs @@ -0,0 +1,33 @@ +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"; +const ROOTS = ["tf-poc", "dev", "staging"].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) { + run(["init", "-backend=false", "-input=false", "-no-color"], root); + run(["validate", "-no-color"], root); +} + +console.log("Terraform formatting and validation passed for tf-poc, dev, and staging."); diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py new file mode 100644 index 00000000..499a5a98 --- /dev/null +++ b/scripts/terraform_import_plan_resources.py @@ -0,0 +1,133 @@ +"""Canonical frontend Terraform ownership and import-ID maps.""" + +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", "tf-poc") +} + +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", + "distribution_id": "E2CWLM1AFB964P", + "workspace_name": "shoc-frontend-new-dev", + }, + "staging": { + "bucket_name": "seahaven-shoc-frontend-staging", + "distribution_id": "E2JDVEZ6EGD49J", + "workspace_name": "shoc-frontend-new-staging", + }, + "tf-poc": { + "bucket_name": "seahaven-shoc-frontend-tf-poc", + "distribution_id": None, + "workspace_name": "shoc-frontend-new-tf-poc", + }, +} + + +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" + ), + }, + "tf-poc": { + **_bucket_imports("seahaven-shoc-frontend-tf-poc"), + "module.environment_owned.aws_cloudfront_distribution.site": None, + "module.environment_owned.aws_cloudfront_origin_access_control.site": None, + "module.environment_owned.aws_cloudfront_function.spa_rewrite": None, + "module.environment_owned.aws_route53_record.site_a": None, + "module.environment_owned.aws_route53_record.site_aaaa": None, + "module.environment_owned.aws_iam_role.github_deploy": ( + "githubdeploy-shoc-frontend-new-tf-poc" + ), + "module.environment_owned.aws_iam_role_policy.github_deploy": None, + }, +} diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py new file mode 100644 index 00000000..e5804796 --- /dev/null +++ b/scripts/test-terraform-import-plan-check.py @@ -0,0 +1,505 @@ +#!/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] + if expected is not None: + return expected + suffix = address.rsplit(".", 1)[-1].replace("_", "-") + return f"tf-poc-generated-{suffix}" + + +def distribution_id(environment: str) -> str: + configured = ENVIRONMENT_CONFIG[environment]["distribution_id"] + return configured if isinstance(configured, str) else "ETFPOCGENERATED123" + + +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 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]: + after_policy = ( + bucket_policy(environment) + if address == BUCKET_POLICY + else deploy_policy(environment) + ) + before_policy = {"Version": "2012-10-17", "Statement": []} + 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_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", + ), + "staging": ( + "local.hosted_zone_id", + "local.certificate_arn", + "local.github_oidc_arn", + "local.cache_policy_id", + ), + "tf-poc": ( + "var.hosted_zone_id", + "var.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 address in CONTROLLED_UPDATE_ADDRESSES: + with self.subTest(address=address): + self.assert_passes( + make_plan( + "tf-poc", + mode="controlled", + controlled_updates={address}, + ), + "tf-poc", + 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_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..ab9068e5 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,367 @@ +# Frontend Terraform adoption runbook + +This tree adopts the existing Sea Haven SHOC frontend hosting resources without +recreating them. It implements the local configuration and plan-safety tooling +only. 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. + +The rollout order is `tf-poc`, dev, then staging. Production and tf-poc teardown +are separate follow-up changes. + +## Fixed targets + +- AWS account: `396287094661` +- AWS region: `us-east-1` +- HCP organization: `seahaven` +- HCP project: `seahaven-external-dev` +- Workspaces: + - `shoc-frontend-new-tf-poc` + - `shoc-frontend-new-dev` + - `shoc-frontend-new-staging` +- HCP auto-apply: off for all three workspaces +- tf-poc site: `frontend-tf-poc.seahaven.com` +- tf-poc API build value: `https://api.tf-poc.seahaven.com/api` +- tf-poc bucket: `seahaven-shoc-frontend-tf-poc` +- tf-poc deploy role: `githubdeploy-shoc-frontend-new-tf-poc` +- tf-poc GitHub environment: `tf-poc` + +The `cloud` blocks identify the organization, project, and workspace. Auto-apply +is an HCP workspace setting and must be verified operationally before connecting +VCS or starting a run. + +## 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 AWS managed `Managed-CachingOptimized` CloudFront +cache policy. + +The following remain outside state: + +- public hosted zones and ACM certificates +- the account-global GitHub OIDC provider +- the AWS managed CloudFront cache policy +- `CDKToolkit` resources and CDK metadata +- S3 auto-delete custom resources, provider Lambda, provider role, and log group +- hosted-zone and ACM validation internals +- CloudFront service-generated resources + +## Exact live inventory + +### Dev + +- Bucket and all bucket subresources: + `seahaven-shoc-frontend-dev` +- Distribution: `E2CWLM1AFB964P` +- OAC: `E30VSIK87N8H64` +- OAC name: + `shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620` +- OAC description: the API empty value, 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` +- Stack: `shoc-frontend-dev` + +### Staging + +- Bucket and all bucket subresources: + `seahaven-shoc-frontend-staging` +- Distribution: `E2JDVEZ6EGD49J` +- OAC: `E1PF5R6QQNBZAI` +- OAC name: + `shocfrontendstagingDistributOrigin1S3OriginAccessControl82B1C17D` +- OAC description: the API empty value, modeled as `""` +- Distribution origin ID: + `shocfrontendstagingDistributionOrigin16E4628FC` +- Function: `us-east-1shocfrontendstagingSpaRewriteE9C0CBDA` +- A import ID: + `Z02602739VQWBWCAGXP4_staging.seahaven.com_A` +- AAAA import ID: + `Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA` +- Deploy role: `githubdeploy-shoc-frontend-new-staging` +- Inline policy import ID: + `githubdeploy-shoc-frontend-new-staging:GithubDeployRoleDefaultPolicyE8F540D1` +- Hosted zone: `Z02602739VQWBWCAGXP4` +- Stack: `shoc-frontend-staging` + +Both live roots inventory the shared certificate: + +`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00` + +The live roots preserve the observed pre-adoption configuration: + +- `adoption_complete = false` +- `Environment`, `ManagedBy=cdk`, and `Project=shoc-frontend` tags +- the S3-only `aws-cdk:auto-delete-objects=true` tag +- the deploy-role-only + `HcpTerraformWorkspace=shoc-frontend-new-` manager tag +- current OAC names, empty descriptions, origin IDs, comments, protocols, cache + policy, certificate, trust subjects, role descriptions, and legacy deploy + policy +- the exact legacy bucket-policy grant for the S3 auto-delete helper +- the mandatory deterministic permissions boundary + `arn:aws:iam::396287094661:policy/shoc-frontend-new--deploy-boundary` + +The approved legacy-owner prerequisite narrows the dev role's exact subject +from `StringLike` to `StringEquals` before import. All roots therefore use +`StringEquals` in both modes. The HCP workspace manager tag remains on the role +in both modes and is never added to S3 or CloudFront resources. + +If a prerequisite changes any live metadata before import, update the matching +root to the newly observed exact value and prove a zero-change import plan. Do +not approve that drift through the controlled-update checker. + +## Prerequisites + +Before any remote plan: + +1. Confirm the deployment workflow for the target environment is paused while + PR validation remains active. +2. Confirm no CloudFormation/CDK update or content deployment can race the + adoption. +3. Confirm the HCP workspace is in the `seahaven-external-dev` project with + auto-apply off. +4. Confirm the org-baseline plan/apply roles and deploy-role permissions + boundary exist with exact workspace trust. +5. Attach the root's deterministic boundary through the approved legacy-owner + procedure. It is mandatory before the import plan. +6. Verify account `396287094661`, region `us-east-1`, all import IDs, current + tags, the dev `StringEquals` trust prerequisite, role description, boundary, + policies, distribution configuration, OAC configuration, function code, DNS + targets, and bucket settings using read-only queries. +7. Confirm the creator stack has retention semantics for all 13 transferred + resources and the S3 auto-delete custom resource. A synthesized template and + reviewed change set are required before mutation. +8. Capture a complete object-version inventory and smoke-test baseline. + +Do not remove or replace the S3 auto-delete custom resource casually. Deleting +it while its handler is active can empty the versioned bucket. Retain it during +ownership transfer and prove the tf-poc path before touching dev. + +## HCP variables + +Configure dynamic AWS credentials in each workspace. Use the exact +org-baseline role ARNs for that workspace: + +- environment variable `TFC_AWS_PROVIDER_AUTH=true` +- environment variable `TFC_AWS_PLAN_ROLE_ARN` +- environment variable `TFC_AWS_APPLY_ROLE_ARN` +- Terraform variable `adoption_complete=false` + +Do not store AWS access keys. Mark sensitive values sensitive even when they are +not credentials. VCS working directories are: + +- `terraform/live/tf-poc` +- `terraform/live/dev` +- `terraform/live/staging` + +tf-poc also requires every variable in `terraform.tfvars.example`. Populate +them only from creator outputs and read-only verification. The check in the +tf-poc root blocks planning while a value is empty or starts with +`REPLACE_WITH_`. + +## Local validation + +From the repository root: + +```powershell +terraform fmt -check -recursive terraform +python scripts/test-terraform-import-plan-check.py +``` + +For every root: + +```powershell +terraform -chdir=terraform/live/tf-poc init -backend=false +terraform -chdir=terraform/live/tf-poc validate +terraform -chdir=terraform/live/dev init -backend=false +terraform -chdir=terraform/live/dev validate +terraform -chdir=terraform/live/staging init -backend=false +terraform -chdir=terraform/live/staging validate +``` + +Initialization without the backend may download providers and write lockfiles, +but it must not contact HCP state or plan against AWS. + +## tf-poc flow + +1. Deploy only the temporary shared stack with `tfPocPhase=zone` and record its + name servers. +2. Apply the separately approved parent-zone NS delegation and verify it + publicly. +3. Use `tfPocPhase=environment` to add the certificate and environment stack. + Do not attempt certificate creation before delegation. +4. Record creator outputs for the distribution, OAC ID/name, function, zone, + certificate, origin ID, role, inline policy, bucket auto-delete helper role, + and DNS import IDs. +5. With the frontend tf-poc HCP role gate still false, set the five org-baseline + tf-poc identifiers from those outputs and deploy the reviewed boundary + update. Confirm the creator role's existing boundary now permits only its + bucket operations and exact distribution invalidation. +6. Deploy the real SPA through GitHub environment `tf-poc`, built with + `VITE_API_URL=https://api.tf-poc.seahaven.com/api`. +7. Pass HTTPS page load, `/login`, extensionless SPA fallback, asset-reference + integrity, expected/forbidden API URL scan, cache headers, invalidation + completion, API CORS/preflight connectivity, and index rollback. +8. Populate HCP variables. Re-run read-only inventory and compare all declared + metadata. +9. Produce the import plan, export JSON, and pass the zero-change import gate. +10. Review and apply only the imports. Require an immediate second no-op plan. +11. Prepare and inspect retention for all transferred resources and the + auto-delete custom resource. Do not detach yet. +12. Set only tf-poc `adoption_complete=true`. Run the controlled-update gate + with the exact addresses below, apply after review, and require a no-op + plan. This removes the bucket policy grant while the CDK auto-delete helper + role still exists. +13. Detach the creator stack with the reviewed retention template. Verify + identifiers, every object version, DNS, HTTPS/API smoke checks, deploy-role + assumption, and a final no-op plan. + +Stop on a missing output, placeholder, nonzero import action, unexpected +address, replacement, inventory mismatch, retention mismatch, or smoke failure. + +## Import plan safety + +Create a saved plan using the approved remote workflow, then export its JSON: + +```powershell +terraform show -json path\to\saved.plan > path\to\plan.json +python scripts/check-terraform-import-plan.py path\to\plan.json --environment tf-poc +``` + +Use `dev` or `staging` for the corresponding root. Import mode requires: + +- exactly the canonical 13 addresses and AWS types +- valid import metadata for every resource +- exact known import IDs for dev and staging +- populated, non-placeholder creator IDs for tf-poc +- zero create, update, delete, or replace actions + +After import apply, export the immediate refresh plan and use the distinct +post-import mode. It requires all 13 resources to be no-op and rejects any +remaining import metadata: + +```powershell +python scripts/check-terraform-import-plan.py path\to\post-import-plan.json ` + --environment tf-poc ` + --post-import-no-op +``` + +The exact controlled-adoption addresses are: + +- `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` + +The bucket-policy update removes only the retained auto-delete helper grant. +The IAM role update changes ownership tags while preserving the exact +`StringEquals` subject, boundary, and HCP manager tag. + +Run controlled mode by repeating the exact option: + +```powershell +python scripts/check-terraform-import-plan.py path\to\plan.json ` + --environment tf-poc ` + --allow-update-address module.environment_owned.aws_s3_bucket.site ` + --allow-update-address module.environment_owned.aws_s3_bucket_policy.site ` + --allow-update-address module.environment_owned.aws_cloudfront_distribution.site ` + --allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite ` + --allow-update-address module.environment_owned.aws_iam_role.github_deploy ` + --allow-update-address module.environment_owned.aws_iam_role_policy.github_deploy +``` + +Controlled mode permits only in-place updates to the addresses explicitly +listed on that invocation. It rejects create, delete, replace, import metadata, +unapproved addresses, and unused allowlist entries. OAC and Route 53 must remain +unchanged. + +If an ownership-tagged resource does not actually update because its final tags +are already present, omit that address from both the plan expectation and the +command. Never leave an unused allowlist entry. + +## Dev and staging flow + +Run one live environment at a time. + +For dev: + +1. Keep releases paused. +2. Complete and verify boundary, retention, and exact-metadata prerequisites. +3. Run and review the zero-change import plan. +4. Apply imports and require a second no-op plan. +5. Prepare and verify the retention template only after tf-poc evidence is + accepted. Do not detach yet. +6. Set `adoption_complete=true`, allow only the exact updating addresses from + the controlled list, apply after review, and require another no-op plan. +7. Detach CloudFormation with the reviewed retention template. +8. Verify IDs, object versions, DNS, TLS, API connectivity, content deployment, + invalidation, rollback, deploy identity, and a final no-op plan. +9. Re-enable dev release only after explicit approval. + +Observe dev for the agreed window. Then repeat the full sequence for staging. +Staging termination protection requires a separately reviewed disable +immediately before retained stack deletion. Do not carry approval from dev into +staging. + +## Evidence + +Retain for each phase: + +- HCP run URL and workspace settings showing auto-apply off +- saved plan JSON and checker output +- state list containing exactly the 13 managed addresses +- read-only inventory before and after each mutation +- synthesized CloudFormation template, reviewed change set, and stack events +- object-version inventory +- exact DNS, certificate, distribution, OAC, function, role, and policy IDs +- deployment, invalidation, smoke, and rollback output +- post-action no-op plan +- phase close-out with completed work, validation, risks, deviations, and + remaining work + +## Rollback + +- Before import apply: discard the run and correct configuration. +- After import but before the controlled ownership update: remove only the + imported Terraform state addresses under a separately reviewed state + operation. CloudFormation remains authoritative. +- After the controlled ownership update but before detachment: do not simply + remove Terraform state or redeploy CloudFormation. Either complete the + reviewed retained detachment or explicitly restore the exact pre-adoption + policy and tags under a separate rollback approval. +- After detachment: Terraform remains authoritative. Restore content from the + versioned bucket and release manifests. Do not recreate the legacy stack over + retained resources. +- Re-establishing CloudFormation ownership requires a reviewed CloudFormation + `IMPORT` change set. An ordinary create/update is not a rollback. + +Any replacement, destroy, cross-environment ID, missing import, broad policy +change, or failed smoke check is a hard stop. diff --git a/terraform/live/dev/.terraform.lock.hcl b/terraform/live/dev/.terraform.lock.hcl new file mode 100644 index 00000000..99cb5b95 --- /dev/null +++ b/terraform/live/dev/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# 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.0" + hashes = [ + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "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..fc937124 --- /dev/null +++ b/terraform/live/dev/main.tf @@ -0,0 +1,96 @@ +variable "adoption_complete" { + type = bool + description = "Enable only after import, no-op verification, and ownership transfer approval." + default = false +} + +locals { + 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 = var.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..ee896ca0 --- /dev/null +++ b/terraform/live/modules/environment-owned/variables.tf @@ -0,0 +1,160 @@ +variable "environment" { + type = string + description = "Environment name." + + validation { + condition = contains(["tf-poc", "dev", "staging"], var.environment) + error_message = "environment must be tf-poc, 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." +} diff --git a/terraform/live/staging/.terraform.lock.hcl b/terraform/live/staging/.terraform.lock.hcl new file mode 100644 index 00000000..99cb5b95 --- /dev/null +++ b/terraform/live/staging/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# 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.0" + hashes = [ + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "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/staging/imports.tf b/terraform/live/staging/imports.tf new file mode 100644 index 00000000..8f5e3e3f --- /dev/null +++ b/terraform/live/staging/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/staging/main.tf b/terraform/live/staging/main.tf new file mode 100644 index 00000000..a80ae2f4 --- /dev/null +++ b/terraform/live/staging/main.tf @@ -0,0 +1,96 @@ +variable "adoption_complete" { + type = bool + description = "Enable only after import, no-op verification, and ownership transfer approval." + default = false +} + +locals { + environment = "staging" + workspace_name = "shoc-frontend-new-staging" + aws_account_id = "396287094661" + aws_region = "us-east-1" + bucket_name = "seahaven-shoc-frontend-staging" + distribution_id = "E2JDVEZ6EGD49J" + oac_id = "E1PF5R6QQNBZAI" + oac_name = "shocfrontendstagingDistributOrigin1S3OriginAccessControl82B1C17D" + origin_id = "shocfrontendstagingDistributionOrigin16E4628FC" + function_name = "us-east-1shocfrontendstagingSpaRewriteE9C0CBDA" + domain_name = "staging.seahaven.com" + hosted_zone_id = "Z02602739VQWBWCAGXP4" + 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-staging" + inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1" + stack_name = "shoc-frontend-staging" + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + permissions_boundary_arn = ( + "arn:aws:iam::396287094661:policy/shoc-frontend-new-staging-deploy-boundary" + ) + bucket_auto_delete_helper_role_arn = ( + "arn:aws:iam::396287094661:role/shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3" + ) + legacy_tags = { + Environment = "staging" + ManagedBy = "cdk" + Project = "shoc-frontend" + } + legacy_bucket_tags = merge(local.legacy_tags, { + "aws-cdk:auto-delete-objects" = "true" + }) + terraform_tags = { + Environment = "staging" + 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 = var.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:environment:staging" + pre_adoption_github_subject_operator = "StringEquals" + post_adoption_github_subject_operator = "StringEquals" + deploy_branch = "staging" + 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/staging/outputs.tf b/terraform/live/staging/outputs.tf new file mode 100644 index 00000000..726ee1e8 --- /dev/null +++ b/terraform/live/staging/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/staging/providers.tf b/terraform/live/staging/providers.tf new file mode 100644 index 00000000..b6c81d54 --- /dev/null +++ b/terraform/live/staging/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = local.aws_region +} diff --git a/terraform/live/staging/versions.tf b/terraform/live/staging/versions.tf new file mode 100644 index 00000000..9e165810 --- /dev/null +++ b/terraform/live/staging/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-staging" + } + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + } +} diff --git a/terraform/live/tf-poc/.terraform.lock.hcl b/terraform/live/tf-poc/.terraform.lock.hcl new file mode 100644 index 00000000..99cb5b95 --- /dev/null +++ b/terraform/live/tf-poc/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# 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.0" + hashes = [ + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "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/tf-poc/imports.tf b/terraform/live/tf-poc/imports.tf new file mode 100644 index 00000000..e23464da --- /dev/null +++ b/terraform/live/tf-poc/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 = var.distribution_id +} + +import { + to = module.environment_owned.aws_cloudfront_origin_access_control.site + id = var.origin_access_control_id +} + +import { + to = module.environment_owned.aws_cloudfront_function.spa_rewrite + id = var.function_name +} + +import { + to = module.environment_owned.aws_route53_record.site_a + id = "${var.hosted_zone_id}_${local.domain_name}_A" +} + +import { + to = module.environment_owned.aws_route53_record.site_aaaa + id = "${var.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}:${var.deploy_inline_policy_name}" +} diff --git a/terraform/live/tf-poc/main.tf b/terraform/live/tf-poc/main.tf new file mode 100644 index 00000000..3e5ac6c1 --- /dev/null +++ b/terraform/live/tf-poc/main.tf @@ -0,0 +1,152 @@ +variable "adoption_complete" { + type = bool + description = "Enable only after import, no-op verification, and ownership transfer approval." + default = false +} + +variable "distribution_id" { + type = string + description = "CloudFront distribution ID emitted by the tf-poc creator." +} + +variable "origin_access_control_id" { + type = string + description = "CloudFront OAC ID emitted by the tf-poc creator." +} + +variable "origin_access_control_name" { + type = string + description = "Exact CloudFront OAC name emitted by the tf-poc creator." +} + +variable "origin_id" { + type = string + description = "Exact distribution origin ID emitted by the tf-poc creator." +} + +variable "function_name" { + type = string + description = "CloudFront Function name emitted by the tf-poc creator." +} + +variable "hosted_zone_id" { + type = string + description = "Dedicated frontend tf-poc hosted zone ID emitted by the creator." +} + +variable "certificate_arn" { + type = string + description = "Dedicated frontend tf-poc ACM certificate ARN emitted by the creator." +} + +variable "deploy_inline_policy_name" { + type = string + description = "Generated inline policy name emitted by the tf-poc creator." +} + +variable "bucket_auto_delete_helper_role_arn" { + type = string + description = "S3 auto-delete helper role ARN emitted by the tf-poc creator." +} + +locals { + environment = "tf-poc" + workspace_name = "shoc-frontend-new-tf-poc" + aws_account_id = "396287094661" + aws_region = "us-east-1" + bucket_name = "seahaven-shoc-frontend-tf-poc" + domain_name = "frontend-tf-poc.seahaven.com" + api_url = "https://api.tf-poc.seahaven.com/api" + github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com" + deploy_role_name = "githubdeploy-shoc-frontend-new-tf-poc" + stack_name = "shoc-frontend-tf-poc" + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + permissions_boundary_arn = ( + "arn:aws:iam::396287094661:policy/shoc-frontend-new-tf-poc-deploy-boundary" + ) + generated_values = { + distribution_id = var.distribution_id + origin_access_control_id = var.origin_access_control_id + origin_access_control_name = var.origin_access_control_name + origin_id = var.origin_id + function_name = var.function_name + hosted_zone_id = var.hosted_zone_id + certificate_arn = var.certificate_arn + deploy_inline_policy_name = var.deploy_inline_policy_name + bucket_auto_delete_helper_role_arn = var.bucket_auto_delete_helper_role_arn + } + legacy_tags = { + Environment = "tf-poc" + ManagedBy = "cdk" + Project = "shoc-frontend" + } + legacy_bucket_tags = merge(local.legacy_tags, { + "aws-cdk:auto-delete-objects" = "true" + }) + terraform_tags = { + Environment = "tf-poc" + ManagedBy = "terraform" + Ownership = "terraform" + Project = "shoc-frontend" + } + manager_tag = { + HcpTerraformWorkspace = local.workspace_name + } +} + +check "creator_outputs_populated" { + assert { + condition = alltrue([ + for value in values(local.generated_values) : + length(trimspace(value)) > 0 && !startswith(value, "REPLACE_WITH_") + ]) + error_message = "Populate every tf-poc generated value from creator outputs before planning." + } +} + +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 = var.hosted_zone_id + certificate_domain = local.domain_name + expected_certificate_arn = var.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 = var.adoption_complete + aws_account_id = local.aws_account_id + aws_region = local.aws_region + bucket_name = local.bucket_name + distribution_id = var.distribution_id + origin_access_control_name = var.origin_access_control_name + origin_access_control_description = "" + origin_id = var.origin_id + function_name = var.function_name + domain_name = local.domain_name + hosted_zone_id = var.hosted_zone_id + certificate_arn = var.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:environment:tf-poc" + pre_adoption_github_subject_operator = "StringEquals" + post_adoption_github_subject_operator = "StringEquals" + deploy_branch = "tf-poc" + deploy_role_name = local.deploy_role_name + deploy_inline_policy_name = var.deploy_inline_policy_name + deploy_permissions_boundary_arn = local.permissions_boundary_arn + cloudformation_stack_name = local.stack_name + bucket_auto_delete_helper_role_arn = var.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/tf-poc/outputs.tf b/terraform/live/tf-poc/outputs.tf new file mode 100644 index 00000000..eb6e590f --- /dev/null +++ b/terraform/live/tf-poc/outputs.tf @@ -0,0 +1,15 @@ +output "bucket_name" { + value = module.environment_owned.bucket_name +} + +output "distribution_id" { + value = module.environment_owned.distribution_id +} + +output "deploy_role_arn" { + value = module.environment_owned.deploy_role_arn +} + +output "api_url" { + value = local.api_url +} diff --git a/terraform/live/tf-poc/providers.tf b/terraform/live/tf-poc/providers.tf new file mode 100644 index 00000000..b6c81d54 --- /dev/null +++ b/terraform/live/tf-poc/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = local.aws_region +} diff --git a/terraform/live/tf-poc/terraform.tfvars.example b/terraform/live/tf-poc/terraform.tfvars.example new file mode 100644 index 00000000..1c0cd706 --- /dev/null +++ b/terraform/live/tf-poc/terraform.tfvars.example @@ -0,0 +1,12 @@ +# Copy to a secure, untracked tfvars file or set equivalent HCP variables. +# Replace every value only with the exact output from the tf-poc creator. +adoption_complete = false +distribution_id = "REPLACE_WITH_TF_POC_DISTRIBUTION_ID" +origin_access_control_id = "REPLACE_WITH_TF_POC_OAC_ID" +origin_access_control_name = "REPLACE_WITH_TF_POC_OAC_NAME" +origin_id = "REPLACE_WITH_TF_POC_ORIGIN_ID" +function_name = "REPLACE_WITH_TF_POC_FUNCTION_NAME" +hosted_zone_id = "REPLACE_WITH_TF_POC_HOSTED_ZONE_ID" +certificate_arn = "REPLACE_WITH_TF_POC_CERTIFICATE_ARN" +deploy_inline_policy_name = "REPLACE_WITH_TF_POC_INLINE_POLICY_NAME" +bucket_auto_delete_helper_role_arn = "REPLACE_WITH_TF_POC_AUTO_DELETE_HELPER_ROLE_ARN" diff --git a/terraform/live/tf-poc/versions.tf b/terraform/live/tf-poc/versions.tf new file mode 100644 index 00000000..68c5434c --- /dev/null +++ b/terraform/live/tf-poc/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-tf-poc" + } + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + } +}