mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 21:33:50 +00:00
Merge pull request #159 from Sea-Haven-Industries/feature/frontend-terraform-adoption
Some checks are pending
Some checks are pending
feat(terraform): adopt dev hosting into HCP Terraform, import-first (SH-300)
This commit is contained in:
commit
763b82a77d
34 changed files with 3855 additions and 207 deletions
8
.github/renovate.json
vendored
8
.github/renovate.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"enabledManagers": ["npm", "custom.regex"],
|
||||
"enabledManagers": ["npm", "custom.regex", "terraform"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"customManagers": [
|
||||
|
|
@ -17,6 +17,12 @@
|
|||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": ["Group non-major Terraform provider updates"],
|
||||
"matchManagers": ["terraform"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "terraform minor and patch"
|
||||
},
|
||||
{
|
||||
"description": ["Do not open major or replacement PRs until approved on the dashboard"],
|
||||
"matchUpdateTypes": ["major", "replacement"],
|
||||
|
|
|
|||
39
.github/workflows/ci.yaml
vendored
39
.github/workflows/ci.yaml
vendored
|
|
@ -23,9 +23,11 @@ jobs:
|
|||
# repository, independent of (and in addition to) the reusable workflow.
|
||||
# `npm run verify` is the single command that chains: format check, lint
|
||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
|
||||
# maintainability gate). If the reusable workflow is later confirmed to run
|
||||
# every gate, this job can be slimmed to `npm run governance`.
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan guard
|
||||
# tests, Terraform isolation gate tests, CDK build/test/synth). If the
|
||||
# reusable workflow is later confirmed to run every gate, this job can be
|
||||
# slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
|
|
@ -53,6 +55,13 @@ jobs:
|
|||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Set up Terraform
|
||||
# Same minor as the HCP workspace (1.16.x) so fmt/validate see what
|
||||
# the remote run will see.
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
|
@ -62,6 +71,30 @@ jobs:
|
|||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
terraform-isolation:
|
||||
# Fails a pull request that changes `terraform/**` together with deployable
|
||||
# application code (scripts/check-terraform-isolation.mjs). A merge that
|
||||
# does both queues an HCP VCS run and a content release at the same time,
|
||||
# and the two race for the workspace lock. The
|
||||
# `terraform-isolation-override` label is the reviewed exception; it is
|
||||
# read when the job runs, so re-run this workflow after labeling.
|
||||
name: Terraform and application changes are isolated
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Check changed files
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
|
||||
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"
|
||||
|
||||
visual-regression:
|
||||
name: Visual regression
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
104
.github/workflows/deploy.yml
vendored
104
.github/workflows/deploy.yml
vendored
|
|
@ -1,22 +1,22 @@
|
|||
name: Deploy
|
||||
name: Deploy dev content
|
||||
|
||||
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
|
||||
# Manual dev content deployment during the Terraform adoption (SH-300).
|
||||
#
|
||||
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
|
||||
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
|
||||
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
|
||||
# CloudFront. Both run as the OIDC deploy role created by the stack.
|
||||
# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are
|
||||
# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are
|
||||
# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the
|
||||
# resources, and move to HCP Terraform (`terraform/README.md`) as adoption
|
||||
# completes. Automatic push-to-`dev` releases return with the Terraform
|
||||
# content-CD change, gated on a repository variable.
|
||||
#
|
||||
# When staging/prod accounts exist, add jobs keyed to their branches and their
|
||||
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
|
||||
# This workflow publishes only content: verify, build, `aws s3 sync`, and a
|
||||
# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC
|
||||
# deploy role. The bucket and distribution are pinned here so a content deploy
|
||||
# keeps working after CloudFormation relinquishes the stack outputs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch: {}
|
||||
|
||||
# OIDC needs id-token: write — it is never in the default token set and cannot
|
||||
# be granted to the reusable workflow unless the caller has it.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
|
@ -27,22 +27,13 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||
with:
|
||||
node-version: "24"
|
||||
region: us-east-1
|
||||
cdk-dir: infra/cdk
|
||||
stack-name: shoc-frontend-dev
|
||||
post-deploy-script: scripts/deploy-web.sh
|
||||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
|
||||
upload-sourcemaps:
|
||||
name: Upload private source maps
|
||||
needs: deploy
|
||||
name: Publish content to dev
|
||||
# Deploy only the exact dev branch ref: workflow_dispatch can be invoked
|
||||
# from arbitrary refs, and the deploy role trusts only refs/heads/dev.
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -52,9 +43,66 @@ jobs:
|
|||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Build exact deployed release
|
||||
run: npm ci && npm run build
|
||||
- name: Upload source maps to Sentry
|
||||
- name: Set up Terraform
|
||||
# Required by `npm run verify` (governance runs terraform fmt/validate).
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- name: Quality gates (full verify before any deploy)
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: origin/dev
|
||||
|
||||
- name: Assume dev deploy role (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds with the dev values committed in .env.production (VITE_API_URL,
|
||||
# Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_URL="https://dev.seahaven.com"
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
echo "Built assets reference the dev API URL."
|
||||
|
||||
# The invalidation has completed, but give edges a short window to
|
||||
# converge before calling the served index.html wrong.
|
||||
remote_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${remote_dir}"' EXIT
|
||||
matched=false
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \
|
||||
&& cmp -s dist/index.html "${remote_dir}/index.html"; then
|
||||
matched=true
|
||||
break
|
||||
fi
|
||||
echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
if [[ "${matched}" != "true" ]]; then
|
||||
echo "::error::Served index.html does not match the build just published." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Served index.html matches the published build."
|
||||
curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login"
|
||||
echo "Extensionless SPA route serves."
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -47,3 +47,15 @@ infra/cdk/bin/*.d.ts
|
|||
infra/cdk/bin/*.js
|
||||
infra/cdk/lib/*.d.ts
|
||||
infra/cdk/lib/*.js
|
||||
|
||||
# terraform (the provider lock file is committed)
|
||||
**/.terraform/*
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ npm run verify
|
|||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. A task is not done until this is green.
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the repository
|
||||
gates: the Terraform import-plan checker tests, the Terraform isolation gate
|
||||
tests, Terraform formatting and validation, and the CDK build, template tests,
|
||||
and synthesis. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
|
|
@ -23,6 +26,11 @@ npm run verify
|
|||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
|
||||
| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes |
|
||||
| Terraform/app change isolation | `ci.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
|
|
@ -36,6 +44,15 @@ npm run verify
|
|||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
- **Terraform gates never touch live state** — `terraform init -backend=false
|
||||
-lockfile=readonly` and `validate` run offline; the plan checker is tested
|
||||
against synthetic plan JSON. Real import and controlled-update plans from HCP
|
||||
are migration evidence reviewed by a human before an approved apply
|
||||
(`terraform/README.md`).
|
||||
- **The isolation gate reads the override when it runs** — the
|
||||
`terraform-isolation-override` label is the only way to merge a mixed
|
||||
Terraform/application PR, the gate logs the override on the run, and the
|
||||
workflow must be re-run after the label is added or removed.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
|
|
@ -44,11 +61,16 @@ npm run verify
|
|||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` so the maintainability ratchets are guaranteed from this
|
||||
repository regardless of the reusable workflow.
|
||||
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
|
||||
ratchets and repository gates are guaranteed from this repository regardless
|
||||
of the reusable workflow. On pull requests the same workflow's
|
||||
`terraform-isolation` job fails when `terraform/**` and application code
|
||||
change together.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`.
|
||||
`package-lock.json` v3; install with `npm ci`. Governance also needs
|
||||
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and
|
||||
`python3` (3.10+) on `PATH`.
|
||||
|
|
|
|||
121
README.md
121
README.md
|
|
@ -29,10 +29,15 @@ graph LR
|
|||
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|cdk deploy + s3 sync + invalidation| S3
|
||||
GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|s3 sync + invalidation| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3
|
||||
```
|
||||
|
||||
Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see
|
||||
[`terraform/README.md`](terraform/README.md) for the phase runbook and the
|
||||
current ownership state.
|
||||
|
||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
|
||||
|
|
@ -57,12 +62,13 @@ No Lambdas, queues, or databases — this stack is static hosting only.
|
|||
|
||||
### Secrets
|
||||
|
||||
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
|
||||
repo secret**:
|
||||
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
|
||||
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
|
||||
repo secret** is:
|
||||
|
||||
| Secret | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
|
||||
| Secret | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
|
||||
|
||||
### Environment variables (build-time, `VITE_*`)
|
||||
|
||||
|
|
@ -78,7 +84,8 @@ build otherwise. See [`.env.example`](.env.example),
|
|||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||
|
||||
CDK context (domain, certificate ARN, hosted zone) lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs
|
||||
`cdk deploy` with no flags.
|
||||
|
||||
## Local Development
|
||||
|
||||
|
|
@ -95,17 +102,22 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
|
|||
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
||||
the deployed dev API).
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
|
||||
`npm run governance` needs `terraform` and `python3` on `PATH` for the
|
||||
Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`,
|
||||
`npm run test:terraform-isolation`) and installs `infra/cdk` for
|
||||
`npm run test:infra`.
|
||||
|
||||
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||
|
|
@ -123,66 +135,81 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
a green CI run and an approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
|
||||
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
|
||||
exists yet).
|
||||
- A PR that changes `terraform/**` may not also change application code (the
|
||||
`terraform-isolation` CI job); ship Terraform in its own PR.
|
||||
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the
|
||||
**Deploy dev content** workflow while the Terraform adoption is in progress)
|
||||
`→ main` (production promotion — no prod environment exists yet).
|
||||
|
||||
## Deployment
|
||||
|
||||
CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
|
||||
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
||||
|
||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||
and PRs to `main`/`dev`, calls
|
||||
and PRs to `main`/`dev`/`staging`, calls
|
||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
that calls `npm run verify` so every gate (including the maintainability
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
|
||||
guaranteed from this repository. Conventions and gates are documented under
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
|
||||
the Terraform gates, and the CDK template tests) is guaranteed from this
|
||||
repository. Conventions and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
|
||||
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
|
||||
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
|
||||
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
- **Terraform isolation** (the `terraform-isolation` job in
|
||||
[`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — fails a PR that
|
||||
mixes `terraform/**` with application code, so a Terraform merge never races
|
||||
a content release for the HCP workspace.
|
||||
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
|
||||
— `workflow_dispatch` on `dev` only while the Terraform adoption is in
|
||||
progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`,
|
||||
and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
||||
CloudFront invalidation. Both run as the OIDC deploy role.
|
||||
CloudFront invalidation, then uploads source maps and checks the served
|
||||
`index.html` matches the build. Push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
- **Staging content**
|
||||
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
|
||||
— on push to `staging`, unchanged.
|
||||
- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence
|
||||
and the HCP Terraform workspace `shoc-frontend-new-dev`
|
||||
([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy`
|
||||
([`infra/cdk/README.md`](infra/cdk/README.md)).
|
||||
|
||||
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
|
||||
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
|
||||
[`infra/cdk/README.md`](infra/cdk/README.md).
|
||||
|
||||
Manual deploy (emergency/reference only — needs credentials for the
|
||||
external-dev AWS account; the normal path is push to `dev`):
|
||||
Manual content deploy (emergency/reference only — needs credentials for the
|
||||
external-dev AWS account):
|
||||
|
||||
```bash
|
||||
(cd infra/cdk && npx cdk deploy)
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \
|
||||
AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
|
||||
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
|
||||
directly and API calls succeed.
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev
|
||||
content** run in the Actions tab; confirm a deep link (e.g. a work-orders
|
||||
route) loads directly and API calls succeed.
|
||||
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
|
||||
are no CloudWatch application logs — the stack is static hosting; runtime
|
||||
errors surface in the browser and on the backend API's side.
|
||||
- **Common failure modes:**
|
||||
- _Stale content after deploy_ — the CloudFront invalidation step failed or
|
||||
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev`
|
||||
on this repo; deploys from other branches/repos are rejected by design.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
|
||||
on this repo; dispatching the workflow from another branch is rejected by
|
||||
design.
|
||||
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
|
||||
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||
not proxy `/api`.
|
||||
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
|
||||
deploys (matches the org's push-time-CD model; gating deploy on CI is known
|
||||
follow-up work).
|
||||
- **Dev has no push-triggered deploy during the adoption.** Merging to `dev`
|
||||
runs CI only; publish through the **Deploy dev content** workflow. Merging a
|
||||
`terraform/**` change also queues an HCP Terraform run that a human confirms
|
||||
or discards (see the operational rules in `terraform/README.md`).
|
||||
|
||||
## Documentation
|
||||
|
||||
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
|
||||
- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
|
||||
design system and UI docs under [`docs/`](docs/)
|
||||
|
|
|
|||
|
|
@ -1,34 +1,44 @@
|
|||
# Infrastructure & CI/CD — Sea Haven SHOC frontend
|
||||
|
||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
|
||||
deployed through the org's **reusable** GitHub Actions workflow.
|
||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo.
|
||||
Infrastructure deploys are administrator-run; GitHub Actions publishes content
|
||||
only.
|
||||
|
||||
> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack
|
||||
> `shoc-frontend-dev` is in the retain/transfer sequence described under
|
||||
> [Terraform adoption mode](#terraform-adoption-mode) and in
|
||||
> [`terraform/README.md`](../terraform/README.md). Do not run a plain
|
||||
> `cdk deploy` against dev while that sequence is in progress. Staging is
|
||||
> unaffected and stays on this CDK path (SH-287 tracks its cutover).
|
||||
|
||||
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
|
||||
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
|
||||
- **API:** the SPA calls the backend **directly** over HTTPS at
|
||||
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
|
||||
allows CORS). CloudFront serves static content only — no `/api` proxy.
|
||||
- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy`
|
||||
picks them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
||||
- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks
|
||||
them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
||||
per-environment (see the note under "Adding staging / prod").
|
||||
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
|
||||
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
|
||||
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
|
||||
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
|
||||
- **Content workflows:** `.github/workflows/deploy.yml` (dev,
|
||||
`workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to
|
||||
`staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy
|
||||
role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was
|
||||
retired with the adoption PR.
|
||||
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
|
||||
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
|
||||
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
|
||||
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
|
||||
|
||||
```
|
||||
infra/cdk/
|
||||
bin/app.ts entry point (reads -c context)
|
||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
bin/app.ts entry point (reads -c context)
|
||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition)
|
||||
test/frontend-stack.test.mjs template assertions for both modes
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
.github/workflows/
|
||||
ci.yaml quality gates (lint / build / test / e2e)
|
||||
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
ci.yaml quality gates (lint / build / test / governance / terraform isolation)
|
||||
deploy.yml dev content publish (workflow_dispatch on dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
```
|
||||
|
||||
## What the stack creates
|
||||
|
|
@ -40,12 +50,67 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
|||
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
|
||||
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
|
||||
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
|
||||
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
|
||||
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
|
||||
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
|
||||
singleton account resource — the stack only _imports_ it (created in step 2),
|
||||
so `cdk destroy` can't delete a resource shared by other roles.
|
||||
The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants:
|
||||
`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`,
|
||||
read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It
|
||||
is left byte-identical on purpose so the Terraform import is a no-op; the
|
||||
Terraform content-CD change narrows it. The OIDC **provider** is a singleton
|
||||
account resource — the stack only _imports_ it (created in step 2), so
|
||||
`cdk destroy` can't delete a resource shared by other roles.
|
||||
|
||||
## Terraform adoption mode
|
||||
|
||||
`-c retainForTerraformAdoption=true` switches the stack into the safety mode
|
||||
used only while HCP Terraform adopts the dev resources. It is off by default
|
||||
and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts
|
||||
both). In adoption mode the stack:
|
||||
|
||||
- pins the origin ID CloudFormation generated for the live distribution
|
||||
(`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is
|
||||
metadata-only; environments without a verified value fail synthesis
|
||||
- attaches the `seahaven-org-baseline` permissions boundary
|
||||
`shoc-frontend-new-dev-deploy-boundary` and the
|
||||
`HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role
|
||||
- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the
|
||||
same exact value
|
||||
- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13
|
||||
transferred resources (bucket, bucket policy, distribution, OAC, SPA
|
||||
function, A and AAAA records, deploy role, inline policy) and to
|
||||
`SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider
|
||||
Lambda and role stay unretained
|
||||
- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no
|
||||
default) and conditions those same resources and every output on it
|
||||
- emits `TerraformImport*` outputs carrying the exact import IDs
|
||||
|
||||
`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must
|
||||
state the ownership phase:
|
||||
|
||||
```bash
|
||||
cd infra/cdk && npm ci
|
||||
|
||||
# Phase 1, before the Terraform import: keep the resources in the stack and
|
||||
# install Retain on them. Update-only change set.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=true
|
||||
|
||||
# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish
|
||||
# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=false
|
||||
```
|
||||
|
||||
Both deploys must use the same reviewed SHA. Review the change set before
|
||||
confirming: Phase 1 must show no create, delete, or replace. After the
|
||||
`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used
|
||||
again. If the `true` deploy rolls back, inspect the stack resources and the
|
||||
live bucket before retrying; retained resources can outlive a failed update and
|
||||
must not be cleaned up automatically. Never delete the auto-delete custom
|
||||
resource while its handler can still empty the versioned bucket.
|
||||
|
||||
Local checks (`npm run test:infra` from the repo root) build the app, run the
|
||||
template assertions, and synthesize both modes.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -102,48 +167,31 @@ cd infra/cdk
|
|||
npx cdk deploy
|
||||
```
|
||||
|
||||
Note the `DeployRoleArn` output. Then push the first content (or just push to
|
||||
`dev` and let CI do everything from here on):
|
||||
Note the `DeployRoleArn` output. Then publish the first content manually:
|
||||
|
||||
```bash
|
||||
# from repo root, optional manual first content publish:
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
### 6. Set the one GitHub secret
|
||||
### 6. Content deploys
|
||||
|
||||
`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable):
|
||||
|
||||
```bash
|
||||
REPO=Sea-Haven-Industries/shoc-frontend-new
|
||||
gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \
|
||||
--body "arn:aws:iam::<acct>:role/githubdeploy-shoc-frontend-new-dev"
|
||||
```
|
||||
|
||||
(Or **Settings → Secrets and variables → Actions → Secrets**.)
|
||||
|
||||
### 7. From now on: push to `dev`
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs
|
||||
`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open
|
||||
the `SiteUrl` output.
|
||||
|
||||
> First-run verification: this first push is what actually exercises the role's
|
||||
> permissions and the OIDC trust through the reusable workflow (the local
|
||||
> bootstrap used admin creds and tested none of that). Watch for
|
||||
> credential/OIDC errors and a green post-deploy step.
|
||||
The deploy role ARN is deterministic and pinned in
|
||||
`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the
|
||||
Terraform adoption, dev content deploys run only through **Actions → Deploy dev
|
||||
content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes
|
||||
`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the
|
||||
pinned bucket and distribution, uploads source maps, and verifies the served
|
||||
`index.html` matches the build. Automatic push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
|
||||
---
|
||||
|
||||
## Staging environment (same account, exact OIDC subject)
|
||||
|
||||
Staging lives in the same AWS account (396287094661) but deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
|
||||
reusable `cd-cdk.yaml`:
|
||||
Staging lives in the same AWS account (396287094661) and deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to
|
||||
`staging`:
|
||||
|
||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||
|
|
@ -212,10 +260,11 @@ for prod.
|
|||
|
||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
||||
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
|
||||
differs only in that its CD workflow also runs `npm run verify` itself
|
||||
before deploying); a red-CI commit still deploys on `dev` (matches the
|
||||
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
|
||||
of enabling CICD.
|
||||
Never run it against dev during or after the Terraform adoption: the
|
||||
adoption-mode stack retains the transferred resources, and after Phase 2
|
||||
Terraform owns them.
|
||||
- **CI and staging CD both fire on push to `staging`** in parallel; the
|
||||
staging CD workflow runs `npm run verify` itself before deploying. Dev has
|
||||
no push-triggered deploy during the adoption.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
|||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
|
||||
// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required
|
||||
// ManageSiteInfrastructure parameter and Retain policies on the transferred
|
||||
// resources. Off by default so ordinary synthesis is unchanged.
|
||||
const retainForTerraformAdoption =
|
||||
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true";
|
||||
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
|
|
@ -40,6 +46,7 @@ const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
|||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
|
||||
import {
|
||||
Aspects,
|
||||
CfnCondition,
|
||||
CfnOutput,
|
||||
CfnParameter,
|
||||
CfnResource,
|
||||
Duration,
|
||||
Fn,
|
||||
RemovalPolicy,
|
||||
Stack,
|
||||
StackProps,
|
||||
Tags,
|
||||
} from "aws-cdk-lib";
|
||||
import { Construct } from "constructs";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
||||
|
|
@ -7,6 +19,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
|||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
||||
import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption";
|
||||
|
||||
export interface FrontendStackProps extends StackProps {
|
||||
/** Environment label, e.g. "dev". Used in names/tags. */
|
||||
|
|
@ -42,6 +55,11 @@ export interface FrontendStackProps extends StackProps {
|
|||
readonly hostedZoneId: string;
|
||||
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
||||
readonly hostedZoneName: string;
|
||||
/**
|
||||
* Opt-in safety mode used only during the reviewed Terraform adoption.
|
||||
* Normal dev/staging synthesis remains unchanged when false.
|
||||
*/
|
||||
readonly retainForTerraformAdoption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,11 +68,10 @@ export interface FrontendStackProps extends StackProps {
|
|||
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
||||
* - a GitHub Actions OIDC deploy role
|
||||
*
|
||||
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
|
||||
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
|
||||
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
|
||||
* stack only owns the infrastructure, and the deploy role carries the
|
||||
* permissions those post-deploy steps need.
|
||||
* Content (the built `dist/`) is NOT uploaded here. Manual environment
|
||||
* workflows run `scripts/deploy-web.sh` independently of infrastructure
|
||||
* changes, so this stack only owns infrastructure and the deploy role carries
|
||||
* content-publication permissions.
|
||||
*/
|
||||
export class FrontendStack extends Stack {
|
||||
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
||||
|
|
@ -69,8 +86,23 @@ export class FrontendStack extends Stack {
|
|||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption = false,
|
||||
} = props;
|
||||
|
||||
const manageSiteInfrastructureCondition = retainForTerraformAdoption
|
||||
? new CfnCondition(this, "ManageSiteInfrastructureCondition", {
|
||||
expression: Fn.conditionEquals(
|
||||
new CfnParameter(this, "ManageSiteInfrastructure", {
|
||||
type: "String",
|
||||
allowedValues: ["true", "false"],
|
||||
description:
|
||||
"Set true only before Terraform adoption. After ownership transfer, always reuse false.",
|
||||
}).valueAsString,
|
||||
"true",
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const hasCustomDomain = domainNames.length > 0;
|
||||
if (hasCustomDomain && !certificateArn) {
|
||||
throw new Error(
|
||||
|
|
@ -114,6 +146,16 @@ export class FrontendStack extends Stack {
|
|||
// --- CloudFront: serves the static SPA from S3 -------------------------
|
||||
// The SPA calls the backend directly at its absolute HTTPS URL
|
||||
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
||||
// Adoption mode pins the origin ID CloudFormation generated for the live
|
||||
// distribution so the retention deploy is a metadata-only update. Only
|
||||
// environments with a read-back-verified value may enter adoption mode.
|
||||
const adoptionOriginIds: Record<string, string> = {
|
||||
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
};
|
||||
const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined;
|
||||
if (retainForTerraformAdoption && !originId) {
|
||||
throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`);
|
||||
}
|
||||
const distribution = new cloudfront.Distribution(this, "Distribution", {
|
||||
comment: `SeaHaven SHOC frontend (${envName})`,
|
||||
defaultRootObject: "index.html",
|
||||
|
|
@ -130,7 +172,9 @@ export class FrontendStack extends Stack {
|
|||
: undefined,
|
||||
defaultBehavior: {
|
||||
// withOriginAccessControl wires up OAC + the bucket policy automatically.
|
||||
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
||||
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, {
|
||||
originId,
|
||||
}),
|
||||
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
||||
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
||||
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
||||
|
|
@ -158,9 +202,9 @@ export class FrontendStack extends Stack {
|
|||
// Trust conditions for the OIDC principal. With a GitHub environment
|
||||
// (staging): exact StringEquals match on both aud and the environment
|
||||
// subject — the staging workflow declares `environment: staging`, so only
|
||||
// runs in that environment can assume the role. Without one (dev): keep
|
||||
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
|
||||
// deploy branch (reusable-workflow runs still carry the caller-based sub).
|
||||
// runs in that environment can assume the role. Normal dev synthesis keeps
|
||||
// the current branch-ref StringLike trust. The adoption prerequisite
|
||||
// narrows that already-exact value to StringEquals before Terraform import.
|
||||
const oidcConditions = githubEnvironment
|
||||
? {
|
||||
StringEquals: {
|
||||
|
|
@ -168,30 +212,48 @@ export class FrontendStack extends Stack {
|
|||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
: retainForTerraformAdoption
|
||||
? {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
|
||||
const deployPermissionsBoundary = retainForTerraformAdoption
|
||||
? iam.ManagedPolicy.fromManagedPolicyArn(
|
||||
this,
|
||||
"GithubDeployPermissionsBoundary",
|
||||
`arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||
maxSessionDuration: Duration.hours(1),
|
||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||
permissionsBoundary: deployPermissionsBoundary,
|
||||
});
|
||||
if (retainForTerraformAdoption) {
|
||||
Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`);
|
||||
}
|
||||
|
||||
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
||||
// intentionally narrower: its recurring promotion workflow only publishes
|
||||
// application assets to this stack's bucket/distribution. Infrastructure
|
||||
// changes remain an administrator-run CDK operation, so the staging OIDC
|
||||
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
||||
// Preserve dev's legacy CDK capability until the reviewed adoption update
|
||||
// replaces this inline policy. Staging is intentionally narrower: its
|
||||
// content role only publishes application assets to this stack's
|
||||
// bucket/distribution. Infrastructure changes remain administrator-run.
|
||||
if (!githubEnvironment) {
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
|
|
@ -224,6 +286,8 @@ export class FrontendStack extends Stack {
|
|||
// --- DNS: point the custom domain at CloudFront ------------------------
|
||||
// Only when a hosted zone is supplied (it must be in THIS account). Creates
|
||||
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
|
||||
let aliasA: route53.ARecord | undefined;
|
||||
let aliasAaaa: route53.AaaaRecord | undefined;
|
||||
if (hostedZoneId && hasCustomDomain) {
|
||||
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
||||
hostedZoneId,
|
||||
|
|
@ -233,31 +297,169 @@ export class FrontendStack extends Stack {
|
|||
// apex record when the domain equals the zone name.
|
||||
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
||||
|
||||
new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
|
||||
aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", {
|
||||
zone,
|
||||
recordName,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
const gateOutput = (output: CfnOutput): CfnOutput => {
|
||||
if (manageSiteInfrastructureCondition) {
|
||||
output.condition = manageSiteInfrastructureCondition;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// --- Outputs -----------------------------------------------------------
|
||||
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
});
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
});
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
});
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
});
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
|
||||
});
|
||||
gateOutput(
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "Pinned GitHub OIDC content-deployment role",
|
||||
}),
|
||||
);
|
||||
|
||||
if (retainForTerraformAdoption) {
|
||||
const originAccessControl = distribution.node
|
||||
.findAll()
|
||||
.find(
|
||||
(node): node is cloudfront.CfnOriginAccessControl =>
|
||||
node instanceof cloudfront.CfnOriginAccessControl,
|
||||
);
|
||||
if (!originAccessControl || !aliasA || !aliasAaaa) {
|
||||
throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records.");
|
||||
}
|
||||
const originAccessControlConfig =
|
||||
originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty;
|
||||
|
||||
const rolePolicy = deployRole.node
|
||||
.findAll()
|
||||
.find((node): node is iam.Policy => node instanceof iam.Policy);
|
||||
const autoDeleteProviderRole = this.node
|
||||
.findAll()
|
||||
.find(
|
||||
(node): node is CfnResource =>
|
||||
node instanceof CfnResource &&
|
||||
node.cfnResourceType === "AWS::IAM::Role" &&
|
||||
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"),
|
||||
);
|
||||
if (!rolePolicy || !autoDeleteProviderRole) {
|
||||
throw new Error("Terraform adoption outputs require deploy and auto-delete roles.");
|
||||
}
|
||||
|
||||
const recordName = domainNames[0];
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformWorkspaceTag", {
|
||||
value: `shoc-frontend-new-${envName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployBoundaryArn", {
|
||||
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
}),
|
||||
);
|
||||
gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }));
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportBucketPolicy", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDistribution", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportOriginAccessControl", {
|
||||
value: originAccessControl.attrId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlName", {
|
||||
value: originAccessControlConfig.name,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
|
||||
value: "EMPTY_STRING",
|
||||
description: "Use an empty Terraform string because the generated OAC has no description",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDistributionOriginId", {
|
||||
value: originId!,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
|
||||
value: spaRewrite.functionName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasA", {
|
||||
value: `${hostedZoneId}_${recordName}_A`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasAAAA", {
|
||||
value: `${hostedZoneId}_${recordName}_AAAA`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRole", {
|
||||
value: deployRole.roleName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
|
||||
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
|
||||
value: rolePolicy.policyName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
|
||||
value: autoDeleteProviderRole.getAtt("Arn").toString(),
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
|
||||
value: "SiteBucket/AutoDeleteObjectsCustomResource",
|
||||
description:
|
||||
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
|
||||
}),
|
||||
);
|
||||
|
||||
Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
infra/cdk/lib/retain-for-terraform-adoption.ts
Normal file
63
infra/cdk/lib/retain-for-terraform-adoption.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
|
||||
import { IConstruct } from "constructs";
|
||||
|
||||
const TRANSFERRED_RESOURCE_TYPES = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
]);
|
||||
|
||||
function isTransferredResource(resource: CfnResource): boolean {
|
||||
if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
resource.cfnResourceType === "Custom::S3AutoDeleteObjects" &&
|
||||
resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(resource.cfnResourceType === "AWS::IAM::Role" ||
|
||||
resource.cfnResourceType === "AWS::IAM::Policy") &&
|
||||
resource.node.path.includes("/GithubDeployRole")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains only the resources in the approved Terraform transfer set.
|
||||
*
|
||||
* The bucket auto-delete custom resource is intentionally retained while the
|
||||
* generated provider Lambda, role, log group, and CDK metadata remain excluded.
|
||||
* When a management condition is supplied, those same resources share it so
|
||||
* CloudFormation can later relinquish them without deleting them.
|
||||
*/
|
||||
export class RetainForTerraformAdoption implements IAspect {
|
||||
constructor(private readonly manageCondition?: CfnCondition) {}
|
||||
|
||||
public visit(node: IConstruct): void {
|
||||
if (!(node instanceof CfnResource) || !isTransferredResource(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the L2 bucket's configured DESTROY policy visible to its
|
||||
// AutoDeleteObjects validator while overriding the emitted CloudFormation
|
||||
// resource. This preserves the custom resource and retains both together.
|
||||
if (node.cfnResourceType === "AWS::S3::Bucket") {
|
||||
node.addOverride("DeletionPolicy", "Retain");
|
||||
node.addOverride("UpdateReplacePolicy", "Retain");
|
||||
} else {
|
||||
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
|
||||
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
|
||||
}
|
||||
|
||||
if (this.manageCondition) {
|
||||
node.cfnOptions.condition = this.manageCondition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,9 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "npm run build && node --test test/*.test.mjs",
|
||||
"synth": "cdk synth",
|
||||
"synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true",
|
||||
"diff": "cdk diff",
|
||||
"deploy": "cdk deploy"
|
||||
},
|
||||
|
|
|
|||
225
infra/cdk/test/frontend-stack.test.mjs
Normal file
225
infra/cdk/test/frontend-stack.test.mjs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { test } from "node:test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { App } = require("aws-cdk-lib");
|
||||
const { Template } = require("aws-cdk-lib/assertions");
|
||||
const { FrontendStack } = require("../lib/frontend-stack.js");
|
||||
|
||||
const account = "396287094661";
|
||||
const region = "us-east-1";
|
||||
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
|
||||
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
|
||||
const CONDITION = "ManageSiteInfrastructureCondition";
|
||||
|
||||
const RETAINED_TYPES = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
"Custom::S3AutoDeleteObjects",
|
||||
]);
|
||||
|
||||
function devTemplate(retainForTerraformAdoption, overrides = {}) {
|
||||
const app = new App();
|
||||
const stack = new FrontendStack(app, "shoc-frontend-dev", {
|
||||
envName: "dev",
|
||||
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||
deployBranch: "dev",
|
||||
domainNames: ["dev.seahaven.com"],
|
||||
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
|
||||
hostedZoneId: "Z07671212N75U4YLPWZR8",
|
||||
hostedZoneName: "dev.seahaven.com",
|
||||
retainForTerraformAdoption,
|
||||
env: { account, region },
|
||||
...overrides,
|
||||
});
|
||||
return Template.fromStack(stack).toJSON();
|
||||
}
|
||||
|
||||
function entriesByType(template, type) {
|
||||
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
|
||||
}
|
||||
|
||||
function isTransferred(logicalId, resource) {
|
||||
const isDeployRoleResource =
|
||||
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
|
||||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
|
||||
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
|
||||
}
|
||||
|
||||
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
|
||||
const template = devTemplate(true);
|
||||
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
|
||||
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
|
||||
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
|
||||
isTransferred(id, resource),
|
||||
);
|
||||
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
|
||||
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
|
||||
// addresses) plus the retained custom resource.
|
||||
assert.equal(transferred.length, 10);
|
||||
});
|
||||
|
||||
test("adoption mode preserves the live dev identifiers", () => {
|
||||
const template = devTemplate(true);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
|
||||
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
|
||||
assert.ok(
|
||||
bucket.Properties.Tags.some(
|
||||
(tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true",
|
||||
),
|
||||
);
|
||||
|
||||
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
|
||||
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
|
||||
assert.equal(
|
||||
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
|
||||
DEV_ORIGIN_ID,
|
||||
);
|
||||
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(
|
||||
deployRole.Properties.PermissionsBoundary,
|
||||
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
|
||||
);
|
||||
assert.ok(
|
||||
deployRole.Properties.Tags.some(
|
||||
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
|
||||
),
|
||||
);
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringEquals["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(condition.StringLike, undefined);
|
||||
|
||||
// Legacy inline policy stays byte-compatible with the live document.
|
||||
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
|
||||
id.startsWith("GithubDeployRole"),
|
||||
);
|
||||
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
|
||||
assert.deepEqual(sids, [
|
||||
"AssumeCdkBootstrapRoles",
|
||||
"DescribeStack",
|
||||
undefined,
|
||||
"InvalidateDistribution",
|
||||
]);
|
||||
|
||||
for (const output of [
|
||||
"TerraformWorkspaceTag",
|
||||
"TerraformDeployBoundaryArn",
|
||||
"TerraformImportBucket",
|
||||
"TerraformImportBucketPolicy",
|
||||
"TerraformImportDistribution",
|
||||
"TerraformImportOriginAccessControl",
|
||||
"TerraformOriginAccessControlName",
|
||||
"TerraformOriginAccessControlDescription",
|
||||
"TerraformDistributionOriginId",
|
||||
"TerraformImportSpaRewriteFunction",
|
||||
"TerraformImportAliasA",
|
||||
"TerraformImportAliasAAAA",
|
||||
"TerraformImportDeployRole",
|
||||
"TerraformImportDeployRolePolicy",
|
||||
"TerraformDeployInlinePolicyName",
|
||||
"TerraformBucketAutoDeleteHelperRoleArn",
|
||||
"TerraformRetainedAutoDeleteCustomResource",
|
||||
]) {
|
||||
assert.ok(template.Outputs[output], `missing output ${output}`);
|
||||
}
|
||||
assert.equal(
|
||||
template.Outputs.TerraformImportAliasA.Value,
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
|
||||
);
|
||||
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
|
||||
});
|
||||
|
||||
test("adoption mode retains exactly the transferred resources", () => {
|
||||
const template = devTemplate(true);
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
|
||||
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
} else {
|
||||
assert.notEqual(resource.DeletionPolicy, "Retain", logicalId);
|
||||
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
}
|
||||
}
|
||||
// The auto-delete provider Lambda, role, and log group stay unretained.
|
||||
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
|
||||
for (const [, resource] of entriesByType(template, type)) {
|
||||
assert.notEqual(resource.DeletionPolicy, "Retain");
|
||||
}
|
||||
}
|
||||
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
|
||||
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
|
||||
);
|
||||
assert.equal(providerRoles.length, 1);
|
||||
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
|
||||
});
|
||||
|
||||
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
|
||||
const template = devTemplate(true);
|
||||
const parameter = template.Parameters.ManageSiteInfrastructure;
|
||||
assert.ok(parameter);
|
||||
assert.equal(parameter.Type, "String");
|
||||
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
|
||||
assert.equal(parameter.Default, undefined);
|
||||
assert.ok(template.Conditions[CONDITION]);
|
||||
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.Condition, CONDITION, logicalId);
|
||||
} else {
|
||||
assert.notEqual(resource.Condition, CONDITION, logicalId);
|
||||
}
|
||||
}
|
||||
for (const [outputName, output] of Object.entries(template.Outputs)) {
|
||||
assert.equal(output.Condition, CONDITION, outputName);
|
||||
}
|
||||
});
|
||||
|
||||
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
|
||||
const template = devTemplate(false);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.DeletionPolicy, "Delete");
|
||||
assert.equal(bucket.UpdateReplacePolicy, "Delete");
|
||||
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
|
||||
assert.notEqual(customResource.DeletionPolicy, "Retain");
|
||||
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
|
||||
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringLike["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
|
||||
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
|
||||
assert.equal(template.Conditions?.[CONDITION], undefined);
|
||||
for (const resource of Object.values(template.Resources)) {
|
||||
assert.equal(resource.Condition, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("adoption mode refuses an environment without a verified origin ID", () => {
|
||||
assert.throws(
|
||||
() => devTemplate(true, { envName: "staging" }),
|
||||
/No verified Terraform adoption origin ID exists for staging/,
|
||||
);
|
||||
});
|
||||
|
|
@ -12,6 +12,10 @@
|
|||
"test:e2e": "playwright test",
|
||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
|
||||
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
|
||||
"test:terraform": "node scripts/terraform-validate.mjs",
|
||||
"test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
|
|
|
|||
628
scripts/check-terraform-import-plan.py
Normal file
628
scripts/check-terraform-import-plan.py
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject plans that violate the frontend Terraform adoption boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
|
||||
DEPLOY_POLICY_ADDRESS = (
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
)
|
||||
DISTRIBUTION_ADDRESS = (
|
||||
"module.environment_owned.aws_cloudfront_distribution.site"
|
||||
)
|
||||
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
|
||||
BUCKET_POLICY_ADDRESS,
|
||||
DEPLOY_POLICY_ADDRESS,
|
||||
}
|
||||
OWNERSHIP_TAGS = {
|
||||
"Environment": None,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("plan_json", type=Path)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
choices=sorted(REQUIRED_RESOURCES),
|
||||
help="Exact environment ownership boundary expected in the plan.",
|
||||
)
|
||||
modes = parser.add_mutually_exclusive_group()
|
||||
modes.add_argument(
|
||||
"--post-import-no-op",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Require all managed resources to be no-op after import and forbid "
|
||||
"import metadata."
|
||||
),
|
||||
)
|
||||
modes.add_argument(
|
||||
"--allow-update-address",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="ADDRESS",
|
||||
help=(
|
||||
"Enter controlled-update mode and allow one exact reviewed address. "
|
||||
"Repeat for every expected update."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_plan(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("plan JSON root must be an object")
|
||||
if not isinstance(value.get("resource_changes"), list):
|
||||
raise ValueError("plan JSON must contain a resource_changes array")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_import_metadata(
|
||||
*,
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
importing = change.get("importing")
|
||||
if not isinstance(importing, dict) or set(importing) != {"id"}:
|
||||
return [f"{address}: import metadata must be exactly {{'id': <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_pre_adoption_bucket_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_pre_adoption_deploy_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
statements: list[dict[str, Any]] = []
|
||||
if environment == "dev":
|
||||
statements.append(
|
||||
{
|
||||
"Sid": "AssumeCdkBootstrapRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
|
||||
}
|
||||
)
|
||||
statements.extend(
|
||||
[
|
||||
{
|
||||
"Sid": "DescribeStack",
|
||||
"Effect": "Allow",
|
||||
"Action": "cloudformation:DescribeStacks",
|
||||
"Resource": (
|
||||
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
|
||||
f"{config['cloudformation_stack_name']}/*"
|
||||
),
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
]
|
||||
)
|
||||
return _canonical({"Version": "2012-10-17", "Statement": statements})
|
||||
|
||||
|
||||
def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadDeploymentBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions",
|
||||
],
|
||||
"Resource": bucket_arn,
|
||||
},
|
||||
{
|
||||
"Sid": "PublishAndRollbackSiteObjects",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjectVersion",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
],
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_tag_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
invalid = {
|
||||
path
|
||||
for path in changed
|
||||
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
|
||||
}
|
||||
violations = [
|
||||
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
|
||||
for path in sorted(invalid)
|
||||
]
|
||||
expected = {**OWNERSHIP_TAGS, "Environment": environment}
|
||||
if address == ROLE_ADDRESS:
|
||||
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
|
||||
"workspace_name"
|
||||
]
|
||||
if address == BUCKET_ADDRESS:
|
||||
expected["aws-cdk:auto-delete-objects"] = None
|
||||
expected_after = {
|
||||
key: value for key, value in expected.items() if value is not None
|
||||
}
|
||||
for tag_attribute in ("tags", "tags_all"):
|
||||
if after.get(tag_attribute) != expected_after:
|
||||
violations.append(
|
||||
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||
)
|
||||
for path in sorted(changed - invalid):
|
||||
key = path[1]
|
||||
if key not in expected:
|
||||
violations.append(f"{address}: tag {key!r} is not an ownership tag")
|
||||
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
|
||||
violations.append(
|
||||
f"{address}: legacy auto-delete ownership tag was not removed"
|
||||
)
|
||||
elif after.get(path[0], {}).get(key) != expected[key]:
|
||||
violations.append(
|
||||
f"{address}: tag {key!r} does not have its expected adopted value"
|
||||
)
|
||||
if not changed:
|
||||
violations.append(f"{address}: update has no changed leaf values")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_policy_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
if changed != {("policy",)}:
|
||||
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
|
||||
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
|
||||
after_policy, after_violations = _parse_policy(
|
||||
after.get("policy"), address, "after"
|
||||
)
|
||||
violations.extend(after_violations)
|
||||
if before_policy == after_policy:
|
||||
violations.append(f"{address}: policy semantics did not change")
|
||||
if distribution_id is None:
|
||||
violations.append(
|
||||
f"{address}: cannot verify policy without the pinned distribution ID"
|
||||
)
|
||||
return violations
|
||||
expected_before = (
|
||||
_expected_pre_adoption_bucket_policy(environment, distribution_id)
|
||||
if address == BUCKET_POLICY_ADDRESS
|
||||
else _expected_pre_adoption_deploy_policy(environment, distribution_id)
|
||||
)
|
||||
expected_after = (
|
||||
_expected_bucket_policy(environment, distribution_id)
|
||||
if address == BUCKET_POLICY_ADDRESS
|
||||
else _expected_deploy_policy(environment, distribution_id)
|
||||
)
|
||||
if before_policy is not None and before_policy != expected_before:
|
||||
violations.append(f"{address}: pre-adoption policy semantics are not exact")
|
||||
if after_policy is not None and after_policy != expected_after:
|
||||
violations.append(f"{address}: post-adoption policy semantics are not exact")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_controlled_update(
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
replace_paths = change.get("replace_paths", [])
|
||||
if replace_paths not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
if _contains_unknown(change.get("after_unknown", {})):
|
||||
violations.append(f"{address}: controlled update contains unknown values")
|
||||
before = change.get("before")
|
||||
after = change.get("after")
|
||||
if not isinstance(before, dict) or not isinstance(after, dict):
|
||||
return [*violations, f"{address}: controlled update requires before/after objects"]
|
||||
if address in TAG_UPDATE_ADDRESSES:
|
||||
violations.extend(_validate_tag_update(address, before, after, environment))
|
||||
elif address in {BUCKET_POLICY_ADDRESS, DEPLOY_POLICY_ADDRESS}:
|
||||
violations.extend(
|
||||
_validate_policy_update(
|
||||
address,
|
||||
before,
|
||||
after,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_plan(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
environment: str,
|
||||
mode: str,
|
||||
allowed_updates: set[str],
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
|
||||
for address in sorted(invalid_allowed):
|
||||
violations.append(
|
||||
f"{address}: address is not eligible for the controlled adoption update"
|
||||
)
|
||||
|
||||
distribution_id = _distribution_id(plan, environment)
|
||||
seen_addresses: set[str] = set()
|
||||
seen_updates: set[str] = set()
|
||||
required_resources = REQUIRED_RESOURCES[environment]
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict):
|
||||
violations.append("<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())
|
||||
120
scripts/check-terraform-isolation.mjs
Normal file
120
scripts/check-terraform-isolation.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// Terraform/application change isolation gate.
|
||||
//
|
||||
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
|
||||
// on the workspace. If the same merge also changes deployable application
|
||||
// code, the content release and the VCS run race for the workspace lock
|
||||
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
|
||||
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
|
||||
// or discarded by a human before the next content release.
|
||||
//
|
||||
// Files that may accompany a Terraform change without triggering a release:
|
||||
// the Terraform tree itself, its plan-guard tooling, and documentation.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
|
||||
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
|
||||
//
|
||||
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
|
||||
// it only when the PR carries the `terraform-isolation-override` label, which
|
||||
// reviewers grant to the rare change that must introduce Terraform variables
|
||||
// together with the workflow that consumes them.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const OVERRIDE_LABEL = "terraform-isolation-override";
|
||||
|
||||
export function isTerraformPath(file) {
|
||||
return file.startsWith("terraform/");
|
||||
}
|
||||
|
||||
export function mayAccompanyTerraform(file) {
|
||||
if (isTerraformPath(file)) return true;
|
||||
if (file.endsWith(".md")) return true;
|
||||
if (file.startsWith("docs/")) return true;
|
||||
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} files changed paths relative to the repository root
|
||||
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
|
||||
*/
|
||||
export function classifyChangedFiles(files) {
|
||||
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
|
||||
const terraform = unique.filter(isTerraformPath);
|
||||
const application = unique.filter((file) => !mayAccompanyTerraform(file));
|
||||
return {
|
||||
terraform,
|
||||
application,
|
||||
mixed: terraform.length > 0 && application.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function changedFilesFromGit(base, head) {
|
||||
const mergeBase = execFileSync("git", ["merge-base", base, head], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
return execFileSync(
|
||||
"git",
|
||||
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { base: null, head: "HEAD", stdin: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--base") options.base = argv[++index];
|
||||
else if (argument === "--head") options.head = argv[++index];
|
||||
else if (argument === "--stdin") options.stdin = true;
|
||||
else throw new Error(`unknown argument: ${argument}`);
|
||||
}
|
||||
if (!options.stdin && !options.base) {
|
||||
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = parseArgs(argv);
|
||||
const files = options.stdin
|
||||
? readFileSync(0, "utf8").split("\n")
|
||||
: changedFilesFromGit(options.base, options.head);
|
||||
const result = classifyChangedFiles(files);
|
||||
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log(
|
||||
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
|
||||
);
|
||||
if (!result.mixed) {
|
||||
console.log(" PASS: Terraform and application changes are not mixed");
|
||||
return 0;
|
||||
}
|
||||
console.log(" Terraform files:");
|
||||
for (const file of result.terraform) console.log(` ${file}`);
|
||||
console.log(" Application files that cannot ship in the same PR:");
|
||||
for (const file of result.application) console.log(` ${file}`);
|
||||
if (override) {
|
||||
console.log(
|
||||
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
console.log(
|
||||
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
115
scripts/check-terraform-isolation.test.mjs
Normal file
115
scripts/check-terraform-isolation.test.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
OVERRIDE_LABEL,
|
||||
classifyChangedFiles,
|
||||
mayAccompanyTerraform,
|
||||
} from "./check-terraform-isolation.mjs";
|
||||
|
||||
const SCRIPT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"check-terraform-isolation.mjs",
|
||||
);
|
||||
|
||||
function runGate(files, env = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
|
||||
input: `${files.join("\n")}\n`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
|
||||
});
|
||||
}
|
||||
|
||||
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
|
||||
for (const file of [
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
"terraform/README.md",
|
||||
"README.md",
|
||||
"docs/adr/0003-terraform.md",
|
||||
"scripts/check-terraform-import-plan.py",
|
||||
"scripts/terraform_import_plan_resources.py",
|
||||
"scripts/test-terraform-import-plan-check.py",
|
||||
"scripts/terraform-validate.mjs",
|
||||
"scripts/check-terraform-isolation.mjs",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("application, workflow, CDK, and dependency files count as application changes", () => {
|
||||
for (const file of [
|
||||
"src/App.tsx",
|
||||
"public/favicon.ico",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
".env.production",
|
||||
"vite.config.ts",
|
||||
".github/workflows/deploy.yml",
|
||||
"infra/cdk/lib/frontend-stack.ts",
|
||||
"scripts/deploy-web.sh",
|
||||
"scripts/governance-check.mjs",
|
||||
"e2e/login.spec.ts",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("terraform-only and application-only changes are not mixed", () => {
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(classifyChangedFiles([]).mixed, false);
|
||||
});
|
||||
|
||||
test("terraform plus application is mixed and lists the offending files", () => {
|
||||
const result = classifyChangedFiles([
|
||||
"terraform/live/dev/main.tf",
|
||||
"src/App.tsx",
|
||||
"README.md",
|
||||
" ",
|
||||
"src/App.tsx",
|
||||
]);
|
||||
assert.equal(result.mixed, true);
|
||||
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
|
||||
assert.deepEqual(result.application, ["src/App.tsx"]);
|
||||
});
|
||||
|
||||
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
|
||||
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
|
||||
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
|
||||
assert.match(mixed.stdout, /FAIL/);
|
||||
assert.match(mixed.stdout, /src\/App\.tsx/);
|
||||
|
||||
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
|
||||
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
|
||||
assert.match(isolated.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("CLI override downgrades a mixed change to a warning that names the label", () => {
|
||||
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /WARNING/);
|
||||
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
|
||||
|
||||
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "yes",
|
||||
});
|
||||
assert.equal(notTrue.status, 1);
|
||||
});
|
||||
|
||||
test("CLI refuses to run without a base ref or --stdin", () => {
|
||||
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Post-deploy step for the org reusable workflow `cd-cdk.yaml`
|
||||
# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`).
|
||||
# Content publish step for the environment deploy workflows
|
||||
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
|
||||
#
|
||||
# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub
|
||||
# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with
|
||||
# the right cache headers, and invalidates CloudFront.
|
||||
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
|
||||
# environment's S3 bucket with the right cache headers, and invalidates
|
||||
# CloudFront. It never touches infrastructure.
|
||||
#
|
||||
# Runs from the repo root. Reads the bucket + distribution from stack outputs,
|
||||
# so it has no hardcoded resource IDs.
|
||||
# Runs from the repo root. The target is resolved from, in order:
|
||||
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
|
||||
# dev, whose CloudFormation outputs disappear during Terraform adoption)
|
||||
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
|
||||
set -euo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
|
|
@ -20,21 +22,31 @@ export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
|||
npm ci
|
||||
npm run build
|
||||
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
BUCKET="${SITE_BUCKET:-}"
|
||||
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
|
||||
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
|
||||
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
|
||||
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ const MAINTAINABILITY_RULES = [
|
|||
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||
// Repository-level gates that run after the source gates. Each is an npm
|
||||
// script so it can also be run on its own.
|
||||
const REPOSITORY_GATES = [
|
||||
["Terraform import-plan contract", "test:terraform-import-plan"],
|
||||
["Terraform isolation gate", "test:terraform-isolation"],
|
||||
["Terraform formatting and validation", "test:terraform"],
|
||||
["CDK build, tests, and synth", "test:infra"],
|
||||
];
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
return (
|
||||
|
|
@ -194,6 +202,20 @@ function plural(count, word) {
|
|||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function runRepositoryGate(label, script) {
|
||||
// Reuse the npm that launched us when available (matches its version and
|
||||
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
|
||||
const npmCli = process.env.npm_execpath;
|
||||
const executable = npmCli ? process.execPath : "npm";
|
||||
const args = npmCli ? [npmCli, "run", script] : ["run", script];
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
return { label, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
|
|
@ -281,6 +303,17 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
for (const [label, script] of REPOSITORY_GATES) {
|
||||
console.log("─".repeat(64));
|
||||
console.log(`${label}: npm run ${script}`);
|
||||
const gate = runRepositoryGate(label, script);
|
||||
if (gate.error) {
|
||||
failures.push(`${label}: could not start: ${gate.error.message}`);
|
||||
} else if (gate.status !== 0) {
|
||||
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (failures.length > 0) {
|
||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||
|
|
|
|||
35
scripts/terraform-validate.mjs
Normal file
35
scripts/terraform-validate.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
|
||||
// Only dev has a live root. Staging adoption (SH-287) adds its own root here.
|
||||
const ENVIRONMENTS = ["dev"];
|
||||
const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment));
|
||||
|
||||
function run(args, cwd = ROOT) {
|
||||
const result = spawnSync(TERRAFORM, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`could not start Terraform: ${result.error.message}`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
|
||||
for (const root of ROOTS) {
|
||||
// -backend=false never touches HCP state; -lockfile=readonly refuses to
|
||||
// silently rewrite the committed provider lock.
|
||||
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root);
|
||||
run(["validate", "-no-color"], root);
|
||||
}
|
||||
|
||||
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
|
||||
131
scripts/terraform_import_plan_resources.py
Normal file
131
scripts/terraform_import_plan_resources.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Canonical frontend Terraform ownership and import-ID maps.
|
||||
|
||||
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
|
||||
are kept so the checker can prove that a dev plan carrying a staging identifier
|
||||
is rejected; they do not authorize a staging import.
|
||||
"""
|
||||
|
||||
COMMON_RESOURCES = {
|
||||
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
|
||||
"module.environment_owned.aws_s3_bucket_public_access_block.site": (
|
||||
"aws_s3_bucket_public_access_block"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_ownership_controls.site": (
|
||||
"aws_s3_bucket_ownership_controls"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": (
|
||||
"aws_s3_bucket_server_side_encryption_configuration"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": (
|
||||
"aws_cloudfront_distribution"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"aws_cloudfront_origin_access_control"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"aws_cloudfront_function"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": "aws_route53_record",
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
|
||||
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
|
||||
}
|
||||
|
||||
REQUIRED_RESOURCES = {
|
||||
environment: dict(COMMON_RESOURCES)
|
||||
for environment in ("dev", "staging")
|
||||
}
|
||||
|
||||
CONTROLLED_UPDATE_ADDRESSES = frozenset(
|
||||
{
|
||||
"module.environment_owned.aws_s3_bucket.site",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
|
||||
"module.environment_owned.aws_iam_role.github_deploy",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
}
|
||||
)
|
||||
|
||||
ENVIRONMENT_CONFIG = {
|
||||
"dev": {
|
||||
"bucket_name": "seahaven-shoc-frontend-dev",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-dev",
|
||||
"distribution_id": "E2CWLM1AFB964P",
|
||||
"workspace_name": "shoc-frontend-new-dev",
|
||||
},
|
||||
"staging": {
|
||||
"bucket_name": "seahaven-shoc-frontend-staging",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-staging",
|
||||
"distribution_id": "E2JDVEZ6EGD49J",
|
||||
"workspace_name": "shoc-frontend-new-staging",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bucket_imports(bucket_name: str) -> dict[str, str]:
|
||||
return {
|
||||
address: bucket_name
|
||||
for address in COMMON_RESOURCES
|
||||
if address.startswith("module.environment_owned.aws_s3_bucket")
|
||||
}
|
||||
|
||||
|
||||
REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
||||
"dev": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-dev"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E30VSIK87N8H64"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
"staging": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-staging"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E1PF5R6QQNBZAI"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
}
|
||||
638
scripts/test-terraform-import-plan-check.py
Normal file
638
scripts/test-terraform-import-plan-check.py
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic unit tests for the frontend Terraform plan checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
|
||||
REPOSITORY = SCRIPT.parent.parent
|
||||
BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET = "module.environment_owned.aws_s3_bucket.site"
|
||||
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
|
||||
|
||||
|
||||
def import_id(environment: str, address: str) -> str:
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
assert expected is not None, f"{environment} must pin an import ID for {address}"
|
||||
return expected
|
||||
|
||||
|
||||
def distribution_id(environment: str) -> str:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
|
||||
return configured
|
||||
|
||||
|
||||
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def bucket_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def pre_adoption_deploy_policy(environment: str) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
statements: list[dict[str, Any]] = []
|
||||
if environment == "dev":
|
||||
statements.append(
|
||||
{
|
||||
"Sid": "AssumeCdkBootstrapRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
|
||||
}
|
||||
)
|
||||
statements.extend(
|
||||
[
|
||||
{
|
||||
"Sid": "DescribeStack",
|
||||
"Effect": "Allow",
|
||||
"Action": "cloudformation:DescribeStacks",
|
||||
"Resource": (
|
||||
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
|
||||
f"{config['cloudformation_stack_name']}/*"
|
||||
),
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
]
|
||||
)
|
||||
return {"Version": "2012-10-17", "Statement": statements}
|
||||
|
||||
|
||||
def deploy_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadDeploymentBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions",
|
||||
],
|
||||
"Resource": bucket_arn,
|
||||
},
|
||||
{
|
||||
"Sid": "PublishAndRollbackSiteObjects",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjectVersion",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
],
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tag_change(environment: str, address: str) -> dict[str, Any]:
|
||||
manager = {
|
||||
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"]
|
||||
}
|
||||
before_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "cdk",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
after_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
if address == ROLE:
|
||||
before_tags.update(manager)
|
||||
after_tags.update(manager)
|
||||
if address == BUCKET:
|
||||
before_tags["aws-cdk:auto-delete-objects"] = "true"
|
||||
before: dict[str, Any] = {
|
||||
"tags": before_tags,
|
||||
"tags_all": before_tags,
|
||||
}
|
||||
after: dict[str, Any] = {
|
||||
"tags": after_tags,
|
||||
"tags_all": after_tags,
|
||||
}
|
||||
if address == DISTRIBUTION:
|
||||
before["id"] = distribution_id(environment)
|
||||
after["id"] = distribution_id(environment)
|
||||
return {"actions": ["update"], "before": before, "after": after}
|
||||
|
||||
|
||||
def policy_change(environment: str, address: str) -> dict[str, Any]:
|
||||
before_policy = (
|
||||
pre_adoption_bucket_policy(environment)
|
||||
if address == BUCKET_POLICY
|
||||
else pre_adoption_deploy_policy(environment)
|
||||
)
|
||||
after_policy = (
|
||||
bucket_policy(environment)
|
||||
if address == BUCKET_POLICY
|
||||
else deploy_policy(environment)
|
||||
)
|
||||
return {
|
||||
"actions": ["update"],
|
||||
"before": {"policy": json.dumps(before_policy)},
|
||||
"after": {"policy": json.dumps(after_policy)},
|
||||
}
|
||||
|
||||
|
||||
def make_plan(
|
||||
environment: str,
|
||||
*,
|
||||
mode: str = "import",
|
||||
controlled_updates: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resources: list[dict[str, Any]] = []
|
||||
updates = controlled_updates or set()
|
||||
for address, resource_type in REQUIRED_RESOURCES[environment].items():
|
||||
if mode == "import":
|
||||
change: dict[str, Any] = {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": import_id(environment, address)},
|
||||
}
|
||||
elif mode == "post-import":
|
||||
change = {"actions": ["no-op"]}
|
||||
elif address in updates:
|
||||
change = (
|
||||
tag_change(environment, address)
|
||||
if address in TAG_ADDRESSES
|
||||
else policy_change(environment, address)
|
||||
)
|
||||
else:
|
||||
change = {"actions": ["no-op"]}
|
||||
if address == DISTRIBUTION:
|
||||
change["after"] = {"id": distribution_id(environment)}
|
||||
resources.append(
|
||||
{
|
||||
"address": address,
|
||||
"mode": "managed",
|
||||
"type": resource_type,
|
||||
"change": change,
|
||||
}
|
||||
)
|
||||
return {"resource_changes": resources}
|
||||
|
||||
|
||||
def resource(plan: dict[str, Any], address: str) -> dict[str, Any]:
|
||||
return next(
|
||||
item for item in plan["resource_changes"] if item["address"] == address
|
||||
)
|
||||
|
||||
|
||||
def run_checker(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "plan.json"
|
||||
path.write_text(json.dumps(plan), encoding="utf-8")
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(path),
|
||||
"--environment",
|
||||
environment,
|
||||
]
|
||||
if post_import:
|
||||
command.append("--post-import-no-op")
|
||||
for address in allowed_updates:
|
||||
command.extend(["--allow-update-address", address])
|
||||
return subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class ImportPlanCheckerTests(unittest.TestCase):
|
||||
def assert_passes(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def assert_fails(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
|
||||
source = (
|
||||
REPOSITORY
|
||||
/ "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
expected = """ spa_rewrite_code = join("\\n", [
|
||||
"function handler(event) {",
|
||||
" var request = event.request;",
|
||||
" var uri = request.uri;",
|
||||
" // No file extension after the last slash -> a client-side route.",
|
||||
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||
" request.uri = '/index.html';",
|
||||
" }",
|
||||
" return request;",
|
||||
"}",
|
||||
])"""
|
||||
self.assertIn(expected, source)
|
||||
|
||||
def test_only_dev_has_a_live_root(self) -> None:
|
||||
live_roots = sorted(
|
||||
path.name
|
||||
for path in (REPOSITORY / "terraform/live").iterdir()
|
||||
if path.is_dir() and path.name != "modules"
|
||||
)
|
||||
self.assertEqual(["dev"], live_roots)
|
||||
|
||||
def test_dev_root_pins_import_phase_in_code(self) -> None:
|
||||
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
self.assertRegex(source, r"\n\s+adoption_complete\s+= false\n")
|
||||
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
|
||||
self.assertNotIn('variable "adoption_complete"', source)
|
||||
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
|
||||
self.assertNotIn(
|
||||
"variable ",
|
||||
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
|
||||
root_file,
|
||||
)
|
||||
|
||||
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
|
||||
expected = {
|
||||
"dev": (
|
||||
"local.hosted_zone_id",
|
||||
"local.certificate_arn",
|
||||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
}
|
||||
for environment, values in expected.items():
|
||||
source = (
|
||||
REPOSITORY / f"terraform/live/{environment}/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
for name, value in zip(
|
||||
(
|
||||
"hosted_zone_id",
|
||||
"certificate_arn",
|
||||
"github_oidc_provider_arn",
|
||||
"cache_policy_id",
|
||||
),
|
||||
values,
|
||||
strict=True,
|
||||
):
|
||||
self.assertIn(f"{name}", source)
|
||||
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
|
||||
self.assertNotRegex(
|
||||
source,
|
||||
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
|
||||
)
|
||||
|
||||
def test_exact_import_plan_passes_for_every_environment(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
with self.subTest(environment=environment):
|
||||
self.assert_passes(make_plan(environment), environment)
|
||||
|
||||
def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None:
|
||||
for mutation in ("missing", "extra", "wrong-type", "cross-environment"):
|
||||
plan = make_plan("dev")
|
||||
if mutation == "missing":
|
||||
plan["resource_changes"].pop()
|
||||
elif mutation == "extra":
|
||||
plan["resource_changes"].append(
|
||||
{
|
||||
"address": "module.inventory.aws_route53_zone.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_zone",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": "Z00000000000000000000"},
|
||||
},
|
||||
}
|
||||
)
|
||||
elif mutation == "wrong-type":
|
||||
plan["resource_changes"][0]["type"] = "aws_s3_object"
|
||||
else:
|
||||
resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = (
|
||||
REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION]
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_import_rejects_mutation_and_invalid_metadata(self) -> None:
|
||||
for actions in (["create"], ["update"], ["delete"], ["delete", "create"]):
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["actions"] = actions
|
||||
with self.subTest(actions=actions):
|
||||
self.assert_fails(plan, "dev")
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": ""}
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_post_import_no_op_passes(self) -> None:
|
||||
self.assert_passes(
|
||||
make_plan("staging", mode="post-import"),
|
||||
"staging",
|
||||
post_import=True,
|
||||
)
|
||||
|
||||
def test_post_import_rejects_import_metadata_and_update(self) -> None:
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"}
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["actions"] = ["update"]
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
|
||||
def test_every_allowed_controlled_diff_passes(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
for address in CONTROLLED_UPDATE_ADDRESSES:
|
||||
with self.subTest(environment=environment, address=address):
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={address},
|
||||
),
|
||||
environment,
|
||||
address,
|
||||
)
|
||||
|
||||
def test_full_exact_controlled_allowlist_passes(self) -> None:
|
||||
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates=set(addresses),
|
||||
),
|
||||
"dev",
|
||||
*addresses,
|
||||
)
|
||||
|
||||
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_tag_update_requires_complete_adopted_tag_sets(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET})
|
||||
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
|
||||
self.assert_fails(plan, "dev", BUCKET)
|
||||
|
||||
def test_role_trust_change_is_rejected(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
role = resource(plan, ROLE)["change"]
|
||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
||||
for mutation in ("principal", "extra"):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={BUCKET_POLICY},
|
||||
)
|
||||
policy = copy.deepcopy(bucket_policy("dev"))
|
||||
if mutation == "principal":
|
||||
policy["Statement"][0]["Principal"] = {"AWS": "*"}
|
||||
else:
|
||||
policy["Statement"].append(
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||
policy
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
|
||||
def test_deploy_policy_rejects_resource_action_and_extra_statement(self) -> None:
|
||||
for mutation in ("resource", "action", "extra"):
|
||||
plan = make_plan(
|
||||
"staging",
|
||||
mode="controlled",
|
||||
controlled_updates={DEPLOY_POLICY},
|
||||
)
|
||||
policy = copy.deepcopy(deploy_policy("staging"))
|
||||
if mutation == "resource":
|
||||
policy["Statement"][0]["Resource"] = "*"
|
||||
elif mutation == "action":
|
||||
policy["Statement"][0]["Action"].append("iam:PassRole")
|
||||
else:
|
||||
policy["Statement"].append(
|
||||
{
|
||||
"Sid": "Extra",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
resource(plan, DEPLOY_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||
policy
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "staging", DEPLOY_POLICY)
|
||||
|
||||
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
for address in (BUCKET_POLICY, DEPLOY_POLICY):
|
||||
plan = make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={address},
|
||||
)
|
||||
change = resource(plan, address)["change"]
|
||||
before = json.loads(change["before"]["policy"])
|
||||
before["Statement"].append(
|
||||
{
|
||||
"Sid": "UnexpectedDrift",
|
||||
"Effect": "Deny",
|
||||
"Action": "*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
change["before"]["policy"] = json.dumps(before)
|
||||
with self.subTest(environment=environment, address=address):
|
||||
self.assert_fails(plan, environment, address)
|
||||
|
||||
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
|
||||
for field, value in (
|
||||
("after_unknown", {"tags": {"ManagedBy": True}}),
|
||||
("replace_paths", [["tags"]]),
|
||||
):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={ROLE},
|
||||
)
|
||||
resource(plan, ROLE)["change"][field] = value
|
||||
with self.subTest(field=field):
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
308
terraform/README.md
Normal file
308
terraform/README.md
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
# Frontend Terraform adoption runbook (dev)
|
||||
|
||||
This tree adopts the existing Sea Haven SHOC frontend dev hosting resources
|
||||
into HCP Terraform without recreating them. It mirrors the backend adoption
|
||||
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs:
|
||||
|
||||
| PR | Branch | Change |
|
||||
| --- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| A | `feature/frontend-terraform-adoption` | This PR. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. |
|
||||
| B | `feature/terraform-dev-adoption` | `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant, CloudFormation detaches. |
|
||||
| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. |
|
||||
|
||||
Creating these files, formatting them, initializing with `-backend=false`, and
|
||||
validating them does not authorize an AWS, HCP Terraform, GitHub,
|
||||
CloudFormation, DNS, or deployment mutation. Every live step below is gated on
|
||||
an explicit go from the owner, with the production impact stated first.
|
||||
|
||||
Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked
|
||||
separately (SH-287) and adds its own root under `live/staging` when it starts.
|
||||
The `staging` constants in `scripts/terraform_import_plan_resources.py` exist
|
||||
only so the checker can prove a dev plan carrying a staging identifier fails.
|
||||
|
||||
## Fixed targets
|
||||
|
||||
- AWS account: `396287094661`
|
||||
- AWS region: `us-east-1`
|
||||
- HCP organization: `seahaven`
|
||||
- HCP project: `seahaven-external-dev`
|
||||
- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working
|
||||
directory `terraform/live/dev`
|
||||
- Site: `dev.seahaven.com`
|
||||
- API build value: `https://api.dev.seahaven.com/api`
|
||||
|
||||
## Workspace invariants
|
||||
|
||||
Set before any Terraform lands on `dev`, read back after setting, and re-read
|
||||
before the first release after any Terraform merge:
|
||||
|
||||
- Auto-apply **off**. GitHub or a human applies every run.
|
||||
- Automatic speculative plans **on** (PR plans are read-only evidence).
|
||||
- Automatic run triggering: **patterns**
|
||||
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
|
||||
prefixes, no tags regex. Do not switch to tag-based triggering.
|
||||
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
|
||||
`>= 1.9.0, < 2.0.0`; CI validates with `1.16.0`).
|
||||
- Dynamic AWS credentials only: environment variables
|
||||
`TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and
|
||||
`TFC_AWS_APPLY_ROLE_ARN` pointing at the `seahaven-org-baseline` roles
|
||||
`hcptf-shoc-frontend-new-dev-plan` and `hcptf-shoc-frontend-new-dev`. No
|
||||
access keys.
|
||||
- **No** `adoption_complete` workspace variable. The dev root pins it in code
|
||||
(`local.adoption_complete`) so the value under review is the value that
|
||||
applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable`
|
||||
block reappears in the root.
|
||||
|
||||
## Ownership boundary
|
||||
|
||||
`live/modules/environment-owned` owns exactly these 13 addresses:
|
||||
|
||||
1. `module.environment_owned.aws_s3_bucket.site`
|
||||
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
|
||||
3. `module.environment_owned.aws_s3_bucket_ownership_controls.site`
|
||||
4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site`
|
||||
5. `module.environment_owned.aws_s3_bucket_versioning.site`
|
||||
6. `module.environment_owned.aws_s3_bucket_policy.site`
|
||||
7. `module.environment_owned.aws_cloudfront_distribution.site`
|
||||
8. `module.environment_owned.aws_cloudfront_origin_access_control.site`
|
||||
9. `module.environment_owned.aws_cloudfront_function.spa_rewrite`
|
||||
10. `module.environment_owned.aws_route53_record.site_a`
|
||||
11. `module.environment_owned.aws_route53_record.site_aaaa`
|
||||
12. `module.environment_owned.aws_iam_role.github_deploy`
|
||||
13. `module.environment_owned.aws_iam_role_policy.github_deploy`
|
||||
|
||||
Every managed resource has `prevent_destroy = true`.
|
||||
|
||||
`live/modules/environment-inventory` is data-only. It resolves and checks the
|
||||
caller account, provider region, public hosted zone, ACM certificate, account
|
||||
GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache
|
||||
policy against pinned values, and fails the plan on any mismatch.
|
||||
|
||||
The following remain outside state:
|
||||
|
||||
- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate
|
||||
- the account-global GitHub OIDC provider
|
||||
- the AWS managed CloudFront cache policy
|
||||
- `CDKToolkit` resources and CDK metadata
|
||||
- the S3 auto-delete custom resource, its provider Lambda and role
|
||||
- the HCP plan/apply roles and the deploy-role permissions boundary
|
||||
(`seahaven-org-baseline` owns them)
|
||||
|
||||
## Exact live inventory (dev)
|
||||
|
||||
- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev`
|
||||
- Distribution: `E2CWLM1AFB964P`
|
||||
- OAC: `E30VSIK87N8H64`, name
|
||||
`shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`,
|
||||
description modeled as `""`
|
||||
- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1`
|
||||
- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8`
|
||||
- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A`
|
||||
- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA`
|
||||
- Deploy role: `githubdeploy-shoc-frontend-new-dev`
|
||||
- Inline policy import ID:
|
||||
`githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1`
|
||||
- Hosted zone: `Z07671212N75U4YLPWZR8`
|
||||
- Certificate:
|
||||
`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`
|
||||
- Legacy stack: `shoc-frontend-dev`
|
||||
- Auto-delete helper role:
|
||||
`arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV`
|
||||
- Permissions boundary:
|
||||
`arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary`
|
||||
|
||||
With `adoption_complete = false` the root declares the configuration observed
|
||||
after the CDK retain deploy (Phase 1, step 2), not the configuration live
|
||||
today:
|
||||
|
||||
- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the
|
||||
S3-only `aws-cdk:auto-delete-objects=true` tag
|
||||
- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag
|
||||
- the permissions boundary attached to the deploy role
|
||||
- `StringEquals` on the OIDC subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev`
|
||||
- the legacy bucket policy including the auto-delete helper grant
|
||||
- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`,
|
||||
bucket read/write, `InvalidateDistribution`)
|
||||
|
||||
The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing.
|
||||
If read-back after that deploy differs from the root in any other way, update
|
||||
the root to the observed value and prove a zero-change import plan. Do not
|
||||
approve drift through the controlled-update checker.
|
||||
|
||||
## Phase 1: import-first adoption (this PR)
|
||||
|
||||
Each step is gated. State the impact, get the go, act, read back, record.
|
||||
|
||||
1. **Workspace invariants.** Set the invariants above on
|
||||
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
|
||||
PR.
|
||||
2. **CDK retain deploy.** From the reviewed PR head, with administrator
|
||||
credentials:
|
||||
|
||||
```bash
|
||||
cd infra/cdk && npm ci
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=true
|
||||
```
|
||||
|
||||
Expected: an update-only change set (no create, no delete, no replace)
|
||||
that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the
|
||||
13 transferred resources and the `Custom::S3AutoDeleteObjects` resource,
|
||||
attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows
|
||||
the trust operator. Read back the role, bucket policy, and stack resources
|
||||
as JSON and attach it to the PR.
|
||||
|
||||
3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply
|
||||
off). Download the plan JSON and run the guard:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-terraform-import-plan.py plan.json --environment dev
|
||||
```
|
||||
|
||||
Confirm the apply only when the plan is exactly 13 imports, 0 create,
|
||||
0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the
|
||||
run and fix the root in a new PR.
|
||||
|
||||
4. **Post-import no-op.** Queue a plan and require it to be no-op:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-terraform-import-plan.py post-import.json \
|
||||
--environment dev --post-import-no-op
|
||||
```
|
||||
|
||||
Post the run URLs and the guard output on SH-300.
|
||||
|
||||
After Phase 1 CloudFormation still owns every resource. Terraform holds state
|
||||
for them and nothing else.
|
||||
|
||||
## Phase 2: controlled ownership transfer (PR B)
|
||||
|
||||
PR B pins `adoption_complete = true`. The controlled apply may update only:
|
||||
|
||||
- `module.environment_owned.aws_s3_bucket.site` (tags)
|
||||
- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the
|
||||
auto-delete helper grant)
|
||||
- `module.environment_owned.aws_cloudfront_distribution.site` (tags)
|
||||
- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags)
|
||||
- `module.environment_owned.aws_iam_role.github_deploy` (tags)
|
||||
|
||||
The OAC, both Route 53 records, and the deploy inline policy must be no-op.
|
||||
PR B keeps the post-adoption inline policy byte-identical to live so the
|
||||
policy address does not appear in the plan. Run the checker with one
|
||||
`--allow-update-address` per updating address; it rejects unused allowlist
|
||||
entries, unknown values, and replacements.
|
||||
|
||||
Dependency: `hcptf-shoc-frontend-new-dev` currently lacks
|
||||
`cloudfront:UpdateDistribution` and `cloudfront:UpdateFunction`. Codify the
|
||||
expansion in `seahaven-org-baseline` (cross-family plus security review) and
|
||||
deploy it before the controlled apply.
|
||||
|
||||
After the apply and a no-op plan, deploy the same reviewed CDK SHA with
|
||||
`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the
|
||||
13 transferred resources and the custom resource. Never deploy with
|
||||
`ManageSiteInfrastructure=true` again after that. See
|
||||
[`infra/cdk/README.md`](../infra/cdk/README.md).
|
||||
|
||||
## Phase 3: content CD through Terraform (PR C)
|
||||
|
||||
Summary only; PR C carries the full design. GitHub builds and uploads to an
|
||||
immutable `releases/<sha>-<run>-<attempt>/` prefix. Terraform owns the
|
||||
`.release/current` pointer, both origin paths of a CloudFront origin group,
|
||||
and the invalidation action. Rollback is one guarded Terraform run swapping
|
||||
the labels. Push-to-`dev` releases return behind the repository variable
|
||||
`TERRAFORM_CONTENT_CD_ENABLED`.
|
||||
|
||||
## Operational rules
|
||||
|
||||
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
|
||||
deployable application code. The `terraform-isolation` job in
|
||||
`.github/workflows/ci.yaml` enforces this; documentation and the
|
||||
`scripts/*terraform*` tooling are allowed alongside. A reviewer may add the
|
||||
`terraform-isolation-override` label for the rare change that must introduce
|
||||
Terraform variables together with the workflow that consumes them (PR A and
|
||||
PR C), then re-run the workflow so the job reads the label. The label is the
|
||||
approval record. The override is temporary: a follow-up PR after PR C
|
||||
removes the label path from the checker and workflow so the gate has no
|
||||
exception.
|
||||
- **Every Terraform merge produces a VCS run.** A human confirms or discards
|
||||
it before the next content release. Do not leave a pending run on the
|
||||
workspace.
|
||||
- **Re-read the workspace invariants** before the first release after any
|
||||
Terraform merge or workspace settings change.
|
||||
- **A red job does not mean the site is down.** Read the live state first
|
||||
(served `index.html`, distribution status, pointer body once PR C lands),
|
||||
then triage.
|
||||
- **Exact-head evidence.** Every live step records the run URL, the SHA, and a
|
||||
machine-readable read-back on the PR or SH-300.
|
||||
|
||||
## Local validation
|
||||
|
||||
From the repository root (also run by `npm run verify` through
|
||||
`scripts/governance-check.mjs`):
|
||||
|
||||
```bash
|
||||
npm run test:terraform # fmt -check, init -backend=false, validate
|
||||
npm run test:terraform-import-plan # checker unit tests against synthetic plans
|
||||
npm run test:terraform-isolation # isolation gate unit tests
|
||||
npm run test:infra # CDK build, template tests, synth in both modes
|
||||
```
|
||||
|
||||
`terraform init -backend=false -lockfile=readonly` may download the provider
|
||||
but never contacts HCP state or plans against AWS. Only HCP runs plan against
|
||||
the account.
|
||||
|
||||
The lock file must carry `h1:` hashes for every platform that runs the gate
|
||||
(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the
|
||||
provider version, refresh them with:
|
||||
|
||||
```bash
|
||||
terraform -chdir=terraform/live/dev providers lock \
|
||||
-platform=linux_amd64 -platform=linux_arm64 \
|
||||
-platform=darwin_amd64 -platform=darwin_arm64
|
||||
```
|
||||
|
||||
## Import plan safety
|
||||
|
||||
Import mode requires exactly the canonical 13 addresses and AWS types, valid
|
||||
import metadata for every resource, the exact dev import IDs (a staging ID in a
|
||||
dev plan fails), and zero create, update, delete, or replace actions.
|
||||
|
||||
Post-import mode requires all 13 resources to be no-op and rejects any
|
||||
remaining import metadata.
|
||||
|
||||
Controlled mode permits only in-place updates to the addresses explicitly
|
||||
listed with `--allow-update-address`, verifies `before` against the exact
|
||||
pre-adoption policies and tags and `after` against the exact adopted values,
|
||||
and rejects create, delete, replace, import metadata, unknown values,
|
||||
unapproved addresses, and unused allowlist entries.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Before import apply: discard the run and correct the root.
|
||||
- After import, before the controlled update (end of Phase 1): remove only the
|
||||
13 imported addresses from state under a separately reviewed state
|
||||
operation. CloudFormation remains authoritative; a
|
||||
`ManageSiteInfrastructure=true` stack is unchanged by this.
|
||||
- After the controlled update, before detachment: either complete the reviewed
|
||||
detachment or restore the exact pre-adoption policy and tags under a
|
||||
separate approval. Do not remove state or redeploy CloudFormation blindly.
|
||||
- After detachment: Terraform is authoritative. Restore content from the
|
||||
versioned bucket. Re-establishing CloudFormation ownership requires a
|
||||
reviewed `IMPORT` change set, never an ordinary update.
|
||||
|
||||
Any replacement, destroy, cross-environment ID, missing import, broad policy
|
||||
change, or failed smoke check is a hard stop.
|
||||
|
||||
## Evidence per phase
|
||||
|
||||
- HCP run URL and the workspace settings read-back
|
||||
- plan JSON and checker output
|
||||
- `terraform state list` showing exactly the 13 addresses
|
||||
- read-only inventory before and after each mutation
|
||||
- synthesized CloudFormation template, change set, and stack events
|
||||
- deploy, invalidation, and smoke output
|
||||
- the post-action no-op plan
|
||||
- phase close-out on SH-300: completed work, validation, risks, deviations,
|
||||
remaining work
|
||||
30
terraform/live/dev/.terraform.lock.hcl
generated
Normal file
30
terraform/live/dev/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "6.62.0"
|
||||
constraints = "~> 6.57"
|
||||
hashes = [
|
||||
"h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=",
|
||||
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
|
||||
"h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=",
|
||||
"h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=",
|
||||
"h1:yOSEz5G8b/n5uhFCZ0gbEsKkAQATtVuhXJEXR3OM5qs=",
|
||||
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
|
||||
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
|
||||
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
|
||||
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
|
||||
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
|
||||
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
|
||||
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
|
||||
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
|
||||
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
|
||||
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
|
||||
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
|
||||
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
|
||||
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
|
||||
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
|
||||
]
|
||||
}
|
||||
64
terraform/live/dev/imports.tf
Normal file
64
terraform/live/dev/imports.tf
Normal 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}"
|
||||
}
|
||||
94
terraform/live/dev/main.tf
Normal file
94
terraform/live/dev/main.tf
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
locals {
|
||||
# Import-first phase. Pinned in code, never a workspace variable: the
|
||||
# controlled ownership transfer flips this to true in its own reviewed PR.
|
||||
adoption_complete = false
|
||||
|
||||
environment = "dev"
|
||||
workspace_name = "shoc-frontend-new-dev"
|
||||
aws_account_id = "396287094661"
|
||||
aws_region = "us-east-1"
|
||||
bucket_name = "seahaven-shoc-frontend-dev"
|
||||
distribution_id = "E2CWLM1AFB964P"
|
||||
oac_id = "E30VSIK87N8H64"
|
||||
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
|
||||
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
|
||||
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
domain_name = "dev.seahaven.com"
|
||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||
certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||
deploy_role_name = "githubdeploy-shoc-frontend-new-dev"
|
||||
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
stack_name = "shoc-frontend-dev"
|
||||
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||
permissions_boundary_arn = (
|
||||
"arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary"
|
||||
)
|
||||
bucket_auto_delete_helper_role_arn = (
|
||||
"arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
|
||||
)
|
||||
legacy_tags = {
|
||||
Environment = "dev"
|
||||
ManagedBy = "cdk"
|
||||
Project = "shoc-frontend"
|
||||
}
|
||||
legacy_bucket_tags = merge(local.legacy_tags, {
|
||||
"aws-cdk:auto-delete-objects" = "true"
|
||||
})
|
||||
terraform_tags = {
|
||||
Environment = "dev"
|
||||
ManagedBy = "terraform"
|
||||
Ownership = "terraform"
|
||||
Project = "shoc-frontend"
|
||||
}
|
||||
manager_tag = {
|
||||
HcpTerraformWorkspace = local.workspace_name
|
||||
}
|
||||
}
|
||||
|
||||
module "inventory" {
|
||||
source = "../modules/environment-inventory"
|
||||
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
hosted_zone_name = local.domain_name
|
||||
expected_hosted_zone_id = local.hosted_zone_id
|
||||
certificate_domain = "*.seahaven.com"
|
||||
expected_certificate_arn = local.certificate_arn
|
||||
expected_github_oidc_provider_arn = local.github_oidc_arn
|
||||
expected_cache_policy_id = local.cache_policy_id
|
||||
}
|
||||
|
||||
module "environment_owned" {
|
||||
source = "../modules/environment-owned"
|
||||
|
||||
environment = local.environment
|
||||
adoption_complete = local.adoption_complete
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
bucket_name = local.bucket_name
|
||||
distribution_id = local.distribution_id
|
||||
origin_access_control_name = local.oac_name
|
||||
origin_access_control_description = ""
|
||||
origin_id = local.origin_id
|
||||
function_name = local.function_name
|
||||
domain_name = local.domain_name
|
||||
hosted_zone_id = local.hosted_zone_id
|
||||
certificate_arn = local.certificate_arn
|
||||
cache_policy_id = local.cache_policy_id
|
||||
github_oidc_provider_arn = local.github_oidc_arn
|
||||
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev"
|
||||
pre_adoption_github_subject_operator = "StringEquals"
|
||||
post_adoption_github_subject_operator = "StringEquals"
|
||||
deploy_branch = "dev"
|
||||
deploy_role_name = local.deploy_role_name
|
||||
deploy_inline_policy_name = local.inline_policy
|
||||
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||
cloudformation_stack_name = local.stack_name
|
||||
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
|
||||
pre_adoption_tags = local.legacy_tags
|
||||
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||
ownership_tags = local.terraform_tags
|
||||
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||
}
|
||||
11
terraform/live/dev/outputs.tf
Normal file
11
terraform/live/dev/outputs.tf
Normal 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
|
||||
}
|
||||
3
terraform/live/dev/providers.tf
Normal file
3
terraform/live/dev/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
provider "aws" {
|
||||
region = local.aws_region
|
||||
}
|
||||
19
terraform/live/dev/versions.tf
Normal file
19
terraform/live/dev/versions.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
terraform/live/modules/environment-inventory/main.tf
Normal file
65
terraform/live/modules/environment-inventory/main.tf
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
19
terraform/live/modules/environment-inventory/outputs.tf
Normal file
19
terraform/live/modules/environment-inventory/outputs.tf
Normal 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."
|
||||
}
|
||||
46
terraform/live/modules/environment-inventory/variables.tf
Normal file
46
terraform/live/modules/environment-inventory/variables.tf
Normal 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"
|
||||
}
|
||||
403
terraform/live/modules/environment-owned/main.tf
Normal file
403
terraform/live/modules/environment-owned/main.tf
Normal 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
|
||||
}
|
||||
}
|
||||
14
terraform/live/modules/environment-owned/outputs.tf
Normal file
14
terraform/live/modules/environment-owned/outputs.tf
Normal 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."
|
||||
}
|
||||
160
terraform/live/modules/environment-owned/variables.tf
Normal file
160
terraform/live/modules/environment-owned/variables.tf
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
variable "environment" {
|
||||
type = string
|
||||
description = "Environment name."
|
||||
|
||||
validation {
|
||||
condition = contains(["dev", "staging"], var.environment)
|
||||
error_message = "environment must be dev or staging."
|
||||
}
|
||||
}
|
||||
|
||||
variable "adoption_complete" {
|
||||
type = bool
|
||||
description = "Switches only ownership tags and the deploy policy to their adopted values."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "aws_account_id" {
|
||||
type = string
|
||||
description = "AWS account containing the resources."
|
||||
}
|
||||
|
||||
variable "aws_region" {
|
||||
type = string
|
||||
description = "AWS region used by the environment."
|
||||
}
|
||||
|
||||
variable "bucket_name" {
|
||||
type = string
|
||||
description = "Existing private S3 origin bucket."
|
||||
}
|
||||
|
||||
variable "distribution_id" {
|
||||
type = string
|
||||
description = "Existing CloudFront distribution ID."
|
||||
}
|
||||
|
||||
variable "origin_access_control_name" {
|
||||
type = string
|
||||
description = "Exact existing CloudFront OAC name."
|
||||
}
|
||||
|
||||
variable "origin_access_control_description" {
|
||||
type = string
|
||||
description = "Exact existing CloudFront OAC description."
|
||||
}
|
||||
|
||||
variable "origin_id" {
|
||||
type = string
|
||||
description = "Exact origin ID in the existing distribution."
|
||||
}
|
||||
|
||||
variable "function_name" {
|
||||
type = string
|
||||
description = "Existing CloudFront Function name."
|
||||
}
|
||||
|
||||
variable "domain_name" {
|
||||
type = string
|
||||
description = "Site hostname."
|
||||
}
|
||||
|
||||
variable "hosted_zone_id" {
|
||||
type = string
|
||||
description = "Inventory-verified hosted zone ID."
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
type = string
|
||||
description = "Inventory-verified ACM certificate ARN."
|
||||
}
|
||||
|
||||
variable "cache_policy_id" {
|
||||
type = string
|
||||
description = "Inventory-verified AWS managed cache policy ID."
|
||||
}
|
||||
|
||||
variable "github_oidc_provider_arn" {
|
||||
type = string
|
||||
description = "Inventory-verified GitHub OIDC provider ARN."
|
||||
}
|
||||
|
||||
variable "github_subject" {
|
||||
type = string
|
||||
description = "Exact GitHub OIDC subject in the existing role."
|
||||
}
|
||||
|
||||
variable "pre_adoption_github_subject_operator" {
|
||||
type = string
|
||||
description = "Condition operator used by the role before adoption."
|
||||
|
||||
validation {
|
||||
condition = contains(["StringEquals", "StringLike"], var.pre_adoption_github_subject_operator)
|
||||
error_message = "pre_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||
}
|
||||
}
|
||||
|
||||
variable "post_adoption_github_subject_operator" {
|
||||
type = string
|
||||
description = "Condition operator used by the role after adoption."
|
||||
|
||||
validation {
|
||||
condition = contains(["StringEquals", "StringLike"], var.post_adoption_github_subject_operator)
|
||||
error_message = "post_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||
}
|
||||
}
|
||||
|
||||
variable "deploy_branch" {
|
||||
type = string
|
||||
description = "Branch or environment named in the existing role description."
|
||||
}
|
||||
|
||||
variable "deploy_role_name" {
|
||||
type = string
|
||||
description = "Existing GitHub deployment role name."
|
||||
}
|
||||
|
||||
variable "deploy_inline_policy_name" {
|
||||
type = string
|
||||
description = "Existing generated inline policy name."
|
||||
}
|
||||
|
||||
variable "deploy_permissions_boundary_arn" {
|
||||
type = string
|
||||
description = "Exact permissions boundary attached before import."
|
||||
}
|
||||
|
||||
variable "cloudformation_stack_name" {
|
||||
type = string
|
||||
description = "Legacy CloudFormation stack used by the pre-adoption policy."
|
||||
}
|
||||
|
||||
variable "bucket_auto_delete_helper_role_arn" {
|
||||
type = string
|
||||
description = "Exact legacy S3 auto-delete helper role ARN."
|
||||
}
|
||||
|
||||
variable "pre_adoption_tags" {
|
||||
type = map(string)
|
||||
description = "Exact tags present while CloudFormation still owns the resources."
|
||||
}
|
||||
|
||||
variable "pre_adoption_bucket_tags" {
|
||||
type = map(string)
|
||||
description = "Exact pre-adoption S3 tags, including the CDK auto-delete marker."
|
||||
}
|
||||
|
||||
variable "ownership_tags" {
|
||||
type = map(string)
|
||||
description = "Tags applied by the controlled ownership transfer."
|
||||
}
|
||||
|
||||
variable "pre_adoption_deploy_role_tags" {
|
||||
type = map(string)
|
||||
description = "Exact pre-adoption deploy-role tags, including its HCP manager tag."
|
||||
}
|
||||
|
||||
variable "post_adoption_deploy_role_tags" {
|
||||
type = map(string)
|
||||
description = "Exact post-adoption deploy-role tags, preserving its HCP manager tag."
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue