feat(terraform): adopt live deployment roles safely

This commit is contained in:
Adam Moussa 2026-08-30 22:34:33 -04:00
parent 8d26a07fa4
commit 8380548917
No known key found for this signature in database
49 changed files with 4422 additions and 463 deletions

View file

@ -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"

View file

@ -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."

53
.github/workflows/deploy-tf-poc.yml vendored Normal file
View file

@ -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

View file

@ -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

13
.gitignore vendored
View file

@ -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]

View file

@ -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

View file

@ -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:** <https://dev.seahaven.com> (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:** <https://dev.seahaven.com> and <https://staging.seahaven.com>
- **Backend APIs:** matching `api.<environment>.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

View file

@ -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 <admin>
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://<ACCOUNT_ID>/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::<account>: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::<acct>: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.

View file

@ -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");
}

View file

@ -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",

View file

@ -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<string, string> = {
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());
}
}
}

View file

@ -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;
}
}

View file

@ -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",
});
}
}

View file

@ -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"
},

View file

@ -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);
});

View file

@ -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 .",

View file

@ -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': <string>}}"]
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("<unknown>: 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("<unknown>: 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())

View file

@ -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."

100
scripts/deploy-web.test.mjs Normal file
View file

@ -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/);
});

View file

@ -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")})`);

View file

@ -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.");

View file

@ -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,
},
}

View file

@ -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()

367
terraform/README.md Normal file
View file

@ -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-<environment>` 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-<environment>-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.

26
terraform/live/dev/.terraform.lock.hcl generated Normal file
View file

@ -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",
]
}

View file

@ -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}"
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1,3 @@
provider "aws" {
region = local.aws_region
}

View file

@ -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"
}
}
}

View file

@ -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."
}
}
}

View file

@ -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."
}

View file

@ -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"
}

View file

@ -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
}
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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",
]
}

View file

@ -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}"
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1,3 @@
provider "aws" {
region = local.aws_region
}

View file

@ -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"
}
}
}

View file

@ -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",
]
}

View file

@ -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}"
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1,3 @@
provider "aws" {
region = local.aws_region
}

View file

@ -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"

View file

@ -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"
}
}
}