feat(terraform): ship dev content CD through Terraform (SH-300) (#180)

* feat(terraform): ship dev content CD through Terraform (SH-300)

GitHub uploads immutable release prefixes; Terraform owns live publish.
Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set.

* fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300)
This commit is contained in:
Adam Moussa 2026-09-11 13:40:14 -04:00 • committed by GitHub
parent b24e6f3b9a
commit 69c24c1c2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 3998 additions and 1950 deletions

View file

@ -24,10 +24,10 @@ jobs:
# `npm run verify` is the single command that chains: format check, lint # `npm run verify` is the single command that chains: format check, lint
# (--max-warnings=0), type-check + build, unit tests, then the governance # (--max-warnings=0), type-check + build, unit tests, then the governance
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file # checks in scripts/governance-check.mjs (godfile ratchet, changed-file
# maintainability gate, Terraform fmt/validate, Terraform import-plan guard # maintainability gate, Terraform fmt/validate, Terraform import-plan and
# tests, Terraform isolation gate tests, CDK build/test/synth). If the # release-plan guards, isolation tests, HCP run guard, CloudFront verify,
# reusable workflow is later confirmed to run every gate, this job can be # and GitHub workflow shell). If the reusable workflow is later confirmed
# slimmed to `npm run governance`. # to run every gate, this job can be slimmed to `npm run governance`.
# #
# GOVERNANCE_BASE points the changed-file gate at the right diff: # GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>) # PR -> the PR target branch (origin/<base_ref>)
@ -66,6 +66,17 @@ jobs:
with: with:
node-version: "24" node-version: "24"
cache: npm 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 ci
- run: npm run verify - run: npm run verify
env: env:

View file

@ -1,40 +1,26 @@
name: Deploy dev content name: Validate and deploy
# Manual dev content deployment during the Terraform adoption (SH-300). # Dev content CD through Terraform (SH-300). GitHub uploads an immutable
# # releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are # group, and invalidation. Push-to-dev stays off until
# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are # vars.TERRAFORM_CONTENT_CD_ENABLED is the string true.
# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the
# resources, and move to HCP Terraform (`terraform/README.md`) as adoption
# completes. Automatic push-to-`dev` releases return with the Terraform
# content-CD change, gated on a repository variable.
#
# This workflow publishes only content: verify, build, `aws s3 sync`, and a
# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC
# deploy role. The bucket and distribution are pinned here so a content deploy
# keeps working after CloudFormation relinquishes the stack outputs.
on: on:
pull_request:
branches: [dev]
push:
branches: [dev]
paths-ignore:
- "terraform/**"
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
id-token: write
contents: read contents: read
concurrency:
group: deploy-dev
cancel-in-progress: false
jobs: jobs:
deploy: validate:
name: Publish content to dev name: Validate production build
# Deploy only the exact dev branch ref: workflow_dispatch can be invoked
# from arbitrary refs, and the deploy role trusts only refs/heads/dev.
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
AWS_REGION: us-east-1
VITE_APP_COMMIT_SHA: ${{ github.sha }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
@ -44,65 +30,316 @@ jobs:
node-version: "24" node-version: "24"
cache: npm cache: npm
- name: Set up Terraform - name: Set up Terraform
# Required by `npm run verify` (governance runs terraform fmt/validate).
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with: with:
terraform_version: "1.16.0" terraform_version: "1.16.0"
terraform_wrapper: false terraform_wrapper: false
- name: Quality gates (full verify before any deploy) - 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
- name: Quality gates
run: npm ci && npm run verify run: npm ci && npm run verify
env: env:
GOVERNANCE_BASE: origin/dev GOVERNANCE_BASE: ${{ github.event.pull_request.base.sha || 'origin/dev' }}
- name: Build with pinned API URL
env:
VITE_API_URL: https://api.dev.seahaven.com/api
VITE_APP_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
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: Assume dev deploy role (OIDC) 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')
needs: validate
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
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- 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 uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with: with:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
aws-region: us-east-1 aws-region: us-east-1
audience: sts.amazonaws.com
# Builds with the dev values committed in .env.production (VITE_API_URL, - name: Assign immutable release identity
# Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront. id: release
- name: Build and publish SPA run: |
run: bash scripts/deploy-web.sh set -euo pipefail
env: version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
SITE_BUCKET: seahaven-shoc-frontend-dev prefix="releases/${version_label}"
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P {
WAIT_FOR_INVALIDATION: "true" echo "version_label=${version_label}"
echo "prefix=${prefix}"
} >> "${GITHUB_OUTPUT}"
- name: Upload private source maps - name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh run: bash scripts/upload-sourcemaps.sh
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE: ${{ steps.release.outputs.version_label }}
- name: Verify deployment - name: Read previous release pointer
id: pointer
run: | run: |
set -euo pipefail set -euo pipefail
SITE_URL="https://dev.seahaven.com" body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
if grep -Rq "api.staging.seahaven.com" dist/; then printf '%s' "${body}" | python3 scripts/read-release-pointer.py
echo "::error::Built assets contain the staging API URL." >&2
exit 1
fi
grep -Rq "api.dev.seahaven.com" dist/
echo "Built assets reference the dev API URL."
# The invalidation has completed, but give edges a short window to - name: Upload immutable release prefix
# converge before calling the served index.html wrong. run: |
remote_dir="$(mktemp -d)" set -euo pipefail
trap 'rm -rf "${remote_dir}"' EXIT prefix="${{ steps.release.outputs.prefix }}"
matched=false aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \
for i in 1 2 3 4 5 6; do --exclude "index.html" \
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \ --exclude "*.map" \
&& cmp -s dist/index.html "${remote_dir}/index.html"; then --cache-control "public,max-age=31536000,immutable"
matched=true aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \
break --cache-control "no-cache,no-store,must-revalidate" \
fi --content-type "text/html"
echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..." aws s3 ls "s3://${SITE_BUCKET}/${prefix}/" | grep -q index.html
sleep 20 index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
done echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
if [[ "${matched}" != "true" ]]; then echo "Uploaded ${prefix}; index.html sha256=${index_sha}"
echo "::error::Served index.html does not match the build just published." >&2
- 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 exit 1
fi fi
echo "Served index.html matches the published build."
curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login" - name: Guard pointer-and-origin-path Terraform plan
echo "Extensionless SPA route serves." 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

10
.gitignore vendored
View file

@ -38,16 +38,6 @@ seed-data.sql
# typescript # typescript
*.tsbuildinfo *.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 (the provider lock file is committed)
**/.terraform/* **/.terraform/*
*.tfstate *.tfstate

View file

@ -8,29 +8,32 @@ npm run verify
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) → `verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
`test` (`vitest run`) → `governance`. Governance also runs the repository `test` (`vitest run`) → `governance`. Governance also runs the repository
gates: the Terraform import-plan checker tests, the Terraform isolation gate gates: Terraform import-plan and release-plan checkers, isolation tests,
tests, Terraform formatting and validation, and the CDK build, template tests, Terraform formatting and validation, the HCP run guard, CloudFront verify, and
and synthesis. A task is not done until this is green. workflow shell checks. A task is not done until this is green.
## Gate matrix ## Gate matrix
| Gate | Command / rule source | Enforced by | Scope | | Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | | 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`) | | 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 | | 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` | | 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 | | 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 | | 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 | | 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 | | 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) | | 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 | | 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 import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier | | Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` | | Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes | | Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | | 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 ## No-false-pass guarantees
@ -76,5 +79,5 @@ and synthesis. A task is not done until this is green.
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use 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 `corepack npm …` if your default `npm` is older). The lockfile is
`package-lock.json` v3; install with `npm ci`. Governance also needs `package-lock.json` v3; install with `npm ci`. Governance also needs
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and `terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.14.0, < 2.0.0`) and
`python3` (3.10+) on `PATH`. `python3` (3.10+) on `PATH`.

109
README.md
View file

@ -5,7 +5,7 @@
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&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) ![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 Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor
portal, uplifts, and related admin features. This is the selective rebuild of portal, uplifts, and related admin features. This is the selective rebuild of
@ -18,25 +18,24 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
## Architecture ## Architecture
Static SPA hosting on AWS, provisioned by a CDK app local to this repo Static SPA hosting on AWS, owned by HCP Terraform
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/` ([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
from a private S3 bucket; the SPA calls the backend directly over HTTPS at `dist/` from a private S3 bucket using a current/previous origin group;
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS). the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
proxy at the CDN — the backend allows CORS).
```mermaid ```mermaid
graph LR graph LR
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront] 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] CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API] U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev] GH[GitHub Actions] -->|OIDC upload releases/*| S3
ROLE -->|s3 sync + invalidation| S3 TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3
``` ```
Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
[`terraform/README.md`](terraform/README.md) for the phase runbook and the uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
current ownership state.
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod, Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
@ -45,8 +44,8 @@ architecture plan for the keep/discard migration matrix).
## AWS Resources ## AWS Resources
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts). `us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
| Resource | Name | Purpose | | Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
@ -83,9 +82,8 @@ repo secret** is:
build otherwise. See [`.env.example`](.env.example), build otherwise. See [`.env.example`](.env.example),
[`.env.development`](.env.development), and [`.env.production`](.env.production). [`.env.development`](.env.development), and [`.env.production`](.env.production).
CDK context (domain, certificate ARN, hosted zone) lives in Pinned hosting constants (domain, certificate ARN, hosted zone) live in
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs [`terraform/live/dev/main.tf`](terraform/live/dev/main.tf).
`cdk deploy` with no flags.
## Local Development ## Local Development
@ -102,22 +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 override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
the deployed dev API). the deployed dev API).
| Command | Description | | Command | Description |
| ------------------------------------------ | ------------------------------------------------------------ | | ------------------------------------------ | ------------------------------------------------------------------ |
| `npm run dev` | Start Vite dev server on port 3000 | | `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` | | `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally | | `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) | | `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 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 lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) | | `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) | | `npm run governance` | Governance checks (godfile, maintainability, Terraform, CD guards) |
| `npm run verify` | **All gates**: format + lint + build + test + governance | | `npm run verify` | **All gates**: format + lint + build + test + governance |
`npm run governance` needs `terraform` and `python3` on `PATH` for the `npm run governance` needs `terraform` and `python3` on `PATH` for the
Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`, Terraform and content-CD gates.
`npm run test:terraform-isolation`) and installs `infra/cdk` for
`npm run test:infra`.
Husky + lint-staged run ESLint and Prettier on staged files at commit; Husky + lint-staged run ESLint and Prettier on staged files at commit;
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
@ -137,8 +133,8 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
Merged branches are deleted automatically. Merged branches are deleted automatically.
- A PR that changes `terraform/**` may not also change application code (the - A PR that changes `terraform/**` may not also change application code (the
`terraform-isolation` CI job); ship Terraform in its own PR. `terraform-isolation` CI job); ship Terraform in its own PR.
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the - Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through
**Deploy dev content** workflow while the Terraform adoption is in progress) Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
`→ main` (production promotion — no prod environment exists yet). `→ main` (production promotion — no prod environment exists yet).
## Deployment ## Deployment
@ -151,7 +147,7 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
format check, lint, build, tests; **and** runs a repo-owned `governance` job format check, lint, build, tests; **and** runs a repo-owned `governance` job
that calls `npm run verify` so every gate (including the maintainability that calls `npm run verify` so every gate (including the maintainability
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs), ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
the Terraform gates, and the CDK template tests) is guaranteed from this the Terraform gates, and the content-CD guards) is guaranteed from this
repository. Conventions and gates are documented under repository. Conventions and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
@ -161,28 +157,21 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
— fails a PR that mixes `terraform/**` with application code, so a Terraform — fails a PR that mixes `terraform/**` with application code, so a Terraform
merge never races a content release for the HCP workspace. merge never races a content release for the HCP workspace.
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) - **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
— `workflow_dispatch` on `dev` only while the Terraform adoption is in — `workflow_dispatch` on `dev`, and push-to-`dev` when
progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`, `vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`).
and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached), `.release/current`, both origin paths, and the invalidation action. Verify
CloudFront invalidation, then uploads source maps and checks the served and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
`index.html` matches the build. Push-to-`dev` releases return with the a live-state summary.
Terraform content-CD change.
- **Staging content** - **Staging content**
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml)) ([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
— on push to `staging`, unchanged. — on push to `staging`, unchanged.
- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence - **Infrastructure** — administrator-run HCP Terraform workspace
and the HCP Terraform workspace `shoc-frontend-new-dev` `shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy` Staging hosting stays on the existing CloudFormation stack until SH-287.
([`infra/cdk/README.md`](infra/cdk/README.md)).
Manual content deploy (emergency/reference only — needs credentials for the Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
external-dev AWS account): content publisher only.
```bash
SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \
AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
## Operations ## Operations
@ -193,8 +182,9 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P
are no CloudWatch application logs — the stack is static hosting; runtime are no CloudWatch application logs — the stack is static hosting; runtime
errors surface in the browser and on the backend API's side. errors surface in the browser and on the backend API's side.
- **Common failure modes:** - **Common failure modes:**
- _Stale content after deploy_ — the CloudFront invalidation step failed or - _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
is still propagating; re-run the Deploy workflow or invalidate `/*` manually. 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 - _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
on this repo; dispatching the workflow from another branch is rejected by on this repo; dispatching the workflow from another branch is rejected by
design. design.
@ -202,14 +192,13 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P
suffix or carrying the wrong environment's host (it is baked in at build time). 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 - _CORS errors_ — the backend must allow the frontend origin; CloudFront does
not proxy `/api`. not proxy `/api`.
- **Dev has no push-triggered deploy during the adoption.** Merging to `dev` - **Push-to-`dev` is gated.** Merging to `dev` publishes only when
runs CI only; publish through the **Deploy dev content** workflow. Merging a `TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
`terraform/**` change also queues an HCP Terraform run that a human confirms an HCP Terraform run that a human confirms or discards before the next
or discards (see the operational rules in `terraform/README.md`). content release (see the operational rules in `terraform/README.md`).
## Documentation ## 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)
- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md)
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md); - Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
design system and UI docs under [`docs/`](docs/) 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 openVendorPage(page, "error");
await expect(page.getByRole("main").getByRole("alert")).toContainText( await expect(page.getByRole("main").getByRole("alert")).toContainText(
/server error|vendor directory unavailable/i, /server error|vendor directory unavailable/i,
{ timeout: 15_000 },
); );
await expect(page.getByRole("progressbar")).toHaveCount(0);
await expectStableScreenshot(page, "vendor-error.png"); await expectStableScreenshot(page, "vendor-error.png");
}); });

View file

@ -30,18 +30,7 @@ const legacyIgnores = [
export default tseslint.config( export default tseslint.config(
{ {
ignores: [ ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
"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,
],
}, },
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,

View file

@ -1,270 +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.
Infrastructure deploys are administrator-run; GitHub Actions publishes content
only.
> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack
> `shoc-frontend-dev` is in the retain/transfer sequence described under
> [Terraform adoption mode](#terraform-adoption-mode) and in
> [`terraform/README.md`](../terraform/README.md). Do not run a plain
> `cdk deploy` against dev while that sequence is in progress. Staging is
> unaffected and stays on this CDK path (SH-287 tracks its cutover).
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
- **API:** the SPA calls the backend **directly** over HTTPS at
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
allows CORS). CloudFront serves static content only — no `/api` proxy.
- Domain/cert/zone values live in `cdk.json` context so `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)
- **Content workflows:** `.github/workflows/deploy.yml` (dev,
`workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to
`staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy
role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was
retired with the adoption PR.
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
```
infra/cdk/
bin/app.ts entry point (reads -c context)
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition)
test/frontend-stack.test.mjs template assertions for both modes
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
.github/workflows/
ci.yaml quality gates (lint / build / test / governance / terraform isolation)
deploy.yml dev content publish (workflow_dispatch on dev)
deploy-staging.yml standalone staging deploy (push to staging)
```
## What the stack creates
| 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 dev role's inline policy still carries the legacy `cd-cdk.yaml` grants:
`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`,
read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It
is left byte-identical on purpose so the Terraform import is a no-op; the
Terraform content-CD change narrows it. The OIDC **provider** is a singleton
account resource — the stack only _imports_ it (created in step 2), so
`cdk destroy` can't delete a resource shared by other roles.
## Terraform adoption mode
`-c retainForTerraformAdoption=true` switches the stack into the safety mode
used only while HCP Terraform adopts the dev resources. It is off by default
and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts
both). In adoption mode the stack:
- pins the origin ID CloudFormation generated for the live distribution
(`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is
metadata-only; environments without a verified value fail synthesis
- attaches the `seahaven-org-baseline` permissions boundary
`shoc-frontend-new-dev-deploy-boundary` and the
`HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role
- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the
same exact value
- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13
transferred resources (bucket, bucket policy, distribution, OAC, SPA
function, A and AAAA records, deploy role, inline policy) and to
`SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider
Lambda and role stay unretained
- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no
default) and conditions those same resources and every output on it
- emits `TerraformImport*` outputs carrying the exact import IDs
`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must
state the ownership phase:
```bash
cd infra/cdk && npm ci
# Phase 1, before the Terraform import: keep the resources in the stack and
# install Retain on them. Update-only change set.
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=true
# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish
# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource.
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=false
```
Both deploys must use the same reviewed SHA. Review the change set before
confirming: Phase 1 must show no create, delete, or replace. After the
`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used
again. If the `true` deploy rolls back, inspect the stack resources and the
live bucket before retrying; retained resources can outlive a failed update and
must not be cleaned up automatically. Never delete the auto-delete custom
resource while its handler can still empty the versioned bucket.
Local checks (`npm run test:infra` from the repo root) build the app, run the
template assertions, and synthesize both modes.
---
## 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 publish the first content manually:
```bash
# from repo root, optional manual first content publish:
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
### 6. Content deploys
The deploy role ARN is deterministic and pinned in
`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the
Terraform adoption, dev content deploys run only through **Actions → Deploy dev
content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes
`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the
pinned bucket and distribution, uploads source maps, and verifies the served
`index.html` matches the build. Automatic push-to-`dev` releases return with the
Terraform content-CD change.
---
## Staging environment (same account, exact OIDC subject)
Staging lives in the same AWS account (396287094661) and deploys through its
own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to
`staging`:
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
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.
Never run it against dev during or after the Terraform adoption: the
adoption-mode stack retains the transferred resources, and after Phase 2
Terraform owns them.
- **CI and staging CD both fire on push to `staging`** in parallel; the
staging CD workflow runs `npm run verify` itself before deploying. Dev has
no push-triggered deploy during the adoption.
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.

View file

@ -1,58 +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") ?? "";
// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required
// ManageSiteInfrastructure parameter and Retain policies on the transferred
// resources. Off by default so ordinary synthesis is unchanged.
const retainForTerraformAdoption =
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true";
// Staging and beyond protect their stacks from accidental deletion; dev
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
// at deploy time — it is not part of the synthesized template.
const terminationProtection = envName !== "dev";
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
envName,
githubRepo,
deployBranch,
githubEnvironment,
terminationProtection,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
retainForTerraformAdoption,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
},
});
Tags.of(stack).add("Project", "shoc-frontend");
Tags.of(stack).add("Environment", envName);
Tags.of(stack).add("ManagedBy", "cdk");

View file

@ -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,481 +0,0 @@
import {
Aspects,
CfnCondition,
CfnOutput,
CfnParameter,
CfnResource,
Duration,
Fn,
RemovalPolicy,
Stack,
StackProps,
Tags,
} from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
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";
import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption";
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;
/**
* Opt-in safety mode used only during the reviewed Terraform adoption.
* Normal dev/staging synthesis remains unchanged when false.
*/
readonly retainForTerraformAdoption?: boolean;
}
/**
* 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. Manual environment
* workflows run `scripts/deploy-web.sh` independently of infrastructure
* changes, so this stack only owns infrastructure and the deploy role carries
* content-publication permissions.
*/
export class FrontendStack extends Stack {
constructor(scope: Construct, id: string, props: FrontendStackProps) {
super(scope, id, props);
const {
envName,
githubRepo,
deployBranch,
githubEnvironment = "",
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
retainForTerraformAdoption = false,
} = props;
const manageSiteInfrastructureCondition = retainForTerraformAdoption
? new CfnCondition(this, "ManageSiteInfrastructureCondition", {
expression: Fn.conditionEquals(
new CfnParameter(this, "ManageSiteInfrastructure", {
type: "String",
allowedValues: ["true", "false"],
description:
"Set true only before Terraform adoption. After ownership transfer, always reuse false.",
}).valueAsString,
"true",
),
})
: undefined;
const hasCustomDomain = domainNames.length > 0;
if (hasCustomDomain && !certificateArn) {
throw new Error(
"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.
// Adoption mode pins the origin ID CloudFormation generated for the live
// distribution so the retention deploy is a metadata-only update. Only
// environments with a read-back-verified value may enter adoption mode.
const adoptionOriginIds: Record<string, string> = {
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
};
const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined;
if (retainForTerraformAdoption && !originId) {
throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`);
}
const distribution = new cloudfront.Distribution(this, "Distribution", {
comment: `SeaHaven SHOC frontend (${envName})`,
defaultRootObject: "index.html",
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, {
originId,
}),
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. Normal dev synthesis keeps
// the current branch-ref StringLike trust. The adoption prerequisite
// narrows that already-exact value to StringEquals before Terraform import.
const oidcConditions = githubEnvironment
? {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
},
}
: retainForTerraformAdoption
? {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
}
: {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
StringLike: {
// Tightly scoped: only pushes to this repo's deploy branch. For a
// reusable-workflow run the OIDC `sub` is still caller-based, so this
// matches even though the deploy job lives in the `.github` repo.
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
};
const deployPermissionsBoundary = retainForTerraformAdoption
? iam.ManagedPolicy.fromManagedPolicyArn(
this,
"GithubDeployPermissionsBoundary",
`arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
)
: undefined;
const deployRole = new iam.Role(this, "GithubDeployRole", {
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
maxSessionDuration: Duration.hours(1),
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
permissionsBoundary: deployPermissionsBoundary,
});
if (retainForTerraformAdoption) {
Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`);
}
// Preserve dev's legacy CDK capability until the reviewed adoption update
// replaces this inline policy. Staging is intentionally narrower: its
// content role only publishes application assets to this stack's
// bucket/distribution. Infrastructure changes remain administrator-run.
if (!githubEnvironment) {
deployRole.addToPolicy(
new iam.PolicyStatement({
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.
let aliasA: route53.ARecord | undefined;
let aliasAaaa: route53.AaaaRecord | undefined;
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];
aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target });
aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", {
zone,
recordName,
target,
});
}
const gateOutput = (output: CfnOutput): CfnOutput => {
if (manageSiteInfrastructureCondition) {
output.condition = manageSiteInfrastructureCondition;
}
return output;
};
// --- Outputs -----------------------------------------------------------
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
gateOutput(
new CfnOutput(this, "SiteUrl", {
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
}),
);
gateOutput(
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
}),
);
gateOutput(
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
}),
);
gateOutput(
new CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
}),
);
gateOutput(
new CfnOutput(this, "DeployRoleArn", {
value: deployRole.roleArn,
description: "Pinned GitHub OIDC content-deployment role",
}),
);
if (retainForTerraformAdoption) {
const originAccessControl = distribution.node
.findAll()
.find(
(node): node is cloudfront.CfnOriginAccessControl =>
node instanceof cloudfront.CfnOriginAccessControl,
);
if (!originAccessControl || !aliasA || !aliasAaaa) {
throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records.");
}
const originAccessControlConfig =
originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty;
const rolePolicy = deployRole.node
.findAll()
.find((node): node is iam.Policy => node instanceof iam.Policy);
const autoDeleteProviderRole = this.node
.findAll()
.find(
(node): node is CfnResource =>
node instanceof CfnResource &&
node.cfnResourceType === "AWS::IAM::Role" &&
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"),
);
const autoDeleteProviderHandler = this.node
.findAll()
.find(
(node): node is CfnResource =>
node instanceof CfnResource &&
node.cfnResourceType === "AWS::Lambda::Function" &&
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler"),
);
if (!rolePolicy || !autoDeleteProviderRole || !autoDeleteProviderHandler) {
throw new Error("Terraform adoption outputs require deploy and auto-delete roles.");
}
// The provider Lambda stays unconditioned so it remains after
// ManageSiteInfrastructure=false. Its generated Description Refs the
// conditioned bucket and CloudFormation rejects that when the condition
// is false. Keep a static description.
autoDeleteProviderHandler.addPropertyOverride(
"Description",
"Lambda function for auto-deleting objects in the site S3 bucket.",
);
const recordName = domainNames[0];
gateOutput(
new CfnOutput(this, "TerraformWorkspaceTag", {
value: `shoc-frontend-new-${envName}`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformDeployBoundaryArn", {
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
}),
);
gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }));
gateOutput(
new CfnOutput(this, "TerraformImportBucketPolicy", {
value: bucket.bucketName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDistribution", {
value: distribution.distributionId,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportOriginAccessControl", {
value: originAccessControl.attrId,
}),
);
gateOutput(
new CfnOutput(this, "TerraformOriginAccessControlName", {
value: originAccessControlConfig.name,
}),
);
gateOutput(
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
value: "EMPTY_STRING",
description: "Use an empty Terraform string because the generated OAC has no description",
}),
);
gateOutput(
new CfnOutput(this, "TerraformDistributionOriginId", {
value: originId!,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
value: spaRewrite.functionName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportAliasA", {
value: `${hostedZoneId}_${recordName}_A`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportAliasAAAA", {
value: `${hostedZoneId}_${recordName}_AAAA`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDeployRole", {
value: deployRole.roleName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
value: rolePolicy.policyName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
value: autoDeleteProviderRole.getAtt("Arn").toString(),
}),
);
gateOutput(
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
value: "SiteBucket/AutoDeleteObjectsCustomResource",
description:
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
}),
);
Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition));
}
}
}

View file

@ -1,63 +0,0 @@
import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
import { IConstruct } from "constructs";
const TRANSFERRED_RESOURCE_TYPES = new Set([
"AWS::S3::Bucket",
"AWS::S3::BucketPolicy",
"AWS::CloudFront::Distribution",
"AWS::CloudFront::Function",
"AWS::CloudFront::OriginAccessControl",
"AWS::Route53::RecordSet",
]);
function isTransferredResource(resource: CfnResource): boolean {
if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) {
return true;
}
if (
resource.cfnResourceType === "Custom::S3AutoDeleteObjects" &&
resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource")
) {
return true;
}
return (
(resource.cfnResourceType === "AWS::IAM::Role" ||
resource.cfnResourceType === "AWS::IAM::Policy") &&
resource.node.path.includes("/GithubDeployRole")
);
}
/**
* Retains only the resources in the approved Terraform transfer set.
*
* The bucket auto-delete custom resource is intentionally retained while the
* generated provider Lambda, role, log group, and CDK metadata remain excluded.
* When a management condition is supplied, those same resources share it so
* CloudFormation can later relinquish them without deleting them.
*/
export class RetainForTerraformAdoption implements IAspect {
constructor(private readonly manageCondition?: CfnCondition) {}
public visit(node: IConstruct): void {
if (!(node instanceof CfnResource) || !isTransferredResource(node)) {
return;
}
// Keep the L2 bucket's configured DESTROY policy visible to its
// AutoDeleteObjects validator while overriding the emitted CloudFormation
// resource. This preserves the custom resource and retains both together.
if (node.cfnResourceType === "AWS::S3::Bucket") {
node.addOverride("DeletionPolicy", "Retain");
node.addOverride("UpdateReplacePolicy", "Retain");
} else {
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
}
if (this.manageCondition) {
node.cfnOptions.condition = this.manageCondition;
}
}
}

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,31 +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",
"test": "npm run build && node --test test/*.test.mjs",
"synth": "cdk synth",
"synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true",
"diff": "cdk diff",
"deploy": "cdk deploy"
},
"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,232 +0,0 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { test } from "node:test";
const require = createRequire(import.meta.url);
const { App } = require("aws-cdk-lib");
const { Template } = require("aws-cdk-lib/assertions");
const { FrontendStack } = require("../lib/frontend-stack.js");
const account = "396287094661";
const region = "us-east-1";
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
const CONDITION = "ManageSiteInfrastructureCondition";
const RETAINED_TYPES = new Set([
"AWS::S3::Bucket",
"AWS::S3::BucketPolicy",
"AWS::CloudFront::Distribution",
"AWS::CloudFront::Function",
"AWS::CloudFront::OriginAccessControl",
"AWS::Route53::RecordSet",
"Custom::S3AutoDeleteObjects",
]);
function devTemplate(retainForTerraformAdoption, overrides = {}) {
const app = new App();
const stack = new FrontendStack(app, "shoc-frontend-dev", {
envName: "dev",
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
deployBranch: "dev",
domainNames: ["dev.seahaven.com"],
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
hostedZoneId: "Z07671212N75U4YLPWZR8",
hostedZoneName: "dev.seahaven.com",
retainForTerraformAdoption,
env: { account, region },
...overrides,
});
return Template.fromStack(stack).toJSON();
}
function entriesByType(template, type) {
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
}
function isTransferred(logicalId, resource) {
const isDeployRoleResource =
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
}
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
const template = devTemplate(true);
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
isTransferred(id, resource),
);
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
// addresses) plus the retained custom resource.
assert.equal(transferred.length, 10);
});
test("adoption mode preserves the live dev identifiers", () => {
const template = devTemplate(true);
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
assert.ok(
bucket.Properties.Tags.some(
(tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true",
),
);
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
assert.equal(
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
DEV_ORIGIN_ID,
);
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
);
assert.equal(
deployRole.Properties.PermissionsBoundary,
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
);
assert.ok(
deployRole.Properties.Tags.some(
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
),
);
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
assert.equal(
condition.StringEquals["token.actions.githubusercontent.com:sub"],
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
);
assert.equal(condition.StringLike, undefined);
// Legacy inline policy stays byte-compatible with the live document.
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
id.startsWith("GithubDeployRole"),
);
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
assert.deepEqual(sids, [
"AssumeCdkBootstrapRoles",
"DescribeStack",
undefined,
"InvalidateDistribution",
]);
for (const output of [
"TerraformWorkspaceTag",
"TerraformDeployBoundaryArn",
"TerraformImportBucket",
"TerraformImportBucketPolicy",
"TerraformImportDistribution",
"TerraformImportOriginAccessControl",
"TerraformOriginAccessControlName",
"TerraformOriginAccessControlDescription",
"TerraformDistributionOriginId",
"TerraformImportSpaRewriteFunction",
"TerraformImportAliasA",
"TerraformImportAliasAAAA",
"TerraformImportDeployRole",
"TerraformImportDeployRolePolicy",
"TerraformDeployInlinePolicyName",
"TerraformBucketAutoDeleteHelperRoleArn",
"TerraformRetainedAutoDeleteCustomResource",
]) {
assert.ok(template.Outputs[output], `missing output ${output}`);
}
assert.equal(
template.Outputs.TerraformImportAliasA.Value,
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
);
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
});
test("adoption mode retains exactly the transferred resources", () => {
const template = devTemplate(true);
for (const [logicalId, resource] of Object.entries(template.Resources)) {
if (isTransferred(logicalId, resource)) {
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
} else {
assert.notEqual(resource.DeletionPolicy, "Retain", logicalId);
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
}
}
// The auto-delete provider Lambda, role, and log group stay unretained.
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
for (const [, resource] of entriesByType(template, type)) {
assert.notEqual(resource.DeletionPolicy, "Retain");
}
}
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
);
assert.equal(providerRoles.length, 1);
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
});
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
const template = devTemplate(true);
const parameter = template.Parameters.ManageSiteInfrastructure;
assert.ok(parameter);
assert.equal(parameter.Type, "String");
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
assert.equal(parameter.Default, undefined);
assert.ok(template.Conditions[CONDITION]);
for (const [logicalId, resource] of Object.entries(template.Resources)) {
if (isTransferred(logicalId, resource)) {
assert.equal(resource.Condition, CONDITION, logicalId);
} else {
assert.notEqual(resource.Condition, CONDITION, logicalId);
}
}
for (const [outputName, output] of Object.entries(template.Outputs)) {
assert.equal(output.Condition, CONDITION, outputName);
}
const [, autoDeleteHandler] = entriesByType(template, "AWS::Lambda::Function")[0];
assert.equal(
autoDeleteHandler.Properties.Description,
"Lambda function for auto-deleting objects in the site S3 bucket.",
);
assert.equal(typeof autoDeleteHandler.Properties.Description, "string");
});
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
const template = devTemplate(false);
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
assert.equal(bucket.DeletionPolicy, "Delete");
assert.equal(bucket.UpdateReplacePolicy, "Delete");
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
assert.notEqual(customResource.DeletionPolicy, "Retain");
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
);
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
assert.equal(
condition.StringLike["token.actions.githubusercontent.com:sub"],
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
);
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
assert.equal(template.Conditions?.[CONDITION], undefined);
for (const resource of Object.values(template.Resources)) {
assert.equal(resource.Condition, undefined);
}
});
test("adoption mode refuses an environment without a verified origin ID", () => {
assert.throws(
() => devTemplate(true, { envName: "staging" }),
/No verified Terraform adoption origin ID exists for staging/,
);
});

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

@ -13,9 +13,12 @@
"test:e2e:visual": "playwright test --config playwright.visual.config.ts", "test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py", "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-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
"test:terraform": "node scripts/terraform-validate.mjs", "test:terraform": "node scripts/terraform-validate.mjs",
"test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption", "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": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .", "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

0
scripts/check-terraform-import-plan.py Normal file → Executable file
View file

View file

@ -45,6 +45,14 @@ export function mayAccompanyTerraform(file) {
if (file.endsWith(".md")) return true; if (file.endsWith(".md")) return true;
if (file.startsWith("docs/")) return true; if (file.startsWith("docs/")) return true;
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) 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; return false;
} }

View file

@ -37,12 +37,21 @@ test("terraform tree, docs, and terraform tooling may accompany a Terraform chan
"scripts/test-terraform-import-plan-check.py", "scripts/test-terraform-import-plan-check.py",
"scripts/terraform-validate.mjs", "scripts/terraform-validate.mjs",
"scripts/check-terraform-isolation.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); assert.equal(mayAccompanyTerraform(file), true, file);
} }
}); });
test("application, workflow, CDK, and dependency files count as application changes", () => { test("application, workflow, and dependency files count as application changes", () => {
for (const file of [ for (const file of [
"src/App.tsx", "src/App.tsx",
"public/favicon.ico", "public/favicon.ico",
@ -52,7 +61,6 @@ test("application, workflow, CDK, and dependency files count as application chan
".env.production", ".env.production",
"vite.config.ts", "vite.config.ts",
".github/workflows/deploy.yml", ".github/workflows/deploy.yml",
"infra/cdk/lib/frontend-stack.ts",
"scripts/deploy-web.sh", "scripts/deploy-web.sh",
"scripts/governance-check.mjs", "scripts/governance-check.mjs",
"e2e/login.spec.ts", "e2e/login.spec.ts",

View file

@ -0,0 +1,521 @@
#!/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
- 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",
}
)
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_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool:
before_rest = {key: value for key, value in before.items() if key != "origin_path"}
after_rest = {key: value for key, value in after.items() if key != "origin_path"}
return before_rest != after_rest
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

@ -21,9 +21,12 @@ const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// script so it can also be run on its own. // script so it can also be run on its own.
const REPOSITORY_GATES = [ const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"], ["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform release-plan contract", "test:terraform-release-plan"],
["Terraform isolation gate", "test:terraform-isolation"], ["Terraform isolation gate", "test:terraform-isolation"],
["Terraform formatting and validation", "test:terraform"], ["Terraform formatting and validation", "test:terraform"],
["CDK build, tests, and synth", "test:infra"], ["HCP run guard", "test:hcp-run-guard"],
["CloudFront release verify", "test:cloudfront-release-verify"],
["GitHub workflow shell", "test:github-workflows"],
]; ];
function isGoverned(relativePath) { function isGoverned(relativePath) {

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"

0
scripts/terraform_import_plan_resources.py Normal file → Executable file
View file

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

14
scripts/test-terraform-import-plan-check.py Normal file → Executable file
View file

@ -452,15 +452,23 @@ class ImportPlanCheckerTests(unittest.TestCase):
with self.subTest(mutation=mutation): with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", BUCKET_POLICY) self.assert_fails(plan, "dev", BUCKET_POLICY)
def test_github_deploy_policy_stays_byte_identical(self) -> None: def test_github_deploy_policy_is_release_prefix_only(self) -> None:
source = ( source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf" REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1] document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
document = document.split("resource ", 1)[0] document = document.split("resource ", 1)[0]
self.assertNotIn("var.adoption_complete", document) self.assertNotIn("var.adoption_complete", document)
self.assertIn("AssumeCdkBootstrapRoles", document) self.assertIn("ListReleasePrefixes", document)
self.assertIn("DescribeStack", 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("ReadDeploymentBucket", document)
self.assertNotIn("PublishAndRollbackSiteObjects", document) self.assertNotIn("PublishAndRollbackSiteObjects", document)
self.assertNotIn( self.assertNotIn(

View file

@ -0,0 +1,331 @@
#!/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 main() -> int:
cases = [
("version-only", run_case("version-only.json"), 0),
("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())
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,251 @@
#!/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"
INDEX_HTML='<!doctype html><html><head></head><body><script src="/assets/app.js"></script>api.dev.seahaven.com</body></html>'
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}"
[[ -n "${output}" ]] && printf 'asset' > "${output}"
[[ -z "${output}" ]] && printf 'asset'
exit 0
fi
body="$(cat "${state_dir}/index.html")"
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
if [[ -n "${output}" ]]; then
printf '%s' "${body}" > "${output}"
else
printf '%s' "${body}"
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}"
}
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,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_ORG="${SENTRY_ORG:-seahaven}"
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}" SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
RELEASE_LABEL="${SENTRY_RELEASE:-${RELEASE_LABEL:-}}"
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then if [[ -n "${RELEASE_LABEL}" ]]; then
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2 RELEASE="${RELEASE_LABEL}"
exit 1 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 fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}"
npm exec --no -- sentry-cli sourcemaps upload \ npm exec --no -- sentry-cli sourcemaps upload \
--org "${SENTRY_ORG}" \ --org "${SENTRY_ORG}" \
--project "${SENTRY_PROJECT}" \ --project "${SENTRY_PROJECT}" \

View file

@ -0,0 +1,177 @@
#!/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')"
local body
body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)"
if [[ -n "${body}" ]]; then
last_hash="$(printf '%s' "${body}" | sha256_of)"
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
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
for forbidden in api.staging.seahaven.com localhost:5141; do
if grep -Fq "${forbidden}" "${tmp}/index.html"; then
echo "FAIL: served index contains forbidden URL ${forbidden}." >&2
exit 1
fi
done
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/index.html"; then
echo "FAIL: served index is missing the dev API URL." >&2
exit 1
fi
asset_path="$(python3 -c 'import re,sys; html=open(sys.argv[1],encoding="utf-8").read(); m=re.search(r"(/assets/[^\"'\'']+)", html); print(m.group(1) if m else "")' "${tmp}/index.html")"
if [[ -z "${asset_path}" ]]; then
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
exit 1
fi
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" -o /dev/null -D "${tmp}/asset.headers"
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
exit 1
fi
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

View file

@ -4,11 +4,11 @@ This tree adopts the existing Sea Haven SHOC frontend dev hosting resources
into HCP Terraform without recreating them. It mirrors the backend adoption into HCP Terraform without recreating them. It mirrors the backend adoption
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs: (`shoc-backend` #94, #98, #99, #102) and lands in three PRs:
| PR | Branch | Change | | PR | Branch | Change |
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| A | `feature/frontend-terraform-adoption` | Merged. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. | | A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. |
| B | `feature/terraform-dev-adoption` | This PR. `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. | | 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` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. | | 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 Creating these files, formatting them, initializing with `-backend=false`, and
validating them does not authorize an AWS, HCP Terraform, GitHub, validating them does not authorize an AWS, HCP Terraform, GitHub,
@ -42,7 +42,7 @@ before the first release after any Terraform merge:
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger `terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
prefixes, no tags regex. Do not switch to tag-based triggering. prefixes, no tags regex. Do not switch to tag-based triggering.
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires - Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
`>= 1.9.0, < 2.0.0`; CI validates with `1.16.0`). `>= 1.14.0, < 2.0.0`; CI validates with `1.16.0`).
- Dynamic AWS credentials only: environment variables - Dynamic AWS credentials only: environment variables
`TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and `TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and
`TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan` `TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan`
@ -54,7 +54,8 @@ before the first release after any Terraform merge:
## Ownership boundary ## Ownership boundary
`live/modules/environment-owned` owns exactly these 13 addresses: `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` 1. `module.environment_owned.aws_s3_bucket.site`
2. `module.environment_owned.aws_s3_bucket_public_access_block.site` 2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
@ -69,7 +70,10 @@ before the first release after any Terraform merge:
11. `module.environment_owned.aws_route53_record.site_aaaa` 11. `module.environment_owned.aws_route53_record.site_aaaa`
12. `module.environment_owned.aws_iam_role.github_deploy` 12. `module.environment_owned.aws_iam_role.github_deploy`
13. `module.environment_owned.aws_iam_role_policy.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`. Every managed resource has `prevent_destroy = true`.
`live/modules/environment-inventory` is data-only. It resolves and checks the `live/modules/environment-inventory` is data-only. It resolves and checks the
@ -135,15 +139,8 @@ Each step is gated. State the impact, get the go, act, read back, record.
1. **Workspace invariants.** Set the invariants above on 1. **Workspace invariants.** Set the invariants above on
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the `shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
PR. PR.
2. **CDK retain deploy.** From the reviewed PR head, with administrator 2. **CDK retain deploy.** Completed from the reviewed PR A head. The CDK app
credentials: is no longer in this repository.
```bash
cd infra/cdk && npm ci
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=true
```
Expected: an update-only change set (no create, no delete, no replace) Expected: an update-only change set (no create, no delete, no replace)
that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the
@ -175,7 +172,7 @@ Each step is gated. State the impact, get the go, act, read back, record.
After Phase 1 CloudFormation still owns every resource. Terraform holds state After Phase 1 CloudFormation still owns every resource. Terraform holds state
for them and nothing else. for them and nothing else.
## Phase 2: controlled ownership transfer (this PR) ## Phase 2: controlled ownership transfer (merged #178)
PR B pins `adoption_complete = true`. The controlled apply may update only: PR B pins `adoption_complete = true`. The controlled apply may update only:
@ -201,23 +198,58 @@ python3 scripts/check-terraform-import-plan.py plan.json --environment dev \
--allow-update-address module.environment_owned.aws_iam_role.github_deploy --allow-update-address module.environment_owned.aws_iam_role.github_deploy
``` ```
After the apply and a no-op plan, deploy the same reviewed CDK SHA with After the apply and a no-op plan, the CDK stack was relinquished with
`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the `ManageSiteInfrastructure=false`. Never deploy that stack with
13 transferred resources and the custom resource. Never deploy with `ManageSiteInfrastructure=true` again. The CDK app was removed in PR C.
`ManageSiteInfrastructure=true` again after that. See
[`infra/cdk/README.md`](../infra/cdk/README.md).
Confirm `dev.seahaven.com` still serves and that a manual `workflow_dispatch` Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual
of `deploy.yml` can still upload with the unchanged GitHub content policy. `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 (PR C) ## Phase 3: content CD through Terraform (this PR)
Summary only; PR C carries the full design. GitHub builds and uploads to an GitHub builds the SPA and uploads only `releases/<sha>-<run>-<attempt>/`.
immutable `releases/<sha>-<run>-<attempt>/` prefix. Terraform owns the The GitHub role may `GetObject` on `.release/current` and read the exact
`.release/current` pointer, both origin paths of a CloudFront origin group, distribution (`GetDistribution` / `GetDistributionConfig`) so verify and
and the invalidation action. Rollback is one guarded Terraform run swapping live-state summary can observe origin paths. It cannot invalidate or write
the labels. Push-to-`dev` releases return behind the repository variable the pointer. Terraform owns `.release/current`, both origin paths of the
`TERRAFORM_CONTENT_CD_ENABLED`. 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 ## Operational rules
@ -240,9 +272,9 @@ the labels. Push-to-`dev` releases return behind the repository variable
workspace. workspace.
- **Re-read the workspace invariants** before the first release after any - **Re-read the workspace invariants** before the first release after any
Terraform merge or workspace settings change. Terraform merge or workspace settings change.
- **A red job does not mean the site is down.** Read the live state first - **A red job does not mean the site is down.** Read the live-state summary
(served `index.html`, distribution status, pointer body once PR C lands), first (served `index.html` hash, distribution status, pointer body, both
then triage. origin paths), then triage.
- **Exact-head evidence.** Every live step records the run URL, the SHA, and a - **Exact-head evidence.** Every live step records the run URL, the SHA, and a
machine-readable read-back on the PR or SH-300. machine-readable read-back on the PR or SH-300.
@ -254,8 +286,11 @@ From the repository root (also run by `npm run verify` through
```bash ```bash
npm run test:terraform # fmt -check, init -backend=false, validate 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-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:terraform-isolation # isolation gate unit tests
npm run test:infra # CDK build, template tests, synth in both modes 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 `terraform init -backend=false -lockfile=readonly` may download the provider

View file

@ -90,4 +90,6 @@ module "environment_owned" {
ownership_tags = local.terraform_tags ownership_tags = local.terraform_tags
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag) pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
post_adoption_deploy_role_tags = merge(local.terraform_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

@ -9,3 +9,11 @@ output "distribution_id" {
output "deploy_role_arn" { output "deploy_role_arn" {
value = module.environment_owned.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,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

@ -1,5 +1,5 @@
terraform { terraform {
required_version = ">= 1.9.0, < 2.0.0" required_version = ">= 1.14.0, < 2.0.0"
cloud { cloud {
organization = "seahaven" organization = "seahaven"

View file

@ -5,6 +5,24 @@ locals {
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_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 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 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", [ spa_rewrite_code = join("\n", [
"function handler(event) {", "function handler(event) {",
@ -19,6 +37,17 @@ locals {
]) ])
} }
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" { data "aws_iam_policy_document" "site_bucket" {
dynamic "statement" { dynamic "statement" {
for_each = var.adoption_complete ? [] : [1] for_each = var.adoption_complete ? [] : [1]
@ -109,53 +138,48 @@ data "aws_iam_policy_document" "github_deploy_assume" {
} }
data "aws_iam_policy_document" "github_deploy" { data "aws_iam_policy_document" "github_deploy" {
# Byte-identical to the live GitHub content policy through Phase 2 so statement {
# aws_iam_role_policy.github_deploy stays no-op. Phase 3 replaces this sid = "ListReleasePrefixes"
# with the release-prefix policy. effect = "Allow"
dynamic "statement" { actions = [
for_each = var.environment == "dev" ? [1] : [] "s3:GetBucketLocation",
"s3:ListBucket",
]
resources = [local.bucket_arn]
content { condition {
sid = "AssumeCdkBootstrapRoles" test = "StringLike"
effect = "Allow" variable = "s3:prefix"
actions = ["sts:AssumeRole"] values = [
resources = ["arn:aws:iam::${var.aws_account_id}:role/cdk-hnb659fds-*"] "releases/",
"releases/*",
]
} }
} }
statement { statement {
sid = "DescribeStack" sid = "PublishReleasePrefix"
effect = "Allow"
actions = ["cloudformation:DescribeStacks"]
resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"]
}
statement {
effect = "Allow" effect = "Allow"
actions = [ actions = [
"s3:Abort*", "s3:GetObject",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject", "s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
]
resources = [
local.bucket_arn,
"${local.bucket_arn}/*",
] ]
resources = ["${local.bucket_arn}/releases/*"]
} }
statement { statement {
sid = "InvalidateDistribution" sid = "ReadReleasePointer"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${local.bucket_arn}/${local.pointer_key}"]
}
statement {
sid = "ReadDistribution"
effect = "Allow" effect = "Allow"
actions = [ actions = [
"cloudfront:CreateInvalidation", "cloudfront:GetDistribution",
"cloudfront:GetInvalidation", "cloudfront:GetDistributionConfig",
] ]
resources = [local.distribution_arn] resources = [local.distribution_arn]
} }
@ -233,6 +257,20 @@ resource "aws_s3_bucket_policy" "site" {
} }
} }
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" { resource "aws_cloudfront_origin_access_control" "site" {
name = var.origin_access_control_name name = var.origin_access_control_name
description = var.origin_access_control_description description = var.origin_access_control_description
@ -275,6 +313,32 @@ resource "aws_cloudfront_distribution" "site" {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.site.id origin_access_control_id = aws_cloudfront_origin_access_control.site.id
origin_id = var.origin_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 { default_cache_behavior {
@ -282,7 +346,7 @@ resource "aws_cloudfront_distribution" "site" {
cache_policy_id = var.cache_policy_id cache_policy_id = var.cache_policy_id
cached_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"]
compress = true compress = true
target_origin_id = var.origin_id target_origin_id = local.origin_group_id
viewer_protocol_policy = "redirect-to-https" viewer_protocol_policy = "redirect-to-https"
function_association { function_association {
@ -305,6 +369,18 @@ resource "aws_cloudfront_distribution" "site" {
lifecycle { lifecycle {
prevent_destroy = true 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 = ["/*"]
} }
} }

View file

@ -12,3 +12,33 @@ output "deploy_role_arn" {
value = aws_iam_role.github_deploy.arn value = aws_iam_role.github_deploy.arn
description = "Imported GitHub deployment role 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

@ -10,10 +10,44 @@ variable "environment" {
variable "adoption_complete" { variable "adoption_complete" {
type = bool type = bool
description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy. The GitHub deploy inline policy stays byte-identical to live until the content-CD PR." description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy."
default = false 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" { variable "aws_account_id" {
type = string type = string
description = "AWS account containing the resources." description = "AWS account containing the resources."