Merge branch 'dev' into feat/SH-191-completion-freeze

This commit is contained in:
Arthur Bassi 2026-09-14 10:22:06 -03:00 • committed by GitHub
commit 1d0c4f8f8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 7123 additions and 1288 deletions

View file

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

View file

@ -23,9 +23,11 @@ jobs:
# repository, independent of (and in addition to) the reusable workflow.
# `npm run verify` is the single command that chains: format check, lint
# (--max-warnings=0), type-check + build, unit tests, then the governance
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
# maintainability gate). If the reusable workflow is later confirmed to run
# every gate, this job can be slimmed to `npm run governance`.
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
# maintainability gate, Terraform fmt/validate, Terraform import-plan and
# release-plan guards, isolation tests, HCP run guard, CloudFront verify,
# and GitHub workflow shell). If the reusable workflow is later confirmed
# to run every gate, this job can be slimmed to `npm run governance`.
#
# GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>)
@ -53,10 +55,28 @@ jobs:
base="origin/dev"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- name: Set up Terraform
# Same minor as the HCP workspace (1.16.x) so fmt/validate see what
# the remote run will see.
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Install actionlint
env:
ACTIONLINT_VERSION: "1.7.12"
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
run: |
set -euo pipefail
curl -fsSL -o actionlint.tar.gz \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c -
tar -xzf actionlint.tar.gz actionlint
sudo mv actionlint /usr/local/bin/actionlint
- run: npm ci
- run: npm run verify
env:

View file

@ -1,60 +1,299 @@
name: Deploy
name: Deploy dev content
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable
# releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
# group, and invalidation. Push-to-dev stays off until
# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true.
#
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
# CloudFront. Both run as the OIDC deploy role created by the stack.
#
# When staging/prod accounts exist, add jobs keyed to their branches and their
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not
# re-run those gates on pull requests, pushes, or workflow_dispatch.
on:
push:
branches: [dev]
paths-ignore:
- "terraform/**"
workflow_dispatch: {}
# OIDC needs id-token: write — it is never in the default token set and cannot
# be granted to the reusable workflow unless the caller has it.
permissions:
id-token: write
contents: read
concurrency:
group: deploy-dev
cancel-in-progress: false
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
node-version: "24"
region: us-east-1
cdk-dir: infra/cdk
stack-name: shoc-frontend-dev
post-deploy-script: scripts/deploy-web.sh
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
upload-sourcemaps:
name: Upload private source maps
needs: deploy
if: github.ref == 'refs/heads/dev'
deploy-dev:
name: Deploy shoc-frontend-new-dev through Terraform
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
contents: read
id-token: write
concurrency:
group: deploy-dev
cancel-in-progress: false
env:
AWS_REGION: us-east-1
TF_CLOUD_ORGANIZATION: seahaven
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
SITE_BUCKET: seahaven-shoc-frontend-dev
DISTRIBUTION_ID: E2CWLM1AFB964P
SITE_URL: https://dev.seahaven.com
VITE_API_URL: https://api.dev.seahaven.com/api
VITE_APP_COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Build exact deployed release
run: npm ci && npm run build
- name: Upload source maps to Sentry
- name: Build SPA
run: |
set -euo pipefail
npm ci
npm run build
if grep -Rq "api.staging.seahaven.com" dist/; then
echo "::error::Built assets contain the staging API URL." >&2
exit 1
fi
if grep -Rq "localhost:5141" dist/; then
echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2
exit 1
fi
grep -Rq "api.dev.seahaven.com" dist/
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Assign immutable release identity
id: release
run: |
set -euo pipefail
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
prefix="releases/${version_label}"
{
echo "version_label=${version_label}"
echo "prefix=${prefix}"
} >> "${GITHUB_OUTPUT}"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE: ${{ steps.release.outputs.version_label }}
- name: Read previous release pointer
id: pointer
run: |
set -euo pipefail
body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
printf '%s' "${body}" | python3 scripts/read-release-pointer.py
- name: Upload immutable release prefix
run: |
set -euo pipefail
prefix="${{ steps.release.outputs.prefix }}"
aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${prefix}/index.html"
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
echo "Uploaded ${prefix}; index.html sha256=${index_sha}"
- name: Capture previous served hash
id: previous-hash
run: |
set -euo pipefail
hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)"
echo "sha256=${hash}" >> "${GITHUB_OUTPUT}"
- name: Discard blocking VCS run before GitHub CD
id: discard-vcs
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
- name: Create Terraform release run
id: release-run
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"'
TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"'
with:
workspace: shoc-frontend-new-dev
message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions"
- name: Read Terraform release plan counts
id: release-plan
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.release-run.outputs.plan_id }}
- name: Reject non-release resource counts
env:
PLAN_ADD: ${{ steps.release-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.release-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
exit 1
fi
- name: Guard pointer-and-origin-path Terraform plan
run: |
set -euo pipefail
# Flags must match check-terraform-release-plan.py. Pointer `before`
# and origin-ID-set stability are asserted from the plan JSON.
python3 scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.release-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.release.outputs.version_label }}" \
--expected-previous-version-label "${{ steps.pointer.outputs.live_current }}"
- name: Discard release run when the guard fails
if: failure() && steps.release-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions
- name: Apply Terraform release run
id: release-apply
continue-on-error: true
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }}
- name: Treat already-applied release run as success
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: |
python3 scripts/hcp-run-guard.py reconcile-apply \
--run-id "${{ steps.release-run.outputs.run_id }}" \
--apply-outcome "${{ steps.release-apply.outcome }}"
- name: Verify CloudFront release
env:
EXPECTED_LABEL: ${{ steps.release.outputs.version_label }}
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }}
run: bash scripts/verify-cloudfront-release.sh
- name: Restore previous release on failure
if: failure()
id: rollback-prepare
run: |
set -euo pipefail
prev="${{ steps.pointer.outputs.live_current }}"
if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then
echo "No Terraform-managed previous label; cannot roll back through HCP." >&2
exit 0
fi
echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}"
echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}"
- name: Discard blocking VCS run before GitHub rollback
id: rollback-discard-vcs
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
- name: Create Terraform rollback run
id: rollback-run
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"'
with:
workspace: shoc-frontend-new-dev
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
- name: Read Terraform rollback plan counts
id: rollback-plan
if: failure() && steps.rollback-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.rollback-run.outputs.plan_id }}
- name: Reject non-release rollback counts
id: rollback-count-guard
if: failure() && steps.rollback-plan.outcome == 'success'
env:
PLAN_ADD: ${{ steps.rollback-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
exit 1
fi
- name: Guard pointer-and-origin-path Terraform rollback plan
id: rollback-json-guard
if: failure() && steps.rollback-count-guard.outcome == 'success'
run: |
set -euo pipefail
python3 scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.rollback-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \
--expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}"
- name: Discard rollback run when the guard fails
if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions
- name: Apply Terraform rollback run
id: rollback-apply
if: failure() && steps.rollback-json-guard.outcome == 'success'
continue-on-error: true
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }}
- name: Treat already-applied rollback run as success
id: rollback-apply-result
if: failure() && steps.rollback-apply.outcome != 'skipped'
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: |
python3 scripts/hcp-run-guard.py reconcile-apply \
--run-id "${{ steps.rollback-run.outputs.run_id }}" \
--apply-outcome "${{ steps.rollback-apply.outcome }}"
- name: Verify CloudFront rollback
if: failure() && steps.rollback-apply-result.outcome == 'success'
env:
EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }}
run: |
set -euo pipefail
expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
export EXPECTED_INDEX_SHA256="${expected_sha}"
bash scripts/verify-cloudfront-release.sh
- name: Live-state summary
if: always()
continue-on-error: true
run: bash scripts/summarize-cloudfront-live-state.sh

View file

@ -0,0 +1,43 @@
name: Terraform isolation
# Own workflow so labeled/unlabeled re-evaluate this gate without starting a
# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events
# would report required checks as success and could merge a failing SHA.
on:
pull_request:
branches: [main, dev, staging]
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
permissions:
contents: read
jobs:
terraform-isolation:
# Fails a pull request that changes Terraform infrastructure together with
# deployable application code (scripts/check-terraform-isolation.mjs). A
# merge that does both queues an HCP VCS run and a content release at the
# same time, and the two race for the workspace lock. The
# `terraform-isolation-override` label is the reviewed exception. This
# job is unconditional so adding or removing that label always reads the
# current label set; a previous green check does not survive removal.
name: Terraform and application changes are isolated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Check changed files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"

20
.gitignore vendored
View file

@ -38,12 +38,14 @@ seed-data.sql
# typescript
*.tsbuildinfo
# cdk (infra/cdk)
infra/cdk/node_modules
infra/cdk/cdk.out
infra/cdk/cdk.context.json
infra/cdk/*.d.ts
infra/cdk/bin/*.d.ts
infra/cdk/bin/*.js
infra/cdk/lib/*.d.ts
infra/cdk/lib/*.js
# terraform (the provider lock file is committed)
**/.terraform/*
*.tfstate
*.tfstate.*
*.tfplan
*.tfvars
*.tfvars.json
# python
__pycache__/
*.py[cod]

View file

@ -7,22 +7,33 @@ npm run verify
```
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
`test` (`vitest run`) → `governance`. A task is not done until this is green.
`test` (`vitest run`) → `governance`. Governance also runs the repository
gates: Terraform import-plan and release-plan checkers, isolation tests,
Terraform formatting and validation, the HCP run guard, CloudFront verify, and
workflow shell checks. A task is not done until this is green.
## Gate matrix
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures |
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
## No-false-pass guarantees
@ -36,6 +47,18 @@ npm run verify
- **Changed-file maintainability fails closed without a valid base** — in CI the
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
(push). An absent or unresolvable base is a failure, not a pass.
- **Terraform gates never touch live state** — `terraform init -backend=false
-lockfile=readonly` and `validate` run offline; the plan checker is tested
against synthetic plan JSON. Real import and controlled-update plans from HCP
are migration evidence reviewed by a human before an approved apply
(`terraform/README.md`).
- **The isolation gate re-evaluates on label changes** — the
`terraform-isolation-override` label is the only way to merge a mixed
Terraform/application PR. `.github/workflows/terraform-isolation.yaml`
runs `terraform-isolation` on `labeled` and `unlabeled` as well as the
default pull-request types, so adding or removing the label re-checks
the current labels without starting a new Frontend checks run. Removing
the label fails a mixed PR that had previously passed with the override.
## Where the gates run
@ -44,11 +67,17 @@ npm run verify
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
format/lint/build/tests, **and** a repo-owned `governance` job runs
`npm run verify` so the maintainability ratchets are guaranteed from this
repository regardless of the reusable workflow.
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
ratchets and repository gates are guaranteed from this repository regardless
of the reusable workflow.
- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):**
on pull requests, fails when Terraform infrastructure and application code
change together. Label add/remove re-runs only this workflow.
## Toolchain pin
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
`corepack npm …` if your default `npm` is older). The lockfile is
`package-lock.json` v3; install with `npm ci`.
`package-lock.json` v3; install with `npm ci`. Governance also needs
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.14.0, < 2.0.0`) and
`python3` (3.10+) on `PATH`.

142
README.md
View file

@ -5,7 +5,7 @@
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
![AWS CDK](https://img.shields.io/badge/AWS_CDK-FF9900?logo=amazonwebservices&logoColor=white)
![Terraform](https://img.shields.io/badge/Terraform-844FBA?logo=terraform&logoColor=white)
Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor
portal, uplifts, and related admin features. This is the selective rebuild of
@ -18,21 +18,25 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
## Architecture
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
Static SPA hosting on AWS, owned by HCP Terraform
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
`dist/` from a private S3 bucket using a current/previous origin group;
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
proxy at the CDN — the backend allows CORS).
```mermaid
graph LR
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev]
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
ROLE -->|cdk deploy + s3 sync + invalidation| S3
GH[GitHub Actions] -->|OIDC upload releases/*| S3
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
```
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
@ -40,8 +44,8 @@ architecture plan for the keep/discard migration matrix).
## AWS Resources
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
| Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
@ -57,12 +61,13 @@ No Lambdas, queues, or databases — this stack is static hosting only.
### Secrets
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
repo secret**:
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
repo secret** is:
| Secret | Purpose |
| --------------------- | ----------------------------------------------------------------------------------- |
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
| Secret | Purpose |
| ------------------- | ------------------------------------------------------------------ |
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
### Environment variables (build-time, `VITE_*`)
@ -77,8 +82,8 @@ repo secret**:
build otherwise. See [`.env.example`](.env.example),
[`.env.development`](.env.development), and [`.env.production`](.env.production).
CDK context (domain, certificate ARN, hosted zone) lives in
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
Pinned hosting constants (domain, certificate ARN, hosted zone) live in
[`terraform/live/dev/main.tf`](terraform/live/dev/main.tf).
## Local Development
@ -95,17 +100,20 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
the deployed dev API).
| Command | Description |
| ------------------------------------------ | -------------------------------------------------------- |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
| `npm run verify` | **All gates**: format + lint + build + test + governance |
| Command | Description |
| ------------------------------------------ | ------------------------------------------------------------------ |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CD guards) |
| `npm run verify` | **All gates**: format + lint + build + test + governance |
`npm run governance` needs `terraform` and `python3` on `PATH` for the
Terraform and content-CD gates.
Husky + lint-staged run ESLint and Prettier on staged files at commit;
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
@ -123,66 +131,74 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
a green CI run and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically.
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
exists yet).
- A PR that changes `terraform/**` may not also change application code (the
`terraform-isolation` CI job); ship Terraform in its own PR.
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
`→ main` (production promotion — no prod environment exists yet).
## Deployment
CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`/`dev`, calls
and PRs to `main`/`dev`/`staging`, calls
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
format check, lint, build, tests; **and** runs a repo-owned `governance` job
that calls `npm run verify` so every gate (including the maintainability
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
guaranteed from this repository. Conventions and gates are documented under
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
the Terraform gates, and the content-CD guards) is guaranteed from this
repository. Conventions and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
CloudFront invalidation. Both run as the OIDC deploy role.
- **Terraform isolation**
([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml))
— fails a PR that mixes `terraform/**` with application code, so a Terraform
merge never races a content release for the HCP workspace.
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
— `workflow_dispatch` on `dev`, and push-to-`dev` when
`vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`).
GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates
`.release/current`, both origin paths, and the invalidation action. Verify
and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
a live-state summary.
- **Staging content**
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
— on push to `staging`, unchanged.
- **Infrastructure** — administrator-run HCP Terraform workspace
`shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
Staging hosting stays on the existing CloudFormation stack until SH-287.
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
[`infra/cdk/README.md`](infra/cdk/README.md).
Manual deploy (emergency/reference only — needs credentials for the
external-dev AWS account; the normal path is push to `dev`):
```bash
(cd infra/cdk && npx cdk deploy)
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
content publisher only.
## Operations
- **Verify:** open <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.
- _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
still serves the previous `index.html` hash. Read the live-state summary
before assuming the site is down.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
on this repo; dispatching the workflow from another branch is rejected by
design.
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
suffix or carrying the wrong environment's host (it is baked in at build time).
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
not proxy `/api`.
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
deploys (matches the org's push-time-CD model; gating deploy on CI is known
follow-up work).
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
an HCP Terraform run that a human confirms or discards before the next
content release (see the operational rules in `terraform/README.md`).
## Documentation
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md)
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
design system and UI docs under [`docs/`](docs/)

View file

@ -244,7 +244,9 @@ test.describe("Vendor deterministic pixel regression", () => {
await openVendorPage(page, "error");
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/server error|vendor directory unavailable/i,
{ timeout: 15_000 },
);
await expect(page.getByRole("progressbar")).toHaveCount(0);
await expectStableScreenshot(page, "vendor-error.png");
});

View file

@ -30,18 +30,7 @@ const legacyIgnores = [
export default tseslint.config(
{
ignores: [
"dist/**",
"build/**",
"node_modules/**",
"coverage/**",
"infra/cdk/cdk.out/**",
"infra/cdk/bin/**/*.d.ts",
"infra/cdk/bin/**/*.js",
"infra/cdk/lib/**/*.d.ts",
"infra/cdk/lib/**/*.js",
...legacyIgnores,
],
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
},
js.configs.recommended,
...tseslint.configs.recommended,

View file

@ -1,221 +0,0 @@
# Infrastructure & CI/CD — Sea Haven SHOC frontend
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
deployed through the org's **reusable** GitHub Actions workflow.
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
- **API:** the SPA calls the backend **directly** over HTTPS at
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
allows CORS). CloudFront serves static content only — no `/api` proxy.
- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy`
picks them up with no flags. `VITE_API_URL` is baked into the build, so it's
per-environment (see the note under "Adding staging / prod").
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
```
infra/cdk/
bin/app.ts entry point (reads -c context)
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
.github/workflows/
ci.yaml quality gates (lint / build / test / e2e)
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
deploy-staging.yml standalone staging deploy (push to staging)
```
## What the stack creates
| Resource | Purpose |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
singleton account resource — the stack only _imports_ it (created in step 2),
so `cdk destroy` can't delete a resource shared by other roles.
---
## One-time setup (run by a human with admin AWS creds)
### 1. Authenticate to the AWS account
```bash
aws configure # or: aws sso login --profile <admin>
aws sts get-caller-identity # confirm the right account + region (us-east-1)
```
### 2. Ensure the GitHub OIDC provider exists (once per account)
```bash
aws iam list-open-id-connect-providers
# If none ends in token.actions.githubusercontent.com, create it (thumbprint is
# no longer required — AWS validates GitHub against its own trust store):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
```
### 3. CDK bootstrap (once per account/region)
```bash
cd infra/cdk
npm ci
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
```
### 4. Domain, cert, and API URL (already wired for dev)
Domain/cert/zone are set in `cdk.json` context (account `396287094661`):
| Context key | Value |
| --------------------------------- | ------------------------------------------------------------ |
| `domainNames` | `dev.seahaven.com` |
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
The stack creates the apex A/AAAA alias in the hosted zone (in this account,
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
baked into the build. Per-environment; override for staging/prod.
### 5. First deploy (locally, with admin creds)
The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it
locally. This provisions infra + the role:
```bash
cd infra/cdk
npx cdk deploy
```
Note the `DeployRoleArn` output. Then push the first content (or just push to
`dev` and let CI do everything from here on):
```bash
# from repo root, optional manual first content publish:
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
### 6. Set the one GitHub secret
`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable):
```bash
REPO=Sea-Haven-Industries/shoc-frontend-new
gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \
--body "arn:aws:iam::<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.
---
## Staging environment (same account, exact OIDC subject)
Staging lives in the same AWS account (396287094661) but deploys through its
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
reusable `cd-cdk.yaml`:
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
environment subject
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
(`StringEquals` on both `aud` and `sub`). The workflow declares
`environment: staging`, so only runs in that environment can assume the role.
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
unchanged.
- **No secret:** the role ARN is static (the role name is deterministic), so
the workflow pins
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
- **Gates first:** the workflow runs the full `npm run verify` before assuming
the staging role, then runs `scripts/deploy-web.sh` with
`STACK_NAME=shoc-frontend-staging`,
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
CloudFront invalidation to complete.
- **Application-only role:** the recurring staging workflow can describe only
its exact stack, publish only to its exact bucket, and invalidate only its
exact distribution. It cannot assume the shared CDK bootstrap roles or
modify infrastructure. Staging infrastructure changes use the Administrator
command below.
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
contain the staging API URL and no dev API URL. (Not browser QA.)
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
1. **GitHub Admin — create the `staging` environment** (Settings →
Environments → New environment → `staging`). Add protection rules as
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
the environment does not exist, GitHub creates it unprotected on first use.
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
OIDC provider and bootstrap already exist in this account):
```bash
cd infra/cdk
npx cdk deploy shoc-frontend-staging \
-c envName=staging \
-c deployBranch=staging \
-c githubEnvironment=staging \
-c domainNames=staging.seahaven.com \
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
-c hostedZoneName=staging.seahaven.com
```
The `DeployRoleArn` output must match the ARN pinned in
`deploy-staging.yml` (it will — the role name is deterministic).
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
allow the `https://staging.seahaven.com` origin.
4. Push to `staging` — `ci.yaml` runs the quality gates and
`deploy-staging.yml` deploys.
### Adding prod later
Same pattern: a prod account/stack with its own contexts and, ideally, its own
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
for prod.
## Notes
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
differs only in that its CD workflow also runs `npm run verify` itself
before deploying); a red-CI commit still deploys on `dev` (matches the
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
of enabling CICD.
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.

View file

@ -1,51 +0,0 @@
#!/usr/bin/env node
import { App, Tags } from "aws-cdk-lib";
import { FrontendStack } from "../lib/frontend-stack";
const app = new App();
// Defaults match the dev setup; override via `-c key=value` on the CLI.
const envName = app.node.tryGetContext("envName") ?? "dev";
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
// branch-ref trust.
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
.split(",")
.map((d: string) => d.trim())
.filter((d: string) => d.length > 0);
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
// Route 53 hosted zone (this account) for the custom-domain alias record.
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
// Staging and beyond protect their stacks from accidental deletion; dev
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
// at deploy time — it is not part of the synthesized template.
const terminationProtection = envName !== "dev";
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
envName,
githubRepo,
deployBranch,
githubEnvironment,
terminationProtection,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
},
});
Tags.of(stack).add("Project", "shoc-frontend");
Tags.of(stack).add("Environment", envName);
Tags.of(stack).add("ManagedBy", "cdk");

View file

@ -1,19 +0,0 @@
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": {
"include": ["**"],
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"]
},
"context": {
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
"domainNames": "dev.seahaven.com",
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
"hostedZoneId": "Z07671212N75U4YLPWZR8",
"hostedZoneName": "dev.seahaven.com"
}
}

View file

@ -1,263 +0,0 @@
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as targets from "aws-cdk-lib/aws-route53-targets";
export interface FrontendStackProps extends StackProps {
/** Environment label, e.g. "dev". Used in names/tags. */
readonly envName: string;
/** GitHub repo in owner/name form, for OIDC trust scoping. */
readonly githubRepo: string;
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
readonly deployBranch: string;
/**
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
* trust uses the EXACT environment subject
* `repo:<owner/name>:environment:<env>` (StringEquals) instead of the
* deploy-branch ref match below. Unset = dev-style branch-ref trust.
*/
readonly githubEnvironment?: string;
/**
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
* Empty = serve on the default *.cloudfront.net domain.
*/
readonly domainNames: string[];
/**
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
* use a certificate from another account, so for Option B the cert must live
* in whichever account this stack deploys to.
*/
readonly certificateArn: string;
/**
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
* record in. Empty = don't manage DNS (add the record manually). When set,
* hostedZoneName must also be provided.
*/
readonly hostedZoneId: string;
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
readonly hostedZoneName: string;
}
/**
* Static SPA hosting for the Sea Haven SHOC frontend:
* - private S3 bucket (no public access; CloudFront reads it via OAC)
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
* - a GitHub Actions OIDC deploy role
*
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
* stack only owns the infrastructure, and the deploy role carries the
* permissions those post-deploy steps need.
*/
export class FrontendStack extends Stack {
constructor(scope: Construct, id: string, props: FrontendStackProps) {
super(scope, id, props);
const {
envName,
githubRepo,
deployBranch,
githubEnvironment = "",
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
} = props;
const hasCustomDomain = domainNames.length > 0;
if (hasCustomDomain && !certificateArn) {
throw new Error(
"certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).",
);
}
// --- Origin bucket: private, encrypted, no public access ----------------
const bucket = new s3.Bucket(this, "SiteBucket", {
bucketName: `seahaven-shoc-frontend-${envName}`,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
versioned: true,
// dev artifacts are reproducible from the build — safe to tear down.
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
// SPA client-side routing: rewrite extensionless paths (e.g. /work-orders)
// to /index.html so deep links resolve. Done with a CloudFront Function
// rather than customErrorResponses so real asset 404s stay 404s.
const spaRewrite = new cloudfront.Function(this, "SpaRewrite", {
comment: "SPA routing: rewrite extensionless paths to /index.html",
code: cloudfront.FunctionCode.fromInline(
[
"function handler(event) {",
" var request = event.request;",
" var uri = request.uri;",
" // No file extension after the last slash -> a client-side route.",
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
" request.uri = '/index.html';",
" }",
" return request;",
"}",
].join("\n"),
),
});
// --- CloudFront: serves the static SPA from S3 -------------------------
// The SPA calls the backend directly at its absolute HTTPS URL
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
const distribution = new cloudfront.Distribution(this, "Distribution", {
comment: `SeaHaven SHOC frontend (${envName})`,
defaultRootObject: "index.html",
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
// Option B: serve on the custom domain(s) with the ACM cert. When unset,
// CloudFront uses its default *.cloudfront.net domain + certificate.
domainNames: hasCustomDomain ? domainNames : undefined,
certificate: hasCustomDomain
? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn)
: undefined,
minimumProtocolVersion: hasCustomDomain
? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021
: undefined,
defaultBehavior: {
// withOriginAccessControl wires up OAC + the bucket policy automatically.
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
compress: true,
functionAssociations: [
{
function: spaRewrite,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
},
],
},
});
// --- GitHub Actions OIDC deploy role -----------------------------------
// The OIDC provider is a singleton account-global resource, created once
// out-of-band (see README step 2) — we only IMPORT it here so this stack's
// lifecycle (including `cdk destroy`) never deletes a resource shared by
// every role in the account.
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
this,
"GitHubOidcProvider",
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
);
// Trust conditions for the OIDC principal. With a GitHub environment
// (staging): exact StringEquals match on both aud and the environment
// subject — the staging workflow declares `environment: staging`, so only
// runs in that environment can assume the role. Without one (dev): keep
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
// deploy branch (reusable-workflow runs still carry the caller-based sub).
const oidcConditions = githubEnvironment
? {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
},
}
: {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
StringLike: {
// Tightly scoped: only pushes to this repo's deploy branch. For a
// reusable-workflow run the OIDC `sub` is still caller-based, so this
// matches even though the deploy job lives in the `.github` repo.
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
};
const deployRole = new iam.Role(this, "GithubDeployRole", {
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
maxSessionDuration: Duration.hours(1),
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
});
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
// intentionally narrower: its recurring promotion workflow only publishes
// application assets to this stack's bucket/distribution. Infrastructure
// changes remain an administrator-run CDK operation, so the staging OIDC
// role cannot inherit the bootstrap roles' account-wide deployment power.
if (!githubEnvironment) {
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "AssumeCdkBootstrapRoles",
actions: ["sts:AssumeRole"],
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
}),
);
}
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "DescribeStack",
actions: ["cloudformation:DescribeStacks"],
resources: [
`arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`,
],
}),
);
bucket.grantReadWrite(deployRole);
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "InvalidateDistribution",
actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
resources: [
`arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`,
],
}),
);
// --- DNS: point the custom domain at CloudFront ------------------------
// Only when a hosted zone is supplied (it must be in THIS account). Creates
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
if (hostedZoneId && hasCustomDomain) {
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
hostedZoneId,
zoneName: hostedZoneName,
});
const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
// apex record when the domain equals the zone name.
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
new route53.ARecord(this, "AliasA", { zone, recordName, target });
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
}
// --- Outputs -----------------------------------------------------------
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
new CfnOutput(this, "SiteUrl", {
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
});
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
});
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
});
new CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
});
new CfnOutput(this, "DeployRoleArn", {
value: deployRole.roleArn,
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
});
}
}

View file

@ -1,514 +0,0 @@
{
"name": "shoc-frontend-infra",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shoc-frontend-infra",
"version": "0.1.0",
"dependencies": {
"aws-cdk-lib": "^2.261.0",
"constructs": "^10.4.2"
},
"bin": {
"app": "bin/app.ts"
},
"devDependencies": {
"@types/node": "^24.13.3",
"aws-cdk": "^2.1130.0",
"ts-node": "^10.9.2",
"typescript": "~6.0.3"
},
"engines": {
"node": ">=22.22.1"
}
},
"node_modules/@aws-cdk/asset-awscli-v1": {
"version": "2.2.282",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/cloud-assembly-schema": {
"version": "54.9.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz",
"integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==",
"bundleDependencies": [
"jsonschema",
"semver"
],
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "^1.5.0",
"semver": "^7.8.5"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
"version": "7.8.5",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/aws-cdk": {
"version": "2.1130.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz",
"integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"cdk": "bin/cdk"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/aws-cdk-lib": {
"version": "2.261.0",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz",
"integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==",
"bundleDependencies": [
"@balena/dockerignore",
"@aws-cdk/cloud-assembly-api",
"case",
"fs-extra",
"ignore",
"jsonschema",
"minimatch",
"punycode",
"semver",
"yaml",
"mime-types"
],
"license": "Apache-2.0",
"dependencies": {
"@aws-cdk/asset-awscli-v1": "2.2.282",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
"@aws-cdk/cloud-assembly-api": "^2.2.5",
"@aws-cdk/cloud-assembly-schema": "^54.0.0",
"@balena/dockerignore": "^1.0.2",
"case": "1.6.3",
"fs-extra": "^11.3.5",
"ignore": "^5.3.2",
"jsonschema": "^1.5.0",
"mime-types": "^2.1.35",
"minimatch": "^10.2.5",
"punycode": "^2.3.1",
"semver": "^7.8.1",
"yaml": "1.10.3"
},
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"constructs": "^10.5.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
"version": "2.2.5",
"inBundle": true,
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "^1.5.0",
"semver": "^7.8.0"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"@aws-cdk/cloud-assembly-schema": ">=53.28.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
"version": "1.0.2",
"inBundle": true,
"license": "Apache-2.0"
},
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
"version": "4.0.4",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
"version": "5.0.6",
"inBundle": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/case": {
"version": "1.6.3",
"inBundle": true,
"license": "(MIT OR GPL-3.0-or-later)",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
"version": "11.3.5",
"inBundle": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
"version": "4.2.11",
"inBundle": true,
"license": "ISC"
},
"node_modules/aws-cdk-lib/node_modules/ignore": {
"version": "5.3.2",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
"version": "6.2.1",
"inBundle": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-db": {
"version": "1.52.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-types": {
"version": "2.1.35",
"inBundle": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/minimatch": {
"version": "10.2.5",
"inBundle": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/aws-cdk-lib/node_modules/punycode": {
"version": "2.3.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/aws-cdk-lib/node_modules/semver": {
"version": "7.8.1",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk-lib/node_modules/universalify": {
"version": "2.0.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/yaml": {
"version": "1.10.3",
"inBundle": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/constructs": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==",
"license": "Apache-2.0"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}

View file

@ -1,29 +0,0 @@
{
"name": "shoc-frontend-infra",
"version": "0.1.0",
"private": true,
"description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.",
"bin": {
"app": "bin/app.ts"
},
"engines": {
"node": ">=22.22.1"
},
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"diff": "cdk diff",
"deploy": "cdk deploy"
},
"devDependencies": {
"@types/node": "^24.13.3",
"aws-cdk": "^2.1130.0",
"ts-node": "^10.9.2",
"typescript": "~6.0.3"
},
"dependencies": {
"aws-cdk-lib": "^2.261.0",
"constructs": "^10.4.2"
},
"packageManager": "npm@11.16.0"
}

View file

@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["bin/**/*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "cdk.out"]
}

View file

@ -12,6 +12,13 @@
"test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui",
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
"test:terraform-release-plan": "python3 scripts/test-terraform-release-plan-check.py",
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
"test:terraform": "node scripts/terraform-validate.mjs",
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
"test:github-workflows": "bash scripts/check-github-workflows.sh",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",

View file

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# bash -n every shell script and every workflow `run:` block. actionlint when present.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}"
for script in scripts/*.sh; do
bash -n "${script}"
done
python3 - "${ROOT}" << 'PY'
import pathlib, re, subprocess, sys, tempfile
root = pathlib.Path(sys.argv[1])
failures = 0
workflow_count = 0
block_count = 0
for workflow in sorted((root / ".github/workflows").glob("*.yml")) + sorted(
(root / ".github/workflows").glob("*.yaml")
):
workflow_count += 1
text = workflow.read_text(encoding="utf-8")
blocks = []
for match in re.finditer(r"^(\s+)run:\s*\|[^\n]*\n((?:\1 .*\n)+)", text, re.M):
indent = len(match.group(1)) + 2
body = []
for line in match.group(2).splitlines():
body.append(line[indent:] if len(line) >= indent else line.lstrip())
blocks.append("\n".join(body) + "\n")
block_count += len(blocks)
for index, block in enumerate(blocks, start=1):
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle:
handle.write(block)
name = handle.name
result = subprocess.run(["bash", "-n", name], capture_output=True, text=True)
pathlib.Path(name).unlink()
if result.returncode != 0:
failures += 1
sys.stderr.write(f"{workflow.relative_to(root)} run block {index}: {result.stderr}")
if failures:
raise SystemExit(1)
print(
f"bash -n passed for scripts and {block_count} run blocks in {workflow_count} workflows"
)
PY
if command -v actionlint >/dev/null 2>&1; then
actionlint -color
else
echo "actionlint not installed; skipped (CI installs it)"
fi

View file

@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""Reject plans that violate the frontend Terraform adoption boundary."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
DISTRIBUTION_ADDRESS = (
"module.environment_owned.aws_cloudfront_distribution.site"
)
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS}
OWNERSHIP_TAGS = {
"Environment": None,
"ManagedBy": "terraform",
"Ownership": "terraform",
"Project": "shoc-frontend",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("plan_json", type=Path)
parser.add_argument(
"--environment",
required=True,
choices=sorted(REQUIRED_RESOURCES),
help="Exact environment ownership boundary expected in the plan.",
)
modes = parser.add_mutually_exclusive_group()
modes.add_argument(
"--post-import-no-op",
action="store_true",
help=(
"Require all managed resources to be no-op after import and forbid "
"import metadata."
),
)
modes.add_argument(
"--allow-update-address",
action="append",
default=[],
metavar="ADDRESS",
help=(
"Enter controlled-update mode and allow one exact reviewed address. "
"Repeat for every expected update."
),
)
return parser.parse_args()
def _load_plan(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError("plan JSON root must be an object")
if not isinstance(value.get("resource_changes"), list):
raise ValueError("plan JSON must contain a resource_changes array")
return value
def _validate_import_metadata(
*,
address: str,
change: dict[str, Any],
environment: str,
) -> list[str]:
importing = change.get("importing")
if not isinstance(importing, dict) or set(importing) != {"id"}:
return [f"{address}: import metadata must be exactly {{'id': <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 _validate_tag_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
changed = _changed_leaf_paths(before, after)
invalid = {
path
for path in changed
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
}
violations = [
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
for path in sorted(invalid)
]
expected = {**OWNERSHIP_TAGS, "Environment": environment}
if address == ROLE_ADDRESS:
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
"workspace_name"
]
if address == BUCKET_ADDRESS:
expected["aws-cdk:auto-delete-objects"] = None
expected_after = {
key: value for key, value in expected.items() if value is not None
}
for tag_attribute in ("tags", "tags_all"):
if after.get(tag_attribute) != expected_after:
violations.append(
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
)
for path in sorted(changed - invalid):
key = path[1]
if key not in expected:
violations.append(f"{address}: tag {key!r} is not an ownership tag")
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
violations.append(
f"{address}: legacy auto-delete ownership tag was not removed"
)
elif after.get(path[0], {}).get(key) != expected[key]:
violations.append(
f"{address}: tag {key!r} does not have its expected adopted value"
)
if not changed:
violations.append(f"{address}: update has no changed leaf values")
return violations
def _validate_policy_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
distribution_id: str | None,
) -> list[str]:
changed = _changed_leaf_paths(before, after)
if changed != {("policy",)}:
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
after_policy, after_violations = _parse_policy(
after.get("policy"), address, "after"
)
violations.extend(after_violations)
if before_policy == after_policy:
violations.append(f"{address}: policy semantics did not change")
if distribution_id is None:
violations.append(
f"{address}: cannot verify policy without the pinned distribution ID"
)
return violations
expected_before = _expected_pre_adoption_bucket_policy(
environment, distribution_id
)
expected_after = _expected_bucket_policy(environment, distribution_id)
if before_policy is not None and before_policy != expected_before:
violations.append(f"{address}: pre-adoption policy semantics are not exact")
if after_policy is not None and after_policy != expected_after:
violations.append(f"{address}: post-adoption policy semantics are not exact")
return violations
def _validate_controlled_update(
address: str,
change: dict[str, Any],
environment: str,
distribution_id: str | None,
) -> list[str]:
violations: list[str] = []
replace_paths = change.get("replace_paths", [])
if replace_paths not in (None, []):
violations.append(f"{address}: replace_paths must be empty")
if _contains_unknown(change.get("after_unknown", {})):
violations.append(f"{address}: controlled update contains unknown values")
before = change.get("before")
after = change.get("after")
if not isinstance(before, dict) or not isinstance(after, dict):
return [*violations, f"{address}: controlled update requires before/after objects"]
if address in TAG_UPDATE_ADDRESSES:
violations.extend(_validate_tag_update(address, before, after, environment))
elif address == BUCKET_POLICY_ADDRESS:
violations.extend(
_validate_policy_update(
address,
before,
after,
environment,
distribution_id,
)
)
return violations
def check_plan(
plan: dict[str, Any],
*,
environment: str,
mode: str,
allowed_updates: set[str],
) -> list[str]:
violations: list[str] = []
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
for address in sorted(invalid_allowed):
violations.append(
f"{address}: address is not eligible for the controlled adoption update"
)
distribution_id = _distribution_id(plan, environment)
seen_addresses: set[str] = set()
seen_updates: set[str] = set()
required_resources = REQUIRED_RESOURCES[environment]
for resource in plan["resource_changes"]:
if not isinstance(resource, dict):
violations.append("<unknown>: resource change must be an object")
continue
if resource.get("mode", "managed") != "managed":
continue
address = resource.get("address")
if not isinstance(address, str):
violations.append("<unknown>: managed resource has no valid address")
continue
if address in seen_addresses:
violations.append(f"{address}: duplicate managed resource change")
seen_addresses.add(address)
expected_type = required_resources.get(address)
if expected_type is None:
violations.append(f"{address}: managed address is outside the ownership boundary")
elif resource.get("type") != expected_type:
violations.append(
f"{address}: expected managed type {expected_type!r}, "
f"got {resource.get('type')!r}"
)
change = resource.get("change")
if not isinstance(change, dict):
violations.append(f"{address}: missing change object")
continue
actions = change.get("actions")
if not isinstance(actions, list) or not all(
isinstance(action, str) for action in actions
):
violations.append(f"{address}: actions must be a string array")
continue
if change.get("replace_paths") not in (None, []):
violations.append(f"{address}: replace_paths must be empty")
if mode == "import":
if actions != ["no-op"]:
violations.append(
f"{address}: import mode requires no-op, got {actions!r}"
)
if expected_type is not None:
violations.extend(
_validate_import_metadata(
address=address,
change=change,
environment=environment,
)
)
elif mode == "post-import":
if actions != ["no-op"]:
violations.append(
f"{address}: post-import mode requires no-op, got {actions!r}"
)
if "importing" in change:
violations.append(
f"{address}: import metadata is forbidden in post-import mode"
)
else:
if "importing" in change:
violations.append(
f"{address}: import metadata is forbidden in controlled-update mode"
)
if actions == ["update"]:
seen_updates.add(address)
if address not in allowed_updates:
violations.append(f"{address}: update is not explicitly allowlisted")
else:
violations.extend(
_validate_controlled_update(
address,
change,
environment,
distribution_id,
)
)
elif actions != ["no-op"]:
violations.append(f"{address}: unsafe controlled actions {actions!r}")
for missing in sorted(set(required_resources) - seen_addresses):
violations.append(f"{missing}: required managed resource is absent")
for unused in sorted(allowed_updates - seen_updates):
violations.append(f"{unused}: allowlisted update address is not updating")
return violations
def main() -> int:
args = parse_args()
try:
plan = _load_plan(args.plan_json)
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr)
return 1
allowed_updates = set(args.allow_update_address or [])
if args.post_import_no_op:
mode = "post-import"
elif allowed_updates:
mode = "controlled"
else:
mode = "import"
violations = check_plan(
plan,
environment=args.environment,
mode=mode,
allowed_updates=allowed_updates,
)
if violations:
print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr)
for violation in violations:
print(f" - {violation}", file=sys.stderr)
return 1
label = {
"import": "zero-change import",
"post-import": "post-import no-op",
"controlled": "controlled update",
}[mode]
print(
f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} "
f"managed resources and {len(allowed_updates)} exact updates"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,137 @@
// Terraform/application change isolation gate.
//
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
// on the workspace. If the same merge also changes deployable application
// code, the content release and the VCS run race for the workspace lock
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
// or discarded by a human before the next content release.
//
// Files that may accompany a Terraform change without triggering a release:
// the Terraform tree itself, its plan-guard tooling, and documentation.
//
// Usage:
// node scripts/check-terraform-isolation.mjs --base <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. The checker has no memory of
// a previous pass: the same mixed diff fails again as soon as the override
// env is unset (label removal).
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const OVERRIDE_LABEL = "terraform-isolation-override";
export function isTerraformPath(file) {
return file.startsWith("terraform/");
}
// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers
// are terraform/live/dev/** and terraform/live/modules/**), so it is not a
// Terraform change for the mixed-PR check.
export function isTerraformInfrastructurePath(file) {
return isTerraformPath(file) && !file.endsWith(".md");
}
export function mayAccompanyTerraform(file) {
if (isTerraformPath(file)) return true;
if (file.endsWith(".md")) return true;
if (file.startsWith("docs/")) return true;
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
if (
/^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test(
file,
)
) {
return true;
}
if (file.startsWith("scripts/testdata/terraform-")) return true;
return false;
}
/**
* @param {string[]} files changed paths relative to the repository root
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
*/
export function classifyChangedFiles(files) {
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
const terraform = unique.filter(isTerraformInfrastructurePath);
const application = unique.filter((file) => !mayAccompanyTerraform(file));
return {
terraform,
application,
mixed: terraform.length > 0 && application.length > 0,
};
}
function changedFilesFromGit(base, head) {
const mergeBase = execFileSync("git", ["merge-base", base, head], {
cwd: ROOT,
encoding: "utf8",
}).trim();
return execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
{ cwd: ROOT, encoding: "utf8" },
)
.split("\n")
.filter(Boolean);
}
function parseArgs(argv) {
const options = { base: null, head: "HEAD", stdin: false };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--base") options.base = argv[++index];
else if (argument === "--head") options.head = argv[++index];
else if (argument === "--stdin") options.stdin = true;
else throw new Error(`unknown argument: ${argument}`);
}
if (!options.stdin && !options.base) {
throw new Error("provide --base <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)));
}

View file

@ -0,0 +1,179 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
OVERRIDE_LABEL,
classifyChangedFiles,
isTerraformInfrastructurePath,
mayAccompanyTerraform,
} from "./check-terraform-isolation.mjs";
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"check-terraform-isolation.mjs",
);
function runGate(files, env = {}) {
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
input: `${files.join("\n")}\n`,
encoding: "utf8",
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
});
}
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
for (const file of [
"terraform/live/dev/main.tf",
"terraform/live/modules/environment-owned/main.tf",
"terraform/README.md",
"README.md",
"docs/adr/0003-terraform.md",
"scripts/check-terraform-import-plan.py",
"scripts/terraform_import_plan_resources.py",
"scripts/test-terraform-import-plan-check.py",
"scripts/terraform-validate.mjs",
"scripts/check-terraform-isolation.mjs",
"scripts/check-terraform-release-plan.py",
"scripts/hcp-run-guard.py",
"scripts/test-hcp-run-guard.py",
"scripts/verify-cloudfront-release.sh",
"scripts/test-verify-cloudfront-release.sh",
"scripts/summarize-cloudfront-live-state.sh",
"scripts/check-github-workflows.sh",
"scripts/read-release-pointer.py",
"scripts/testdata/terraform-release-plans/version-only.json",
]) {
assert.equal(mayAccompanyTerraform(file), true, file);
}
});
test("application, workflow, and dependency files count as application changes", () => {
for (const file of [
"src/App.tsx",
"public/favicon.ico",
"index.html",
"package.json",
"package-lock.json",
".env.production",
"vite.config.ts",
".github/workflows/deploy.yml",
"scripts/deploy-web.sh",
"scripts/governance-check.mjs",
"e2e/login.spec.ts",
]) {
assert.equal(mayAccompanyTerraform(file), false, file);
}
});
test("terraform-only and application-only changes are not mixed", () => {
assert.equal(
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
false,
);
assert.equal(
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
false,
);
assert.equal(classifyChangedFiles([]).mixed, false);
});
test("terraform documentation does not mix with application or workflow changes", () => {
assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false);
assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true);
assert.equal(
classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed,
false,
);
const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]);
assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr);
assert.match(docsOnly.stdout, /PASS/);
});
test("terraform plus application is mixed and lists the offending files", () => {
const result = classifyChangedFiles([
"terraform/live/dev/main.tf",
"src/App.tsx",
"README.md",
" ",
"src/App.tsx",
]);
assert.equal(result.mixed, true);
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
assert.deepEqual(result.application, ["src/App.tsx"]);
});
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
assert.match(mixed.stdout, /FAIL/);
assert.match(mixed.stdout, /src\/App\.tsx/);
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
assert.match(isolated.stdout, /PASS/);
});
test("CLI override downgrades a mixed change to a warning that names the label", () => {
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /WARNING/);
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "yes",
});
assert.equal(notTrue.status, 1);
});
test("removing the override fails a mixed change that was previously green", () => {
const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"];
const previouslyGreen = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr);
assert.match(previouslyGreen.stdout, /WARNING/);
// CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is
// the string "false" after the label is removed. A stale green check must
// not survive that.
const afterLabelRemoved = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "false",
});
assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr);
assert.match(afterLabelRemoved.stdout, /FAIL/);
assert.match(afterLabelRemoved.stdout, /deploy\.yml/);
});
test("CLI refuses to run without a base ref or --stdin", () => {
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
assert.notEqual(result.status, 0);
});
test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => {
const workflows = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
".github/workflows",
);
const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8");
const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8");
for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) {
assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType);
}
assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m);
assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m);
assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m);
assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/);
assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m);
assert.match(isolationYaml, /name: Terraform and application changes are isolated/);
assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/);
});

View file

@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""Reject HCP Terraform plans that are not a frontend content-release update.
Accepts exactly:
- an update of the release pointer (content, plus computed etag/version_id)
- an update of the distribution with only origin[*].origin_path changed
(response_completion_timeout 0, null, and a missing key are equivalent)
- exactly one action invocation for the CloudFront invalidation
after origin_path values must match the expected labels. before origin_path
values must match the pointer's prior current/previous. This script may read a
local plan JSON file or download plan JSON from the documented HashiCorp
endpoint:
GET https://app.terraform.io/api/v2/plans/:id/json-output
The download follows exactly one redirect, and only to archivist.terraform.io.
It does not create, apply, discard, or poll runs.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import ssl
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlparse
POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer"
DISTRIBUTION_ADDRESS = "module.environment_owned.aws_cloudfront_distribution.site"
ACTION_ADDRESS = (
"module.environment_owned.action.aws_cloudfront_create_invalidation.release"
)
API_HOST = "app.terraform.io"
ARCHIVE_HOST = "archivist.terraform.io"
PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$")
VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$")
IGNORED_ACTIONS = {"no-op", "read"}
UNSAFE_ACTIONS = {"create", "delete"}
POINTER_UNKNOWN_ATTRIBUTES = frozenset({"etag", "version_id"})
DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset(
{
"etag",
"last_modified_time",
"status",
"in_progress_validation_batches",
}
)
# Not an after_unknown allowlist. AWS returns 0 when the timeout is unset;
# the provider writes null on origin_path updates. Treat 0, null, and a
# missing key as the same. Any other value still fails closed.
ORIGIN_RESPONSE_COMPLETION_TIMEOUT = "response_completion_timeout"
ORIGIN_TIMEOUT_UNSET = frozenset({0, None})
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
UrlOpen = Callable[..., Any]
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Return the redirect response instead of following it."""
def http_error_301(self, req, fp, code, msg, headers):
return self._capture(req, fp, code, headers)
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
@staticmethod
def _capture(req, fp, code, headers):
response = urllib.response.addinfourl(fp, headers, req.full_url, code=code)
response.msg = "Redirect"
return response
def _urlopen_without_redirects(
*handlers: urllib.request.BaseHandler,
) -> UrlOpen:
context = ssl.create_default_context()
opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context),
_NoRedirectHandler,
*handlers,
)
return opener.open
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument(
"plan_json",
type=Path,
nargs="?",
help="Local Terraform plan JSON. Mutually exclusive with --plan-id.",
)
source.add_argument(
"--plan-id",
help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.",
)
parser.add_argument(
"--expected-version-label",
required=True,
help="Immutable current release the plan must apply. Empty string is the legacy root.",
)
parser.add_argument(
"--expected-previous-version-label",
default="",
help="Previous release label the origin group must fail over to.",
)
parser.add_argument(
"--evidence-out",
type=Path,
help="Write machine-readable proof after every assertion passes.",
)
return parser.parse_args()
def download_plan_json(
plan_id: str,
token: str,
*,
urlopen: UrlOpen | None = None,
handlers: tuple[urllib.request.BaseHandler, ...] = (),
) -> dict[str, Any]:
if not PLAN_ID_RE.fullmatch(plan_id):
raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id")
if not token:
raise ValueError("TF_API_TOKEN is required to download plan JSON")
opener = urlopen or _urlopen_without_redirects(*handlers)
api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output"
request = urllib.request.Request(
api_url,
method="GET",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
"Accept": "application/json",
},
)
first = _open_pinned(opener, request, allowed_host=API_HOST)
try:
if first.status == 204:
raise ValueError(
"plan JSON is not ready; refusing to poll the plans endpoint"
)
if first.status not in REDIRECT_STATUSES:
raise ValueError(
f"expected a redirect from {API_HOST}, got HTTP {first.status}"
)
location = first.headers.get("Location")
if not location:
raise ValueError(f"{API_HOST} redirect is missing a Location header")
archive = urlparse(location)
if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST:
raise ValueError(
"refusing redirect that is not https://"
f"{ARCHIVE_HOST}/"
)
archive_request = urllib.request.Request(location, method="GET")
second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST)
try:
if second.status in REDIRECT_STATUSES:
raise ValueError(
f"refusing a second redirect from {ARCHIVE_HOST}"
)
if second.status != 200:
raise ValueError(
f"plan JSON download from {ARCHIVE_HOST} returned "
f"HTTP {second.status}"
)
payload = second.read()
finally:
second.close()
finally:
first.close()
plan = json.loads(payload.decode("utf-8"))
if not isinstance(plan, dict):
raise ValueError("plan JSON must be an object")
return plan
def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str):
parsed = urlparse(request.full_url)
if parsed.scheme != "https" or parsed.hostname != allowed_host:
raise ValueError(
f"refusing to contact {parsed.scheme}://{parsed.hostname} "
f"(pinned host is {allowed_host})"
)
context = ssl.create_default_context()
try:
return urlopen(request, context=context, timeout=30)
except TypeError:
return urlopen(request, timeout=30)
def _is_nested_unknown(value: Any) -> bool:
if isinstance(value, dict):
return any(item is True or _is_nested_unknown(item) for item in value.values())
if isinstance(value, list):
return any(item is True or _is_nested_unknown(item) for item in value)
return False
def changed_attributes(
change: dict[str, Any],
*,
computed_unknown: frozenset[str],
) -> set[str]:
before = change.get("before") or {}
after = change.get("after") or {}
unknown = change.get("after_unknown") or {}
keys = set(before) | set(after) | set(unknown)
changed: set[str] = set()
for key in keys:
unknown_value = unknown.get(key)
if unknown_value is True:
if key in computed_unknown:
continue
changed.add(key)
continue
if _is_nested_unknown(unknown_value):
changed.add(key)
continue
if before.get(key) != after.get(key):
changed.add(key)
return changed
def _label_ok(label: str) -> bool:
return label == "" or bool(VERSION_LABEL_RE.fullmatch(label))
def origin_path_for_label(label: str) -> str:
return "" if label == "" else f"/releases/{label}"
def _origin_map(origins: Any) -> dict[str, dict[str, Any]]:
if not isinstance(origins, list):
return {}
mapped: dict[str, dict[str, Any]] = {}
for origin in origins:
if not isinstance(origin, dict):
continue
origin_id = origin.get("origin_id")
if not isinstance(origin_id, str) or not origin_id:
continue
mapped[origin_id] = origin
return mapped
def _origin_paths(origins: Any) -> dict[str, str]:
return {
origin_id: origin.get("origin_path") or ""
for origin_id, origin in _origin_map(origins).items()
}
def _decode_pointer(content: Any) -> dict[str, str]:
if not isinstance(content, str) or not content:
return {}
try:
payload = json.loads(content)
except json.JSONDecodeError:
return {}
if not isinstance(payload, dict):
return {}
return {
"current": payload.get("current") or "",
"previous": payload.get("previous") or "",
}
def _validate_pointer(
resource: dict[str, Any],
expected_current: str,
expected_previous: str,
) -> list[str]:
violations: list[str] = []
change = resource.get("change") or {}
changed = changed_attributes(change, computed_unknown=POINTER_UNKNOWN_ATTRIBUTES)
if changed != {"content"}:
violations.append(
f"{POINTER_ADDRESS}: expected only content to change, found "
f"{sorted(changed) if changed else 'no attribute changes'}"
)
after = _decode_pointer((change.get("after") or {}).get("content"))
if after.get("current") != expected_current:
violations.append(
f"{POINTER_ADDRESS}: after current {after.get('current')!r} does not match "
f"{expected_current!r}"
)
if after.get("previous") != expected_previous:
violations.append(
f"{POINTER_ADDRESS}: after previous {after.get('previous')!r} does not match "
f"{expected_previous!r}"
)
unknown = change.get("after_unknown") or {}
if unknown.get("content") is True:
violations.append(f"{POINTER_ADDRESS}: content after value is unknown")
return violations
def _origin_fields_for_compare(origin: dict[str, Any]) -> dict[str, Any]:
rest = {key: value for key, value in origin.items() if key != "origin_path"}
timeout = rest.get(ORIGIN_RESPONSE_COMPLETION_TIMEOUT)
if timeout in ORIGIN_TIMEOUT_UNSET:
rest.pop(ORIGIN_RESPONSE_COMPLETION_TIMEOUT, None)
return rest
def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool:
return _origin_fields_for_compare(before) != _origin_fields_for_compare(after)
def _validate_distribution(
resource: dict[str, Any],
pointer_before: dict[str, str],
expected_current: str,
expected_previous: str,
) -> list[str]:
violations: list[str] = []
change = resource.get("change") or {}
changed = changed_attributes(
change, computed_unknown=DISTRIBUTION_UNKNOWN_ATTRIBUTES
)
if changed != {"origin"}:
violations.append(
f"{DISTRIBUTION_ADDRESS}: expected only origin to change, found "
f"{sorted(changed) if changed else 'no attribute changes'}"
)
return violations
before_origins = _origin_map((change.get("before") or {}).get("origin"))
after_origins = _origin_map((change.get("after") or {}).get("origin"))
if set(before_origins) != set(after_origins):
violations.append(
f"{DISTRIBUTION_ADDRESS}: origin IDs changed "
f"from {sorted(before_origins)} to {sorted(after_origins)}"
)
return violations
for origin_id, before_origin in before_origins.items():
if _origin_non_path_fields_changed(before_origin, after_origins[origin_id]):
violations.append(
f"{DISTRIBUTION_ADDRESS}: origin {origin_id!r} changed a field other than origin_path"
)
after_paths = sorted(_origin_paths((change.get("after") or {}).get("origin")).values())
expected_after = sorted(
[
origin_path_for_label(expected_current),
origin_path_for_label(expected_previous),
]
)
if after_paths != expected_after:
violations.append(
f"{DISTRIBUTION_ADDRESS}: after origin_path {after_paths} does not match "
f"{expected_after}"
)
before_paths = sorted(_origin_paths((change.get("before") or {}).get("origin")).values())
expected_before = sorted(
[
origin_path_for_label(pointer_before.get("current", "")),
origin_path_for_label(pointer_before.get("previous", "")),
]
)
if before_paths != expected_before:
violations.append(
f"{DISTRIBUTION_ADDRESS}: before origin_path {before_paths} does not match "
f"pointer prior values {expected_before}"
)
return violations
def _validate_actions(plan: dict[str, Any]) -> list[str]:
invocations = plan.get("action_invocations")
if invocations is None:
return ["plan is missing action_invocations"]
if not isinstance(invocations, list):
return ["action_invocations must be a list"]
addresses = [
item.get("address")
for item in invocations
if isinstance(item, dict)
]
if addresses != [ACTION_ADDRESS]:
return [
"expected exactly one action_invocations entry "
f"{ACTION_ADDRESS}, found {addresses}"
]
return []
def validate_plan(
plan: dict[str, Any],
expected_current: str,
expected_previous: str,
) -> list[str]:
violations: list[str] = []
if not _label_ok(expected_current):
violations.append(
"expected version label must be empty or <full-sha>-<run-id>-<attempt>"
)
return violations
if not _label_ok(expected_previous):
violations.append(
"expected previous version label must be empty or <full-sha>-<run-id>-<attempt>"
)
return violations
updates: dict[str, dict[str, Any]] = {}
for resource in plan.get("resource_changes", []):
if resource.get("mode", "managed") != "managed":
continue
address = resource.get("address", "<unknown>")
change = resource.get("change") or {}
actions = list(change.get("actions") or [])
action_set = set(actions)
if action_set <= IGNORED_ACTIONS:
continue
if change.get("importing"):
violations.append(f"{address}: import actions are not allowed")
unsafe = sorted(action_set & UNSAFE_ACTIONS)
if unsafe:
violations.append(f"{address}: unsafe actions {unsafe}")
if "replace" in action_set or actions in (
["delete", "create"],
["create", "delete"],
):
violations.append(f"{address}: replacement is not allowed")
if "update" in action_set:
updates[address] = resource
if action_set != {"update"}:
violations.append(
f"{address}: update must be the only action, got {actions}"
)
if address not in {POINTER_ADDRESS, DISTRIBUTION_ADDRESS} and (
action_set - IGNORED_ACTIONS
):
violations.append(
f"{address}: managed address is outside the content-release update"
)
if set(updates) != {POINTER_ADDRESS, DISTRIBUTION_ADDRESS}:
violations.append(
"expected exactly the pointer and distribution updates, found "
f"{sorted(updates)}"
)
violations.extend(_validate_actions(plan))
return violations
pointer_change = updates[POINTER_ADDRESS].get("change") or {}
pointer_before = _decode_pointer((pointer_change.get("before") or {}).get("content"))
violations.extend(
_validate_pointer(updates[POINTER_ADDRESS], expected_current, expected_previous)
)
violations.extend(
_validate_distribution(
updates[DISTRIBUTION_ADDRESS],
pointer_before,
expected_current,
expected_previous,
)
)
violations.extend(_validate_actions(plan))
return violations
def main() -> int:
args = parse_args()
if args.plan_id:
try:
plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", ""))
except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr)
return 1
else:
if args.plan_json is None:
print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr)
return 1
plan = json.loads(args.plan_json.read_text(encoding="utf-8"))
violations = validate_plan(
plan,
args.expected_version_label,
args.expected_previous_version_label,
)
if violations:
print("FAIL: Terraform plan is not a content-release update", file=sys.stderr)
for violation in violations:
print(f" - {violation}", file=sys.stderr)
return 1
if args.evidence_out:
evidence = {
"pointer_address": POINTER_ADDRESS,
"distribution_address": DISTRIBUTION_ADDRESS,
"action_address": ACTION_ADDRESS,
"expected_version_label": args.expected_version_label,
"expected_previous_version_label": args.expected_previous_version_label,
"managed_updates": 2,
"action_invocations": 1,
"creates": 0,
"deletes": 0,
"replacements": 0,
}
args.evidence_out.write_text(
json.dumps(evidence, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
"PASS: content-release plan updates "
f"{POINTER_ADDRESS} and {DISTRIBUTION_ADDRESS} to "
f"{args.expected_version_label} (previous {args.expected_previous_version_label!r})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

@ -17,6 +17,17 @@ const MAINTAINABILITY_RULES = [
const GOVERNED_ROOTS = ["src/", "config/"];
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// Repository-level gates that run after the source gates. Each is an npm
// script so it can also be run on its own.
const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform release-plan contract", "test:terraform-release-plan"],
["Terraform isolation gate", "test:terraform-isolation"],
["Terraform formatting and validation", "test:terraform"],
["HCP run guard", "test:hcp-run-guard"],
["CloudFront release verify", "test:cloudfront-release-verify"],
["GitHub workflow shell", "test:github-workflows"],
];
function isGoverned(relativePath) {
return (
@ -194,6 +205,20 @@ function plural(count, word) {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
function runRepositoryGate(label, script) {
// Reuse the npm that launched us when available (matches its version and
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
const npmCli = process.env.npm_execpath;
const executable = npmCli ? process.execPath : "npm";
const args = npmCli ? [npmCli, "run", script] : ["run", script];
const result = spawnSync(executable, args, {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
});
return { label, status: result.status, error: result.error };
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
@ -281,6 +306,17 @@ function main() {
}
}
for (const [label, script] of REPOSITORY_GATES) {
console.log("─".repeat(64));
console.log(`${label}: npm run ${script}`);
const gate = runRepositoryGate(label, script);
if (gate.error) {
failures.push(`${label}: could not start: ${gate.error.message}`);
} else if (gate.status !== 0) {
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
}
}
console.log("─".repeat(64));
if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);

207
scripts/hcp-run-guard.py Executable file
View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Guard HCP Terraform runs used by GitHub content CD.
Subcommands:
check-and-discard Refuse unsafe workspace settings. Discard a blocking
non-speculative VCS run so GitHub CD can create-run.
reconcile-apply Treat an HCP run whose status is already ``applied`` as
success when the GitHub apply-run step reported failure.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any, Callable
API = "https://app.terraform.io/api/v2"
DEFAULT_WORKSPACE = "shoc-frontend-new-dev"
EXPECTED_TRIGGER_PATTERNS = [
"terraform/live/dev/**",
"terraform/live/modules/**",
]
DISCARDABLE = {
"pending",
"planned",
"cost_estimated",
"policy_checked",
"policy_override",
}
APPLYING = {"applying", "apply_queued"}
HttpGet = Callable[[str], dict[str, Any]]
HttpPost = Callable[[str, dict[str, Any]], int]
class GuardError(Exception):
"""Refused to continue."""
def _headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
}
def default_get(token: str) -> HttpGet:
def get(url: str) -> dict[str, Any]:
request = urllib.request.Request(url, headers=_headers(token))
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
return get
def default_post(token: str) -> HttpPost:
def post(url: str, payload: dict[str, Any]) -> int:
data = json.dumps(payload).encode()
request = urllib.request.Request(
url, data=data, method="POST", headers=_headers(token)
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return int(response.status)
except urllib.error.HTTPError as exc:
if exc.code in (409, 404):
body = exc.read().decode("utf-8", "replace")
print(f"discard returned HTTP {exc.code}: {body}")
return exc.code
raise
return post
def require_token(token: str) -> str:
if not token:
raise GuardError("TF_API_TOKEN is required")
return token
def check_invariants(attrs: dict[str, Any], workspace: str) -> None:
if attrs.get("auto-apply") is True:
raise GuardError(f"{workspace} auto-apply is on; refuse to continue")
if not attrs.get("speculative-enabled"):
raise GuardError("speculative plans are off; refuse to continue")
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
raise GuardError("tag-based VCS triggering is set; refuse to continue")
if attrs.get("trigger-patterns") != EXPECTED_TRIGGER_PATTERNS:
raise GuardError(
"trigger-patterns must be "
f"{EXPECTED_TRIGGER_PATTERNS}; got {attrs.get('trigger-patterns')}"
)
def check_and_discard(
*,
workspace: str,
token: str,
get: HttpGet | None = None,
post: HttpPost | None = None,
) -> int:
token = require_token(token)
get = get or default_get(token)
post = post or default_post(token)
workspace_payload = get(
f"{API}/organizations/seahaven/workspaces/{workspace}"
)["data"]
attrs = workspace_payload["attributes"]
check_invariants(attrs, workspace)
if not attrs.get("locked"):
print("workspace is unlocked")
return 0
current = (
workspace_payload.get("relationships", {})
.get("current-run", {})
.get("data")
)
if not current:
raise GuardError("workspace is locked without a current run")
run_id = current["id"]
run = get(f"{API}/runs/{run_id}")["data"]
run_attrs = run["attributes"]
status = run_attrs.get("status")
plan_only = run_attrs.get("plan-only")
print(f"current run {run_id} status={status} plan-only={plan_only}")
if plan_only:
print("speculative run does not block GitHub CD")
return 0
if status in APPLYING:
raise GuardError(f"{run_id} is {status}; wait, do not discard an apply")
if status not in DISCARDABLE:
raise GuardError(f"{run_id} status {status} is not discardable")
code = post(
f"{API}/runs/{run_id}/actions/discard",
{
"comment": (
"Discarded so GitHub CD can create the content-release applyable run"
)
},
)
print(f"discarded {run_id} http={code}")
return 0
def reconcile_apply(
*,
run_id: str,
apply_outcome: str,
token: str,
get: HttpGet | None = None,
) -> int:
token = require_token(token)
if not run_id:
raise GuardError("run id is required")
if apply_outcome == "success":
print("Apply succeeded.")
return 0
get = get or default_get(token)
status = get(f"{API}/runs/{run_id}")["data"]["attributes"]["status"]
print(f"HCP run {run_id} status={status}")
if status == "applied":
return 0
raise GuardError(
f"Apply failed: GitHub outcome={apply_outcome} HCP status={status}"
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
check = sub.add_parser("check-and-discard")
check.add_argument("--workspace", default=DEFAULT_WORKSPACE)
check.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
reconcile = sub.add_parser("reconcile-apply")
reconcile.add_argument("--run-id", required=True)
reconcile.add_argument(
"--apply-outcome",
default=os.environ.get("APPLY_OUTCOME", ""),
)
reconcile.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
if args.command == "check-and-discard":
return check_and_discard(workspace=args.workspace, token=args.token)
return reconcile_apply(
run_id=args.run_id,
apply_outcome=args.apply_outcome,
token=args.token,
)
except GuardError as exc:
print(str(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

29
scripts/read-release-pointer.py Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Read .release/current JSON from stdin and write GitHub Actions outputs."""
from __future__ import annotations
import json
import os
import sys
def main() -> int:
raw = sys.stdin.read().strip()
data = json.loads(raw) if raw else {}
current = data.get("current") or ""
previous = data.get("previous") or ""
output_path = os.environ["GITHUB_OUTPUT"]
with open(output_path, "a", encoding="utf-8") as handle:
handle.write(f"live_current={current}\n")
handle.write(f"live_previous={previous}\n")
print(
"Pointer live current="
+ (current or "<empty>")
+ " previous="
+ (previous or "<empty>")
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Print pointer body, origin paths, distribution status, and served index hash.
# Used by deploy.yml's always() summary. Never fails the job on a missing pointer.
set -u
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}"
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}"
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
echo "=== CloudFront live state ==="
echo "pointer:"
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)"
echo
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
import json, sys
payload = json.load(sys.stdin)
dist = payload.get("Distribution") or {}
config = dist.get("DistributionConfig") or {}
print("status:", dist.get("Status"))
for origin in ((config.get("Origins") or {}).get("Items") or []):
print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or ""))
'
echo
echo -n "served index sha256: "
curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"

View 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(", ")}.`);

View file

@ -0,0 +1,130 @@
"""Canonical frontend Terraform ownership and import-ID maps.
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
are kept so the checker can prove that a dev plan carrying a staging identifier
is rejected; they do not authorize a staging import.
"""
COMMON_RESOURCES = {
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
"module.environment_owned.aws_s3_bucket_public_access_block.site": (
"aws_s3_bucket_public_access_block"
),
"module.environment_owned.aws_s3_bucket_ownership_controls.site": (
"aws_s3_bucket_ownership_controls"
),
"module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": (
"aws_s3_bucket_server_side_encryption_configuration"
),
"module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning",
"module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy",
"module.environment_owned.aws_cloudfront_distribution.site": (
"aws_cloudfront_distribution"
),
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
"aws_cloudfront_origin_access_control"
),
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
"aws_cloudfront_function"
),
"module.environment_owned.aws_route53_record.site_a": "aws_route53_record",
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
}
REQUIRED_RESOURCES = {
environment: dict(COMMON_RESOURCES)
for environment in ("dev", "staging")
}
CONTROLLED_UPDATE_ADDRESSES = frozenset(
{
"module.environment_owned.aws_s3_bucket.site",
"module.environment_owned.aws_s3_bucket_policy.site",
"module.environment_owned.aws_cloudfront_distribution.site",
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
"module.environment_owned.aws_iam_role.github_deploy",
}
)
ENVIRONMENT_CONFIG = {
"dev": {
"bucket_name": "seahaven-shoc-frontend-dev",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
),
"cloudformation_stack_name": "shoc-frontend-dev",
"distribution_id": "E2CWLM1AFB964P",
"workspace_name": "shoc-frontend-new-dev",
},
"staging": {
"bucket_name": "seahaven-shoc-frontend-staging",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
),
"cloudformation_stack_name": "shoc-frontend-staging",
"distribution_id": "E2JDVEZ6EGD49J",
"workspace_name": "shoc-frontend-new-staging",
},
}
def _bucket_imports(bucket_name: str) -> dict[str, str]:
return {
address: bucket_name
for address in COMMON_RESOURCES
if address.startswith("module.environment_owned.aws_s3_bucket")
}
REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"dev": {
**_bucket_imports("seahaven-shoc-frontend-dev"),
"module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P",
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
"E30VSIK87N8H64"
),
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
"us-east-1shocfrontenddevSpaRewrite58674DB8"
),
"module.environment_owned.aws_route53_record.site_a": (
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A"
),
"module.environment_owned.aws_route53_record.site_aaaa": (
"Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA"
),
"module.environment_owned.aws_iam_role.github_deploy": (
"githubdeploy-shoc-frontend-new-dev"
),
"module.environment_owned.aws_iam_role_policy.github_deploy": (
"githubdeploy-shoc-frontend-new-dev:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
},
"staging": {
**_bucket_imports("seahaven-shoc-frontend-staging"),
"module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J",
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
"E1PF5R6QQNBZAI"
),
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
"us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
),
"module.environment_owned.aws_route53_record.site_a": (
"Z02602739VQWBWCAGXP4_staging.seahaven.com_A"
),
"module.environment_owned.aws_route53_record.site_aaaa": (
"Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA"
),
"module.environment_owned.aws_iam_role.github_deploy": (
"githubdeploy-shoc-frontend-new-staging"
),
"module.environment_owned.aws_iam_role_policy.github_deploy": (
"githubdeploy-shoc-frontend-new-staging:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
},
}

243
scripts/test-hcp-run-guard.py Executable file
View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Tests for every hcp-run-guard refusal, exit-0, discard, and reconcile case."""
from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any
SCRIPT = Path(__file__).with_name("hcp-run-guard.py")
WORKSPACE = "shoc-frontend-new-dev"
PATTERNS = [
"terraform/live/dev/**",
"terraform/live/modules/**",
]
def load_module():
spec = importlib.util.spec_from_file_location("hcp_run_guard", SCRIPT)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def workspace_payload(
*,
auto_apply: bool = False,
speculative: bool = True,
tags_regex: str | None = None,
trigger_patterns: list[str] | None = None,
locked: bool = False,
current_run: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"data": {
"attributes": {
"auto-apply": auto_apply,
"speculative-enabled": speculative,
"vcs-repo": {"tags-regex": tags_regex},
"trigger-patterns": PATTERNS if trigger_patterns is None else trigger_patterns,
"locked": locked,
},
"relationships": {
"current-run": {"data": current_run},
},
}
}
def run_payload(*, status: str, plan_only: bool = False) -> dict[str, Any]:
return {"data": {"attributes": {"status": status, "plan-only": plan_only}}}
def check(module, payloads: dict[str, Any], posts: list | None = None):
calls: list[str] = []
def get(url: str) -> dict[str, Any]:
calls.append(url)
if url not in payloads:
raise AssertionError(f"unexpected GET {url}")
return payloads[url]
recorded: list[tuple[str, dict[str, Any]]] = []
def post(url: str, payload: dict[str, Any]) -> int:
recorded.append((url, payload))
if posts:
return posts.pop(0)
return 202
try:
code = module.check_and_discard(
workspace=WORKSPACE,
token="test-token",
get=get,
post=post,
)
return code, None, calls, recorded
except module.GuardError as exc:
return 1, str(exc), calls, recorded
def reconcile(module, outcome: str, payloads: dict[str, Any], run_id: str = "run-1"):
def get(url: str) -> dict[str, Any]:
if url not in payloads:
raise AssertionError(f"unexpected GET {url}")
return payloads[url]
try:
code = module.reconcile_apply(
run_id=run_id,
apply_outcome=outcome,
token="test-token",
get=get,
)
return code, None
except module.GuardError as exc:
return 1, str(exc)
def main() -> int:
module = load_module()
ws = f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{WORKSPACE}"
run_url = "https://app.terraform.io/api/v2/runs/run-1"
discard_url = f"{run_url}/actions/discard"
failures: list[str] = []
def expect_refuse(name: str, payloads: dict[str, Any], fragment: str) -> None:
code, error, _, recorded = check(module, payloads)
if code != 1 or not error or fragment not in error:
failures.append(f"{name}: expected refuse containing {fragment!r}, got {code} {error}")
if recorded:
failures.append(f"{name}: discard was posted on a refusal")
expect_refuse(
"auto-apply",
{ws: workspace_payload(auto_apply=True)},
"auto-apply is on",
)
expect_refuse(
"speculative-off",
{ws: workspace_payload(speculative=False)},
"speculative plans are off",
)
expect_refuse(
"tags-regex",
{ws: workspace_payload(tags_regex="^v")},
"tag-based VCS triggering",
)
expect_refuse(
"wrong-patterns",
{ws: workspace_payload(trigger_patterns=["terraform/**"])},
"trigger-patterns must be",
)
expect_refuse(
"locked-without-run",
{ws: workspace_payload(locked=True, current_run=None)},
"locked without a current run",
)
expect_refuse(
"applying",
{
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
run_url: run_payload(status="applying"),
},
"wait, do not discard an apply",
)
expect_refuse(
"not-discardable",
{
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
run_url: run_payload(status="errored"),
},
"is not discardable",
)
code, error, _, recorded = check(module, {ws: workspace_payload(locked=False)})
if code != 0 or error is not None or recorded:
failures.append(f"unlocked: expected exit 0, got {code} {error} {recorded}")
code, error, _, recorded = check(
module,
{
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
run_url: run_payload(status="planned", plan_only=True),
},
)
if code != 0 or recorded:
failures.append(f"plan-only: expected exit 0 without discard, got {code} {recorded}")
code, error, _, recorded = check(
module,
{
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
run_url: run_payload(status="planned"),
},
)
if code != 0 or error is not None:
failures.append(f"discard: expected exit 0, got {code} {error}")
if not recorded or recorded[0][0] != discard_url:
failures.append(f"discard: posted {recorded}")
code, error, _, recorded = check(
module,
{
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
run_url: run_payload(status="policy_checked"),
},
posts=[409],
)
if code != 0:
failures.append(f"discard-409: expected exit 0, got {code} {error}")
try:
module.check_and_discard(workspace=WORKSPACE, token="", get=lambda _url: {})
failures.append("missing-token: accepted empty token")
except module.GuardError:
pass
code, error = reconcile(module, "success", {})
if code != 0:
failures.append(f"reconcile-success: expected 0, got {code} {error}")
code, error = reconcile(
module,
"failure",
{run_url: run_payload(status="applied")},
)
if code != 0:
failures.append(f"reconcile-applied: expected 0, got {code} {error}")
code, error = reconcile(
module,
"failure",
{run_url: run_payload(status="errored")},
)
if code != 1 or not error or "errored" not in error:
failures.append(f"reconcile-errored: expected refuse, got {code} {error}")
try:
module.reconcile_apply(run_id="", apply_outcome="failure", token="test-token")
failures.append("reconcile-missing-run: accepted empty run id")
except module.GuardError:
pass
try:
module.reconcile_apply(run_id="run-1", apply_outcome="failure", token="")
failures.append("reconcile-missing-token: accepted empty token")
except module.GuardError:
pass
if failures:
print("FAIL: hcp-run-guard cases failed", file=__import__("sys").stderr)
for item in failures:
print(f" - {item}", file=__import__("sys").stderr)
return 1
print("PASS: HCP run guard checks")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""Deterministic unit tests for the frontend Terraform plan checker."""
from __future__ import annotations
import copy
import json
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
REPOSITORY = SCRIPT.parent.parent
BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site"
BUCKET = "module.environment_owned.aws_s3_bucket.site"
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY}
def import_id(environment: str, address: str) -> str:
expected = REQUIRED_IMPORT_IDS[environment][address]
assert expected is not None, f"{environment} must pin an import ID for {address}"
return expected
def distribution_id(environment: str) -> str:
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
return configured
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
source = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": config["bucket_auto_delete_helper_role_arn"]
},
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
def bucket_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}"
source = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
def tag_change(environment: str, address: str) -> dict[str, Any]:
manager = {
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"]
}
before_tags = {
"Environment": environment,
"ManagedBy": "cdk",
"Project": "shoc-frontend",
}
after_tags = {
"Environment": environment,
"ManagedBy": "terraform",
"Ownership": "terraform",
"Project": "shoc-frontend",
}
if address == ROLE:
before_tags.update(manager)
after_tags.update(manager)
if address == BUCKET:
before_tags["aws-cdk:auto-delete-objects"] = "true"
before: dict[str, Any] = {
"tags": before_tags,
"tags_all": before_tags,
}
after: dict[str, Any] = {
"tags": after_tags,
"tags_all": after_tags,
}
if address == DISTRIBUTION:
before["id"] = distribution_id(environment)
after["id"] = distribution_id(environment)
return {"actions": ["update"], "before": before, "after": after}
def policy_change(environment: str, address: str) -> dict[str, Any]:
if address != BUCKET_POLICY:
raise AssertionError(f"{address} is not a reviewed policy update")
return {
"actions": ["update"],
"before": {"policy": json.dumps(pre_adoption_bucket_policy(environment))},
"after": {"policy": json.dumps(bucket_policy(environment))},
}
def make_plan(
environment: str,
*,
mode: str = "import",
controlled_updates: set[str] | None = None,
) -> dict[str, Any]:
resources: list[dict[str, Any]] = []
updates = controlled_updates or set()
for address, resource_type in REQUIRED_RESOURCES[environment].items():
if mode == "import":
change: dict[str, Any] = {
"actions": ["no-op"],
"importing": {"id": import_id(environment, address)},
}
elif mode == "post-import":
change = {"actions": ["no-op"]}
elif address in updates:
change = (
tag_change(environment, address)
if address in TAG_ADDRESSES
else policy_change(environment, address)
)
else:
change = {"actions": ["no-op"]}
if address == DISTRIBUTION:
change["after"] = {"id": distribution_id(environment)}
resources.append(
{
"address": address,
"mode": "managed",
"type": resource_type,
"change": change,
}
)
return {"resource_changes": resources}
def resource(plan: dict[str, Any], address: str) -> dict[str, Any]:
return next(
item for item in plan["resource_changes"] if item["address"] == address
)
def run_checker(
plan: dict[str, Any],
environment: str,
*allowed_updates: str,
post_import: bool = False,
) -> subprocess.CompletedProcess[str]:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "plan.json"
path.write_text(json.dumps(plan), encoding="utf-8")
command = [
sys.executable,
str(SCRIPT),
str(path),
"--environment",
environment,
]
if post_import:
command.append("--post-import-no-op")
for address in allowed_updates:
command.extend(["--allow-update-address", address])
return subprocess.run(
command,
check=False,
capture_output=True,
text=True,
)
class ImportPlanCheckerTests(unittest.TestCase):
def assert_passes(
self,
plan: dict[str, Any],
environment: str,
*allowed_updates: str,
post_import: bool = False,
) -> None:
result = run_checker(
plan,
environment,
*allowed_updates,
post_import=post_import,
)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
def assert_fails(
self,
plan: dict[str, Any],
environment: str,
*allowed_updates: str,
post_import: bool = False,
) -> None:
result = run_checker(
plan,
environment,
*allowed_updates,
post_import=post_import,
)
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
source = (
REPOSITORY
/ "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
expected = """ spa_rewrite_code = join("\\n", [
"function handler(event) {",
" var request = event.request;",
" var uri = request.uri;",
" // No file extension after the last slash -> a client-side route.",
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
" request.uri = '/index.html';",
" }",
" return request;",
"}",
])"""
self.assertIn(expected, source)
def test_only_dev_has_a_live_root(self) -> None:
live_roots = sorted(
path.name
for path in (REPOSITORY / "terraform/live").iterdir()
if path.is_dir() and path.name != "modules"
)
self.assertEqual(["dev"], live_roots)
def test_dev_root_pins_adoption_complete_in_code(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n")
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
self.assertNotIn('variable "adoption_complete"', source)
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
self.assertNotIn(
"variable ",
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
root_file,
)
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
expected = {
"dev": (
"local.hosted_zone_id",
"local.certificate_arn",
"local.github_oidc_arn",
"local.cache_policy_id",
),
}
for environment, values in expected.items():
source = (
REPOSITORY / f"terraform/live/{environment}/main.tf"
).read_text(encoding="utf-8")
for name, value in zip(
(
"hosted_zone_id",
"certificate_arn",
"github_oidc_provider_arn",
"cache_policy_id",
),
values,
strict=True,
):
self.assertIn(f"{name}", source)
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
self.assertNotRegex(
source,
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
)
def test_exact_import_plan_passes_for_every_environment(self) -> None:
for environment in REQUIRED_RESOURCES:
with self.subTest(environment=environment):
self.assert_passes(make_plan(environment), environment)
def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None:
for mutation in ("missing", "extra", "wrong-type", "cross-environment"):
plan = make_plan("dev")
if mutation == "missing":
plan["resource_changes"].pop()
elif mutation == "extra":
plan["resource_changes"].append(
{
"address": "module.inventory.aws_route53_zone.site",
"mode": "managed",
"type": "aws_route53_zone",
"change": {
"actions": ["no-op"],
"importing": {"id": "Z00000000000000000000"},
},
}
)
elif mutation == "wrong-type":
plan["resource_changes"][0]["type"] = "aws_s3_object"
else:
resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = (
REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION]
)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev")
def test_import_rejects_mutation_and_invalid_metadata(self) -> None:
for actions in (["create"], ["update"], ["delete"], ["delete", "create"]):
plan = make_plan("dev")
plan["resource_changes"][0]["change"]["actions"] = actions
with self.subTest(actions=actions):
self.assert_fails(plan, "dev")
plan = make_plan("dev")
plan["resource_changes"][0]["change"]["importing"] = {"id": ""}
self.assert_fails(plan, "dev")
def test_post_import_no_op_passes(self) -> None:
self.assert_passes(
make_plan("staging", mode="post-import"),
"staging",
post_import=True,
)
def test_post_import_rejects_import_metadata_and_update(self) -> None:
plan = make_plan("dev", mode="post-import")
plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"}
self.assert_fails(plan, "dev", post_import=True)
plan = make_plan("dev", mode="post-import")
plan["resource_changes"][0]["change"]["actions"] = ["update"]
self.assert_fails(plan, "dev", post_import=True)
def test_every_allowed_controlled_diff_passes(self) -> None:
for environment in REQUIRED_RESOURCES:
for address in CONTROLLED_UPDATE_ADDRESSES:
with self.subTest(environment=environment, address=address):
self.assert_passes(
make_plan(
environment,
mode="controlled",
controlled_updates={address},
),
environment,
address,
)
def test_full_exact_controlled_allowlist_passes(self) -> None:
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
self.assert_passes(
make_plan(
"dev",
mode="controlled",
controlled_updates=set(addresses),
),
"dev",
*addresses,
)
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
self.assert_fails(plan, "dev", ROLE)
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
self.assert_fails(plan, "dev", ROLE)
def test_tag_update_requires_complete_adopted_tag_sets(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET})
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
self.assert_fails(plan, "dev", BUCKET)
def test_role_trust_change_is_rejected(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
role = resource(plan, ROLE)["change"]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
self.assert_fails(plan, "dev", ROLE)
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
for mutation in ("principal", "extra"):
plan = make_plan(
"dev",
mode="controlled",
controlled_updates={BUCKET_POLICY},
)
policy = copy.deepcopy(bucket_policy("dev"))
if mutation == "principal":
policy["Statement"][0]["Principal"] = {"AWS": "*"}
else:
policy["Statement"].append(
{
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": "*",
}
)
resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps(
policy
)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", BUCKET_POLICY)
def test_github_deploy_policy_is_release_prefix_only(self) -> None:
source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
document = document.split("resource ", 1)[0]
self.assertNotIn("var.adoption_complete", document)
self.assertIn("ListReleasePrefixes", document)
self.assertIn("PublishReleasePrefix", document)
self.assertIn("ReadReleasePointer", document)
self.assertIn("ReadDistribution", document)
self.assertIn("cloudfront:GetDistribution", document)
self.assertIn("cloudfront:GetDistributionConfig", document)
self.assertIn("releases/*", document)
self.assertNotIn("AssumeCdkBootstrapRoles", document)
self.assertNotIn("DescribeStack", document)
self.assertNotIn("CreateInvalidation", document)
self.assertNotIn("ReadDeploymentBucket", document)
self.assertNotIn("PublishAndRollbackSiteObjects", document)
self.assertNotIn(
"module.environment_owned.aws_iam_role_policy.github_deploy",
CONTROLLED_UPDATE_ADDRESSES,
)
def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates=set())
self.assert_fails(plan, "dev", DEPLOY_POLICY)
plan = make_plan("dev", mode="controlled", controlled_updates=set())
resource(plan, DEPLOY_POLICY)["change"] = {
"actions": ["update"],
"before": {"policy": "{}"},
"after": {"policy": '{"Version":"2012-10-17"}'},
}
self.assert_fails(plan, "dev", DEPLOY_POLICY)
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
for environment in REQUIRED_RESOURCES:
plan = make_plan(
environment,
mode="controlled",
controlled_updates={BUCKET_POLICY},
)
change = resource(plan, BUCKET_POLICY)["change"]
before = json.loads(change["before"]["policy"])
before["Statement"].append(
{
"Sid": "UnexpectedDrift",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
}
)
change["before"]["policy"] = json.dumps(before)
with self.subTest(environment=environment):
self.assert_fails(plan, environment, BUCKET_POLICY)
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
for field, value in (
("after_unknown", {"tags": {"ManagedBy": True}}),
("replace_paths", [["tags"]]),
):
plan = make_plan(
"dev",
mode="controlled",
controlled_updates={ROLE},
)
resource(plan, ROLE)["change"][field] = value
with self.subTest(field=field):
self.assert_fails(plan, "dev", ROLE)
def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
self.assert_fails(plan, "dev", BUCKET_POLICY)
plan = make_plan("dev", mode="controlled", controlled_updates=set())
self.assert_fails(plan, "dev", ROLE)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""Deterministic tests for check-terraform-release-plan.py."""
from __future__ import annotations
import importlib.util
import io
import subprocess
import sys
import urllib.request
from email.message import EmailMessage
from pathlib import Path
from urllib.request import Request
SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py")
FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans"
EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
EXPECTED_PREVIOUS = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
PLAN_ID = "plan-8F5JFydVYAmtTjET"
POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer"
def run_case(
fixture_name: str,
*,
expected_label: str = EXPECTED_LABEL,
expected_previous: str = EXPECTED_PREVIOUS,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
sys.executable,
str(SCRIPT),
str(FIXTURES / fixture_name),
"--expected-version-label",
expected_label,
"--expected-previous-version-label",
expected_previous,
],
check=False,
capture_output=True,
text=True,
)
class FakeResponse:
def __init__(
self,
*,
url: str,
status: int,
headers: dict[str, str] | None = None,
body: bytes = b"",
) -> None:
self.url = url
self.status = status
self.headers = headers or {}
self._body = body
def read(self) -> bytes:
return self._body
def close(self) -> None:
return None
def load_check_module():
spec = importlib.util.spec_from_file_location(
"check_terraform_release_plan", SCRIPT
)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_download_pinning() -> list[str]:
module = load_check_module()
fixture = (FIXTURES / "version-only.json").read_bytes()
archive_url = "https://archivist.terraform.io/v1/object/example"
calls: list[str] = []
def fake_urlopen(request: Request, **_kwargs):
url = request.full_url
calls.append(url)
host = request.host if hasattr(request, "host") else ""
if url.startswith("https://app.terraform.io/api/v2/plans/"):
if request.get_header("Authorization") != "Bearer test-token":
raise AssertionError("API request is missing the bearer token")
if "/runs" in url or "/apply" in url or "/discard" in url:
raise AssertionError(f"download contacted a run-control path: {url}")
return FakeResponse(
url=url,
status=307,
headers={"Location": archive_url},
)
if url == archive_url:
if request.get_header("Authorization"):
raise AssertionError("archivist request must not send TF_API_TOKEN")
return FakeResponse(url=url, status=200, body=fixture)
raise AssertionError(f"unexpected URL {url} host={host}")
plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen)
failures: list[str] = []
addresses = [item["address"] for item in plan["resource_changes"]]
if POINTER_ADDRESS not in addresses:
failures.append("download did not return the version-only fixture")
if calls != [
f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output",
archive_url,
]:
failures.append(f"download URLs were {calls}")
try:
module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen)
failures.append("invalid plan id was accepted")
except ValueError:
pass
def redirect_elsewhere(request: Request, **_kwargs):
return FakeResponse(
url=request.full_url,
status=307,
headers={"Location": "https://evil.example/plan.json"},
)
try:
module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere)
failures.append("redirect to a non-archivist host was accepted")
except ValueError:
pass
def double_redirect(request: Request, **_kwargs):
if request.full_url.startswith("https://app.terraform.io/"):
return FakeResponse(
url=request.full_url,
status=307,
headers={"Location": archive_url},
)
return FakeResponse(
url=request.full_url,
status=307,
headers={"Location": "https://archivist.terraform.io/v1/object/other"},
)
try:
module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect)
failures.append("second archivist redirect was accepted")
except ValueError:
pass
def not_ready(request: Request, **_kwargs):
return FakeResponse(url=request.full_url, status=204)
try:
module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready)
failures.append("HTTP 204 was polled or accepted")
except ValueError as exc:
if "poll" not in str(exc):
failures.append(f"HTTP 204 error was {exc}")
source = SCRIPT.read_text(encoding="utf-8")
for banned in ("/apply", "/discard", "/runs"):
if banned in source:
failures.append(f"download client contains run-control path {banned}")
return failures
def _scripted_https_handler(fixture: bytes, archive_url: str):
calls: list[str] = []
api_prefix = "https://app.terraform.io/api/v2/plans/"
class ScriptedHTTPSHandler(urllib.request.BaseHandler):
handler_order = 100
def https_open(self, req: Request):
url = req.full_url
calls.append(url)
headers = EmailMessage()
if url.startswith(api_prefix):
headers["Location"] = archive_url
body = b""
status = 307
msg = "Temporary Redirect"
elif url == archive_url:
body = fixture
status = 200
msg = "OK"
else:
raise AssertionError(f"unexpected URL {url}")
response = urllib.response.addinfourl(
io.BytesIO(body),
headers,
url,
code=status,
)
response.msg = msg
return response
return ScriptedHTTPSHandler(), calls
def test_download_standard_opener_redirect() -> list[str]:
"""urllib follows the HCP 307; the guard must still inspect that first hop."""
module = load_check_module()
fixture = (FIXTURES / "version-only.json").read_bytes()
archive_url = "https://archivist.terraform.io/v1/object/example"
api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output"
failures: list[str] = []
following_handler, following_calls = _scripted_https_handler(fixture, archive_url)
followed = urllib.request.build_opener(following_handler).open(api_url)
try:
if followed.status != 200:
failures.append(
f"standard opener first status was {followed.status}, not 200"
)
if following_calls != [api_url, archive_url]:
failures.append(f"standard opener URLs were {following_calls}")
finally:
followed.close()
guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url)
try:
plan = module.download_plan_json(
PLAN_ID,
"test-token",
handlers=(guard_handler,),
)
except ValueError as exc:
failures.append(f"no-redirect download failed: {exc}")
return failures
addresses = [item["address"] for item in plan["resource_changes"]]
if POINTER_ADDRESS not in addresses:
failures.append("no-redirect download did not return the version-only fixture")
if guard_calls != [api_url, archive_url]:
failures.append(f"no-redirect download URLs were {guard_calls}")
following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url)
following_urlopen = urllib.request.build_opener(following_urlopen_handler).open
try:
module.download_plan_json(
PLAN_ID,
"test-token",
urlopen=following_urlopen,
)
failures.append("redirect-following urlopen was accepted as the first hop")
except ValueError as exc:
if "expected a redirect" not in str(exc):
failures.append(f"following urlopen error was {exc}")
return failures
def test_deploy_workflow_uses_script_flags() -> list[str]:
workflow = (
Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml"
).read_text(encoding="utf-8")
failures: list[str] = []
if workflow.count("--expected-version-label") < 2:
failures.append(
"deploy.yml must pass --expected-version-label on release and rollback"
)
if workflow.count("--expected-previous-version-label") < 2:
failures.append(
"deploy.yml must pass --expected-previous-version-label on release and rollback"
)
for forbidden in (
"--expected-current-label",
"--expected-previous-label",
"--before-current-label",
"--before-previous-label",
"--current-origin-id",
"--previous-origin-id",
"CURRENT_ORIGIN_ID",
):
if forbidden in workflow:
failures.append(f"deploy.yml still passes unknown flag {forbidden}")
return failures
def test_deploy_workflow_confirms_prefix_with_head_object() -> list[str]:
workflow = (
Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml"
).read_text(encoding="utf-8")
failures: list[str] = []
if "aws s3api head-object" not in workflow:
failures.append(
"deploy.yml must confirm the uploaded index.html with s3api head-object"
)
if "aws s3 ls" in workflow:
failures.append("deploy.yml must not list the prefix with aws s3 ls")
if any(
line.lstrip().startswith("run:") and "npm run verify" in line
for line in workflow.splitlines()
):
failures.append(
"deploy.yml must not re-run npm run verify; Frontend checks owns that gate"
)
if any(line.lstrip().startswith("pull_request:") for line in workflow.splitlines()):
failures.append(
"deploy.yml must not run on pull_request; Frontend checks owns PR verify"
)
return failures
def main() -> int:
cases = [
("version-only", run_case("version-only.json"), 0),
(
"origin-timeout-normalization",
run_case("origin-timeout-normalization.json"),
0,
),
("origin-timeout-change", run_case("origin-timeout-change.json"), 1),
("wrong-label", run_case("wrong-label.json"), 1),
("wrong-before", run_case("wrong-before.json"), 1),
("extra-origin-change", run_case("extra-origin-change.json"), 1),
("iam-update", run_case("iam-update.json"), 1),
("dns-update", run_case("dns-update.json"), 1),
("create", run_case("create.json"), 1),
("delete", run_case("delete.json"), 1),
("replace", run_case("replace.json"), 1),
("multiple-updates", run_case("multiple-updates.json"), 1),
("nested-unknown", run_case("nested-unknown.json"), 1),
("unknown-only", run_case("unknown-only.json"), 1),
("empty", run_case("empty.json"), 1),
("missing-action", run_case("missing-action.json"), 1),
("extra-action", run_case("extra-action.json"), 1),
]
failures = [
(name, result, expected)
for name, result, expected in cases
if result.returncode != expected
]
download_failures = test_download_pinning()
redirect_failures = test_download_standard_opener_redirect()
download_failures.extend(redirect_failures)
download_failures.extend(test_deploy_workflow_uses_script_flags())
download_failures.extend(test_deploy_workflow_confirms_prefix_with_head_object())
if failures or download_failures:
if failures:
print(
"FAIL: release plan-check cases failed: "
+ ", ".join(name for name, _, _ in failures),
file=sys.stderr,
)
for name, result, expected in failures:
print(
f"{name}: expected {expected}, got {result.returncode}\n"
f"{result.stdout}{result.stderr}",
file=sys.stderr,
)
for item in download_failures:
print(f"FAIL: {item}", file=sys.stderr)
return 1
print("PASS: Terraform release plan safety checks")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,302 @@
#!/usr/bin/env bash
# Stubbed aws/curl tests for scripts/verify-cloudfront-release.sh.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh"
CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111"
OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000"
# Vite writes index.html with a trailing newline. Keep it in the fixture so
# the expected hash covers every served byte, exactly like dist/index.html.
INDEX_HTML=$'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>\n'
INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
failures=0
assert_exit() {
local name="$1" expected="$2" got="$3" log="$4"
if [[ "${got}" != "${expected}" ]]; then
echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2
sed -n '1,80p' "${log}" >&2
failures=$((failures + 1))
else
echo "PASS: ${name}"
fi
}
make_stubs() {
local bin="$1"
mkdir -p "${bin}"
cat > "${bin}/aws" << 'AWS'
#!/usr/bin/env bash
set -euo pipefail
state_dir="${STUB_STATE}"
if [[ "${1:-}" == "s3" ]]; then
cat "${state_dir}/pointer.json"
exit 0
fi
cat "${state_dir}/distribution.json"
AWS
cat > "${bin}/curl" << 'CURL'
#!/usr/bin/env bash
set -euo pipefail
state_dir="${STUB_STATE}"
method="GET"
url=""
dump=""
output=""
write_out=""
args=("$@")
i=0
while [[ $i -lt ${#args[@]} ]]; do
arg="${args[$i]}"
case "${arg}" in
-X) i=$((i + 1)); method="${args[$i]}" ;;
-D) i=$((i + 1)); dump="${args[$i]}" ;;
-o) i=$((i + 1)); output="${args[$i]}" ;;
-w) i=$((i + 1)); write_out="${args[$i]}" ;;
-H|--max-time|-s|-S|-f|-fsS|-sS) ;;
http*) url="${arg}" ;;
esac
i=$((i + 1))
done
if [[ "${method}" == "OPTIONS" ]]; then
[[ -n "${dump}" ]] && printf 'HTTP/1.1 204 No Content\nAccess-Control-Allow-Origin: https://dev.seahaven.com\n\n' > "${dump}"
[[ -n "${write_out}" ]] && printf '204'
exit 0
fi
if [[ "${url}" == *"/assets/"* ]]; then
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: public,max-age=31536000,immutable\n\n' > "${dump}"
if [[ -f "${state_dir}/asset.js" ]]; then
body="$(cat "${state_dir}/asset.js")"
else
body='const api="https://api.dev.seahaven.com/api";'
fi
[[ -n "${output}" ]] && printf '%s' "${body}" > "${output}"
[[ -z "${output}" ]] && printf '%s' "${body}"
exit 0
fi
# Serve index.html byte-for-byte, trailing newline included, like real curl.
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
if [[ -n "${output}" ]]; then
cat "${state_dir}/index.html" > "${output}"
else
cat "${state_dir}/index.html"
fi
exit 0
CURL
chmod +x "${bin}/aws" "${bin}/curl"
}
dist_json() {
local status="$1" current_path="$2"
python3 -c 'import json,sys
status, path = sys.argv[1], sys.argv[2]
print(json.dumps({
"Distribution": {
"Status": status,
"DistributionConfig": {
"Origins": {"Items": [
{"Id": "current", "OriginPath": path},
{"Id": "previous", "OriginPath": ""},
]}
}
}
}))' "${status}" "${current_path}"
}
pointer_json() {
python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2"
}
run_case() {
local name="$1"
local dir
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}"
export PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com"
export SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=3
export INTERVAL=0
local log="${dir}/log.txt"
set +e
bash "${VERIFY}" > "${log}" 2>&1
local code=$?
set -e
assert_exit "${name}" "$2" "${code}" "${log}"
rm -rf "${dir}"
}
# 1. Right config, then propagates (InProgress -> Deployed, hash already matches).
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
printf 'InProgress\n' > "${dir}/status"
cat > "${dir}/bin/aws" << AWS
#!/usr/bin/env bash
set -euo pipefail
if [[ "\${1:-}" == "s3" ]]; then
cat "${dir}/pointer.json"
exit 0
fi
status="\$(cat "${dir}/status")"
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}"
echo Deployed > "${dir}/status"
AWS
chmod +x "${dir}/bin/aws"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=5 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt"
rm -rf "${dir}"
}
# 2. Right config never propagates (Deployed, stale hash).
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf 'stale' > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt"
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 3. Wrong origin path fails fast.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt"
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 4. Wrong pointer fails fast.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt"
grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 5. Never Deployed.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "never-deployed" 1 "${code}" "${dir}/log.txt"
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: never-deployed missing last observed state" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 6. Hash-matched Deployed release whose JS assets omit the baked API URL.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const x=1;' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt"
grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 7. Hash-matched Deployed release whose JS assets contain the staging API URL.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "forbidden-staging-api-url" 1 "${code}" "${dir}/log.txt"
grep -q "forbidden URL api.staging.seahaven.com" "${dir}/log.txt" || { echo "FAIL: staging API URL did not name forbidden URL" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
if [[ "${failures}" -ne 0 ]]; then
echo "FAIL: ${failures} verify-cloudfront-release cases failed" >&2
exit 1
fi
echo "PASS: CloudFront release verify checks"

View file

@ -0,0 +1,94 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["create"],
"before": null,
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}"
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,94 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["delete"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}"
},
"after": null
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,116 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
},
{
"address": "module.environment_owned.aws_route53_record.site_a",
"mode": "managed",
"type": "aws_route53_record",
"change": {
"actions": ["update"],
"before": {
"ttl": 60
},
"after": {
"ttl": 300
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,9 @@
{
"resource_changes": [],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,106 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
},
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release_extra",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,102 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 20,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,116 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
},
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["update"],
"before": {
"policy": "{}"
},
"after": {
"policy": "{\"Version\":\"2012-10-17\"}"
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,97 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": []
}

View file

@ -0,0 +1,130 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
},
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["update"],
"before": {
"policy": "{}"
},
"after": {
"policy": "{\"Version\":\"2012-10-17\"}"
}
}
},
{
"address": "module.environment_owned.aws_route53_record.site_a",
"mode": "managed",
"type": "aws_route53_record",
"change": {
"actions": ["update"],
"before": {
"ttl": 60
},
"after": {
"ttl": 300
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,105 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true,
"tags": {
"Environment": true
}
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,104 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
"response_completion_timeout": 10
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
"response_completion_timeout": 60
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,105 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
"response_completion_timeout": 0
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1",
"response_completion_timeout": 0
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
"response_completion_timeout": null
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,58 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["delete", "create"],
"before": {
"origin": []
},
"after": {
"origin": []
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,103 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true,
"comment": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,102 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,102 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -0,0 +1,102 @@
{
"resource_changes": [
{
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"change": {
"actions": ["no-op"],
"before": {
"name": "policy"
},
"after": {
"name": "policy"
}
}
},
{
"address": "module.environment_owned.aws_s3_object.release_pointer",
"mode": "managed",
"type": "aws_s3_object",
"change": {
"actions": ["update"],
"before": {
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
"key": ".release/current"
},
"after": {
"content": "{\"current\":\"cccccccccccccccccccccccccccccccccccccccc-3-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
"key": ".release/current"
},
"after_unknown": {
"etag": true,
"version_id": true
}
}
},
{
"address": "module.environment_owned.aws_cloudfront_distribution.site",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"change": {
"actions": ["update"],
"before": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after": {
"origin": [
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
"origin_path": "/releases/cccccccccccccccccccccccccccccccccccccccc-3-1"
},
{
"connection_attempts": 3,
"connection_timeout": 10,
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
"origin_access_control_id": "E30VSIK87N8H64",
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
}
],
"enabled": true,
"comment": "SeaHaven SHOC frontend (dev)"
},
"after_unknown": {
"etag": true,
"last_modified_time": true,
"status": true,
"in_progress_validation_batches": true
}
}
}
],
"action_invocations": [
{
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
"type": "aws_cloudfront_create_invalidation"
}
]
}

View file

@ -6,15 +6,19 @@ set -euo pipefail
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
RELEASE_LABEL="${SENTRY_RELEASE:-${RELEASE_LABEL:-}}"
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
exit 1
if [[ -n "${RELEASE_LABEL}" ]]; then
RELEASE="${RELEASE_LABEL}"
else
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
exit 1
fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}"
fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}"
npm exec --no -- sentry-cli sourcemaps upload \
--org "${SENTRY_ORG}" \
--project "${SENTRY_PROJECT}" \

View file

@ -0,0 +1,210 @@
#!/usr/bin/env bash
# Verify a CloudFront content release or rollback.
#
# Fail fast when origin_path or .release/current is the wrong label.
# Poll while the distribution is InProgress or the served index.html hash
# still matches the previous release. On timeout, print last observed state.
set -euo pipefail
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
EXPECTED_LABEL="${EXPECTED_LABEL:-}"
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
SITE_URL="${SITE_URL:-}"
SITE_BUCKET="${SITE_BUCKET:-}"
PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}"
API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
BUDGET="${BUDGET:-40}"
INTERVAL="${INTERVAL:-15}"
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then
echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2
exit 2
fi
SITE_URL="${SITE_URL%/}"
if [[ -n "${EXPECTED_LABEL}" ]]; then
EXPECTED_PATH="/releases/${EXPECTED_LABEL}"
else
EXPECTED_PATH=""
fi
sha256_of() {
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
}
read_pointer() {
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true
}
read_distribution_json() {
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
}
parse_distribution() {
python3 -c '
import json, os, sys
payload = json.load(sys.stdin)
dist = payload.get("Distribution") or payload
status = dist.get("Status") or "Unknown"
config = dist.get("DistributionConfig") or {}
origins = ((config.get("Origins") or {}).get("Items")) or []
paths = [origin.get("OriginPath") or "" for origin in origins]
expected = os.environ["EXPECTED_PATH"]
print(status)
print("\x1f".join(paths))
print("yes" if expected in paths else "no")
'
}
pointer_current() {
POINTER_BODY="$1" python3 -c '
import json, os
raw = os.environ.get("POINTER_BODY", "").strip()
if not raw:
print("")
raise SystemExit
print(json.loads(raw).get("current") or "")
'
}
last_status="Unknown"
last_paths="Unknown"
last_pointer="Unknown"
last_hash="Unknown"
last_path_ok="no"
observe() {
last_pointer="$(read_pointer)"
local parsed
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)"
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')"
# Hash the response stream directly. Capturing the body in "$(...)" strips
# trailing newlines, so the hash never matched dist/index.html.
local hash
if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then
last_hash="${hash}"
else
last_hash="unreachable"
fi
}
report_state() {
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}"
}
fail_fast_if_misconfigured() {
local current
current="$(pointer_current "${last_pointer}")"
if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then
echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2
report_state >&2
exit 1
fi
if [[ "${last_path_ok}" != "yes" ]]; then
echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2
report_state >&2
exit 1
fi
}
observe
fail_fast_if_misconfigured
attempt=0
while [[ "${attempt}" -lt "${BUDGET}" ]]; do
attempt=$((attempt + 1))
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
fail_fast_if_misconfigured
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
break
fi
sleep "${INTERVAL}"
observe
done
if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA256}" ]]; then
echo "FAIL: release did not converge within the budget." >&2
report_state >&2
exit 1
fi
write_asset_paths() {
python3 -c '
import re, sys
html = open(sys.argv[1], encoding="utf-8").read()
seen = []
for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
if path not in seen:
seen.append(path)
print(path)
' "$1"
}
assert_baked_api_url() {
local tmp="$1"
if [[ ! -s "${tmp}/asset-paths.txt" ]]; then
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
exit 1
fi
: > "${tmp}/assets.txt"
local immutable_ok="no"
local asset_path
while IFS= read -r asset_path; do
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \
-o "${tmp}/asset-body" -D "${tmp}/asset.headers"
cat "${tmp}/asset-body" >> "${tmp}/assets.txt"
if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
exit 1
fi
immutable_ok="yes"
fi
done < "${tmp}/asset-paths.txt"
if [[ "${immutable_ok}" != "yes" ]]; then
echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2
exit 1
fi
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
local forbidden
for forbidden in api.staging.seahaven.com localhost:5141; do
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
exit 1
fi
done
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then
echo "FAIL: served JS assets are missing the baked dev API URL." >&2
exit 1
fi
}
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers"
curl -fsS --max-time 30 "${SITE_URL}/login" -o "${tmp}/login.html"
curl -fsS --max-time 30 "${SITE_URL}/work-orders" -o "${tmp}/route.html"
if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then
echo "FAIL: HTML Cache-Control is missing no-store." >&2
exit 1
fi
write_asset_paths "${tmp}/index.html" > "${tmp}/asset-paths.txt"
assert_baked_api_url "${tmp}"
cors_code="$(curl -sS --max-time 30 -o /dev/null -D "${tmp}/cors.headers" -w '%{http_code}' -X OPTIONS "${API_URL}" \
-H "Origin: ${SITE_URL}" \
-H "Access-Control-Request-Method: GET")"
if [[ "${cors_code}" != "200" && "${cors_code}" != "204" ]]; then
echo "FAIL: CORS preflight returned HTTP ${cors_code}." >&2
exit 1
fi
if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
echo "FAIL: CORS preflight is missing Access-Control-Allow-Origin." >&2
exit 1
fi
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean."
report_state

352
terraform/README.md Normal file
View file

@ -0,0 +1,352 @@
# 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` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. |
| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, 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.14.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 `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 these 14 addresses (13 imported hosting
resources plus the release pointer created in Phase 3):
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`
14. `module.environment_owned.aws_s3_object.release_pointer`
The CloudFront invalidation is a Terraform action
(`action.aws_cloudfront_create_invalidation.release`), not a managed resource.
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
## 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 (merged)
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.** Completed from the reviewed PR A head. The CDK app
is no longer in this repository.
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 (merged #178)
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 GitHub deploy inline policy byte-identical to live so
`aws_iam_role_policy.github_deploy` 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:
```bash
python3 scripts/check-terraform-import-plan.py plan.json --environment dev \
--allow-update-address module.environment_owned.aws_s3_bucket.site \
--allow-update-address module.environment_owned.aws_s3_bucket_policy.site \
--allow-update-address module.environment_owned.aws_cloudfront_distribution.site \
--allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \
--allow-update-address module.environment_owned.aws_iam_role.github_deploy
```
After the apply and a no-op plan, the CDK stack was relinquished with
`ManageSiteInfrastructure=false`. Never deploy that stack with
`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C.
Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual
`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged
GitHub content policy. PR C replaces that policy with the release-prefix
document during bootstrap.
## Phase 3: content CD through Terraform (this PR)
GitHub builds the SPA and uploads only `releases/<sha>-<run>-<attempt>/`.
The GitHub role may `GetObject` on `.release/current` and read the exact
distribution (`GetDistribution` / `GetDistributionConfig`) so verify and
live-state summary can observe origin paths. It cannot invalidate or write
the pointer. Terraform owns `.release/current`, both origin paths of the
CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one
guarded Terraform run that swaps the labels. Push-to-`dev` stays off until
`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls
`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287).
Release vars `release_version_label` and `previous_release_version_label` are
nullable, default null, and must not be set on the workspace or in tfvars.
Null VCS plans read the pointer back from S3. Empty string is the legacy root
layout.
Per GitHub content release after bootstrap: exactly two managed updates plus
one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py`
accepts a plan that updates only the pointer `content` and
`origin[*].origin_path`, with `after` equal to the expected labels, `before`
equal to the pointer's prior values, and exactly one invalidation
`action_invocations` entry.
The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates
`.release/current` (legacy empty labels), adds the previous origin and origin
group, switches the default behavior to the group, replaces the GitHub inline
policy with the release-prefix document, and invokes invalidation. A human
confirms that apply. GitHub CD starts only after bootstrap is applied.
Activation (each step gated; do not run without an explicit go):
1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard
the HCP VCS run. Apply bootstrap as a human-confirmed controlled update.
2. Re-read workspace invariants (auto-apply off, speculative on, trigger
patterns only, no prefixes, no tags-regex).
3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation,
smoke, and rollback readiness from the live-state summary.
4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner
approval.
5. Confirm the first push-to-`dev` run. Close SH-300 on that proof.
A red job does not mean the site is down. Read the live-state summary first.
## Operational rules
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
deployable application code. The `terraform-isolation` job in
`.github/workflows/terraform-isolation.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 C).
Adding or removing that label re-runs only that workflow against the labels
currently on the PR; Frontend checks does not start a new run. Removing the
label fails a mixed PR that had previously passed with the override, so a
stale green check cannot merge. Markdown under `terraform/` does not count as a Terraform
change for this gate; it does not match the workspace trigger patterns.
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 summary
first (served `index.html` hash, distribution status, pointer body, both
origin paths), 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-release-plan # content-release plan guard
npm run test:terraform-isolation # isolation gate unit tests
npm run test:hcp-run-guard # workspace invariant and apply reconcile
npm run test:cloudfront-release-verify
npm run test:github-workflows # bash -n and actionlint
```
`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
View 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",
]
}

View file

@ -0,0 +1,64 @@
import {
to = module.environment_owned.aws_s3_bucket.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_public_access_block.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_ownership_controls.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_versioning.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_s3_bucket_policy.site
id = local.bucket_name
}
import {
to = module.environment_owned.aws_cloudfront_distribution.site
id = local.distribution_id
}
import {
to = module.environment_owned.aws_cloudfront_origin_access_control.site
id = local.oac_id
}
import {
to = module.environment_owned.aws_cloudfront_function.spa_rewrite
id = local.function_name
}
import {
to = module.environment_owned.aws_route53_record.site_a
id = "${local.hosted_zone_id}_${local.domain_name}_A"
}
import {
to = module.environment_owned.aws_route53_record.site_aaaa
id = "${local.hosted_zone_id}_${local.domain_name}_AAAA"
}
import {
to = module.environment_owned.aws_iam_role.github_deploy
id = local.deploy_role_name
}
import {
to = module.environment_owned.aws_iam_role_policy.github_deploy
id = "${local.deploy_role_name}:${local.inline_policy}"
}

View file

@ -0,0 +1,95 @@
locals {
# Controlled ownership transfer. Pinned in code, never a workspace variable.
adoption_complete = true
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)
release_version_label = var.release_version_label
previous_release_version_label = var.previous_release_version_label
}

View file

@ -0,0 +1,19 @@
output "bucket_name" {
value = module.environment_owned.bucket_name
}
output "distribution_id" {
value = module.environment_owned.distribution_id
}
output "deploy_role_arn" {
value = module.environment_owned.deploy_role_arn
}
output "current_origin_id" {
value = module.environment_owned.current_origin_id
}
output "previous_origin_id" {
value = module.environment_owned.previous_origin_id
}

View file

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

View file

@ -0,0 +1,33 @@
variable "release_version_label" {
type = string
default = null
nullable = true
description = "Immutable content release label. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.release_version_label == null ||
var.release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
)
error_message = "release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}
variable "previous_release_version_label" {
type = string
default = null
nullable = true
description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.previous_release_version_label == null ||
var.previous_release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label))
)
error_message = "previous_release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}

View file

@ -0,0 +1,19 @@
terraform {
required_version = ">= 1.14.0, < 2.0.0"
cloud {
organization = "seahaven"
workspaces {
project = "seahaven-external-dev"
name = "shoc-frontend-new-dev"
}
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
}
}

View file

@ -0,0 +1,65 @@
data "aws_caller_identity" "current" {
lifecycle {
postcondition {
condition = self.account_id == var.aws_account_id
error_message = "Refusing to inspect resources outside the expected AWS account."
}
}
}
data "aws_region" "current" {
lifecycle {
postcondition {
condition = self.region == var.aws_region
error_message = "Refusing to inspect resources outside the expected AWS region."
}
}
}
data "aws_route53_zone" "site" {
name = "${trimsuffix(var.hosted_zone_name, ".")}."
private_zone = false
lifecycle {
postcondition {
condition = self.zone_id == var.expected_hosted_zone_id
error_message = "The resolved Route 53 zone does not match the pinned hosted zone."
}
}
}
data "aws_acm_certificate" "shared" {
domain = var.certificate_domain
statuses = ["ISSUED"]
types = ["AMAZON_ISSUED"]
most_recent = true
lifecycle {
postcondition {
condition = self.arn == var.expected_certificate_arn
error_message = "The resolved ACM certificate does not match the pinned certificate."
}
}
}
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
lifecycle {
postcondition {
condition = self.arn == var.expected_github_oidc_provider_arn
error_message = "The GitHub OIDC provider does not match the pinned account provider."
}
}
}
data "aws_cloudfront_cache_policy" "managed" {
name = var.cache_policy_name
lifecycle {
postcondition {
condition = self.id == var.expected_cache_policy_id
error_message = "The AWS managed CloudFront cache policy does not match the pinned ID."
}
}
}

View file

@ -0,0 +1,19 @@
output "hosted_zone_id" {
value = data.aws_route53_zone.site.zone_id
description = "Verified hosted zone ID."
}
output "certificate_arn" {
value = data.aws_acm_certificate.shared.arn
description = "Verified ACM certificate ARN."
}
output "github_oidc_provider_arn" {
value = data.aws_iam_openid_connect_provider.github.arn
description = "Verified GitHub OIDC provider ARN."
}
output "cache_policy_id" {
value = data.aws_cloudfront_cache_policy.managed.id
description = "Verified AWS managed cache policy ID."
}

View file

@ -0,0 +1,46 @@
variable "aws_account_id" {
type = string
description = "Expected AWS account ID."
}
variable "aws_region" {
type = string
description = "Expected AWS provider region."
}
variable "hosted_zone_name" {
type = string
description = "Public hosted zone DNS name."
}
variable "expected_hosted_zone_id" {
type = string
description = "Pinned hosted zone ID."
}
variable "certificate_domain" {
type = string
description = "Domain used to resolve the expected certificate."
}
variable "expected_certificate_arn" {
type = string
description = "Pinned ACM certificate ARN."
}
variable "expected_github_oidc_provider_arn" {
type = string
description = "Pinned account-global GitHub OIDC provider ARN."
}
variable "cache_policy_name" {
type = string
description = "AWS managed CloudFront cache policy name."
default = "Managed-CachingOptimized"
}
variable "expected_cache_policy_id" {
type = string
description = "Pinned AWS managed CloudFront cache policy ID."
default = "658327ea-f89d-4fab-a63d-7e88639e58f6"
}

View file

@ -0,0 +1,441 @@
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
previous_origin_id = "${var.origin_id}-previous"
origin_group_id = "${var.origin_id}-group"
pointer_key = ".release/current"
pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {})
# coalesce() skips empty strings, so a null var plus a missing pointer
# would error. Empty string is the legacy root layout and must be valid.
current_label = (
var.release_version_label != null
? var.release_version_label
: try(local.pointer_body.current, "")
)
previous_label = (
var.previous_release_version_label != null
? var.previous_release_version_label
: try(local.pointer_body.previous, "")
)
current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}"
previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}"
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_s3_objects" "release_prefix" {
bucket = aws_s3_bucket.site.bucket
prefix = ".release/"
}
data "aws_s3_object" "release_pointer" {
count = contains(coalesce(data.aws_s3_objects.release_prefix.keys, []), local.pointer_key) ? 1 : 0
bucket = aws_s3_bucket.site.bucket
key = local.pointer_key
}
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" {
statement {
sid = "ListReleasePrefixes"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
]
resources = [local.bucket_arn]
condition {
test = "StringLike"
variable = "s3:prefix"
values = [
"releases/",
"releases/*",
]
}
}
statement {
sid = "PublishReleasePrefix"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
]
resources = ["${local.bucket_arn}/releases/*"]
}
statement {
sid = "ReadReleasePointer"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${local.bucket_arn}/${local.pointer_key}"]
}
statement {
sid = "ReadDistribution"
effect = "Allow"
actions = [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
]
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_s3_object" "release_pointer" {
bucket = aws_s3_bucket.site.bucket
key = local.pointer_key
content_type = "application/json"
content = jsonencode({
current = local.current_label
previous = local.previous_label
})
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
origin_path = local.current_origin_path
}
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 = local.previous_origin_id
origin_path = local.previous_origin_path
}
origin_group {
origin_id = local.origin_group_id
failover_criteria {
status_codes = [403, 404]
}
member {
origin_id = var.origin_id
}
member {
origin_id = local.previous_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 = local.origin_group_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
action_trigger {
events = [after_update]
actions = [action.aws_cloudfront_create_invalidation.release]
}
}
}
action "aws_cloudfront_create_invalidation" "release" {
config {
distribution_id = aws_cloudfront_distribution.site.id
paths = ["/*"]
}
}
resource "aws_route53_record" "site_a" {
zone_id = var.hosted_zone_id
name = var.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_route53_record" "site_aaaa" {
zone_id = var.hosted_zone_id
name = var.domain_name
type = "AAAA"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role" "github_deploy" {
name = var.deploy_role_name
path = "/"
description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600
permissions_boundary = var.deploy_permissions_boundary_arn
tags = local.deploy_role_tags
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = var.deploy_inline_policy_name
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
lifecycle {
prevent_destroy = true
}
}

View file

@ -0,0 +1,44 @@
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."
}
output "current_release_label" {
value = local.current_label
description = "Pointer current release label. Empty string is the legacy root layout."
}
output "previous_release_label" {
value = local.previous_label
description = "Pointer previous release label. Empty string is the legacy root layout."
}
output "current_origin_path" {
value = local.current_origin_path
description = "CloudFront origin_path for the current member of the origin group."
}
output "previous_origin_path" {
value = local.previous_origin_path
description = "CloudFront origin_path for the previous member of the origin group."
}
output "current_origin_id" {
value = var.origin_id
description = "CloudFront origin ID for the current release."
}
output "previous_origin_id" {
value = local.previous_origin_id
description = "CloudFront origin ID for the previous release."
}

View file

@ -0,0 +1,194 @@
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 ownership tags and drops the auto-delete helper grant from the bucket policy."
default = false
}
variable "release_version_label" {
type = string
default = null
nullable = true
description = "Immutable content release label. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.release_version_label == null ||
var.release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
)
error_message = "release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}
variable "previous_release_version_label" {
type = string
default = null
nullable = true
description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.previous_release_version_label == null ||
var.previous_release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label))
)
error_message = "previous_release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}
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."
}