mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
Merge branch 'main' into fix/ab/sh-364-inline-technician-registration
This commit is contained in:
commit
4d3dd82afb
40 changed files with 1524 additions and 1918 deletions
56
.github/workflows/ci-terraform.yaml
vendored
Normal file
56
.github/workflows/ci-terraform.yaml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: Terraform CI
|
||||
|
||||
# Static checks only. Plans run in HCP Terraform as speculative VCS runs on
|
||||
# the PR (shoc-frontend-new-dev and shoc-frontend-new-staging). Applies are
|
||||
# HCP auto-apply on merge to main (dev) and on a vX.Y.Z-staging tag (staging).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "terraform/**"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
- ".github/workflows/deploy-web.yaml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "terraform/**"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform fmt
|
||||
run: terraform fmt -check -recursive terraform
|
||||
|
||||
- name: Validate live/dev
|
||||
run: |
|
||||
terraform -chdir=terraform/live/dev init -backend=false -input=false -lockfile=readonly -no-color
|
||||
terraform -chdir=terraform/live/dev validate -no-color
|
||||
|
||||
- name: Validate live/staging
|
||||
run: |
|
||||
terraform -chdir=terraform/live/staging init -backend=false -input=false -lockfile=readonly -no-color
|
||||
terraform -chdir=terraform/live/staging validate -no-color
|
||||
|
||||
- name: Import plan guard tests
|
||||
run: python3 scripts/test-terraform-import-plan-check.py
|
||||
|
||||
- name: App/Terraform isolation tests
|
||||
run: python3 scripts/test_check_app_terraform_isolation.py
|
||||
17
.github/workflows/ci.yaml
vendored
17
.github/workflows/ci.yaml
vendored
|
|
@ -2,9 +2,9 @@ name: Frontend checks
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev, staging]
|
||||
branches: [main, dev]
|
||||
push:
|
||||
branches: [main, dev, staging]
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
|
|
@ -24,15 +24,16 @@ jobs:
|
|||
# `npm run verify` is the single command that chains: format check, lint
|
||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan and
|
||||
# release-plan guards, isolation tests, HCP run guard, CloudFront verify,
|
||||
# and GitHub workflow shell). If the reusable workflow is later confirmed
|
||||
# to run every gate, this job can be slimmed to `npm run governance`.
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan
|
||||
# guard, HCP run guard, CloudFront verify, GitHub workflow shell, and G13
|
||||
# app/Terraform isolation). Runs on PRs to main or dev; push is main only.
|
||||
# If the reusable workflow is later confirmed to run every gate, this job
|
||||
# can be slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
# push-> the previous commit on the branch (github.event.before)
|
||||
# manual -> dev, for exact-head recovery runs
|
||||
# manual -> main, for exact-head recovery runs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -52,7 +53,7 @@ jobs:
|
|||
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
|
||||
base="${EVENT_BEFORE}"
|
||||
else
|
||||
base="origin/dev"
|
||||
base="origin/main"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Set up Terraform
|
||||
|
|
|
|||
148
.github/workflows/deploy-staging.yml
vendored
148
.github/workflows/deploy-staging.yml
vendored
|
|
@ -1,148 +0,0 @@
|
|||
name: Deploy staging
|
||||
|
||||
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
|
||||
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
|
||||
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
|
||||
# job to declare `environment: staging` and run in this repo, with the
|
||||
# non-secret role ARN pinned below (created by the staging stack itself).
|
||||
#
|
||||
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
|
||||
# No secrets are used — OIDC + the static role ARN are the only credentials.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to staging
|
||||
# Deploy only the exact staging branch ref, never a tag or other ref
|
||||
# (workflow_dispatch can be invoked from arbitrary refs).
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
env:
|
||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
AWS_REGION: us-east-1
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Resolve governance comparison ref
|
||||
id: governance-ref
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
|
||||
base="${EVENT_BEFORE}"
|
||||
else
|
||||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
# Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin
|
||||
# (npm@11.16.0) matches this CI environment.
|
||||
- name: Quality gates (full verify before any deploy)
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
- name: Assume staging deploy role (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds the SPA with the staging VITE_API_URL and Sentry environment
|
||||
# label (process env overrides the dev values committed in
|
||||
# .env.production), syncs to the staging bucket, and invalidates
|
||||
# CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
STACK_NAME: shoc-frontend-staging
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name shoc-frontend-staging \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
DIST_DOMAIN="$(stack_output DistributionDomainName)"
|
||||
SITE_URL="$(stack_output SiteUrl)"
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then
|
||||
echo "::error::Could not resolve bucket/distribution from stack outputs." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}"
|
||||
|
||||
aws s3api head-bucket --bucket "${BUCKET}"
|
||||
echo "Bucket exists."
|
||||
# The distribution is proven to exist and serve by the HTTPS check
|
||||
# below: the custom domain is an alias to this distribution, and the
|
||||
# deploy role deliberately carries no cloudfront:GetDistribution
|
||||
# (least privilege; the dev template is shared and must not drift).
|
||||
|
||||
if grep -Rq "api.dev.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2
|
||||
grep -Rl "api.dev.seahaven.com" dist/ >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Built assets carry no dev API URL."
|
||||
grep -Rq "api.staging.seahaven.com" dist/
|
||||
echo "Built assets reference the staging API URL."
|
||||
|
||||
# Verify the actual post-invalidation HTML and its referenced assets,
|
||||
# not only the local build or a generic endpoint response.
|
||||
remote_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${remote_dir}"' EXIT
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then
|
||||
break
|
||||
fi
|
||||
echo "Endpoint not ready (attempt ${i}); retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
test -s "${remote_dir}/index.html"
|
||||
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \
|
||||
| sed -E 's/^(src|href)="([^"]+)"$/\2/' \
|
||||
| sort -u > "${remote_dir}/asset-paths.txt"
|
||||
test -s "${remote_dir}/asset-paths.txt"
|
||||
while IFS= read -r asset_path; do
|
||||
curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \
|
||||
>> "${remote_dir}/assets.txt"
|
||||
done < "${remote_dir}/asset-paths.txt"
|
||||
if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then
|
||||
echo "::error::Deployed assets contain the dev API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt"
|
||||
echo "Deployed staging assets reference only the staging API URL."
|
||||
317
.github/workflows/deploy-web.yaml
vendored
Normal file
317
.github/workflows/deploy-web.yaml
vendored
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
name: Deploy Web
|
||||
|
||||
# SPA CD. GitHub Actions builds dist/ and syncs it to the S3 origin bucket
|
||||
# root, then invalidates CloudFront. Terraform owns the bucket and the
|
||||
# distribution and never touches content.
|
||||
#
|
||||
# push to main -> dev, at github.sha
|
||||
# release: published -> staging, at vX.Y.Z-staging (must be on main)
|
||||
# workflow_dispatch -> chosen environment at a chosen ref
|
||||
#
|
||||
# Releases are cut by a human with
|
||||
# `gh release create vX.Y.Z-staging --target main --generate-notes`.
|
||||
# A workflow cannot do it: releases created with GITHUB_TOKEN do not fire
|
||||
# `release: published`. Core vX.Y.Z waits until a prod distribution exists.
|
||||
#
|
||||
# Bucket and distribution come from SSM after assuming the Environment's
|
||||
# DEPLOY_ROLE_ARN. Nothing here creates an HCP run. Quality gates live in CI.
|
||||
#
|
||||
# The SPA checkout is the resolved content ref. Deploy scripts are copied
|
||||
# from github.workflow_sha so workflow_dispatch of an older SHA still runs
|
||||
# the current upload/verify path.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "terraform/**"
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- ".github/workflows/ci.yaml"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
- ".github/workflows/deploy-web.yaml"
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target Environment"
|
||||
required: true
|
||||
type: choice
|
||||
options: [dev, staging]
|
||||
ref:
|
||||
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
target:
|
||||
name: Resolve target
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
environment: ${{ steps.resolve.outputs.environment }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- id: resolve
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REF_NAME_IN: ${{ github.ref }}
|
||||
GITHUB_SHA_IN: ${{ github.sha }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_ENVIRONMENT: ${{ inputs.environment }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${EVENT_NAME}" in
|
||||
push)
|
||||
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
|
||||
echo "push deploys only run from main" >&2
|
||||
exit 1
|
||||
fi
|
||||
environment=dev
|
||||
ref="${GITHUB_SHA_IN}"
|
||||
;;
|
||||
release)
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-staging$ ]]; then
|
||||
echo "release tag ${RELEASE_TAG} is not vX.Y.Z-staging; refusing until a prod distribution exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
environment=staging
|
||||
ref="${RELEASE_TAG}"
|
||||
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
|
||||
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
||||
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
workflow_dispatch)
|
||||
environment="${INPUT_ENVIRONMENT}"
|
||||
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported event ${EVENT_NAME}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
{
|
||||
echo "environment=${environment}"
|
||||
echo "ref=${ref}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
echo "Deploying ${ref} to ${environment}"
|
||||
|
||||
deploy:
|
||||
name: Deploy SPA to ${{ needs.target.outputs.environment }}
|
||||
needs: target
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: ${{ needs.target.outputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-web-${{ needs.target.outputs.environment }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.target.outputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve commit
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Checkout workflow deploy scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
sparse-checkout-cone-mode: true
|
||||
path: .workflow-scripts
|
||||
|
||||
- name: Install workflow deploy scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f .workflow-scripts/scripts/upload-sourcemaps.sh
|
||||
test -f .workflow-scripts/scripts/verify-cloudfront-release.sh
|
||||
test -f .workflow-scripts/scripts/summarize-cloudfront-live-state.sh
|
||||
mkdir -p scripts
|
||||
cp .workflow-scripts/scripts/upload-sourcemaps.sh scripts/
|
||||
cp .workflow-scripts/scripts/verify-cloudfront-release.sh scripts/
|
||||
cp .workflow-scripts/scripts/summarize-cloudfront-live-state.sh scripts/
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Build SPA
|
||||
env:
|
||||
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
||||
VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
||||
VITE_SENTRY_RELEASE: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# vars.VITE_SENTRY_DSN is unset today. An empty env value would
|
||||
# override .env.production and disable Sentry (Vite does not let
|
||||
# .env overwrite an existing variable).
|
||||
if [ -n "${VITE_SENTRY_DSN:-}" ]; then
|
||||
export VITE_SENTRY_DSN
|
||||
else
|
||||
unset VITE_SENTRY_DSN
|
||||
fi
|
||||
case "${TARGET_ENVIRONMENT}" in
|
||||
dev)
|
||||
export VITE_API_URL="https://api.dev.seahaven.com/api"
|
||||
forbidden="api.staging.seahaven.com"
|
||||
required="api.dev.seahaven.com"
|
||||
;;
|
||||
staging)
|
||||
export VITE_API_URL="https://api.staging.seahaven.com/api"
|
||||
forbidden="api.dev.seahaven.com"
|
||||
required="api.staging.seahaven.com"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported environment ${TARGET_ENVIRONMENT}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
npm ci
|
||||
npm run build
|
||||
test -f dist/index.html
|
||||
if grep -Rq "${forbidden}" dist/; then
|
||||
echo "Built assets contain the forbidden URL ${forbidden}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Rq "localhost:5141" dist/; then
|
||||
echo "Built assets contain the Vite proxy target localhost:5141." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "${required}" dist/
|
||||
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
|
||||
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
|
||||
echo "VITE_API_URL=${VITE_API_URL}" >> "${GITHUB_ENV}"
|
||||
echo "dist/index.html sha256=${index_sha}"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
|
||||
- name: Strip source maps from dist/
|
||||
run: |
|
||||
set -euo pipefail
|
||||
find dist -name '*.map' -delete
|
||||
if find dist -name '*.map' | grep -q .; then
|
||||
echo "SPA source maps must not ship in dist/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: spa-dist-${{ needs.target.outputs.environment }}-${{ steps.commit.outputs.sha }}
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Get deploy parameters
|
||||
id: deploy
|
||||
env:
|
||||
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="/shoc-frontend-new/${TARGET_ENVIRONMENT}/deploy"
|
||||
BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text)
|
||||
DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text)
|
||||
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
|
||||
# Refuse to touch the bucket until Terraform has moved every origin
|
||||
# to the bucket root. The previous CD pointed origins at
|
||||
# /releases/<label>; syncing and pruning under that layout would
|
||||
# serve a broken site or delete the live prefix.
|
||||
origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \
|
||||
--query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')"
|
||||
if [ -n "${origin_paths}" ]; then
|
||||
echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2
|
||||
echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "bucket=${BUCKET}"
|
||||
echo "distribution_id=${DIST_ID}"
|
||||
echo "site_url=https://${DOMAIN}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Sync dist/ to the bucket root
|
||||
env:
|
||||
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html
|
||||
|
||||
- name: Invalidate CloudFront
|
||||
env:
|
||||
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
invalidation_id="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DISTRIBUTION_ID}" \
|
||||
--paths "/*" \
|
||||
--query Invalidation.Id --output text)"
|
||||
echo "Invalidation ${invalidation_id} created; waiting"
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DISTRIBUTION_ID}" \
|
||||
--id "${invalidation_id}"
|
||||
|
||||
- name: Verify served release
|
||||
env:
|
||||
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
|
||||
SITE_URL: ${{ steps.deploy.outputs.site_url }}
|
||||
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
|
||||
API_URL: ${{ env.VITE_API_URL }}
|
||||
run: bash scripts/verify-cloudfront-release.sh
|
||||
|
||||
- name: Live-state summary
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
|
||||
SITE_URL: ${{ steps.deploy.outputs.site_url }}
|
||||
run: bash scripts/summarize-cloudfront-live-state.sh
|
||||
300
.github/workflows/deploy.yml
vendored
300
.github/workflows/deploy.yml
vendored
|
|
@ -1,300 +0,0 @@
|
|||
name: Deploy dev content
|
||||
|
||||
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable
|
||||
# releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
|
||||
# group, and invalidation. Push-to-dev stays off until
|
||||
# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true.
|
||||
#
|
||||
# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not
|
||||
# re-run those gates on pull requests, pushes, or workflow_dispatch.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths-ignore:
|
||||
- "terraform/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
name: Deploy shoc-frontend-new-dev through Terraform
|
||||
if: >
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
|
||||
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
concurrency:
|
||||
group: deploy-dev
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
TF_CLOUD_ORGANIZATION: seahaven
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
SITE_URL: https://dev.seahaven.com
|
||||
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- 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
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Assign immutable release identity
|
||||
id: release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
prefix="releases/${version_label}"
|
||||
{
|
||||
echo "version_label=${version_label}"
|
||||
echo "prefix=${prefix}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Sentry release is shoc-frontend@${GITHUB_SHA} via VITE_APP_COMMIT_SHA,
|
||||
# distinct from the S3/Terraform version_label.
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Read previous release pointer
|
||||
id: pointer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
|
||||
printf '%s' "${body}" | python3 scripts/read-release-pointer.py
|
||||
|
||||
- name: Upload immutable release prefix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="${{ steps.release.outputs.prefix }}"
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
aws s3api head-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key "${prefix}/index.html"
|
||||
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
|
||||
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
|
||||
echo "Uploaded ${prefix}; index.html sha256=${index_sha}"
|
||||
|
||||
- name: Capture previous served hash
|
||||
id: previous-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)"
|
||||
echo "sha256=${hash}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Discard blocking VCS run before GitHub CD
|
||||
id: discard-vcs
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
|
||||
|
||||
- name: Create Terraform release run
|
||||
id: release-run
|
||||
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
env:
|
||||
TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"'
|
||||
TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"'
|
||||
with:
|
||||
workspace: shoc-frontend-new-dev
|
||||
message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions"
|
||||
|
||||
- name: Read Terraform release plan counts
|
||||
id: release-plan
|
||||
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
plan: ${{ steps.release-run.outputs.plan_id }}
|
||||
|
||||
- name: Reject non-release resource counts
|
||||
env:
|
||||
PLAN_ADD: ${{ steps.release-plan.outputs.add }}
|
||||
PLAN_CHANGE: ${{ steps.release-plan.outputs.change }}
|
||||
PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
|
||||
echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard pointer-and-origin-path Terraform plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Flags must match check-terraform-release-plan.py. Pointer `before`
|
||||
# and origin-ID-set stability are asserted from the plan JSON.
|
||||
python3 scripts/check-terraform-release-plan.py \
|
||||
--plan-id "${{ steps.release-run.outputs.plan_id }}" \
|
||||
--expected-version-label "${{ steps.release.outputs.version_label }}" \
|
||||
--expected-previous-version-label "${{ steps.pointer.outputs.live_current }}"
|
||||
|
||||
- name: Discard release run when the guard fails
|
||||
if: failure() && steps.release-run.outcome == 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.release-run.outputs.run_id }}
|
||||
comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions
|
||||
|
||||
- name: Apply Terraform release run
|
||||
id: release-apply
|
||||
continue-on-error: true
|
||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.release-run.outputs.run_id }}
|
||||
comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }}
|
||||
|
||||
- name: Treat already-applied release run as success
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/hcp-run-guard.py reconcile-apply \
|
||||
--run-id "${{ steps.release-run.outputs.run_id }}" \
|
||||
--apply-outcome "${{ steps.release-apply.outcome }}"
|
||||
|
||||
- name: Verify CloudFront release
|
||||
env:
|
||||
EXPECTED_LABEL: ${{ steps.release.outputs.version_label }}
|
||||
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
|
||||
PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }}
|
||||
run: bash scripts/verify-cloudfront-release.sh
|
||||
|
||||
- name: Restore previous release on failure
|
||||
if: failure()
|
||||
id: rollback-prepare
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prev="${{ steps.pointer.outputs.live_current }}"
|
||||
if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then
|
||||
echo "No Terraform-managed previous label; cannot roll back through HCP." >&2
|
||||
exit 0
|
||||
fi
|
||||
echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}"
|
||||
echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Discard blocking VCS run before GitHub rollback
|
||||
id: rollback-discard-vcs
|
||||
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
|
||||
|
||||
- name: Create Terraform rollback run
|
||||
id: rollback-run
|
||||
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
env:
|
||||
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
|
||||
TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"'
|
||||
with:
|
||||
workspace: shoc-frontend-new-dev
|
||||
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
|
||||
|
||||
- name: Read Terraform rollback plan counts
|
||||
id: rollback-plan
|
||||
if: failure() && steps.rollback-run.outcome == 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
plan: ${{ steps.rollback-run.outputs.plan_id }}
|
||||
|
||||
- name: Reject non-release rollback counts
|
||||
id: rollback-count-guard
|
||||
if: failure() && steps.rollback-plan.outcome == 'success'
|
||||
env:
|
||||
PLAN_ADD: ${{ steps.rollback-plan.outputs.add }}
|
||||
PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }}
|
||||
PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
|
||||
echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard pointer-and-origin-path Terraform rollback plan
|
||||
id: rollback-json-guard
|
||||
if: failure() && steps.rollback-count-guard.outcome == 'success'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/check-terraform-release-plan.py \
|
||||
--plan-id "${{ steps.rollback-run.outputs.plan_id }}" \
|
||||
--expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \
|
||||
--expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}"
|
||||
|
||||
- name: Discard rollback run when the guard fails
|
||||
if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.rollback-run.outputs.run_id }}
|
||||
comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions
|
||||
|
||||
- name: Apply Terraform rollback run
|
||||
id: rollback-apply
|
||||
if: failure() && steps.rollback-json-guard.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.rollback-run.outputs.run_id }}
|
||||
comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }}
|
||||
|
||||
- name: Treat already-applied rollback run as success
|
||||
id: rollback-apply-result
|
||||
if: failure() && steps.rollback-apply.outcome != 'skipped'
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/hcp-run-guard.py reconcile-apply \
|
||||
--run-id "${{ steps.rollback-run.outputs.run_id }}" \
|
||||
--apply-outcome "${{ steps.rollback-apply.outcome }}"
|
||||
|
||||
- name: Verify CloudFront rollback
|
||||
if: failure() && steps.rollback-apply-result.outcome == 'success'
|
||||
env:
|
||||
EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
export EXPECTED_INDEX_SHA256="${expected_sha}"
|
||||
bash scripts/verify-cloudfront-release.sh
|
||||
|
||||
- name: Live-state summary
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: bash scripts/summarize-cloudfront-live-state.sh
|
||||
43
.github/workflows/terraform-isolation.yaml
vendored
43
.github/workflows/terraform-isolation.yaml
vendored
|
|
@ -1,43 +0,0 @@
|
|||
name: Terraform isolation
|
||||
|
||||
# Own workflow so labeled/unlabeled re-evaluate this gate without starting a
|
||||
# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events
|
||||
# would report required checks as success and could merge a failing SHA.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev, staging]
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform-isolation:
|
||||
# Fails a pull request that changes Terraform infrastructure together with
|
||||
# deployable application code (scripts/check-terraform-isolation.mjs). A
|
||||
# merge that does both queues an HCP VCS run and a content release at the
|
||||
# same time, and the two race for the workspace lock. The
|
||||
# `terraform-isolation-override` label is the reviewed exception. This
|
||||
# job is unconditional so adding or removing that label always reads the
|
||||
# current label set; a previous green check does not survive removal.
|
||||
name: Terraform and application changes are isolated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Check changed files
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
|
||||
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"
|
||||
|
|
@ -28,9 +28,10 @@ npm run verify
|
|||
|
||||
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
|
||||
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
|
||||
checks (`npm run governance`). **Do not claim a task is done until `npm run
|
||||
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
|
||||
`governance` job, so a green local run mirrors CI.
|
||||
checks (`npm run governance`), including G13 app/Terraform isolation. **Do not
|
||||
claim a task is done until `npm run verify` is green locally.** CI runs the same
|
||||
`npm run verify` in a repo-owned `governance` job, so a green local run mirrors
|
||||
CI.
|
||||
|
||||
## Non-negotiable rules (enforced; do not work around)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,32 +8,30 @@ npm run verify
|
|||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the repository
|
||||
gates: Terraform import-plan and release-plan checkers, isolation tests,
|
||||
Terraform formatting and validation, the HCP run guard, CloudFront verify, and
|
||||
workflow shell checks. A task is not done until this is green.
|
||||
gates: Terraform import-plan checker, Terraform formatting and validation, the
|
||||
HCP run guard, CloudFront verify, workflow shell checks, and G13 (app/Terraform
|
||||
isolation). A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures |
|
||||
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
|
||||
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
|
||||
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
|
||||
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
|
||||
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------- |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
|
||||
| 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 |
|
||||
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
|
|
@ -51,14 +49,12 @@ workflow shell checks. A task is not done until this is green.
|
|||
-lockfile=readonly` and `validate` run offline; the plan checker is tested
|
||||
against synthetic plan JSON. Real import and controlled-update plans from HCP
|
||||
are migration evidence reviewed by a human before an approved apply
|
||||
(`terraform/README.md`).
|
||||
- **The isolation gate re-evaluates on label changes** — the
|
||||
`terraform-isolation-override` label is the only way to merge a mixed
|
||||
Terraform/application PR. `.github/workflows/terraform-isolation.yaml`
|
||||
runs `terraform-isolation` on `labeled` and `unlabeled` as well as the
|
||||
default pull-request types, so adding or removing the label re-checks
|
||||
the current labels without starting a new Frontend checks run. Removing
|
||||
the label fails a mixed PR that had previously passed with the override.
|
||||
(`terraform/README.md`). G13 fails a diff that contains both `terraform/`
|
||||
and deployable application files (`src/`, `public/`, `pages/`, `config/`,
|
||||
`index.html`, Vite/tsconfig, or `.env*`). Workflow,
|
||||
docs, and gate-script changes may travel with either side. Runtime isolation
|
||||
stays: `deploy-web.yaml` ignores `terraform/**`, and app-only tags skip HCP
|
||||
when workspace trigger patterns miss.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
|
|
@ -70,9 +66,9 @@ workflow shell checks. A task is not done until this is green.
|
|||
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
|
||||
ratchets and repository gates are guaranteed from this repository regardless
|
||||
of the reusable workflow.
|
||||
- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):**
|
||||
on pull requests, fails when Terraform infrastructure and application code
|
||||
change together. Label add/remove re-runs only this workflow.
|
||||
- **Terraform CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):**
|
||||
fmt, `init -backend=false`, validate, import-plan unit tests, and G13
|
||||
classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
|
|
|
|||
102
README.md
102
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# SHOC Frontend (`shoc-frontend-new`)
|
||||
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
|
||||

|
||||

|
||||

|
||||
|
|
@ -20,22 +20,23 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
|||
|
||||
Static SPA hosting on AWS, owned by HCP Terraform
|
||||
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
|
||||
`dist/` from a private S3 bucket using a current/previous origin group;
|
||||
`dist/` from a private S3 bucket at the bucket root;
|
||||
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
|
||||
proxy at the CDN — the backend allows CORS).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
|
||||
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -->|OAC bucket root| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||
GH[GitHub Actions] -->|OIDC upload releases/*| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
|
||||
GH[GitHub Actions] -->|OIDC s3 sync dist/| S3
|
||||
TF[HCP Terraform] -->|bucket CloudFront IAM SSM| CF
|
||||
```
|
||||
|
||||
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
|
||||
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
|
||||
Dev and staging hosting live in `terraform/live/dev` and
|
||||
`terraform/live/staging`. GitHub `.github/workflows/deploy-web.yaml` syncs
|
||||
content.
|
||||
|
||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||
|
|
@ -47,13 +48,13 @@ architecture plan for the keep/discard migration matrix).
|
|||
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
|
||||
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
|
||||
|
||||
| Resource | Name | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
|
||||
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
||||
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
|
||||
| Resource | Name | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
|
||||
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
||||
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to Environment `dev` plus `deploy-web.yaml` |
|
||||
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
|
||||
|
||||
No Lambdas, queues, or databases — this stack is static hosting only.
|
||||
|
||||
|
|
@ -61,8 +62,8 @@ No Lambdas, queues, or databases — this stack is static hosting only.
|
|||
|
||||
### Secrets
|
||||
|
||||
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
|
||||
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
|
||||
No Secrets Manager. Deploy looks up `/shoc-frontend-new/<env>/deploy/{bucket,distribution-id}`
|
||||
after assuming `DEPLOY_ROLE_ARN`. AWS access is OIDC only. The one **GitHub Actions
|
||||
repo secret** is:
|
||||
|
||||
| Secret | Purpose |
|
||||
|
|
@ -127,56 +128,45 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
- Commit messages follow
|
||||
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
|
||||
rejects anything else at commit time.
|
||||
- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs
|
||||
a green CI run and an approving review from a code owner
|
||||
- Open PRs against `main`. Protected branches need a green CI run and an
|
||||
approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- A PR that changes `terraform/**` may not also change application code (the
|
||||
`terraform-isolation` CI job); ship Terraform in its own PR.
|
||||
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through
|
||||
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
|
||||
`→ main` (production promotion — no prod environment exists yet).
|
||||
- PRs cannot mix `terraform/` with deployable application files (G13). Workflow,
|
||||
docs, and gate-script changes may travel with either side. `deploy-web.yaml`
|
||||
still ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
|
||||
- Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts
|
||||
`vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
|
||||
prod distribution exists.
|
||||
|
||||
## Deployment
|
||||
|
||||
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
||||
|
||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||
and PRs to `main`/`dev`/`staging`, calls
|
||||
and PRs to `main`, calls
|
||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
that calls `npm run verify` so every gate (including the maintainability
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
|
||||
the Terraform gates, and the content-CD guards) is guaranteed from this
|
||||
repository. Conventions and gates are documented under
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)
|
||||
and the Terraform gates) is guaranteed from this repository. Conventions
|
||||
and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **Terraform isolation**
|
||||
([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml))
|
||||
— fails a PR that mixes `terraform/**` with application code, so a Terraform
|
||||
merge never races a content release for the HCP workspace.
|
||||
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
|
||||
— `workflow_dispatch` on `dev`, and push-to-`dev` when
|
||||
`vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`).
|
||||
GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates
|
||||
`.release/current`, both origin paths, and the invalidation action. Verify
|
||||
and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
|
||||
a live-state summary.
|
||||
- **Staging content**
|
||||
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
|
||||
— on push to `staging`, unchanged.
|
||||
- **Infrastructure** — administrator-run HCP Terraform workspace
|
||||
`shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
|
||||
Staging hosting stays on the existing CloudFormation stack until SH-287.
|
||||
|
||||
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
|
||||
content publisher only.
|
||||
- **Terraform CI**
|
||||
([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml))
|
||||
— fmt, `init -backend=false`, validate, and import-plan tests.
|
||||
- **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml))
|
||||
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
|
||||
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
|
||||
- **Infrastructure** — HCP workspaces `shoc-frontend-new-dev` and
|
||||
`shoc-frontend-new-staging` ([`terraform/README.md`](terraform/README.md)).
|
||||
|
||||
## Operations
|
||||
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev
|
||||
content** run in the Actions tab; confirm a deep link (e.g. a work-orders
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy Web**
|
||||
run in the Actions tab; confirm a deep link (e.g. a work-orders
|
||||
route) loads directly and API calls succeed.
|
||||
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
|
||||
are no CloudWatch application logs — the stack is static hosting; runtime
|
||||
|
|
@ -185,20 +175,18 @@ content publisher only.
|
|||
- _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
|
||||
still serves the previous `index.html` hash. Read the live-state summary
|
||||
before assuming the site is down.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
|
||||
on this repo; dispatching the workflow from another branch is rejected by
|
||||
design.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to Environment
|
||||
`dev` or `staging` plus `deploy-web.yaml`. A job without `environment:`
|
||||
cannot assume the role.
|
||||
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
|
||||
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||
not proxy `/api`.
|
||||
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when
|
||||
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
|
||||
an HCP Terraform run that a human confirms or discards before the next
|
||||
content release (see the operational rules in `terraform/README.md`).
|
||||
- _Non-empty origin path_ — `deploy-web.yaml` refuses to sync until Terraform
|
||||
has moved every origin to the bucket root.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Terraform runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
|
||||
design system and UI docs under [`docs/`](docs/)
|
||||
|
|
|
|||
|
|
@ -83,3 +83,10 @@ A review is complete when it records, briefly:
|
|||
|
||||
Do not write a monolithic review body or a validation transcript into the PR
|
||||
surface; keep comments inline and high-signal.
|
||||
|
||||
Infra and application **PRs** stay separate. GitHub Actions owns SPA content
|
||||
(`deploy-web.yaml`). HCP Terraform owns the bucket and CloudFront. A change set
|
||||
that includes both `terraform/` and deployable application files (`src/`,
|
||||
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
|
||||
fails G13. Workflow, docs, and gate-script changes may travel with either
|
||||
side.
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@
|
|||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
|
||||
"test:terraform-release-plan": "python3 scripts/test-terraform-release-plan-check.py",
|
||||
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
|
||||
"test:terraform": "node scripts/terraform-validate.mjs",
|
||||
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
|
||||
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
|
||||
"test:github-workflows": "bash scripts/check-github-workflows.sh",
|
||||
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
ALLOWED_CREATE_ADDRESSES,
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
GITHUB_OIDC_PROVIDER_ARN,
|
||||
GITHUB_REPO,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
|
@ -22,7 +25,12 @@ DISTRIBUTION_ADDRESS = (
|
|||
"module.environment_owned.aws_cloudfront_distribution.site"
|
||||
)
|
||||
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS}
|
||||
ROLE_POLICY_ADDRESS = "module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
|
||||
BUCKET_POLICY_ADDRESS,
|
||||
ROLE_ADDRESS,
|
||||
ROLE_POLICY_ADDRESS,
|
||||
}
|
||||
OWNERSHIP_TAGS = {
|
||||
"Environment": None,
|
||||
"ManagedBy": "terraform",
|
||||
|
|
@ -330,6 +338,113 @@ def _validate_policy_update(
|
|||
return violations
|
||||
|
||||
|
||||
def _expected_github_deploy_assume_policy(environment: str) -> dict[str, Any]:
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "GithubDeployOidc",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"token.actions.githubusercontent.com:aud": (
|
||||
"sts.amazonaws.com"
|
||||
),
|
||||
"token.actions.githubusercontent.com:sub": (
|
||||
f"repo:{GITHUB_REPO}:environment:{environment}"
|
||||
),
|
||||
},
|
||||
"StringLike": {
|
||||
"token.actions.githubusercontent.com:job_workflow_ref": [
|
||||
(
|
||||
f"{GITHUB_REPO}/.github/workflows/"
|
||||
"deploy-web.yaml@refs/heads/main"
|
||||
),
|
||||
(
|
||||
f"{GITHUB_REPO}/.github/workflows/"
|
||||
"deploy-web.yaml@refs/tags/v*"
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_role_assume_policy(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
before_policy, violations = _parse_policy(
|
||||
before.get("assume_role_policy"), address, "before"
|
||||
)
|
||||
after_policy, after_violations = _parse_policy(
|
||||
after.get("assume_role_policy"), address, "after"
|
||||
)
|
||||
violations.extend(after_violations)
|
||||
if before_policy == after_policy:
|
||||
violations.append(f"{address}: assume_role_policy semantics did not change")
|
||||
expected_after = _expected_github_deploy_assume_policy(environment)
|
||||
if after_policy is not None and after_policy != expected_after:
|
||||
violations.append(
|
||||
f"{address}: post-adoption assume_role_policy semantics are not exact"
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_role_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
allowed_roots = {"tags", "tags_all", "assume_role_policy", "description"}
|
||||
invalid = {path for path in changed if not path or path[0] not in allowed_roots}
|
||||
violations = [
|
||||
f"{address}: controlled role update changes forbidden path {'.'.join(path)}"
|
||||
for path in sorted(invalid)
|
||||
]
|
||||
if not changed:
|
||||
violations.append(f"{address}: update has no changed leaf values")
|
||||
expected = {
|
||||
**OWNERSHIP_TAGS,
|
||||
"Environment": environment,
|
||||
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"],
|
||||
}
|
||||
expected_after = {key: value for key, value in expected.items() if value is not None}
|
||||
for tag_attribute in ("tags", "tags_all"):
|
||||
if after.get(tag_attribute) != expected_after:
|
||||
violations.append(
|
||||
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||
)
|
||||
if any(path and path[0] == "assume_role_policy" for path in changed):
|
||||
violations.extend(
|
||||
_validate_role_assume_policy(address, before, after, environment)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_iam_policy_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
if changed != {("policy",)}:
|
||||
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
|
||||
if before.get("policy") == after.get("policy"):
|
||||
return [f"{address}: policy semantics did not change"]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_controlled_update(
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
|
|
@ -348,6 +463,10 @@ def _validate_controlled_update(
|
|||
return [*violations, f"{address}: controlled update requires before/after objects"]
|
||||
if address in TAG_UPDATE_ADDRESSES:
|
||||
violations.extend(_validate_tag_update(address, before, after, environment))
|
||||
elif address == ROLE_ADDRESS:
|
||||
violations.extend(_validate_role_update(address, before, after, environment))
|
||||
elif address == ROLE_POLICY_ADDRESS:
|
||||
violations.extend(_validate_iam_policy_update(address, before, after))
|
||||
elif address == BUCKET_POLICY_ADDRESS:
|
||||
violations.extend(
|
||||
_validate_policy_update(
|
||||
|
|
@ -416,19 +535,30 @@ def check_plan(
|
|||
if change.get("replace_paths") not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
|
||||
import_id = REQUIRED_IMPORT_IDS[environment].get(address)
|
||||
if mode == "import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if expected_type is not None:
|
||||
violations.extend(
|
||||
_validate_import_metadata(
|
||||
address=address,
|
||||
change=change,
|
||||
environment=environment,
|
||||
if address in ALLOWED_CREATE_ADDRESSES and import_id is None:
|
||||
if actions != ["create"]:
|
||||
violations.append(
|
||||
f"{address}: import mode requires create for deploy parameters, got {actions!r}"
|
||||
)
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden for created deploy parameters"
|
||||
)
|
||||
else:
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if expected_type is not None:
|
||||
violations.extend(
|
||||
_validate_import_metadata(
|
||||
address=address,
|
||||
change=change,
|
||||
environment=environment,
|
||||
)
|
||||
)
|
||||
)
|
||||
elif mode == "post-import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
|
|
@ -456,6 +586,8 @@ def check_plan(
|
|||
distribution_id,
|
||||
)
|
||||
)
|
||||
elif actions == ["create"] and address in ALLOWED_CREATE_ADDRESSES:
|
||||
pass
|
||||
elif actions != ["no-op"]:
|
||||
violations.append(f"{address}: unsafe controlled actions {actions!r}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
// Terraform/application change isolation gate.
|
||||
//
|
||||
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
|
||||
// on the workspace. If the same merge also changes deployable application
|
||||
// code, the content release and the VCS run race for the workspace lock
|
||||
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
|
||||
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
|
||||
// or discarded by a human before the next content release.
|
||||
//
|
||||
// Files that may accompany a Terraform change without triggering a release:
|
||||
// the Terraform tree itself, its plan-guard tooling, and documentation.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
|
||||
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
|
||||
//
|
||||
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
|
||||
// it only when the PR carries the `terraform-isolation-override` label, which
|
||||
// reviewers grant to the rare change that must introduce Terraform variables
|
||||
// together with the workflow that consumes them. The checker has no memory of
|
||||
// a previous pass: the same mixed diff fails again as soon as the override
|
||||
// env is unset (label removal).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const OVERRIDE_LABEL = "terraform-isolation-override";
|
||||
|
||||
export function isTerraformPath(file) {
|
||||
return file.startsWith("terraform/");
|
||||
}
|
||||
|
||||
// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers
|
||||
// are terraform/live/dev/** and terraform/live/modules/**), so it is not a
|
||||
// Terraform change for the mixed-PR check.
|
||||
export function isTerraformInfrastructurePath(file) {
|
||||
return isTerraformPath(file) && !file.endsWith(".md");
|
||||
}
|
||||
|
||||
export function mayAccompanyTerraform(file) {
|
||||
if (isTerraformPath(file)) return true;
|
||||
if (file.endsWith(".md")) return true;
|
||||
if (file.startsWith("docs/")) return true;
|
||||
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
|
||||
if (
|
||||
/^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test(
|
||||
file,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (file.startsWith("scripts/testdata/terraform-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} files changed paths relative to the repository root
|
||||
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
|
||||
*/
|
||||
export function classifyChangedFiles(files) {
|
||||
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
|
||||
const terraform = unique.filter(isTerraformInfrastructurePath);
|
||||
const application = unique.filter((file) => !mayAccompanyTerraform(file));
|
||||
return {
|
||||
terraform,
|
||||
application,
|
||||
mixed: terraform.length > 0 && application.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function changedFilesFromGit(base, head) {
|
||||
const mergeBase = execFileSync("git", ["merge-base", base, head], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
return execFileSync(
|
||||
"git",
|
||||
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { base: null, head: "HEAD", stdin: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--base") options.base = argv[++index];
|
||||
else if (argument === "--head") options.head = argv[++index];
|
||||
else if (argument === "--stdin") options.stdin = true;
|
||||
else throw new Error(`unknown argument: ${argument}`);
|
||||
}
|
||||
if (!options.stdin && !options.base) {
|
||||
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = parseArgs(argv);
|
||||
const files = options.stdin
|
||||
? readFileSync(0, "utf8").split("\n")
|
||||
: changedFilesFromGit(options.base, options.head);
|
||||
const result = classifyChangedFiles(files);
|
||||
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log(
|
||||
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
|
||||
);
|
||||
if (!result.mixed) {
|
||||
console.log(" PASS: Terraform and application changes are not mixed");
|
||||
return 0;
|
||||
}
|
||||
console.log(" Terraform files:");
|
||||
for (const file of result.terraform) console.log(` ${file}`);
|
||||
console.log(" Application files that cannot ship in the same PR:");
|
||||
for (const file of result.application) console.log(` ${file}`);
|
||||
if (override) {
|
||||
console.log(
|
||||
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
console.log(
|
||||
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
OVERRIDE_LABEL,
|
||||
classifyChangedFiles,
|
||||
isTerraformInfrastructurePath,
|
||||
mayAccompanyTerraform,
|
||||
} from "./check-terraform-isolation.mjs";
|
||||
|
||||
const SCRIPT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"check-terraform-isolation.mjs",
|
||||
);
|
||||
|
||||
function runGate(files, env = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
|
||||
input: `${files.join("\n")}\n`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
|
||||
});
|
||||
}
|
||||
|
||||
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
|
||||
for (const file of [
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
"terraform/README.md",
|
||||
"README.md",
|
||||
"docs/adr/0003-terraform.md",
|
||||
"scripts/check-terraform-import-plan.py",
|
||||
"scripts/terraform_import_plan_resources.py",
|
||||
"scripts/test-terraform-import-plan-check.py",
|
||||
"scripts/terraform-validate.mjs",
|
||||
"scripts/check-terraform-isolation.mjs",
|
||||
"scripts/check-terraform-release-plan.py",
|
||||
"scripts/hcp-run-guard.py",
|
||||
"scripts/test-hcp-run-guard.py",
|
||||
"scripts/verify-cloudfront-release.sh",
|
||||
"scripts/test-verify-cloudfront-release.sh",
|
||||
"scripts/summarize-cloudfront-live-state.sh",
|
||||
"scripts/check-github-workflows.sh",
|
||||
"scripts/read-release-pointer.py",
|
||||
"scripts/testdata/terraform-release-plans/version-only.json",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("application, workflow, and dependency files count as application changes", () => {
|
||||
for (const file of [
|
||||
"src/App.tsx",
|
||||
"public/favicon.ico",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
".env.production",
|
||||
"vite.config.ts",
|
||||
".github/workflows/deploy.yml",
|
||||
"scripts/deploy-web.sh",
|
||||
"scripts/governance-check.mjs",
|
||||
"e2e/login.spec.ts",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("terraform-only and application-only changes are not mixed", () => {
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(classifyChangedFiles([]).mixed, false);
|
||||
});
|
||||
|
||||
test("terraform documentation does not mix with application or workflow changes", () => {
|
||||
assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false);
|
||||
assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed,
|
||||
false,
|
||||
);
|
||||
const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]);
|
||||
assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr);
|
||||
assert.match(docsOnly.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("terraform plus application is mixed and lists the offending files", () => {
|
||||
const result = classifyChangedFiles([
|
||||
"terraform/live/dev/main.tf",
|
||||
"src/App.tsx",
|
||||
"README.md",
|
||||
" ",
|
||||
"src/App.tsx",
|
||||
]);
|
||||
assert.equal(result.mixed, true);
|
||||
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
|
||||
assert.deepEqual(result.application, ["src/App.tsx"]);
|
||||
});
|
||||
|
||||
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
|
||||
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
|
||||
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
|
||||
assert.match(mixed.stdout, /FAIL/);
|
||||
assert.match(mixed.stdout, /src\/App\.tsx/);
|
||||
|
||||
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
|
||||
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
|
||||
assert.match(isolated.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("CLI override downgrades a mixed change to a warning that names the label", () => {
|
||||
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /WARNING/);
|
||||
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
|
||||
|
||||
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "yes",
|
||||
});
|
||||
assert.equal(notTrue.status, 1);
|
||||
});
|
||||
|
||||
test("removing the override fails a mixed change that was previously green", () => {
|
||||
const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"];
|
||||
const previouslyGreen = runGate(files, {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr);
|
||||
assert.match(previouslyGreen.stdout, /WARNING/);
|
||||
|
||||
// CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is
|
||||
// the string "false" after the label is removed. A stale green check must
|
||||
// not survive that.
|
||||
const afterLabelRemoved = runGate(files, {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "false",
|
||||
});
|
||||
assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr);
|
||||
assert.match(afterLabelRemoved.stdout, /FAIL/);
|
||||
assert.match(afterLabelRemoved.stdout, /deploy\.yml/);
|
||||
});
|
||||
|
||||
test("CLI refuses to run without a base ref or --stdin", () => {
|
||||
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
|
||||
test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => {
|
||||
const workflows = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
".github/workflows",
|
||||
);
|
||||
const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8");
|
||||
const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8");
|
||||
|
||||
for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) {
|
||||
assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType);
|
||||
}
|
||||
|
||||
assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m);
|
||||
assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m);
|
||||
assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m);
|
||||
assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/);
|
||||
|
||||
assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m);
|
||||
assert.match(isolationYaml, /name: Terraform and application changes are isolated/);
|
||||
assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/);
|
||||
});
|
||||
78
scripts/check_app_terraform_isolation.py
Normal file
78
scripts/check_app_terraform_isolation.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail when a change set mixes Terraform with deployable application files.
|
||||
|
||||
Workflow, docs, and gate-script changes may travel with either side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
APP_ROOTS = (
|
||||
"src/",
|
||||
"public/",
|
||||
"pages/",
|
||||
"config/",
|
||||
)
|
||||
|
||||
APP_FILES = {
|
||||
"index.html",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
}
|
||||
|
||||
|
||||
def is_terraform_path(path: str) -> bool:
|
||||
return path == "terraform" or path.startswith("terraform/")
|
||||
|
||||
|
||||
def is_app_path(path: str) -> bool:
|
||||
normalized = path.replace("\\", "/")
|
||||
if normalized in APP_FILES:
|
||||
return True
|
||||
if normalized in {"src", "public", "pages", "config"}:
|
||||
return True
|
||||
if normalized.startswith(APP_ROOTS):
|
||||
return True
|
||||
if normalized.startswith("tsconfig"):
|
||||
return True
|
||||
return normalized.startswith(".env")
|
||||
|
||||
|
||||
def isolation_violation(paths: list[str]) -> tuple[list[str], list[str]] | None:
|
||||
terraform_files = sorted({path for path in paths if is_terraform_path(path)})
|
||||
app_files = sorted({path for path in paths if is_app_path(path)})
|
||||
if terraform_files and app_files:
|
||||
return terraform_files, app_files
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
nargs="*",
|
||||
help="Changed paths. Omit and pass newline-separated paths on stdin.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
paths = list(args.paths)
|
||||
if not paths and not sys.stdin.isatty():
|
||||
paths = [line.strip() for line in sys.stdin if line.strip()]
|
||||
violation = isolation_violation(paths)
|
||||
if violation is None:
|
||||
print("PASS: application and Terraform changes are isolated")
|
||||
return 0
|
||||
terraform_files, app_files = violation
|
||||
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr)
|
||||
print("terraform:", file=sys.stderr)
|
||||
for path in terraform_files:
|
||||
print(f" {path}", file=sys.stderr)
|
||||
print("application:", file=sys.stderr)
|
||||
for path in app_files:
|
||||
print(f" {path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Content publish step for the environment deploy workflows
|
||||
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
|
||||
#
|
||||
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
|
||||
# environment's S3 bucket with the right cache headers, and invalidates
|
||||
# CloudFront. It never touches infrastructure.
|
||||
#
|
||||
# Runs from the repo root. The target is resolved from, in order:
|
||||
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
|
||||
# dev, whose CloudFormation outputs disappear during Terraform adoption)
|
||||
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
|
||||
set -euo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
REGION="${AWS_REGION:-us-east-1}"
|
||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
||||
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
BUCKET="${SITE_BUCKET:-}"
|
||||
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
|
||||
|
||||
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
|
||||
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
|
||||
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
|
||||
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
# Everything except index.html: long-lived + immutable, prune stale objects.
|
||||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
|
||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--paths "/*" \
|
||||
--query 'Invalidation.Id' \
|
||||
--output text)"
|
||||
|
||||
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
|
||||
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--id "${INVALIDATION_ID}"
|
||||
fi
|
||||
|
||||
echo "Web deploy complete."
|
||||
|
|
@ -21,12 +21,11 @@ const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
|||
// script so it can also be run on its own.
|
||||
const REPOSITORY_GATES = [
|
||||
["Terraform import-plan contract", "test:terraform-import-plan"],
|
||||
["Terraform release-plan contract", "test:terraform-release-plan"],
|
||||
["Terraform isolation gate", "test:terraform-isolation"],
|
||||
["Terraform formatting and validation", "test:terraform"],
|
||||
["HCP run guard", "test:hcp-run-guard"],
|
||||
["CloudFront release verify", "test:cloudfront-release-verify"],
|
||||
["GitHub workflow shell", "test:github-workflows"],
|
||||
["App/Terraform isolation tests", "test:app-terraform-isolation"],
|
||||
];
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
|
|
@ -142,7 +141,7 @@ function godfileRatchet(baseRef) {
|
|||
function resolveBaseRef() {
|
||||
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
|
||||
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
||||
for (const candidate of ["origin/dev", "origin/main"]) {
|
||||
for (const candidate of ["origin/main", "origin/dev"]) {
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", candidate], {
|
||||
cwd: ROOT,
|
||||
|
|
@ -219,6 +218,15 @@ function runRepositoryGate(label, script) {
|
|||
return { label, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
function runIsolationGate(baseRef) {
|
||||
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]);
|
||||
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
input: `${files.join("\n")}\n`,
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
|
|
@ -317,6 +325,27 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`);
|
||||
if (!baseRef) {
|
||||
console.log(" FAIL (no valid base ref)");
|
||||
failures.push(
|
||||
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
||||
);
|
||||
} else {
|
||||
const isolation = runIsolationGate(baseRef);
|
||||
if (isolation.error) {
|
||||
console.log(" FAIL (could not start)");
|
||||
failures.push(`G13: could not start: ${isolation.error.message}`);
|
||||
} else {
|
||||
const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim();
|
||||
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
|
||||
if (isolation.status !== 0) {
|
||||
failures.push("G13: do not mix deployable application files with terraform/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (failures.length > 0) {
|
||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
#!/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.
|
||||
# Print origin paths, distribution status, and served index hash.
|
||||
# Used by deploy-web.yaml's always() summary. Never fails the job.
|
||||
set -u
|
||||
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}"
|
||||
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}"
|
||||
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
|
||||
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
|
||||
SITE_URL="${SITE_URL:-}"
|
||||
echo "=== CloudFront live state ==="
|
||||
echo "pointer:"
|
||||
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)"
|
||||
echo
|
||||
if [[ -z "${DISTRIBUTION_ID}" ]]; then
|
||||
echo "DISTRIBUTION_ID unset"
|
||||
exit 0
|
||||
fi
|
||||
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
|
||||
import json, sys
|
||||
payload = json.load(sys.stdin)
|
||||
|
|
@ -19,5 +19,7 @@ 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"
|
||||
if [[ -n "${SITE_URL}" ]]; then
|
||||
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"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
|
||||
// Only dev has a live root. Staging adoption (SH-287) adds its own root here.
|
||||
const ENVIRONMENTS = ["dev"];
|
||||
const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment));
|
||||
const LIVE_ROOTS = ["terraform/live/dev", "terraform/live/staging"];
|
||||
|
||||
function run(args, cwd = ROOT) {
|
||||
function run(args, cwd = ROOT, env = process.env) {
|
||||
const result = spawnSync(TERRAFORM, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`could not start Terraform: ${result.error.message}`, {
|
||||
|
|
@ -24,12 +24,13 @@ function run(args, cwd = ROOT) {
|
|||
}
|
||||
}
|
||||
|
||||
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
|
||||
for (const root of ROOTS) {
|
||||
// -backend=false never touches HCP state; -lockfile=readonly refuses to
|
||||
// silently rewrite the committed provider lock.
|
||||
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root);
|
||||
run(["validate", "-no-color"], root);
|
||||
run(["fmt", "-check", "-recursive", "terraform"]);
|
||||
for (const liveRoot of LIVE_ROOTS) {
|
||||
const abs = path.join(ROOT, liveRoot);
|
||||
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], abs);
|
||||
run(["validate", "-no-color"], abs);
|
||||
}
|
||||
|
||||
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
|
||||
console.log(
|
||||
"Terraform formatting and validation passed for terraform/live/dev and terraform/live/staging.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"""Canonical frontend Terraform ownership and import-ID maps.
|
||||
|
||||
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
|
||||
are kept so the checker can prove that a dev plan carrying a staging identifier
|
||||
is rejected; they do not authorize a staging import.
|
||||
Dev is already in HCP state. Staging constants authorize the first import of
|
||||
the live CDK stack onto terraform/live/staging.
|
||||
"""
|
||||
|
||||
COMMON_RESOURCES = {
|
||||
|
|
@ -31,6 +30,10 @@ COMMON_RESOURCES = {
|
|||
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
|
||||
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_bucket": "aws_ssm_parameter",
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": (
|
||||
"aws_ssm_parameter"
|
||||
),
|
||||
}
|
||||
|
||||
REQUIRED_RESOURCES = {
|
||||
|
|
@ -45,9 +48,22 @@ CONTROLLED_UPDATE_ADDRESSES = frozenset(
|
|||
"module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
|
||||
"module.environment_owned.aws_iam_role.github_deploy",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
}
|
||||
)
|
||||
|
||||
ALLOWED_CREATE_ADDRESSES = frozenset(
|
||||
{
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_bucket",
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id",
|
||||
}
|
||||
)
|
||||
|
||||
GITHUB_REPO = "Sea-Haven-Industries/shoc-frontend-new"
|
||||
GITHUB_OIDC_PROVIDER_ARN = (
|
||||
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||
)
|
||||
|
||||
ENVIRONMENT_CONFIG = {
|
||||
"dev": {
|
||||
"bucket_name": "seahaven-shoc-frontend-dev",
|
||||
|
|
@ -103,6 +119,8 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
|||
"githubdeploy-shoc-frontend-new-dev:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
|
||||
},
|
||||
"staging": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-staging"),
|
||||
|
|
@ -126,5 +144,7 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
|||
"githubdeploy-shoc-frontend-new-staging:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
|
||||
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
ALLOWED_CREATE_ADDRESSES,
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
GITHUB_OIDC_PROVIDER_ARN,
|
||||
GITHUB_REPO,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
|
@ -27,7 +30,7 @@ BUCKET = "module.environment_owned.aws_s3_bucket.site"
|
|||
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY}
|
||||
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
|
||||
|
||||
|
||||
def import_id(environment: str, address: str) -> str:
|
||||
|
|
@ -83,6 +86,40 @@ def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def github_deploy_assume_policy(environment: str) -> dict[str, Any]:
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "GithubDeployOidc",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": (
|
||||
f"repo:{GITHUB_REPO}:environment:{environment}"
|
||||
),
|
||||
},
|
||||
"StringLike": {
|
||||
"token.actions.githubusercontent.com:job_workflow_ref": [
|
||||
(
|
||||
f"{GITHUB_REPO}/.github/workflows/"
|
||||
"deploy-web.yaml@refs/heads/main"
|
||||
),
|
||||
(
|
||||
f"{GITHUB_REPO}/.github/workflows/"
|
||||
"deploy-web.yaml@refs/tags/v*"
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def bucket_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
|
|
@ -146,6 +183,19 @@ def tag_change(environment: str, address: str) -> dict[str, Any]:
|
|||
|
||||
|
||||
def policy_change(environment: str, address: str) -> dict[str, Any]:
|
||||
if address == DEPLOY_POLICY:
|
||||
return {
|
||||
"actions": ["update"],
|
||||
"before": {"policy": json.dumps({"Version": "2012-10-17", "Statement": []})},
|
||||
"after": {
|
||||
"policy": json.dumps(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{"Sid": "ListWebBucket", "Effect": "Allow"}],
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
if address != BUCKET_POLICY:
|
||||
raise AssertionError(f"{address} is not a reviewed policy update")
|
||||
return {
|
||||
|
|
@ -165,10 +215,14 @@ def make_plan(
|
|||
updates = controlled_updates or set()
|
||||
for address, resource_type in REQUIRED_RESOURCES[environment].items():
|
||||
if mode == "import":
|
||||
change: dict[str, Any] = {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": import_id(environment, address)},
|
||||
}
|
||||
import_id_value = REQUIRED_IMPORT_IDS[environment][address]
|
||||
if import_id_value is None:
|
||||
change = {"actions": ["create"]}
|
||||
else:
|
||||
change = {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": import_id_value},
|
||||
}
|
||||
elif mode == "post-import":
|
||||
change = {"actions": ["no-op"]}
|
||||
elif address in updates:
|
||||
|
|
@ -177,6 +231,8 @@ def make_plan(
|
|||
if address in TAG_ADDRESSES
|
||||
else policy_change(environment, address)
|
||||
)
|
||||
elif address in ALLOWED_CREATE_ADDRESSES:
|
||||
change = {"actions": ["create"]}
|
||||
else:
|
||||
change = {"actions": ["no-op"]}
|
||||
if address == DISTRIBUTION:
|
||||
|
|
@ -259,8 +315,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
|
||||
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
|
||||
source = (
|
||||
REPOSITORY
|
||||
/ "terraform/live/modules/environment-owned/main.tf"
|
||||
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
expected = """ spa_rewrite_code = join("\\n", [
|
||||
"function handler(event) {",
|
||||
|
|
@ -275,55 +330,41 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
])"""
|
||||
self.assertIn(expected, source)
|
||||
|
||||
def test_only_dev_has_a_live_root(self) -> None:
|
||||
live_roots = sorted(
|
||||
path.name
|
||||
for path in (REPOSITORY / "terraform/live").iterdir()
|
||||
if path.is_dir() and path.name != "modules"
|
||||
)
|
||||
self.assertEqual(["dev"], live_roots)
|
||||
def test_live_roots_are_not_flattened(self) -> None:
|
||||
live = REPOSITORY / "terraform" / "live"
|
||||
self.assertTrue((live / "dev" / "versions.tf").is_file())
|
||||
self.assertTrue((live / "dev" / "main.tf").is_file())
|
||||
self.assertTrue((live / "staging" / "versions.tf").is_file())
|
||||
self.assertTrue((live / "staging" / "main.tf").is_file())
|
||||
self.assertTrue((live / "modules" / "environment-owned" / "main.tf").is_file())
|
||||
self.assertFalse((REPOSITORY / "terraform" / "versions.tf").exists())
|
||||
self.assertFalse((REPOSITORY / "terraform" / "main.tf").exists())
|
||||
|
||||
def test_dev_root_pins_adoption_complete_in_code(self) -> None:
|
||||
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n")
|
||||
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
|
||||
self.assertNotIn('variable "adoption_complete"', source)
|
||||
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
|
||||
self.assertNotIn(
|
||||
"variable ",
|
||||
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
|
||||
root_file,
|
||||
)
|
||||
def test_adoption_complete_is_pinned_in_locals(self) -> None:
|
||||
dev = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
staging = (REPOSITORY / "terraform/live/staging/main.tf").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertRegex(dev, r"adoption_complete\s+= true")
|
||||
self.assertRegex(staging, r"adoption_complete\s+= true")
|
||||
self.assertNotIn('variable "adoption_complete"', dev)
|
||||
self.assertNotIn('variable "environment"', dev)
|
||||
self.assertNotIn('variable "release_version_label"', dev)
|
||||
|
||||
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
|
||||
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
expected = {
|
||||
"dev": (
|
||||
"local.hosted_zone_id",
|
||||
"local.certificate_arn",
|
||||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
"hosted_zone_id": "local.hosted_zone_id",
|
||||
"certificate_arn": "local.certificate_arn",
|
||||
"github_oidc_provider_arn": "local.github_oidc_arn",
|
||||
"cache_policy_id": "local.cache_policy_id",
|
||||
}
|
||||
for environment, values in expected.items():
|
||||
source = (
|
||||
REPOSITORY / f"terraform/live/{environment}/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
for name, value in zip(
|
||||
(
|
||||
"hosted_zone_id",
|
||||
"certificate_arn",
|
||||
"github_oidc_provider_arn",
|
||||
"cache_policy_id",
|
||||
),
|
||||
values,
|
||||
strict=True,
|
||||
):
|
||||
self.assertIn(f"{name}", source)
|
||||
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
|
||||
self.assertNotRegex(
|
||||
source,
|
||||
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
|
||||
)
|
||||
for name, value in expected.items():
|
||||
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
|
||||
self.assertNotRegex(
|
||||
source,
|
||||
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
|
||||
)
|
||||
|
||||
def test_exact_import_plan_passes_for_every_environment(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
|
|
@ -409,7 +450,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
|
||||
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
|
||||
resource(plan, ROLE)["change"]["after"]["max_session_duration"] = 7200
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
|
||||
|
|
@ -420,12 +461,34 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
|
||||
self.assert_fails(plan, "dev", BUCKET)
|
||||
|
||||
def test_role_trust_change_is_rejected(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
role = resource(plan, ROLE)["change"]
|
||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
def test_role_trust_change_is_allowed(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
plan = make_plan(
|
||||
environment, mode="controlled", controlled_updates={ROLE}
|
||||
)
|
||||
role = resource(plan, ROLE)["change"]
|
||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||
role["after"]["assume_role_policy"] = json.dumps(
|
||||
github_deploy_assume_policy(environment)
|
||||
)
|
||||
with self.subTest(environment=environment):
|
||||
self.assert_passes(plan, environment, ROLE)
|
||||
|
||||
def test_role_trust_rejects_mutated_document(self) -> None:
|
||||
for mutation in ("principal", "missing-sub"):
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
role = resource(plan, ROLE)["change"]
|
||||
policy = github_deploy_assume_policy("dev")
|
||||
if mutation == "principal":
|
||||
policy["Statement"][0]["Principal"] = {"AWS": "*"}
|
||||
else:
|
||||
del policy["Statement"][0]["Condition"]["StringEquals"][
|
||||
"token.actions.githubusercontent.com:sub"
|
||||
]
|
||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||
role["after"]["assume_role_policy"] = json.dumps(policy)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
||||
for mutation in ("principal", "extra"):
|
||||
|
|
@ -452,40 +515,70 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
|
||||
def test_github_deploy_policy_is_release_prefix_only(self) -> None:
|
||||
def test_github_deploy_policy_is_bucket_root_sync(self) -> None:
|
||||
source = (
|
||||
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
|
||||
document = document.split("resource ", 1)[0]
|
||||
self.assertNotIn("var.adoption_complete", document)
|
||||
self.assertIn("ListReleasePrefixes", document)
|
||||
self.assertIn("PublishReleasePrefix", document)
|
||||
self.assertIn("ReadReleasePointer", document)
|
||||
self.assertIn("ReadDistribution", document)
|
||||
self.assertIn("ListWebBucket", document)
|
||||
self.assertIn("SyncWebBucket", document)
|
||||
self.assertIn("InvalidateDistribution", document)
|
||||
self.assertIn("DeployParams", document)
|
||||
self.assertIn("s3:DeleteObject", document)
|
||||
self.assertIn("cloudfront:CreateInvalidation", document)
|
||||
self.assertIn("cloudfront:GetInvalidation", document)
|
||||
self.assertIn("cloudfront:GetDistribution", document)
|
||||
self.assertIn("cloudfront:GetDistributionConfig", document)
|
||||
self.assertIn("releases/*", document)
|
||||
self.assertIn("ssm:GetParameter", document)
|
||||
self.assertNotIn("ListReleasePrefixes", document)
|
||||
self.assertNotIn("PublishReleasePrefix", document)
|
||||
self.assertNotIn("ReadReleasePointer", document)
|
||||
self.assertNotIn("releases/*", document)
|
||||
self.assertNotIn("AssumeCdkBootstrapRoles", document)
|
||||
self.assertNotIn("DescribeStack", document)
|
||||
self.assertNotIn("CreateInvalidation", document)
|
||||
self.assertNotIn("ReadDeploymentBucket", document)
|
||||
self.assertNotIn("PublishAndRollbackSiteObjects", document)
|
||||
self.assertNotIn(
|
||||
self.assertIn(
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
)
|
||||
|
||||
def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
self.assert_fails(plan, "dev", DEPLOY_POLICY)
|
||||
def test_github_deploy_assume_document_matches_module(self) -> None:
|
||||
source = (
|
||||
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
document = source.split(
|
||||
'data "aws_iam_policy_document" "github_deploy_assume" {', 1
|
||||
)[1]
|
||||
document = document.split(
|
||||
'data "aws_iam_policy_document" "github_deploy" {', 1
|
||||
)[0]
|
||||
self.assertIn("GithubDeployOidc", document)
|
||||
self.assertIn("sts:AssumeRoleWithWebIdentity", document)
|
||||
self.assertIn("token.actions.githubusercontent.com:aud", document)
|
||||
self.assertIn("sts.amazonaws.com", document)
|
||||
self.assertIn("token.actions.githubusercontent.com:sub", document)
|
||||
self.assertIn("local.github_subject", document)
|
||||
self.assertIn("token.actions.githubusercontent.com:job_workflow_ref", document)
|
||||
self.assertIn("deploy-web.yaml@refs/heads/main", document)
|
||||
self.assertIn("deploy-web.yaml@refs/tags/v*", document)
|
||||
|
||||
def test_deploy_policy_controlled_update_passes(self) -> None:
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={DEPLOY_POLICY},
|
||||
),
|
||||
"dev",
|
||||
DEPLOY_POLICY,
|
||||
)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
resource(plan, DEPLOY_POLICY)["change"] = {
|
||||
"actions": ["update"],
|
||||
"before": {"policy": "{}"},
|
||||
"after": {"policy": '{"Version":"2012-10-17"}'},
|
||||
}
|
||||
self.assert_fails(plan, "dev", DEPLOY_POLICY)
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ set -euo pipefail
|
|||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh"
|
||||
CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111"
|
||||
OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
# Vite writes index.html with a trailing newline. Keep it in the fixture so
|
||||
# the expected hash covers every served byte, exactly like dist/index.html.
|
||||
INDEX_HTML=$'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>\n'
|
||||
INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
|
||||
|
|
@ -31,12 +26,7 @@ make_stubs() {
|
|||
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"
|
||||
cat "${STUB_STATE}/distribution.json"
|
||||
AWS
|
||||
cat > "${bin}/curl" << 'CURL'
|
||||
#!/usr/bin/env bash
|
||||
|
|
@ -77,7 +67,6 @@ if [[ "${url}" == *"/assets/"* ]]; then
|
|||
[[ -z "${output}" ]] && printf '%s' "${body}"
|
||||
exit 0
|
||||
fi
|
||||
# Serve index.html byte-for-byte, trailing newline included, like real curl.
|
||||
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
|
||||
if [[ -n "${output}" ]]; then
|
||||
cat "${state_dir}/index.html" > "${output}"
|
||||
|
|
@ -98,149 +87,94 @@ print(json.dumps({
|
|||
"Status": status,
|
||||
"DistributionConfig": {
|
||||
"Origins": {"Items": [
|
||||
{"Id": "current", "OriginPath": path},
|
||||
{"Id": "previous", "OriginPath": ""},
|
||||
{"Id": "current", "OriginPath": path}
|
||||
]}
|
||||
}
|
||||
}
|
||||
}))' "${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).
|
||||
# 1. Empty origin, 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}"
|
||||
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","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 DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export API_URL="https://api.dev.seahaven.com/api"
|
||||
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"
|
||||
assert_exit "empty-origin-then-propagates" 0 "${code}" "${dir}/log.txt"
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 2. Right config never propagates (Deployed, stale hash).
|
||||
# 2. Empty origin 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"
|
||||
dist_json "Deployed" "" > "${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 DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export API_URL="https://api.dev.seahaven.com/api"
|
||||
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"
|
||||
assert_exit "empty-origin-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.
|
||||
# 3. Non-empty 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"
|
||||
dist_json "Deployed" "/releases/deadbeef" > "${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 DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export API_URL="https://api.dev.seahaven.com/api"
|
||||
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)); }
|
||||
assert_exit "nonempty-origin-path" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: nonempty origin path did not name origin_path" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 4. Wrong pointer fails fast.
|
||||
# 4. Never Deployed.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
dist_json "InProgress" "" > "${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 DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export API_URL="https://api.dev.seahaven.com/api"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
|
|
@ -251,40 +185,40 @@ AWS
|
|||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 6. Hash-matched Deployed release whose JS assets omit the baked API URL.
|
||||
# 5. Hash-matched Deployed release whose JS assets omit the baked API URL.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
dist_json "Deployed" "" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
printf 'const x=1;' > "${dir}/asset.js"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export API_URL="https://api.dev.seahaven.com/api"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); }
|
||||
grep -q "baked API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked API URL" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 7. Hash-matched Deployed release whose JS assets contain the staging API URL.
|
||||
# 6. Hash-matched Deployed release whose JS assets contain the staging API URL.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
dist_json "Deployed" "" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export API_URL="https://api.dev.seahaven.com/api"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
|
|
|
|||
69
scripts/test_check_app_terraform_isolation.py
Normal file
69
scripts/test_check_app_terraform_isolation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for check_app_terraform_isolation.isolation_violation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from check_app_terraform_isolation import isolation_violation
|
||||
|
||||
|
||||
class IsolationTests(unittest.TestCase):
|
||||
def test_terraform_only(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/README.md",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_app_only(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"src/app/routes.tsx",
|
||||
"public/favicon.ico",
|
||||
"index.html",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_docs_workflows_and_gate_scripts_with_terraform(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
".github/workflows/deploy-web.yaml",
|
||||
"QUALITY_GATES.md",
|
||||
"scripts/governance-check.mjs",
|
||||
"scripts/check_app_terraform_isolation.py",
|
||||
"package.json",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_mixed_src_and_terraform_fails(self) -> None:
|
||||
violation = isolation_violation(
|
||||
[
|
||||
"terraform/live/dev/main.tf",
|
||||
"src/app/routes.tsx",
|
||||
]
|
||||
)
|
||||
self.assertIsNotNone(violation)
|
||||
terraform_files, app_files = violation or ([], [])
|
||||
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
|
||||
self.assertEqual(app_files, ["src/app/routes.tsx"])
|
||||
|
||||
def test_mixed_vite_config_and_terraform_fails(self) -> None:
|
||||
violation = isolation_violation(["terraform/live/dev/versions.tf", "vite.config.ts"])
|
||||
self.assertIsNotNone(violation)
|
||||
|
||||
def test_mixed_env_and_terraform_fails(self) -> None:
|
||||
violation = isolation_violation(["terraform/live/dev/main.tf", ".env.production"])
|
||||
self.assertIsNotNone(violation)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -14,6 +14,11 @@ fi
|
|||
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE="shoc-frontend@${COMMIT_SHA}"
|
||||
|
||||
if ! npm exec --no -- sentry-cli --version >/dev/null 2>&1; then
|
||||
echo "sentry-cli is not in this SPA tree; skipping source-map upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
npm exec --no -- sentry-cli sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
--project "${SENTRY_PROJECT}" \
|
||||
|
|
|
|||
|
|
@ -1,87 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
# Verify a CloudFront content release or rollback.
|
||||
# Verify a CloudFront SPA deploy from deploy-web.yaml.
|
||||
#
|
||||
# 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.
|
||||
# Fail fast when any origin_path is still non-empty. Poll while the
|
||||
# distribution is InProgress or the served index.html hash does not match
|
||||
# the build. Then smoke-check caching headers, hashed assets, the baked
|
||||
# API URL, and CORS against the target API.
|
||||
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
|
||||
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" ]]; then
|
||||
echo "Usage: DISTRIBUTION_ID EXPECTED_INDEX_SHA256 SITE_URL 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
|
||||
API_URL="${API_URL%/}"
|
||||
|
||||
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
|
||||
import json, 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"]
|
||||
nonempty = [path for path in paths if 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 "")
|
||||
print("yes" if nonempty else "no")
|
||||
'
|
||||
}
|
||||
|
||||
last_status="Unknown"
|
||||
last_paths="Unknown"
|
||||
last_pointer="Unknown"
|
||||
last_hash="Unknown"
|
||||
last_path_ok="no"
|
||||
last_path_nonempty="no"
|
||||
|
||||
observe() {
|
||||
last_pointer="$(read_pointer)"
|
||||
local parsed
|
||||
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)"
|
||||
parsed="$(read_distribution_json | parse_distribution)"
|
||||
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
|
||||
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
|
||||
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')"
|
||||
# Hash the response stream directly. Capturing the body in "$(...)" strips
|
||||
# trailing newlines, so the hash never matched dist/index.html.
|
||||
last_path_nonempty="$(printf '%s\n' "${parsed}" | sed -n '3p')"
|
||||
local hash
|
||||
if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then
|
||||
last_hash="${hash}"
|
||||
|
|
@ -91,32 +66,25 @@ observe() {
|
|||
}
|
||||
|
||||
report_state() {
|
||||
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}"
|
||||
echo "last observed: status=${last_status} 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
|
||||
fail_fast_if_origin_path() {
|
||||
if [[ "${last_path_nonempty}" == "yes" ]]; then
|
||||
echo "FAIL: live origin_path values are '${last_paths}'; expected empty bucket-root origins." >&2
|
||||
report_state >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
observe
|
||||
fail_fast_if_misconfigured
|
||||
fail_fast_if_origin_path
|
||||
|
||||
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
|
||||
fail_fast_if_origin_path
|
||||
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
|
||||
break
|
||||
fi
|
||||
|
|
@ -142,6 +110,10 @@ for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
|
|||
' "$1"
|
||||
}
|
||||
|
||||
api_host() {
|
||||
python3 -c 'import os,urllib.parse; print(urllib.parse.urlparse(os.environ["API_URL"]).hostname or "")'
|
||||
}
|
||||
|
||||
assert_baked_api_url() {
|
||||
local tmp="$1"
|
||||
if [[ ! -s "${tmp}/asset-paths.txt" ]]; then
|
||||
|
|
@ -168,15 +140,27 @@ assert_baked_api_url() {
|
|||
exit 1
|
||||
fi
|
||||
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
|
||||
local forbidden
|
||||
for forbidden in api.staging.seahaven.com localhost:5141; do
|
||||
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
|
||||
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
|
||||
local host
|
||||
host="$(api_host)"
|
||||
local forbidden=""
|
||||
case "${host}" in
|
||||
api.dev.seahaven.com) forbidden="api.staging.seahaven.com" ;;
|
||||
api.staging.seahaven.com) forbidden="api.dev.seahaven.com" ;;
|
||||
*)
|
||||
echo "FAIL: API_URL host '${host}' is not a known SHOC API." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then
|
||||
echo "FAIL: served JS assets are missing the baked dev API URL." >&2
|
||||
;;
|
||||
esac
|
||||
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
|
||||
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Fq "localhost:5141" "${tmp}/served.txt"; then
|
||||
echo "FAIL: served assets contain forbidden URL localhost:5141." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "${host}" "${tmp}/served.txt"; then
|
||||
echo "FAIL: served JS assets are missing the baked API URL ${host}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
|
@ -206,5 +190,5 @@ if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean."
|
||||
echo "PASS: CloudFront release is Deployed, hash-matched, and smoke-clean."
|
||||
report_state
|
||||
|
|
|
|||
|
|
@ -1,352 +1,74 @@
|
|||
# Frontend Terraform adoption runbook (dev)
|
||||
# Frontend Terraform (SPA CD)
|
||||
|
||||
This tree adopts the existing Sea Haven SHOC frontend dev hosting resources
|
||||
into HCP Terraform without recreating them. It mirrors the backend adoption
|
||||
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs:
|
||||
`terraform/live/dev` and `terraform/live/staging` in AWS account `396287094661`.
|
||||
HCP Terraform owns the bucket, CloudFront, DNS, and the GitHub deploy role.
|
||||
GitHub Actions owns content: `.github/workflows/deploy-web.yaml` syncs `dist/`
|
||||
to the bucket root and invalidates `/*`.
|
||||
|
||||
| PR | Branch | Change |
|
||||
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. |
|
||||
| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
|
||||
| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, rollback. |
|
||||
Creating, formatting, initializing with `-backend=false`, and validating these
|
||||
files does not authorize an AWS, HCP Terraform, GitHub, or deployment
|
||||
mutation.
|
||||
|
||||
Creating these files, formatting them, initializing with `-backend=false`, and
|
||||
validating them does not authorize an AWS, HCP Terraform, GitHub,
|
||||
CloudFormation, DNS, or deployment mutation. Every live step below is gated on
|
||||
an explicit go from the owner, with the production impact stated first.
|
||||
|
||||
Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked
|
||||
separately (SH-287) and adds its own root under `live/staging` when it starts.
|
||||
The `staging` constants in `scripts/terraform_import_plan_resources.py` exist
|
||||
only so the checker can prove a dev plan carrying a staging identifier fails.
|
||||
Do not collapse these roots into one `terraform/` tree. Flattening retargets
|
||||
two live HCP working directories and is its own change.
|
||||
|
||||
## Fixed targets
|
||||
|
||||
- AWS account: `396287094661`
|
||||
- AWS region: `us-east-1`
|
||||
- HCP organization: `seahaven`
|
||||
- HCP project: `seahaven-external-dev`
|
||||
- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working
|
||||
directory `terraform/live/dev`
|
||||
- Site: `dev.seahaven.com`
|
||||
- API build value: `https://api.dev.seahaven.com/api`
|
||||
| | dev | staging |
|
||||
| ------------- | ------------------------------------ | ---------------------------------------- |
|
||||
| Site | `dev.seahaven.com` | `staging.seahaven.com` |
|
||||
| Bucket | `seahaven-shoc-frontend-dev` | `seahaven-shoc-frontend-staging` |
|
||||
| Distribution | `E2CWLM1AFB964P` | `E2JDVEZ6EGD49J` |
|
||||
| Deploy role | `githubdeploy-shoc-frontend-new-dev` | `githubdeploy-shoc-frontend-new-staging` |
|
||||
| HCP workspace | `shoc-frontend-new-dev` | `shoc-frontend-new-staging` |
|
||||
| Working dir | `terraform/live/dev` | `terraform/live/staging` |
|
||||
|
||||
## Workspace invariants
|
||||
There is no prod CloudFront in this round. Do not create
|
||||
`shoc-frontend-new-prod`.
|
||||
|
||||
Set before any Terraform lands on `dev`, read back after setting, and re-read
|
||||
before the first release after any Terraform merge:
|
||||
## Ownership
|
||||
|
||||
- Auto-apply **off**. GitHub or a human applies every run.
|
||||
- Automatic speculative plans **on** (PR plans are read-only evidence).
|
||||
- Automatic run triggering: **patterns**
|
||||
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
|
||||
prefixes, no tags regex. Do not switch to tag-based triggering.
|
||||
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
|
||||
`>= 1.14.0, < 2.0.0`; CI validates with `1.16.0`).
|
||||
- Dynamic AWS credentials only: environment variables
|
||||
`TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and
|
||||
`TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan`
|
||||
and `hcptf-shoc-frontend-new-dev`. No access keys.
|
||||
- **No** `adoption_complete` workspace variable. The dev root pins it in code
|
||||
(`local.adoption_complete`) so the value under review is the value that
|
||||
applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable`
|
||||
block reappears in the root.
|
||||
`module.environment_owned` keeps the same addresses as the adopted HCP
|
||||
`shoc-frontend-new-dev` state. The SPA origin path is empty. The release
|
||||
pointer is forgotten (`removed { destroy = false }`), not destroyed.
|
||||
|
||||
## Ownership boundary
|
||||
Deploy parameters live under `/shoc-frontend-new/<env>/deploy/{bucket,distribution-id}`.
|
||||
`githubdeploy` may List/Get/Put/Delete the bucket root, CreateInvalidation, and
|
||||
GetParameter on those two names. OIDC trust is `environment:<env>` plus
|
||||
`job_workflow_ref` for `.github/workflows/deploy-web.yaml` at `refs/heads/main`
|
||||
and `refs/tags/v*`.
|
||||
|
||||
`live/modules/environment-owned` owns these 14 addresses (13 imported hosting
|
||||
resources plus the release pointer created in Phase 3):
|
||||
`adoption_complete` is pinned in each live root. It is not a workspace
|
||||
variable.
|
||||
|
||||
1. `module.environment_owned.aws_s3_bucket.site`
|
||||
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
|
||||
3. `module.environment_owned.aws_s3_bucket_ownership_controls.site`
|
||||
4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site`
|
||||
5. `module.environment_owned.aws_s3_bucket_versioning.site`
|
||||
6. `module.environment_owned.aws_s3_bucket_policy.site`
|
||||
7. `module.environment_owned.aws_cloudfront_distribution.site`
|
||||
8. `module.environment_owned.aws_cloudfront_origin_access_control.site`
|
||||
9. `module.environment_owned.aws_cloudfront_function.spa_rewrite`
|
||||
10. `module.environment_owned.aws_route53_record.site_a`
|
||||
11. `module.environment_owned.aws_route53_record.site_aaaa`
|
||||
12. `module.environment_owned.aws_iam_role.github_deploy`
|
||||
13. `module.environment_owned.aws_iam_role_policy.github_deploy`
|
||||
14. `module.environment_owned.aws_s3_object.release_pointer`
|
||||
|
||||
The CloudFront invalidation is a Terraform action
|
||||
(`action.aws_cloudfront_create_invalidation.release`), not a managed resource.
|
||||
Every managed resource has `prevent_destroy = true`.
|
||||
|
||||
`live/modules/environment-inventory` is data-only. It resolves and checks the
|
||||
caller account, provider region, public hosted zone, ACM certificate, account
|
||||
GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache
|
||||
policy against pinned values, and fails the plan on any mismatch.
|
||||
|
||||
The following remain outside state:
|
||||
|
||||
- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate
|
||||
- the account-global GitHub OIDC provider
|
||||
- the AWS managed CloudFront cache policy
|
||||
- `CDKToolkit` resources and CDK metadata
|
||||
- the S3 auto-delete custom resource, its provider Lambda and role
|
||||
- the HCP plan/apply roles and the deploy-role permissions boundary
|
||||
|
||||
## Exact live inventory (dev)
|
||||
|
||||
- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev`
|
||||
- Distribution: `E2CWLM1AFB964P`
|
||||
- OAC: `E30VSIK87N8H64`, name
|
||||
`shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`,
|
||||
description modeled as `""`
|
||||
- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1`
|
||||
- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8`
|
||||
- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A`
|
||||
- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA`
|
||||
- Deploy role: `githubdeploy-shoc-frontend-new-dev`
|
||||
- Inline policy import ID:
|
||||
`githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1`
|
||||
- Hosted zone: `Z07671212N75U4YLPWZR8`
|
||||
- Certificate:
|
||||
`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`
|
||||
- Legacy stack: `shoc-frontend-dev`
|
||||
- Auto-delete helper role:
|
||||
`arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV`
|
||||
- Permissions boundary:
|
||||
`arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary`
|
||||
|
||||
With `adoption_complete = false` the root declares the configuration observed
|
||||
after the CDK retain deploy (Phase 1, step 2), not the configuration live
|
||||
today:
|
||||
|
||||
- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the
|
||||
S3-only `aws-cdk:auto-delete-objects=true` tag
|
||||
- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag
|
||||
- the permissions boundary attached to the deploy role
|
||||
- `StringEquals` on the OIDC subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev`
|
||||
- the legacy bucket policy including the auto-delete helper grant
|
||||
- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`,
|
||||
bucket read/write, `InvalidateDistribution`)
|
||||
|
||||
The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing.
|
||||
If read-back after that deploy differs from the root in any other way, update
|
||||
the root to the observed value and prove a zero-change import plan. Do not
|
||||
approve drift through the controlled-update checker.
|
||||
|
||||
## Phase 1: import-first adoption (merged)
|
||||
|
||||
Each step is gated. State the impact, get the go, act, read back, record.
|
||||
|
||||
1. **Workspace invariants.** Set the invariants above on
|
||||
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
|
||||
PR.
|
||||
2. **CDK retain deploy.** Completed from the reviewed PR A head. The CDK app
|
||||
is no longer in this repository.
|
||||
|
||||
Expected: an update-only change set (no create, no delete, no replace)
|
||||
that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the
|
||||
13 transferred resources and the `Custom::S3AutoDeleteObjects` resource,
|
||||
attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows
|
||||
the trust operator. Read back the role, bucket policy, and stack resources
|
||||
as JSON and attach it to the PR.
|
||||
|
||||
3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply
|
||||
off). Download the plan JSON and run the guard:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-terraform-import-plan.py plan.json --environment dev
|
||||
```
|
||||
|
||||
Confirm the apply only when the plan is exactly 13 imports, 0 create,
|
||||
0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the
|
||||
run and fix the root in a new PR.
|
||||
|
||||
4. **Post-import no-op.** Queue a plan and require it to be no-op:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-terraform-import-plan.py post-import.json \
|
||||
--environment dev --post-import-no-op
|
||||
```
|
||||
|
||||
Post the run URLs and the guard output on SH-300.
|
||||
|
||||
After Phase 1 CloudFormation still owns every resource. Terraform holds state
|
||||
for them and nothing else.
|
||||
|
||||
## Phase 2: controlled ownership transfer (merged #178)
|
||||
|
||||
PR B pins `adoption_complete = true`. The controlled apply may update only:
|
||||
|
||||
- `module.environment_owned.aws_s3_bucket.site` (tags)
|
||||
- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the
|
||||
auto-delete helper grant)
|
||||
- `module.environment_owned.aws_cloudfront_distribution.site` (tags)
|
||||
- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags)
|
||||
- `module.environment_owned.aws_iam_role.github_deploy` (tags)
|
||||
|
||||
The OAC, both Route 53 records, and the deploy inline policy must be no-op.
|
||||
PR B keeps the GitHub deploy inline policy byte-identical to live so
|
||||
`aws_iam_role_policy.github_deploy` does not appear in the plan. Run the
|
||||
checker with one `--allow-update-address` per updating address; it rejects
|
||||
unused allowlist entries, unknown values, and replacements:
|
||||
## Local checks (no apply)
|
||||
|
||||
```bash
|
||||
python3 scripts/check-terraform-import-plan.py plan.json --environment dev \
|
||||
--allow-update-address module.environment_owned.aws_s3_bucket.site \
|
||||
--allow-update-address module.environment_owned.aws_s3_bucket_policy.site \
|
||||
--allow-update-address module.environment_owned.aws_cloudfront_distribution.site \
|
||||
--allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \
|
||||
--allow-update-address module.environment_owned.aws_iam_role.github_deploy
|
||||
terraform fmt -check -recursive terraform
|
||||
terraform -chdir=terraform/live/dev init -backend=false -lockfile=readonly
|
||||
terraform -chdir=terraform/live/dev validate
|
||||
terraform -chdir=terraform/live/staging init -backend=false -lockfile=readonly
|
||||
terraform -chdir=terraform/live/staging validate
|
||||
python3 scripts/test-terraform-import-plan-check.py
|
||||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
bash scripts/test-verify-cloudfront-release.sh
|
||||
```
|
||||
|
||||
After the apply and a no-op plan, the CDK stack was relinquished with
|
||||
`ManageSiteInfrastructure=false`. Never deploy that stack with
|
||||
`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C.
|
||||
PRs cannot mix `terraform/` with deployable application files. Workflow, docs,
|
||||
and gate-script changes may travel with either side. G13 is
|
||||
`python3 scripts/check_app_terraform_isolation.py` against the PR base.
|
||||
|
||||
Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual
|
||||
`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged
|
||||
GitHub content policy. PR C replaces that policy with the release-prefix
|
||||
document during bootstrap.
|
||||
`npm run test:terraform` and `npm run verify` wrap the same gates. They never
|
||||
create an HCP run or touch AWS.
|
||||
|
||||
## Phase 3: content CD through Terraform (this PR)
|
||||
## Workspaces
|
||||
|
||||
GitHub builds the SPA and uploads only `releases/<sha>-<run>-<attempt>/`.
|
||||
The GitHub role may `GetObject` on `.release/current` and read the exact
|
||||
distribution (`GetDistribution` / `GetDistributionConfig`) so verify and
|
||||
live-state summary can observe origin paths. It cannot invalidate or write
|
||||
the pointer. Terraform owns `.release/current`, both origin paths of the
|
||||
CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one
|
||||
guarded Terraform run that swaps the labels. Push-to-`dev` stays off until
|
||||
`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls
|
||||
`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287).
|
||||
Dev (`shoc-frontend-new-dev`) watches `main` with working directory
|
||||
`terraform/live/dev` and auto-apply on. Staging (`shoc-frontend-new-staging`)
|
||||
watches tag regex `^v[0-9]+\.[0-9]+\.[0-9]+-staging$` with working directory
|
||||
`terraform/live/staging` and auto-apply on. Merges to `main` do not apply
|
||||
staging.
|
||||
|
||||
Release vars `release_version_label` and `previous_release_version_label` are
|
||||
nullable, default null, and must not be set on the workspace or in tfvars.
|
||||
Null VCS plans read the pointer back from S3. Empty string is the legacy root
|
||||
layout.
|
||||
|
||||
Per GitHub content release after bootstrap: exactly two managed updates plus
|
||||
one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py`
|
||||
accepts a plan that updates only the pointer `content` and
|
||||
`origin[*].origin_path`, with `after` equal to the expected labels, `before`
|
||||
equal to the pointer's prior values, and exactly one invalidation
|
||||
`action_invocations` entry.
|
||||
|
||||
The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates
|
||||
`.release/current` (legacy empty labels), adds the previous origin and origin
|
||||
group, switches the default behavior to the group, replaces the GitHub inline
|
||||
policy with the release-prefix document, and invokes invalidation. A human
|
||||
confirms that apply. GitHub CD starts only after bootstrap is applied.
|
||||
|
||||
Activation (each step gated; do not run without an explicit go):
|
||||
|
||||
1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard
|
||||
the HCP VCS run. Apply bootstrap as a human-confirmed controlled update.
|
||||
2. Re-read workspace invariants (auto-apply off, speculative on, trigger
|
||||
patterns only, no prefixes, no tags-regex).
|
||||
3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation,
|
||||
smoke, and rollback readiness from the live-state summary.
|
||||
4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner
|
||||
approval.
|
||||
5. Confirm the first push-to-`dev` run. Close SH-300 on that proof.
|
||||
|
||||
A red job does not mean the site is down. Read the live-state summary first.
|
||||
|
||||
## Operational rules
|
||||
|
||||
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
|
||||
deployable application code. The `terraform-isolation` job in
|
||||
`.github/workflows/terraform-isolation.yaml` enforces this; documentation and the
|
||||
`scripts/*terraform*` tooling are allowed alongside. A reviewer may add the
|
||||
`terraform-isolation-override` label for the rare change that must introduce
|
||||
Terraform variables together with the workflow that consumes them (PR C).
|
||||
Adding or removing that label re-runs only that workflow against the labels
|
||||
currently on the PR; Frontend checks does not start a new run. Removing the
|
||||
label fails a mixed PR that had previously passed with the override, so a
|
||||
stale green check cannot merge. Markdown under `terraform/` does not count as a Terraform
|
||||
change for this gate; it does not match the workspace trigger patterns.
|
||||
The label is the approval record. The override is temporary:
|
||||
a follow-up PR after PR C removes the label path from the checker and
|
||||
workflow so the gate has no exception.
|
||||
- **Every Terraform merge produces a VCS run.** A human confirms or discards
|
||||
it before the next content release. Do not leave a pending run on the
|
||||
workspace.
|
||||
- **Re-read the workspace invariants** before the first release after any
|
||||
Terraform merge or workspace settings change.
|
||||
- **A red job does not mean the site is down.** Read the live-state summary
|
||||
first (served `index.html` hash, distribution status, pointer body, both
|
||||
origin paths), then triage.
|
||||
- **Exact-head evidence.** Every live step records the run URL, the SHA, and a
|
||||
machine-readable read-back on the PR or SH-300.
|
||||
|
||||
## Local validation
|
||||
|
||||
From the repository root (also run by `npm run verify` through
|
||||
`scripts/governance-check.mjs`):
|
||||
|
||||
```bash
|
||||
npm run test:terraform # fmt -check, init -backend=false, validate
|
||||
npm run test:terraform-import-plan # checker unit tests against synthetic plans
|
||||
npm run test:terraform-release-plan # content-release plan guard
|
||||
npm run test:terraform-isolation # isolation gate unit tests
|
||||
npm run test:hcp-run-guard # workspace invariant and apply reconcile
|
||||
npm run test:cloudfront-release-verify
|
||||
npm run test:github-workflows # bash -n and actionlint
|
||||
```
|
||||
|
||||
`terraform init -backend=false -lockfile=readonly` may download the provider
|
||||
but never contacts HCP state or plans against AWS. Only HCP runs plan against
|
||||
the account.
|
||||
|
||||
The lock file must carry `h1:` hashes for every platform that runs the gate
|
||||
(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the
|
||||
provider version, refresh them with:
|
||||
|
||||
```bash
|
||||
terraform -chdir=terraform/live/dev providers lock \
|
||||
-platform=linux_amd64 -platform=linux_arm64 \
|
||||
-platform=darwin_amd64 -platform=darwin_arm64
|
||||
```
|
||||
|
||||
## Import plan safety
|
||||
|
||||
Import mode requires exactly the canonical 13 addresses and AWS types, valid
|
||||
import metadata for every resource, the exact dev import IDs (a staging ID in a
|
||||
dev plan fails), and zero create, update, delete, or replace actions.
|
||||
|
||||
Post-import mode requires all 13 resources to be no-op and rejects any
|
||||
remaining import metadata.
|
||||
|
||||
Controlled mode permits only in-place updates to the addresses explicitly
|
||||
listed with `--allow-update-address`, verifies `before` against the exact
|
||||
pre-adoption policies and tags and `after` against the exact adopted values,
|
||||
and rejects create, delete, replace, import metadata, unknown values,
|
||||
unapproved addresses, and unused allowlist entries.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Before import apply: discard the run and correct the root.
|
||||
- After import, before the controlled update (end of Phase 1): remove only the
|
||||
13 imported addresses from state under a separately reviewed state
|
||||
operation. CloudFormation remains authoritative; a
|
||||
`ManageSiteInfrastructure=true` stack is unchanged by this.
|
||||
- After the controlled update, before detachment: either complete the reviewed
|
||||
detachment or restore the exact pre-adoption policy and tags under a
|
||||
separate approval. Do not remove state or redeploy CloudFormation blindly.
|
||||
- After detachment: Terraform is authoritative. Restore content from the
|
||||
versioned bucket. Re-establishing CloudFormation ownership requires a
|
||||
reviewed `IMPORT` change set, never an ordinary update.
|
||||
|
||||
Any replacement, destroy, cross-environment ID, missing import, broad policy
|
||||
change, or failed smoke check is a hard stop.
|
||||
|
||||
## Evidence per phase
|
||||
|
||||
- HCP run URL and the workspace settings read-back
|
||||
- plan JSON and checker output
|
||||
- `terraform state list` showing exactly the 13 addresses
|
||||
- read-only inventory before and after each mutation
|
||||
- synthesized CloudFormation template, change set, and stack events
|
||||
- deploy, invalidation, and smoke output
|
||||
- the post-action no-op plan
|
||||
- phase close-out on SH-300: completed work, validation, risks, deviations,
|
||||
remaining work
|
||||
GitHub Environments `dev` and `staging` set `DEPLOY_ROLE_ARN` and allow `main`
|
||||
plus tag `v*`. Promote staging with `gh release create vX.Y.Z-staging --target
|
||||
main`.
|
||||
|
|
|
|||
8
terraform/live/README.md
Normal file
8
terraform/live/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Live Terraform roots
|
||||
|
||||
`live/dev/` is the adopted HCP workspace `shoc-frontend-new-dev`.
|
||||
`live/staging/` is `shoc-frontend-new-staging` (`adoption_complete = true`).
|
||||
|
||||
Do not collapse these into one `terraform/` root in the same PR as application
|
||||
CD. Flattening retargets two live HCP working directories and belongs in its
|
||||
own change.
|
||||
|
|
@ -2,20 +2,25 @@ locals {
|
|||
# Controlled ownership transfer. Pinned in code, never a workspace variable.
|
||||
adoption_complete = true
|
||||
|
||||
environment = "dev"
|
||||
workspace_name = "shoc-frontend-new-dev"
|
||||
aws_account_id = "396287094661"
|
||||
aws_region = "us-east-1"
|
||||
bucket_name = "seahaven-shoc-frontend-dev"
|
||||
distribution_id = "E2CWLM1AFB964P"
|
||||
oac_id = "E30VSIK87N8H64"
|
||||
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
|
||||
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
|
||||
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
domain_name = "dev.seahaven.com"
|
||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||
certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||
environment = "dev"
|
||||
workspace_name = "shoc-frontend-new-dev"
|
||||
github_repo = "Sea-Haven-Industries/shoc-frontend-new"
|
||||
aws_account_id = "396287094661"
|
||||
aws_region = "us-east-1"
|
||||
bucket_name = "seahaven-shoc-frontend-dev"
|
||||
distribution_id = "E2CWLM1AFB964P"
|
||||
oac_id = "E30VSIK87N8H64"
|
||||
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
|
||||
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
|
||||
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
domain_name = "dev.seahaven.com"
|
||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||
certificate_arn = (
|
||||
"arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||
)
|
||||
github_oidc_arn = (
|
||||
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||
)
|
||||
deploy_role_name = "githubdeploy-shoc-frontend-new-dev"
|
||||
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
stack_name = "shoc-frontend-dev"
|
||||
|
|
@ -61,35 +66,30 @@ module "inventory" {
|
|||
module "environment_owned" {
|
||||
source = "../modules/environment-owned"
|
||||
|
||||
environment = local.environment
|
||||
adoption_complete = local.adoption_complete
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
bucket_name = local.bucket_name
|
||||
distribution_id = local.distribution_id
|
||||
origin_access_control_name = local.oac_name
|
||||
origin_access_control_description = ""
|
||||
origin_id = local.origin_id
|
||||
function_name = local.function_name
|
||||
domain_name = local.domain_name
|
||||
hosted_zone_id = local.hosted_zone_id
|
||||
certificate_arn = local.certificate_arn
|
||||
cache_policy_id = local.cache_policy_id
|
||||
github_oidc_provider_arn = local.github_oidc_arn
|
||||
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev"
|
||||
pre_adoption_github_subject_operator = "StringEquals"
|
||||
post_adoption_github_subject_operator = "StringEquals"
|
||||
deploy_branch = "dev"
|
||||
deploy_role_name = local.deploy_role_name
|
||||
deploy_inline_policy_name = local.inline_policy
|
||||
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||
cloudformation_stack_name = local.stack_name
|
||||
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
|
||||
pre_adoption_tags = local.legacy_tags
|
||||
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||
ownership_tags = local.terraform_tags
|
||||
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||
release_version_label = var.release_version_label
|
||||
previous_release_version_label = var.previous_release_version_label
|
||||
environment = local.environment
|
||||
adoption_complete = local.adoption_complete
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
github_repo = local.github_repo
|
||||
bucket_name = local.bucket_name
|
||||
distribution_id = local.distribution_id
|
||||
origin_access_control_name = local.oac_name
|
||||
origin_access_control_description = ""
|
||||
origin_id = local.origin_id
|
||||
function_name = local.function_name
|
||||
domain_name = local.domain_name
|
||||
hosted_zone_id = local.hosted_zone_id
|
||||
certificate_arn = local.certificate_arn
|
||||
cache_policy_id = local.cache_policy_id
|
||||
github_oidc_provider_arn = local.github_oidc_arn
|
||||
deploy_role_name = local.deploy_role_name
|
||||
deploy_inline_policy_name = local.inline_policy
|
||||
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||
cloudformation_stack_name = local.stack_name
|
||||
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
|
||||
pre_adoption_tags = local.legacy_tags
|
||||
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||
ownership_tags = local.terraform_tags
|
||||
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
output "bucket_name" {
|
||||
value = module.environment_owned.bucket_name
|
||||
value = module.environment_owned.bucket_name
|
||||
description = "SPA origin bucket name."
|
||||
}
|
||||
|
||||
output "distribution_id" {
|
||||
value = module.environment_owned.distribution_id
|
||||
value = module.environment_owned.distribution_id
|
||||
description = "CloudFront distribution ID."
|
||||
}
|
||||
|
||||
output "deploy_role_arn" {
|
||||
value = module.environment_owned.deploy_role_arn
|
||||
value = module.environment_owned.deploy_role_arn
|
||||
description = "GitHub Actions 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
|
||||
output "origin_id" {
|
||||
value = module.environment_owned.origin_id
|
||||
description = "CloudFront origin ID for the bucket-root SPA."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
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>."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,11 @@
|
|||
locals {
|
||||
bucket_arn = "arn:aws:s3:::${var.bucket_name}"
|
||||
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
|
||||
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
|
||||
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
|
||||
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
|
||||
github_subject_operator = var.pre_adoption_github_subject_operator
|
||||
previous_origin_id = "${var.origin_id}-previous"
|
||||
origin_group_id = "${var.origin_id}-group"
|
||||
pointer_key = ".release/current"
|
||||
pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {})
|
||||
# coalesce() skips empty strings, so a null var plus a missing pointer
|
||||
# would error. Empty string is the legacy root layout and must be valid.
|
||||
current_label = (
|
||||
var.release_version_label != null
|
||||
? var.release_version_label
|
||||
: try(local.pointer_body.current, "")
|
||||
)
|
||||
previous_label = (
|
||||
var.previous_release_version_label != null
|
||||
? var.previous_release_version_label
|
||||
: try(local.pointer_body.previous, "")
|
||||
)
|
||||
current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}"
|
||||
previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}"
|
||||
bucket_arn = "arn:aws:s3:::${var.bucket_name}"
|
||||
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
|
||||
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
|
||||
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
|
||||
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
|
||||
ssm_prefix = "/shoc-frontend-new/${var.environment}"
|
||||
github_subject = "repo:${var.github_repo}:environment:${var.environment}"
|
||||
|
||||
spa_rewrite_code = join("\n", [
|
||||
"function handler(event) {",
|
||||
|
|
@ -37,17 +20,6 @@ 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" {
|
||||
dynamic "statement" {
|
||||
for_each = var.adoption_complete ? [] : [1]
|
||||
|
|
@ -115,6 +87,7 @@ data "aws_iam_policy_document" "site_bucket" {
|
|||
|
||||
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||
statement {
|
||||
sid = "GithubDeployOidc"
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||
|
||||
|
|
@ -130,59 +103,70 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
|||
}
|
||||
|
||||
condition {
|
||||
test = local.github_subject_operator
|
||||
test = "StringEquals"
|
||||
variable = "token.actions.githubusercontent.com:sub"
|
||||
values = [var.github_subject]
|
||||
values = [local.github_subject]
|
||||
}
|
||||
|
||||
# StringLike: a release-triggered job loads the workflow file from the tag,
|
||||
# so job_workflow_ref ends in @refs/tags/vX.Y.Z-staging there and
|
||||
# @refs/heads/main on push and workflow_dispatch. The environment claim in
|
||||
# sub is the gate.
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||
values = [
|
||||
"${var.github_repo}/.github/workflows/deploy-web.yaml@refs/heads/main",
|
||||
"${var.github_repo}/.github/workflows/deploy-web.yaml@refs/tags/v*",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "github_deploy" {
|
||||
statement {
|
||||
sid = "ListReleasePrefixes"
|
||||
sid = "ListWebBucket"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:ListBucket",
|
||||
]
|
||||
resources = [local.bucket_arn]
|
||||
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "s3:prefix"
|
||||
values = [
|
||||
"releases/",
|
||||
"releases/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "PublishReleasePrefix"
|
||||
sid = "SyncWebBucket"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject",
|
||||
]
|
||||
resources = ["${local.bucket_arn}/releases/*"]
|
||||
resources = ["${local.bucket_arn}/*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ReadReleasePointer"
|
||||
effect = "Allow"
|
||||
actions = ["s3:GetObject"]
|
||||
resources = ["${local.bucket_arn}/${local.pointer_key}"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ReadDistribution"
|
||||
sid = "InvalidateDistribution"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
"cloudfront:GetDistribution",
|
||||
"cloudfront:GetDistributionConfig",
|
||||
]
|
||||
resources = [local.distribution_arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "DeployParams"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"ssm:GetParameter",
|
||||
]
|
||||
resources = [
|
||||
aws_ssm_parameter.deploy_bucket.arn,
|
||||
aws_ssm_parameter.deploy_distribution_id.arn,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket" "site" {
|
||||
|
|
@ -257,17 +241,13 @@ 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
|
||||
})
|
||||
# Pointer leftover from Terraform-promoted content CD. Forgotten, not destroyed.
|
||||
# deploy-web.yaml syncs dist/ to the bucket root with --delete.
|
||||
removed {
|
||||
from = aws_s3_object.release_pointer
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
destroy = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,32 +293,7 @@ resource "aws_cloudfront_distribution" "site" {
|
|||
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||
origin_id = var.origin_id
|
||||
origin_path = local.current_origin_path
|
||||
}
|
||||
|
||||
origin {
|
||||
connection_attempts = 3
|
||||
connection_timeout = 10
|
||||
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||
origin_id = local.previous_origin_id
|
||||
origin_path = local.previous_origin_path
|
||||
}
|
||||
|
||||
origin_group {
|
||||
origin_id = local.origin_group_id
|
||||
|
||||
failover_criteria {
|
||||
status_codes = [403, 404]
|
||||
}
|
||||
|
||||
member {
|
||||
origin_id = var.origin_id
|
||||
}
|
||||
|
||||
member {
|
||||
origin_id = local.previous_origin_id
|
||||
}
|
||||
origin_path = ""
|
||||
}
|
||||
|
||||
default_cache_behavior {
|
||||
|
|
@ -346,7 +301,7 @@ resource "aws_cloudfront_distribution" "site" {
|
|||
cache_policy_id = var.cache_policy_id
|
||||
cached_methods = ["GET", "HEAD"]
|
||||
compress = true
|
||||
target_origin_id = local.origin_group_id
|
||||
target_origin_id = var.origin_id
|
||||
viewer_protocol_policy = "redirect-to-https"
|
||||
|
||||
function_association {
|
||||
|
|
@ -369,18 +324,6 @@ resource "aws_cloudfront_distribution" "site" {
|
|||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
|
||||
action_trigger {
|
||||
events = [after_update]
|
||||
actions = [action.aws_cloudfront_create_invalidation.release]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
action "aws_cloudfront_create_invalidation" "release" {
|
||||
config {
|
||||
distribution_id = aws_cloudfront_distribution.site.id
|
||||
paths = ["/*"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -419,7 +362,7 @@ resource "aws_route53_record" "site_aaaa" {
|
|||
resource "aws_iam_role" "github_deploy" {
|
||||
name = var.deploy_role_name
|
||||
path = "/"
|
||||
description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}"
|
||||
description = "GitHub Actions SPA deploy role for ${var.github_repo} Environment ${var.environment}"
|
||||
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
||||
max_session_duration = 3600
|
||||
permissions_boundary = var.deploy_permissions_boundary_arn
|
||||
|
|
@ -427,6 +370,9 @@ resource "aws_iam_role" "github_deploy" {
|
|||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
# SCP ProtectDeploymentPrincipalLifecycle denies UpdateRoleDescription on
|
||||
# githubdeploy-* for HCP apply roles.
|
||||
ignore_changes = [description]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -439,3 +385,23 @@ resource "aws_iam_role_policy" "github_deploy" {
|
|||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_bucket" {
|
||||
name = "${local.ssm_prefix}/deploy/bucket"
|
||||
type = "String"
|
||||
data_type = "text"
|
||||
tier = "Standard"
|
||||
value = aws_s3_bucket.site.id
|
||||
description = "SPA origin bucket; deploy-web syncs dist/ to the bucket root"
|
||||
tags = local.resource_tags
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_distribution_id" {
|
||||
name = "${local.ssm_prefix}/deploy/distribution-id"
|
||||
type = "String"
|
||||
data_type = "text"
|
||||
tier = "Standard"
|
||||
value = aws_cloudfront_distribution.site.id
|
||||
description = "CloudFront distribution ID; deploy-web invalidates /* after sync"
|
||||
tags = local.resource_tags
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,32 +13,7 @@ output "deploy_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" {
|
||||
output "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."
|
||||
description = "CloudFront origin ID for the bucket-root SPA."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,40 +14,6 @@ variable "adoption_complete" {
|
|||
default = false
|
||||
}
|
||||
|
||||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable content release label. Null VCS plans read the live pointer from S3."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
var.release_version_label == "" ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
|
||||
variable "previous_release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.previous_release_version_label == null ||
|
||||
var.previous_release_version_label == "" ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label))
|
||||
)
|
||||
error_message = "previous_release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
|
||||
variable "aws_account_id" {
|
||||
type = string
|
||||
description = "AWS account containing the resources."
|
||||
|
|
@ -58,6 +24,11 @@ variable "aws_region" {
|
|||
description = "AWS region used by the environment."
|
||||
}
|
||||
|
||||
variable "github_repo" {
|
||||
type = string
|
||||
description = "owner/name used in OIDC job_workflow_ref."
|
||||
}
|
||||
|
||||
variable "bucket_name" {
|
||||
type = string
|
||||
description = "Existing private S3 origin bucket."
|
||||
|
|
@ -113,36 +84,6 @@ variable "github_oidc_provider_arn" {
|
|||
description = "Inventory-verified GitHub OIDC provider ARN."
|
||||
}
|
||||
|
||||
variable "github_subject" {
|
||||
type = string
|
||||
description = "Exact GitHub OIDC subject in the existing role."
|
||||
}
|
||||
|
||||
variable "pre_adoption_github_subject_operator" {
|
||||
type = string
|
||||
description = "Condition operator used by the role before adoption."
|
||||
|
||||
validation {
|
||||
condition = contains(["StringEquals", "StringLike"], var.pre_adoption_github_subject_operator)
|
||||
error_message = "pre_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||
}
|
||||
}
|
||||
|
||||
variable "post_adoption_github_subject_operator" {
|
||||
type = string
|
||||
description = "Condition operator used by the role after adoption."
|
||||
|
||||
validation {
|
||||
condition = contains(["StringEquals", "StringLike"], var.post_adoption_github_subject_operator)
|
||||
error_message = "post_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||
}
|
||||
}
|
||||
|
||||
variable "deploy_branch" {
|
||||
type = string
|
||||
description = "Branch or environment named in the existing role description."
|
||||
}
|
||||
|
||||
variable "deploy_role_name" {
|
||||
type = string
|
||||
description = "Existing GitHub deployment role name."
|
||||
|
|
|
|||
30
terraform/live/staging/.terraform.lock.hcl
generated
Normal file
30
terraform/live/staging/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "6.62.0"
|
||||
constraints = "~> 6.57"
|
||||
hashes = [
|
||||
"h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=",
|
||||
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
|
||||
"h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=",
|
||||
"h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=",
|
||||
"h1:yOSEz5G8b/n5uhFCZ0gbEsKkAQATtVuhXJEXR3OM5qs=",
|
||||
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
|
||||
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
|
||||
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
|
||||
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
|
||||
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
|
||||
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
|
||||
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
|
||||
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
|
||||
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
|
||||
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
|
||||
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
|
||||
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
|
||||
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
|
||||
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
|
||||
]
|
||||
}
|
||||
64
terraform/live/staging/imports.tf
Normal file
64
terraform/live/staging/imports.tf
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
to = module.environment_owned.aws_s3_bucket.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_public_access_block.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_ownership_controls.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_versioning.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_policy.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_cloudfront_distribution.site
|
||||
id = local.distribution_id
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_cloudfront_origin_access_control.site
|
||||
id = local.oac_id
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_cloudfront_function.spa_rewrite
|
||||
id = local.function_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_route53_record.site_a
|
||||
id = "${local.hosted_zone_id}_${local.domain_name}_A"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_route53_record.site_aaaa
|
||||
id = "${local.hosted_zone_id}_${local.domain_name}_AAAA"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_iam_role.github_deploy
|
||||
id = local.deploy_role_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_iam_role_policy.github_deploy
|
||||
id = "${local.deploy_role_name}:${local.inline_policy}"
|
||||
}
|
||||
95
terraform/live/staging/main.tf
Normal file
95
terraform/live/staging/main.tf
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
locals {
|
||||
# Controlled ownership transfer. Pinned in code, never a workspace variable.
|
||||
adoption_complete = true
|
||||
|
||||
environment = "staging"
|
||||
workspace_name = "shoc-frontend-new-staging"
|
||||
github_repo = "Sea-Haven-Industries/shoc-frontend-new"
|
||||
aws_account_id = "396287094661"
|
||||
aws_region = "us-east-1"
|
||||
bucket_name = "seahaven-shoc-frontend-staging"
|
||||
distribution_id = "E2JDVEZ6EGD49J"
|
||||
oac_id = "E1PF5R6QQNBZAI"
|
||||
oac_name = "shocfrontendstagingDistributOrigin1S3OriginAccessControl82B1C17D"
|
||||
origin_id = "shocfrontendstagingDistributionOrigin16E4628FC"
|
||||
function_name = "us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
|
||||
domain_name = "staging.seahaven.com"
|
||||
hosted_zone_id = "Z02602739VQWBWCAGXP4"
|
||||
certificate_arn = (
|
||||
"arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||
)
|
||||
github_oidc_arn = (
|
||||
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||
)
|
||||
deploy_role_name = "githubdeploy-shoc-frontend-new-staging"
|
||||
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
stack_name = "shoc-frontend-staging"
|
||||
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||
permissions_boundary_arn = (
|
||||
"arn:aws:iam::396287094661:policy/shoc-frontend-new-staging-deploy-boundary"
|
||||
)
|
||||
bucket_auto_delete_helper_role_arn = (
|
||||
"arn:aws:iam::396287094661:role/shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
|
||||
)
|
||||
legacy_tags = {
|
||||
Environment = "staging"
|
||||
ManagedBy = "cdk"
|
||||
Project = "shoc-frontend"
|
||||
}
|
||||
legacy_bucket_tags = merge(local.legacy_tags, {
|
||||
"aws-cdk:auto-delete-objects" = "true"
|
||||
})
|
||||
terraform_tags = {
|
||||
Environment = "staging"
|
||||
ManagedBy = "terraform"
|
||||
Ownership = "terraform"
|
||||
Project = "shoc-frontend"
|
||||
}
|
||||
manager_tag = {
|
||||
HcpTerraformWorkspace = local.workspace_name
|
||||
}
|
||||
}
|
||||
|
||||
module "inventory" {
|
||||
source = "../modules/environment-inventory"
|
||||
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
hosted_zone_name = local.domain_name
|
||||
expected_hosted_zone_id = local.hosted_zone_id
|
||||
certificate_domain = "*.seahaven.com"
|
||||
expected_certificate_arn = local.certificate_arn
|
||||
expected_github_oidc_provider_arn = local.github_oidc_arn
|
||||
expected_cache_policy_id = local.cache_policy_id
|
||||
}
|
||||
|
||||
module "environment_owned" {
|
||||
source = "../modules/environment-owned"
|
||||
|
||||
environment = local.environment
|
||||
adoption_complete = local.adoption_complete
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
github_repo = local.github_repo
|
||||
bucket_name = local.bucket_name
|
||||
distribution_id = local.distribution_id
|
||||
origin_access_control_name = local.oac_name
|
||||
origin_access_control_description = ""
|
||||
origin_id = local.origin_id
|
||||
function_name = local.function_name
|
||||
domain_name = local.domain_name
|
||||
hosted_zone_id = local.hosted_zone_id
|
||||
certificate_arn = local.certificate_arn
|
||||
cache_policy_id = local.cache_policy_id
|
||||
github_oidc_provider_arn = local.github_oidc_arn
|
||||
deploy_role_name = local.deploy_role_name
|
||||
deploy_inline_policy_name = local.inline_policy
|
||||
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||
cloudformation_stack_name = local.stack_name
|
||||
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
|
||||
pre_adoption_tags = local.legacy_tags
|
||||
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||
ownership_tags = local.terraform_tags
|
||||
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||
}
|
||||
19
terraform/live/staging/outputs.tf
Normal file
19
terraform/live/staging/outputs.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
output "bucket_name" {
|
||||
value = module.environment_owned.bucket_name
|
||||
description = "SPA origin bucket name."
|
||||
}
|
||||
|
||||
output "distribution_id" {
|
||||
value = module.environment_owned.distribution_id
|
||||
description = "CloudFront distribution ID."
|
||||
}
|
||||
|
||||
output "deploy_role_arn" {
|
||||
value = module.environment_owned.deploy_role_arn
|
||||
description = "GitHub Actions deploy role ARN."
|
||||
}
|
||||
|
||||
output "origin_id" {
|
||||
value = module.environment_owned.origin_id
|
||||
description = "CloudFront origin ID for the bucket-root SPA."
|
||||
}
|
||||
3
terraform/live/staging/providers.tf
Normal file
3
terraform/live/staging/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
provider "aws" {
|
||||
region = local.aws_region
|
||||
}
|
||||
19
terraform/live/staging/versions.tf
Normal file
19
terraform/live/staging/versions.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
terraform {
|
||||
required_version = ">= 1.14.0, < 2.0.0"
|
||||
|
||||
cloud {
|
||||
organization = "seahaven"
|
||||
|
||||
workspaces {
|
||||
project = "seahaven-external-dev"
|
||||
name = "shoc-frontend-new-staging"
|
||||
}
|
||||
}
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 6.57"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue