mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
feat(terraform): ship dev content CD through Terraform (SH-300) (#180)
* feat(terraform): ship dev content CD through Terraform (SH-300) GitHub uploads immutable release prefixes; Terraform owns live publish. Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set. * fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300)
This commit is contained in:
parent
b24e6f3b9a
commit
69c24c1c2c
56 changed files with 3998 additions and 1950 deletions
19
.github/workflows/ci.yaml
vendored
19
.github/workflows/ci.yaml
vendored
|
|
@ -24,10 +24,10 @@ 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 guard
|
||||
# tests, Terraform isolation gate tests, CDK build/test/synth). If the
|
||||
# reusable workflow is later confirmed to run every gate, this job can be
|
||||
# slimmed to `npm run governance`.
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan and
|
||||
# release-plan guards, isolation tests, HCP run guard, CloudFront verify,
|
||||
# and GitHub workflow shell). If the reusable workflow is later confirmed
|
||||
# to run every gate, this job can be slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
|
|
@ -66,6 +66,17 @@ jobs:
|
|||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: "1.7.12"
|
||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o actionlint.tar.gz \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c -
|
||||
tar -xzf actionlint.tar.gz actionlint
|
||||
sudo mv actionlint /usr/local/bin/actionlint
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
env:
|
||||
|
|
|
|||
369
.github/workflows/deploy.yml
vendored
369
.github/workflows/deploy.yml
vendored
|
|
@ -1,40 +1,26 @@
|
|||
name: Deploy dev content
|
||||
name: Validate and deploy
|
||||
|
||||
# Manual dev content deployment during the Terraform adoption (SH-300).
|
||||
#
|
||||
# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are
|
||||
# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are
|
||||
# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the
|
||||
# resources, and move to HCP Terraform (`terraform/README.md`) as adoption
|
||||
# completes. Automatic push-to-`dev` releases return with the Terraform
|
||||
# content-CD change, gated on a repository variable.
|
||||
#
|
||||
# This workflow publishes only content: verify, build, `aws s3 sync`, and a
|
||||
# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC
|
||||
# deploy role. The bucket and distribution are pinned here so a content deploy
|
||||
# keeps working after CloudFormation relinquishes the stack outputs.
|
||||
# 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.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
push:
|
||||
branches: [dev]
|
||||
paths-ignore:
|
||||
- "terraform/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-dev
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Publish content to dev
|
||||
# Deploy only the exact dev branch ref: workflow_dispatch can be invoked
|
||||
# from arbitrary refs, and the deploy role trusts only refs/heads/dev.
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
validate:
|
||||
name: Validate production build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
|
|
@ -44,65 +30,316 @@ jobs:
|
|||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Set up Terraform
|
||||
# Required by `npm run verify` (governance runs terraform fmt/validate).
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- name: Quality gates (full verify before any deploy)
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: "1.7.12"
|
||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o actionlint.tar.gz \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c -
|
||||
tar -xzf actionlint.tar.gz actionlint
|
||||
sudo mv actionlint /usr/local/bin/actionlint
|
||||
- name: Quality gates
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: origin/dev
|
||||
GOVERNANCE_BASE: ${{ github.event.pull_request.base.sha || 'origin/dev' }}
|
||||
- name: Build with pinned API URL
|
||||
env:
|
||||
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run build
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Rq "localhost:5141" dist/; then
|
||||
echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
|
||||
- name: Assume dev deploy role (OIDC)
|
||||
deploy-dev:
|
||||
name: Deploy shoc-frontend-new-dev through Terraform
|
||||
if: >
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
|
||||
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
concurrency:
|
||||
group: deploy-dev
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
TF_CLOUD_ORGANIZATION: seahaven
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
SITE_URL: https://dev.seahaven.com
|
||||
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Build SPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Rq "localhost:5141" dist/; then
|
||||
echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
# Builds with the dev values committed in .env.production (VITE_API_URL,
|
||||
# Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
- name: Assign immutable release identity
|
||||
id: release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
prefix="releases/${version_label}"
|
||||
{
|
||||
echo "version_label=${version_label}"
|
||||
echo "prefix=${prefix}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE: ${{ steps.release.outputs.version_label }}
|
||||
|
||||
- name: Verify deployment
|
||||
- name: Read previous release pointer
|
||||
id: pointer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_URL="https://dev.seahaven.com"
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
echo "Built assets reference the dev API URL."
|
||||
body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
|
||||
printf '%s' "${body}" | python3 scripts/read-release-pointer.py
|
||||
|
||||
# The invalidation has completed, but give edges a short window to
|
||||
# converge before calling the served index.html wrong.
|
||||
remote_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${remote_dir}"' EXIT
|
||||
matched=false
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \
|
||||
&& cmp -s dist/index.html "${remote_dir}/index.html"; then
|
||||
matched=true
|
||||
break
|
||||
fi
|
||||
echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
if [[ "${matched}" != "true" ]]; then
|
||||
echo "::error::Served index.html does not match the build just published." >&2
|
||||
- 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 s3 ls "s3://${SITE_BUCKET}/${prefix}/" | grep -q 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
|
||||
echo "Served index.html matches the published build."
|
||||
curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login"
|
||||
echo "Extensionless SPA route serves."
|
||||
|
||||
- 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
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -38,16 +38,6 @@ seed-data.sql
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# cdk (infra/cdk)
|
||||
infra/cdk/node_modules
|
||||
infra/cdk/cdk.out
|
||||
infra/cdk/cdk.context.json
|
||||
infra/cdk/*.d.ts
|
||||
infra/cdk/bin/*.d.ts
|
||||
infra/cdk/bin/*.js
|
||||
infra/cdk/lib/*.d.ts
|
||||
infra/cdk/lib/*.js
|
||||
|
||||
# terraform (the provider lock file is committed)
|
||||
**/.terraform/*
|
||||
*.tfstate
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ npm run verify
|
|||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the repository
|
||||
gates: the Terraform import-plan checker tests, the Terraform isolation gate
|
||||
tests, Terraform formatting and validation, and the CDK build, template tests,
|
||||
and synthesis. A task is not done until this is green.
|
||||
gates: Terraform import-plan and release-plan checkers, isolation tests,
|
||||
Terraform formatting and validation, the HCP run guard, CloudFront verify, and
|
||||
workflow shell checks. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
|
|
@ -27,9 +27,12 @@ and synthesis. A task is not done until this is green.
|
|||
| 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` |
|
||||
| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes |
|
||||
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
|
||||
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
|
||||
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
|
||||
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
|
@ -76,5 +79,5 @@ and synthesis. A task is not done until this is green.
|
|||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`. Governance also needs
|
||||
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and
|
||||
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.14.0, < 2.0.0`) and
|
||||
`python3` (3.10+) on `PATH`.
|
||||
|
|
|
|||
91
README.md
91
README.md
|
|
@ -5,7 +5,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor
|
||||
portal, uplifts, and related admin features. This is the selective rebuild of
|
||||
|
|
@ -18,25 +18,24 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
|||
|
||||
## Architecture
|
||||
|
||||
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
|
||||
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
|
||||
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
|
||||
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
|
||||
Static SPA hosting on AWS, owned by HCP Terraform
|
||||
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
|
||||
`dist/` from a private S3 bucket using a current/previous origin group;
|
||||
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
|
||||
proxy at the CDN — the backend allows CORS).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
|
||||
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||
GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|s3 sync + invalidation| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3
|
||||
GH[GitHub Actions] -->|OIDC upload releases/*| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
|
||||
```
|
||||
|
||||
Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see
|
||||
[`terraform/README.md`](terraform/README.md) for the phase runbook and the
|
||||
current ownership state.
|
||||
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
|
||||
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
|
||||
|
||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||
|
|
@ -45,8 +44,8 @@ architecture plan for the keep/discard migration matrix).
|
|||
|
||||
## AWS Resources
|
||||
|
||||
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
|
||||
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
||||
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
|
||||
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
|
||||
|
||||
| Resource | Name | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -83,9 +82,8 @@ repo secret** is:
|
|||
build otherwise. See [`.env.example`](.env.example),
|
||||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||
|
||||
CDK context (domain, certificate ARN, hosted zone) lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs
|
||||
`cdk deploy` with no flags.
|
||||
Pinned hosting constants (domain, certificate ARN, hosted zone) live in
|
||||
[`terraform/live/dev/main.tf`](terraform/live/dev/main.tf).
|
||||
|
||||
## Local Development
|
||||
|
||||
|
|
@ -103,7 +101,7 @@ override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
|||
the deployed dev API).
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ------------------------------------------------------------ |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
|
|
@ -111,13 +109,11 @@ the deployed dev API).
|
|||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) |
|
||||
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CD guards) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
|
||||
`npm run governance` needs `terraform` and `python3` on `PATH` for the
|
||||
Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`,
|
||||
`npm run test:terraform-isolation`) and installs `infra/cdk` for
|
||||
`npm run test:infra`.
|
||||
Terraform and content-CD gates.
|
||||
|
||||
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||
|
|
@ -137,8 +133,8 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
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 the
|
||||
**Deploy dev content** workflow while the Terraform adoption is in progress)
|
||||
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through
|
||||
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
|
||||
`→ main` (production promotion — no prod environment exists yet).
|
||||
|
||||
## Deployment
|
||||
|
|
@ -151,7 +147,7 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
|||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
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 CDK template tests) is guaranteed from this
|
||||
the Terraform gates, and the content-CD guards) is guaranteed from this
|
||||
repository. Conventions and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
|
|
@ -161,28 +157,21 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
|||
— fails a PR that mixes `terraform/**` with application code, so a Terraform
|
||||
merge never races a content release for the HCP workspace.
|
||||
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
|
||||
— `workflow_dispatch` on `dev` only while the Terraform adoption is in
|
||||
progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`,
|
||||
and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
||||
CloudFront invalidation, then uploads source maps and checks the served
|
||||
`index.html` matches the build. Push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
— `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. Dev: the CDK retain/transfer sequence
|
||||
and the HCP Terraform workspace `shoc-frontend-new-dev`
|
||||
([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy`
|
||||
([`infra/cdk/README.md`](infra/cdk/README.md)).
|
||||
- **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.
|
||||
|
||||
Manual content deploy (emergency/reference only — needs credentials for the
|
||||
external-dev AWS account):
|
||||
|
||||
```bash
|
||||
SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \
|
||||
AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
|
||||
content publisher only.
|
||||
|
||||
## Operations
|
||||
|
||||
|
|
@ -193,8 +182,9 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P
|
|||
are no CloudWatch application logs — the stack is static hosting; runtime
|
||||
errors surface in the browser and on the backend API's side.
|
||||
- **Common failure modes:**
|
||||
- _Stale content after deploy_ — the CloudFront invalidation step failed or
|
||||
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
|
||||
- _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.
|
||||
|
|
@ -202,14 +192,13 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P
|
|||
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||
not proxy `/api`.
|
||||
- **Dev has no push-triggered deploy during the adoption.** Merging to `dev`
|
||||
runs CI only; publish through the **Deploy dev content** workflow. Merging a
|
||||
`terraform/**` change also queues an HCP Terraform run that a human confirms
|
||||
or discards (see the operational rules in `terraform/README.md`).
|
||||
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when
|
||||
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
|
||||
an HCP Terraform run that a human confirms or discards before the next
|
||||
content release (see the operational rules in `terraform/README.md`).
|
||||
|
||||
## Documentation
|
||||
|
||||
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
|
||||
- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
|
||||
design system and UI docs under [`docs/`](docs/)
|
||||
|
|
|
|||
2
e2e/vendors/vendors.visual.spec.ts
vendored
2
e2e/vendors/vendors.visual.spec.ts
vendored
|
|
@ -244,7 +244,9 @@ test.describe("Vendor deterministic pixel regression", () => {
|
|||
await openVendorPage(page, "error");
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||
/server error|vendor directory unavailable/i,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(page.getByRole("progressbar")).toHaveCount(0);
|
||||
await expectStableScreenshot(page, "vendor-error.png");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -30,18 +30,7 @@ const legacyIgnores = [
|
|||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"infra/cdk/cdk.out/**",
|
||||
"infra/cdk/bin/**/*.d.ts",
|
||||
"infra/cdk/bin/**/*.js",
|
||||
"infra/cdk/lib/**/*.d.ts",
|
||||
"infra/cdk/lib/**/*.js",
|
||||
...legacyIgnores,
|
||||
],
|
||||
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
|
|
|||
|
|
@ -1,270 +0,0 @@
|
|||
# Infrastructure & CI/CD — Sea Haven SHOC frontend
|
||||
|
||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo.
|
||||
Infrastructure deploys are administrator-run; GitHub Actions publishes content
|
||||
only.
|
||||
|
||||
> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack
|
||||
> `shoc-frontend-dev` is in the retain/transfer sequence described under
|
||||
> [Terraform adoption mode](#terraform-adoption-mode) and in
|
||||
> [`terraform/README.md`](../terraform/README.md). Do not run a plain
|
||||
> `cdk deploy` against dev while that sequence is in progress. Staging is
|
||||
> unaffected and stays on this CDK path (SH-287 tracks its cutover).
|
||||
|
||||
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
|
||||
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
|
||||
- **API:** the SPA calls the backend **directly** over HTTPS at
|
||||
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
|
||||
allows CORS). CloudFront serves static content only — no `/api` proxy.
|
||||
- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks
|
||||
them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
||||
per-environment (see the note under "Adding staging / prod").
|
||||
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
|
||||
- **Content workflows:** `.github/workflows/deploy.yml` (dev,
|
||||
`workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to
|
||||
`staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy
|
||||
role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was
|
||||
retired with the adoption PR.
|
||||
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
|
||||
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
|
||||
|
||||
```
|
||||
infra/cdk/
|
||||
bin/app.ts entry point (reads -c context)
|
||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition)
|
||||
test/frontend-stack.test.mjs template assertions for both modes
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
.github/workflows/
|
||||
ci.yaml quality gates (lint / build / test / governance / terraform isolation)
|
||||
deploy.yml dev content publish (workflow_dispatch on dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
```
|
||||
|
||||
## What the stack creates
|
||||
|
||||
| Resource | Purpose |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
|
||||
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
|
||||
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
|
||||
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
|
||||
The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants:
|
||||
`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`,
|
||||
read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It
|
||||
is left byte-identical on purpose so the Terraform import is a no-op; the
|
||||
Terraform content-CD change narrows it. The OIDC **provider** is a singleton
|
||||
account resource — the stack only _imports_ it (created in step 2), so
|
||||
`cdk destroy` can't delete a resource shared by other roles.
|
||||
|
||||
## Terraform adoption mode
|
||||
|
||||
`-c retainForTerraformAdoption=true` switches the stack into the safety mode
|
||||
used only while HCP Terraform adopts the dev resources. It is off by default
|
||||
and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts
|
||||
both). In adoption mode the stack:
|
||||
|
||||
- pins the origin ID CloudFormation generated for the live distribution
|
||||
(`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is
|
||||
metadata-only; environments without a verified value fail synthesis
|
||||
- attaches the `seahaven-org-baseline` permissions boundary
|
||||
`shoc-frontend-new-dev-deploy-boundary` and the
|
||||
`HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role
|
||||
- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the
|
||||
same exact value
|
||||
- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13
|
||||
transferred resources (bucket, bucket policy, distribution, OAC, SPA
|
||||
function, A and AAAA records, deploy role, inline policy) and to
|
||||
`SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider
|
||||
Lambda and role stay unretained
|
||||
- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no
|
||||
default) and conditions those same resources and every output on it
|
||||
- emits `TerraformImport*` outputs carrying the exact import IDs
|
||||
|
||||
`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must
|
||||
state the ownership phase:
|
||||
|
||||
```bash
|
||||
cd infra/cdk && npm ci
|
||||
|
||||
# Phase 1, before the Terraform import: keep the resources in the stack and
|
||||
# install Retain on them. Update-only change set.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=true
|
||||
|
||||
# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish
|
||||
# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=false
|
||||
```
|
||||
|
||||
Both deploys must use the same reviewed SHA. Review the change set before
|
||||
confirming: Phase 1 must show no create, delete, or replace. After the
|
||||
`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used
|
||||
again. If the `true` deploy rolls back, inspect the stack resources and the
|
||||
live bucket before retrying; retained resources can outlive a failed update and
|
||||
must not be cleaned up automatically. Never delete the auto-delete custom
|
||||
resource while its handler can still empty the versioned bucket.
|
||||
|
||||
Local checks (`npm run test:infra` from the repo root) build the app, run the
|
||||
template assertions, and synthesize both modes.
|
||||
|
||||
---
|
||||
|
||||
## One-time setup (run by a human with admin AWS creds)
|
||||
|
||||
### 1. Authenticate to the AWS account
|
||||
|
||||
```bash
|
||||
aws configure # or: aws sso login --profile <admin>
|
||||
aws sts get-caller-identity # confirm the right account + region (us-east-1)
|
||||
```
|
||||
|
||||
### 2. Ensure the GitHub OIDC provider exists (once per account)
|
||||
|
||||
```bash
|
||||
aws iam list-open-id-connect-providers
|
||||
# If none ends in token.actions.githubusercontent.com, create it (thumbprint is
|
||||
# no longer required — AWS validates GitHub against its own trust store):
|
||||
aws iam create-open-id-connect-provider \
|
||||
--url https://token.actions.githubusercontent.com \
|
||||
--client-id-list sts.amazonaws.com
|
||||
```
|
||||
|
||||
### 3. CDK bootstrap (once per account/region)
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npm ci
|
||||
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
|
||||
```
|
||||
|
||||
### 4. Domain, cert, and API URL (already wired for dev)
|
||||
|
||||
Domain/cert/zone are set in `cdk.json` context (account `396287094661`):
|
||||
|
||||
| Context key | Value |
|
||||
| --------------------------------- | ------------------------------------------------------------ |
|
||||
| `domainNames` | `dev.seahaven.com` |
|
||||
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
|
||||
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
|
||||
|
||||
The stack creates the apex A/AAAA alias in the hosted zone (in this account,
|
||||
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
|
||||
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
|
||||
baked into the build. Per-environment; override for staging/prod.
|
||||
|
||||
### 5. First deploy (locally, with admin creds)
|
||||
|
||||
The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it
|
||||
locally. This provisions infra + the role:
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy
|
||||
```
|
||||
|
||||
Note the `DeployRoleArn` output. Then publish the first content manually:
|
||||
|
||||
```bash
|
||||
# from repo root, optional manual first content publish:
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
### 6. Content deploys
|
||||
|
||||
The deploy role ARN is deterministic and pinned in
|
||||
`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the
|
||||
Terraform adoption, dev content deploys run only through **Actions → Deploy dev
|
||||
content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes
|
||||
`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the
|
||||
pinned bucket and distribution, uploads source maps, and verifies the served
|
||||
`index.html` matches the build. Automatic push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
|
||||
---
|
||||
|
||||
## Staging environment (same account, exact OIDC subject)
|
||||
|
||||
Staging lives in the same AWS account (396287094661) and deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to
|
||||
`staging`:
|
||||
|
||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||
environment subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
|
||||
(`StringEquals` on both `aud` and `sub`). The workflow declares
|
||||
`environment: staging`, so only runs in that environment can assume the role.
|
||||
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
|
||||
unchanged.
|
||||
- **No secret:** the role ARN is static (the role name is deterministic), so
|
||||
the workflow pins
|
||||
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
|
||||
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
|
||||
- **Gates first:** the workflow runs the full `npm run verify` before assuming
|
||||
the staging role, then runs `scripts/deploy-web.sh` with
|
||||
`STACK_NAME=shoc-frontend-staging`,
|
||||
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
|
||||
CloudFront invalidation to complete.
|
||||
- **Application-only role:** the recurring staging workflow can describe only
|
||||
its exact stack, publish only to its exact bucket, and invalidate only its
|
||||
exact distribution. It cannot assume the shared CDK bootstrap roles or
|
||||
modify infrastructure. Staging infrastructure changes use the Administrator
|
||||
command below.
|
||||
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
|
||||
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
|
||||
contain the staging API URL and no dev API URL. (Not browser QA.)
|
||||
|
||||
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
|
||||
|
||||
1. **GitHub Admin — create the `staging` environment** (Settings →
|
||||
Environments → New environment → `staging`). Add protection rules as
|
||||
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
|
||||
the environment does not exist, GitHub creates it unprotected on first use.
|
||||
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
|
||||
OIDC provider and bootstrap already exist in this account):
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy shoc-frontend-staging \
|
||||
-c envName=staging \
|
||||
-c deployBranch=staging \
|
||||
-c githubEnvironment=staging \
|
||||
-c domainNames=staging.seahaven.com \
|
||||
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
|
||||
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
|
||||
-c hostedZoneName=staging.seahaven.com
|
||||
```
|
||||
|
||||
The `DeployRoleArn` output must match the ARN pinned in
|
||||
`deploy-staging.yml` (it will — the role name is deterministic).
|
||||
|
||||
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
|
||||
allow the `https://staging.seahaven.com` origin.
|
||||
4. Push to `staging` — `ci.yaml` runs the quality gates and
|
||||
`deploy-staging.yml` deploys.
|
||||
|
||||
### Adding prod later
|
||||
|
||||
Same pattern: a prod account/stack with its own contexts and, ideally, its own
|
||||
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
|
||||
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
|
||||
for prod.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
||||
Never run it against dev during or after the Terraform adoption: the
|
||||
adoption-mode stack retains the transferred resources, and after Phase 2
|
||||
Terraform owns them.
|
||||
- **CI and staging CD both fire on push to `staging`** in parallel; the
|
||||
staging CD workflow runs `npm run verify` itself before deploying. Dev has
|
||||
no push-triggered deploy during the adoption.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import { App, Tags } from "aws-cdk-lib";
|
||||
import { FrontendStack } from "../lib/frontend-stack";
|
||||
|
||||
const app = new App();
|
||||
|
||||
// Defaults match the dev setup; override via `-c key=value` on the CLI.
|
||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
||||
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
|
||||
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
|
||||
// branch-ref trust.
|
||||
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
|
||||
|
||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||
.split(",")
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d: string) => d.length > 0);
|
||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||
|
||||
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
|
||||
// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required
|
||||
// ManageSiteInfrastructure parameter and Retain policies on the transferred
|
||||
// resources. Off by default so ordinary synthesis is unchanged.
|
||||
const retainForTerraformAdoption =
|
||||
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true";
|
||||
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
const terminationProtection = envName !== "dev";
|
||||
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment,
|
||||
terminationProtection,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
},
|
||||
});
|
||||
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", envName);
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
|
||||
"watch": {
|
||||
"include": ["**"],
|
||||
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"]
|
||||
},
|
||||
"context": {
|
||||
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true,
|
||||
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
|
||||
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
|
||||
|
||||
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
|
||||
"domainNames": "dev.seahaven.com",
|
||||
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
|
||||
"hostedZoneId": "Z07671212N75U4YLPWZR8",
|
||||
"hostedZoneName": "dev.seahaven.com"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
import {
|
||||
Aspects,
|
||||
CfnCondition,
|
||||
CfnOutput,
|
||||
CfnParameter,
|
||||
CfnResource,
|
||||
Duration,
|
||||
Fn,
|
||||
RemovalPolicy,
|
||||
Stack,
|
||||
StackProps,
|
||||
Tags,
|
||||
} from "aws-cdk-lib";
|
||||
import { Construct } from "constructs";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
||||
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
||||
import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption";
|
||||
|
||||
export interface FrontendStackProps extends StackProps {
|
||||
/** Environment label, e.g. "dev". Used in names/tags. */
|
||||
readonly envName: string;
|
||||
/** GitHub repo in owner/name form, for OIDC trust scoping. */
|
||||
readonly githubRepo: string;
|
||||
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
||||
readonly deployBranch: string;
|
||||
/**
|
||||
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
|
||||
* trust uses the EXACT environment subject
|
||||
* `repo:<owner/name>:environment:<env>` (StringEquals) instead of the
|
||||
* deploy-branch ref match below. Unset = dev-style branch-ref trust.
|
||||
*/
|
||||
readonly githubEnvironment?: string;
|
||||
/**
|
||||
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
|
||||
* Empty = serve on the default *.cloudfront.net domain.
|
||||
*/
|
||||
readonly domainNames: string[];
|
||||
/**
|
||||
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
|
||||
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
|
||||
* use a certificate from another account, so for Option B the cert must live
|
||||
* in whichever account this stack deploys to.
|
||||
*/
|
||||
readonly certificateArn: string;
|
||||
/**
|
||||
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
|
||||
* record in. Empty = don't manage DNS (add the record manually). When set,
|
||||
* hostedZoneName must also be provided.
|
||||
*/
|
||||
readonly hostedZoneId: string;
|
||||
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
||||
readonly hostedZoneName: string;
|
||||
/**
|
||||
* Opt-in safety mode used only during the reviewed Terraform adoption.
|
||||
* Normal dev/staging synthesis remains unchanged when false.
|
||||
*/
|
||||
readonly retainForTerraformAdoption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static SPA hosting for the Sea Haven SHOC frontend:
|
||||
* - private S3 bucket (no public access; CloudFront reads it via OAC)
|
||||
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
||||
* - a GitHub Actions OIDC deploy role
|
||||
*
|
||||
* Content (the built `dist/`) is NOT uploaded here. Manual environment
|
||||
* workflows run `scripts/deploy-web.sh` independently of infrastructure
|
||||
* changes, so this stack only owns infrastructure and the deploy role carries
|
||||
* content-publication permissions.
|
||||
*/
|
||||
export class FrontendStack extends Stack {
|
||||
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
const {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment = "",
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption = false,
|
||||
} = props;
|
||||
|
||||
const manageSiteInfrastructureCondition = retainForTerraformAdoption
|
||||
? new CfnCondition(this, "ManageSiteInfrastructureCondition", {
|
||||
expression: Fn.conditionEquals(
|
||||
new CfnParameter(this, "ManageSiteInfrastructure", {
|
||||
type: "String",
|
||||
allowedValues: ["true", "false"],
|
||||
description:
|
||||
"Set true only before Terraform adoption. After ownership transfer, always reuse false.",
|
||||
}).valueAsString,
|
||||
"true",
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const hasCustomDomain = domainNames.length > 0;
|
||||
if (hasCustomDomain && !certificateArn) {
|
||||
throw new Error(
|
||||
"certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Origin bucket: private, encrypted, no public access ----------------
|
||||
const bucket = new s3.Bucket(this, "SiteBucket", {
|
||||
bucketName: `seahaven-shoc-frontend-${envName}`,
|
||||
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
||||
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
|
||||
encryption: s3.BucketEncryption.S3_MANAGED,
|
||||
enforceSSL: true,
|
||||
versioned: true,
|
||||
// dev artifacts are reproducible from the build — safe to tear down.
|
||||
removalPolicy: RemovalPolicy.DESTROY,
|
||||
autoDeleteObjects: true,
|
||||
});
|
||||
|
||||
// SPA client-side routing: rewrite extensionless paths (e.g. /work-orders)
|
||||
// to /index.html so deep links resolve. Done with a CloudFront Function
|
||||
// rather than customErrorResponses so real asset 404s stay 404s.
|
||||
const spaRewrite = new cloudfront.Function(this, "SpaRewrite", {
|
||||
comment: "SPA routing: rewrite extensionless paths to /index.html",
|
||||
code: cloudfront.FunctionCode.fromInline(
|
||||
[
|
||||
"function handler(event) {",
|
||||
" var request = event.request;",
|
||||
" var uri = request.uri;",
|
||||
" // No file extension after the last slash -> a client-side route.",
|
||||
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||
" request.uri = '/index.html';",
|
||||
" }",
|
||||
" return request;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
),
|
||||
});
|
||||
|
||||
// --- CloudFront: serves the static SPA from S3 -------------------------
|
||||
// The SPA calls the backend directly at its absolute HTTPS URL
|
||||
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
||||
// Adoption mode pins the origin ID CloudFormation generated for the live
|
||||
// distribution so the retention deploy is a metadata-only update. Only
|
||||
// environments with a read-back-verified value may enter adoption mode.
|
||||
const adoptionOriginIds: Record<string, string> = {
|
||||
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
};
|
||||
const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined;
|
||||
if (retainForTerraformAdoption && !originId) {
|
||||
throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`);
|
||||
}
|
||||
const distribution = new cloudfront.Distribution(this, "Distribution", {
|
||||
comment: `SeaHaven SHOC frontend (${envName})`,
|
||||
defaultRootObject: "index.html",
|
||||
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
|
||||
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
|
||||
// Option B: serve on the custom domain(s) with the ACM cert. When unset,
|
||||
// CloudFront uses its default *.cloudfront.net domain + certificate.
|
||||
domainNames: hasCustomDomain ? domainNames : undefined,
|
||||
certificate: hasCustomDomain
|
||||
? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn)
|
||||
: undefined,
|
||||
minimumProtocolVersion: hasCustomDomain
|
||||
? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021
|
||||
: undefined,
|
||||
defaultBehavior: {
|
||||
// withOriginAccessControl wires up OAC + the bucket policy automatically.
|
||||
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, {
|
||||
originId,
|
||||
}),
|
||||
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
||||
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
||||
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
||||
compress: true,
|
||||
functionAssociations: [
|
||||
{
|
||||
function: spaRewrite,
|
||||
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// --- GitHub Actions OIDC deploy role -----------------------------------
|
||||
// The OIDC provider is a singleton account-global resource, created once
|
||||
// out-of-band (see README step 2) — we only IMPORT it here so this stack's
|
||||
// lifecycle (including `cdk destroy`) never deletes a resource shared by
|
||||
// every role in the account.
|
||||
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
||||
this,
|
||||
"GitHubOidcProvider",
|
||||
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
|
||||
);
|
||||
|
||||
// Trust conditions for the OIDC principal. With a GitHub environment
|
||||
// (staging): exact StringEquals match on both aud and the environment
|
||||
// subject — the staging workflow declares `environment: staging`, so only
|
||||
// runs in that environment can assume the role. Normal dev synthesis keeps
|
||||
// the current branch-ref StringLike trust. The adoption prerequisite
|
||||
// narrows that already-exact value to StringEquals before Terraform import.
|
||||
const oidcConditions = githubEnvironment
|
||||
? {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
||||
},
|
||||
}
|
||||
: retainForTerraformAdoption
|
||||
? {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
|
||||
const deployPermissionsBoundary = retainForTerraformAdoption
|
||||
? iam.ManagedPolicy.fromManagedPolicyArn(
|
||||
this,
|
||||
"GithubDeployPermissionsBoundary",
|
||||
`arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||
maxSessionDuration: Duration.hours(1),
|
||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||
permissionsBoundary: deployPermissionsBoundary,
|
||||
});
|
||||
if (retainForTerraformAdoption) {
|
||||
Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`);
|
||||
}
|
||||
|
||||
// Preserve dev's legacy CDK capability until the reviewed adoption update
|
||||
// replaces this inline policy. Staging is intentionally narrower: its
|
||||
// content role only publishes application assets to this stack's
|
||||
// bucket/distribution. Infrastructure changes remain administrator-run.
|
||||
if (!githubEnvironment) {
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AssumeCdkBootstrapRoles",
|
||||
actions: ["sts:AssumeRole"],
|
||||
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
|
||||
}),
|
||||
);
|
||||
}
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "DescribeStack",
|
||||
actions: ["cloudformation:DescribeStacks"],
|
||||
resources: [
|
||||
`arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
bucket.grantReadWrite(deployRole);
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "InvalidateDistribution",
|
||||
actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
|
||||
resources: [
|
||||
`arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// --- DNS: point the custom domain at CloudFront ------------------------
|
||||
// Only when a hosted zone is supplied (it must be in THIS account). Creates
|
||||
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
|
||||
let aliasA: route53.ARecord | undefined;
|
||||
let aliasAaaa: route53.AaaaRecord | undefined;
|
||||
if (hostedZoneId && hasCustomDomain) {
|
||||
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
||||
hostedZoneId,
|
||||
zoneName: hostedZoneName,
|
||||
});
|
||||
const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
|
||||
// apex record when the domain equals the zone name.
|
||||
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
||||
|
||||
aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", {
|
||||
zone,
|
||||
recordName,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
const gateOutput = (output: CfnOutput): CfnOutput => {
|
||||
if (manageSiteInfrastructureCondition) {
|
||||
output.condition = manageSiteInfrastructureCondition;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// --- Outputs -----------------------------------------------------------
|
||||
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
|
||||
gateOutput(
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "Pinned GitHub OIDC content-deployment role",
|
||||
}),
|
||||
);
|
||||
|
||||
if (retainForTerraformAdoption) {
|
||||
const originAccessControl = distribution.node
|
||||
.findAll()
|
||||
.find(
|
||||
(node): node is cloudfront.CfnOriginAccessControl =>
|
||||
node instanceof cloudfront.CfnOriginAccessControl,
|
||||
);
|
||||
if (!originAccessControl || !aliasA || !aliasAaaa) {
|
||||
throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records.");
|
||||
}
|
||||
const originAccessControlConfig =
|
||||
originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty;
|
||||
|
||||
const rolePolicy = deployRole.node
|
||||
.findAll()
|
||||
.find((node): node is iam.Policy => node instanceof iam.Policy);
|
||||
const autoDeleteProviderRole = this.node
|
||||
.findAll()
|
||||
.find(
|
||||
(node): node is CfnResource =>
|
||||
node instanceof CfnResource &&
|
||||
node.cfnResourceType === "AWS::IAM::Role" &&
|
||||
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"),
|
||||
);
|
||||
const autoDeleteProviderHandler = this.node
|
||||
.findAll()
|
||||
.find(
|
||||
(node): node is CfnResource =>
|
||||
node instanceof CfnResource &&
|
||||
node.cfnResourceType === "AWS::Lambda::Function" &&
|
||||
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler"),
|
||||
);
|
||||
if (!rolePolicy || !autoDeleteProviderRole || !autoDeleteProviderHandler) {
|
||||
throw new Error("Terraform adoption outputs require deploy and auto-delete roles.");
|
||||
}
|
||||
// The provider Lambda stays unconditioned so it remains after
|
||||
// ManageSiteInfrastructure=false. Its generated Description Refs the
|
||||
// conditioned bucket and CloudFormation rejects that when the condition
|
||||
// is false. Keep a static description.
|
||||
autoDeleteProviderHandler.addPropertyOverride(
|
||||
"Description",
|
||||
"Lambda function for auto-deleting objects in the site S3 bucket.",
|
||||
);
|
||||
|
||||
const recordName = domainNames[0];
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformWorkspaceTag", {
|
||||
value: `shoc-frontend-new-${envName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployBoundaryArn", {
|
||||
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
}),
|
||||
);
|
||||
gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }));
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportBucketPolicy", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDistribution", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportOriginAccessControl", {
|
||||
value: originAccessControl.attrId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlName", {
|
||||
value: originAccessControlConfig.name,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
|
||||
value: "EMPTY_STRING",
|
||||
description: "Use an empty Terraform string because the generated OAC has no description",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDistributionOriginId", {
|
||||
value: originId!,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
|
||||
value: spaRewrite.functionName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasA", {
|
||||
value: `${hostedZoneId}_${recordName}_A`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasAAAA", {
|
||||
value: `${hostedZoneId}_${recordName}_AAAA`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRole", {
|
||||
value: deployRole.roleName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
|
||||
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
|
||||
value: rolePolicy.policyName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
|
||||
value: autoDeleteProviderRole.getAtt("Arn").toString(),
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
|
||||
value: "SiteBucket/AutoDeleteObjectsCustomResource",
|
||||
description:
|
||||
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
|
||||
}),
|
||||
);
|
||||
|
||||
Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
|
||||
import { IConstruct } from "constructs";
|
||||
|
||||
const TRANSFERRED_RESOURCE_TYPES = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
]);
|
||||
|
||||
function isTransferredResource(resource: CfnResource): boolean {
|
||||
if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
resource.cfnResourceType === "Custom::S3AutoDeleteObjects" &&
|
||||
resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(resource.cfnResourceType === "AWS::IAM::Role" ||
|
||||
resource.cfnResourceType === "AWS::IAM::Policy") &&
|
||||
resource.node.path.includes("/GithubDeployRole")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains only the resources in the approved Terraform transfer set.
|
||||
*
|
||||
* The bucket auto-delete custom resource is intentionally retained while the
|
||||
* generated provider Lambda, role, log group, and CDK metadata remain excluded.
|
||||
* When a management condition is supplied, those same resources share it so
|
||||
* CloudFormation can later relinquish them without deleting them.
|
||||
*/
|
||||
export class RetainForTerraformAdoption implements IAspect {
|
||||
constructor(private readonly manageCondition?: CfnCondition) {}
|
||||
|
||||
public visit(node: IConstruct): void {
|
||||
if (!(node instanceof CfnResource) || !isTransferredResource(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the L2 bucket's configured DESTROY policy visible to its
|
||||
// AutoDeleteObjects validator while overriding the emitted CloudFormation
|
||||
// resource. This preserves the custom resource and retains both together.
|
||||
if (node.cfnResourceType === "AWS::S3::Bucket") {
|
||||
node.addOverride("DeletionPolicy", "Retain");
|
||||
node.addOverride("UpdateReplacePolicy", "Retain");
|
||||
} else {
|
||||
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
|
||||
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
|
||||
}
|
||||
|
||||
if (this.manageCondition) {
|
||||
node.cfnOptions.condition = this.manageCondition;
|
||||
}
|
||||
}
|
||||
}
|
||||
514
infra/cdk/package-lock.json
generated
514
infra/cdk/package-lock.json
generated
|
|
@ -1,514 +0,0 @@
|
|||
{
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "^2.261.0",
|
||||
"constructs": "^10.4.2"
|
||||
},
|
||||
"bin": {
|
||||
"app": "bin/app.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"aws-cdk": "^2.1130.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-awscli-v1": {
|
||||
"version": "2.2.282",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
|
||||
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
|
||||
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema": {
|
||||
"version": "54.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz",
|
||||
"integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==",
|
||||
"bundleDependencies": [
|
||||
"jsonschema",
|
||||
"semver"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1130.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz",
|
||||
"integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"cdk": "bin/cdk"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib": {
|
||||
"version": "2.261.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz",
|
||||
"integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==",
|
||||
"bundleDependencies": [
|
||||
"@balena/dockerignore",
|
||||
"@aws-cdk/cloud-assembly-api",
|
||||
"case",
|
||||
"fs-extra",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"minimatch",
|
||||
"punycode",
|
||||
"semver",
|
||||
"yaml",
|
||||
"mime-types"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-cdk/asset-awscli-v1": "2.2.282",
|
||||
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
|
||||
"@aws-cdk/cloud-assembly-api": "^2.2.5",
|
||||
"@aws-cdk/cloud-assembly-schema": "^54.0.0",
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"case": "1.6.3",
|
||||
"fs-extra": "^11.3.5",
|
||||
"ignore": "^5.3.2",
|
||||
"jsonschema": "^1.5.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"minimatch": "^10.2.5",
|
||||
"punycode": "^2.3.1",
|
||||
"semver": "^7.8.1",
|
||||
"yaml": "1.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"constructs": "^10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
|
||||
"version": "2.2.5",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-cdk/cloud-assembly-schema": ">=53.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
|
||||
"version": "1.0.2",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/case": {
|
||||
"version": "1.6.3",
|
||||
"inBundle": true,
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
|
||||
"version": "11.3.5",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"inBundle": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"inBundle": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/yaml": {
|
||||
"version": "1.10.3",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/constructs": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
|
||||
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.",
|
||||
"bin": {
|
||||
"app": "bin/app.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "npm run build && node --test test/*.test.mjs",
|
||||
"synth": "cdk synth",
|
||||
"synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true",
|
||||
"diff": "cdk diff",
|
||||
"deploy": "cdk deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"aws-cdk": "^2.1130.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "^2.261.0",
|
||||
"constructs": "^10.4.2"
|
||||
},
|
||||
"packageManager": "npm@11.16.0"
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { test } from "node:test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { App } = require("aws-cdk-lib");
|
||||
const { Template } = require("aws-cdk-lib/assertions");
|
||||
const { FrontendStack } = require("../lib/frontend-stack.js");
|
||||
|
||||
const account = "396287094661";
|
||||
const region = "us-east-1";
|
||||
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
|
||||
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
|
||||
const CONDITION = "ManageSiteInfrastructureCondition";
|
||||
|
||||
const RETAINED_TYPES = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
"Custom::S3AutoDeleteObjects",
|
||||
]);
|
||||
|
||||
function devTemplate(retainForTerraformAdoption, overrides = {}) {
|
||||
const app = new App();
|
||||
const stack = new FrontendStack(app, "shoc-frontend-dev", {
|
||||
envName: "dev",
|
||||
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||
deployBranch: "dev",
|
||||
domainNames: ["dev.seahaven.com"],
|
||||
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
|
||||
hostedZoneId: "Z07671212N75U4YLPWZR8",
|
||||
hostedZoneName: "dev.seahaven.com",
|
||||
retainForTerraformAdoption,
|
||||
env: { account, region },
|
||||
...overrides,
|
||||
});
|
||||
return Template.fromStack(stack).toJSON();
|
||||
}
|
||||
|
||||
function entriesByType(template, type) {
|
||||
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
|
||||
}
|
||||
|
||||
function isTransferred(logicalId, resource) {
|
||||
const isDeployRoleResource =
|
||||
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
|
||||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
|
||||
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
|
||||
}
|
||||
|
||||
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
|
||||
const template = devTemplate(true);
|
||||
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
|
||||
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
|
||||
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
|
||||
isTransferred(id, resource),
|
||||
);
|
||||
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
|
||||
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
|
||||
// addresses) plus the retained custom resource.
|
||||
assert.equal(transferred.length, 10);
|
||||
});
|
||||
|
||||
test("adoption mode preserves the live dev identifiers", () => {
|
||||
const template = devTemplate(true);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
|
||||
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
|
||||
assert.ok(
|
||||
bucket.Properties.Tags.some(
|
||||
(tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true",
|
||||
),
|
||||
);
|
||||
|
||||
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
|
||||
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
|
||||
assert.equal(
|
||||
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
|
||||
DEV_ORIGIN_ID,
|
||||
);
|
||||
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(
|
||||
deployRole.Properties.PermissionsBoundary,
|
||||
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
|
||||
);
|
||||
assert.ok(
|
||||
deployRole.Properties.Tags.some(
|
||||
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
|
||||
),
|
||||
);
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringEquals["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(condition.StringLike, undefined);
|
||||
|
||||
// Legacy inline policy stays byte-compatible with the live document.
|
||||
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
|
||||
id.startsWith("GithubDeployRole"),
|
||||
);
|
||||
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
|
||||
assert.deepEqual(sids, [
|
||||
"AssumeCdkBootstrapRoles",
|
||||
"DescribeStack",
|
||||
undefined,
|
||||
"InvalidateDistribution",
|
||||
]);
|
||||
|
||||
for (const output of [
|
||||
"TerraformWorkspaceTag",
|
||||
"TerraformDeployBoundaryArn",
|
||||
"TerraformImportBucket",
|
||||
"TerraformImportBucketPolicy",
|
||||
"TerraformImportDistribution",
|
||||
"TerraformImportOriginAccessControl",
|
||||
"TerraformOriginAccessControlName",
|
||||
"TerraformOriginAccessControlDescription",
|
||||
"TerraformDistributionOriginId",
|
||||
"TerraformImportSpaRewriteFunction",
|
||||
"TerraformImportAliasA",
|
||||
"TerraformImportAliasAAAA",
|
||||
"TerraformImportDeployRole",
|
||||
"TerraformImportDeployRolePolicy",
|
||||
"TerraformDeployInlinePolicyName",
|
||||
"TerraformBucketAutoDeleteHelperRoleArn",
|
||||
"TerraformRetainedAutoDeleteCustomResource",
|
||||
]) {
|
||||
assert.ok(template.Outputs[output], `missing output ${output}`);
|
||||
}
|
||||
assert.equal(
|
||||
template.Outputs.TerraformImportAliasA.Value,
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
|
||||
);
|
||||
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
|
||||
});
|
||||
|
||||
test("adoption mode retains exactly the transferred resources", () => {
|
||||
const template = devTemplate(true);
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
|
||||
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
} else {
|
||||
assert.notEqual(resource.DeletionPolicy, "Retain", logicalId);
|
||||
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
}
|
||||
}
|
||||
// The auto-delete provider Lambda, role, and log group stay unretained.
|
||||
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
|
||||
for (const [, resource] of entriesByType(template, type)) {
|
||||
assert.notEqual(resource.DeletionPolicy, "Retain");
|
||||
}
|
||||
}
|
||||
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
|
||||
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
|
||||
);
|
||||
assert.equal(providerRoles.length, 1);
|
||||
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
|
||||
});
|
||||
|
||||
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
|
||||
const template = devTemplate(true);
|
||||
const parameter = template.Parameters.ManageSiteInfrastructure;
|
||||
assert.ok(parameter);
|
||||
assert.equal(parameter.Type, "String");
|
||||
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
|
||||
assert.equal(parameter.Default, undefined);
|
||||
assert.ok(template.Conditions[CONDITION]);
|
||||
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.Condition, CONDITION, logicalId);
|
||||
} else {
|
||||
assert.notEqual(resource.Condition, CONDITION, logicalId);
|
||||
}
|
||||
}
|
||||
for (const [outputName, output] of Object.entries(template.Outputs)) {
|
||||
assert.equal(output.Condition, CONDITION, outputName);
|
||||
}
|
||||
|
||||
const [, autoDeleteHandler] = entriesByType(template, "AWS::Lambda::Function")[0];
|
||||
assert.equal(
|
||||
autoDeleteHandler.Properties.Description,
|
||||
"Lambda function for auto-deleting objects in the site S3 bucket.",
|
||||
);
|
||||
assert.equal(typeof autoDeleteHandler.Properties.Description, "string");
|
||||
});
|
||||
|
||||
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
|
||||
const template = devTemplate(false);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.DeletionPolicy, "Delete");
|
||||
assert.equal(bucket.UpdateReplacePolicy, "Delete");
|
||||
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
|
||||
assert.notEqual(customResource.DeletionPolicy, "Retain");
|
||||
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
|
||||
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringLike["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
|
||||
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
|
||||
assert.equal(template.Conditions?.[CONDITION], undefined);
|
||||
for (const resource of Object.values(template.Resources)) {
|
||||
assert.equal(resource.Condition, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("adoption mode refuses an environment without a verified origin ID", () => {
|
||||
assert.throws(
|
||||
() => devTemplate(true, { envName: "staging" }),
|
||||
/No verified Terraform adoption origin ID exists for staging/,
|
||||
);
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["bin/**/*.ts", "lib/**/*.ts"],
|
||||
"exclude": ["node_modules", "cdk.out"]
|
||||
}
|
||||
|
|
@ -13,9 +13,12 @@
|
|||
"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:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption",
|
||||
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
|
||||
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
|
||||
"test:github-workflows": "bash scripts/check-github-workflows.sh",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
|
|
|
|||
50
scripts/check-github-workflows.sh
Executable file
50
scripts/check-github-workflows.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
# bash -n every shell script and every workflow `run:` block. actionlint when present.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
for script in scripts/*.sh; do
|
||||
bash -n "${script}"
|
||||
done
|
||||
|
||||
python3 - "${ROOT}" << 'PY'
|
||||
import pathlib, re, subprocess, sys, tempfile
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
failures = 0
|
||||
workflow_count = 0
|
||||
block_count = 0
|
||||
for workflow in sorted((root / ".github/workflows").glob("*.yml")) + sorted(
|
||||
(root / ".github/workflows").glob("*.yaml")
|
||||
):
|
||||
workflow_count += 1
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
blocks = []
|
||||
for match in re.finditer(r"^(\s+)run:\s*\|[^\n]*\n((?:\1 .*\n)+)", text, re.M):
|
||||
indent = len(match.group(1)) + 2
|
||||
body = []
|
||||
for line in match.group(2).splitlines():
|
||||
body.append(line[indent:] if len(line) >= indent else line.lstrip())
|
||||
blocks.append("\n".join(body) + "\n")
|
||||
block_count += len(blocks)
|
||||
for index, block in enumerate(blocks, start=1):
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle:
|
||||
handle.write(block)
|
||||
name = handle.name
|
||||
result = subprocess.run(["bash", "-n", name], capture_output=True, text=True)
|
||||
pathlib.Path(name).unlink()
|
||||
if result.returncode != 0:
|
||||
failures += 1
|
||||
sys.stderr.write(f"{workflow.relative_to(root)} run block {index}: {result.stderr}")
|
||||
if failures:
|
||||
raise SystemExit(1)
|
||||
print(
|
||||
f"bash -n passed for scripts and {block_count} run blocks in {workflow_count} workflows"
|
||||
)
|
||||
PY
|
||||
|
||||
if command -v actionlint >/dev/null 2>&1; then
|
||||
actionlint -color
|
||||
else
|
||||
echo "actionlint not installed; skipped (CI installs it)"
|
||||
fi
|
||||
0
scripts/check-terraform-import-plan.py
Normal file → Executable file
0
scripts/check-terraform-import-plan.py
Normal file → Executable file
|
|
@ -45,6 +45,14 @@ export function mayAccompanyTerraform(file) {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,12 +37,21 @@ test("terraform tree, docs, and terraform tooling may accompany a Terraform chan
|
|||
"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, CDK, and dependency files count as application changes", () => {
|
||||
test("application, workflow, and dependency files count as application changes", () => {
|
||||
for (const file of [
|
||||
"src/App.tsx",
|
||||
"public/favicon.ico",
|
||||
|
|
@ -52,7 +61,6 @@ test("application, workflow, CDK, and dependency files count as application chan
|
|||
".env.production",
|
||||
"vite.config.ts",
|
||||
".github/workflows/deploy.yml",
|
||||
"infra/cdk/lib/frontend-stack.ts",
|
||||
"scripts/deploy-web.sh",
|
||||
"scripts/governance-check.mjs",
|
||||
"e2e/login.spec.ts",
|
||||
|
|
|
|||
521
scripts/check-terraform-release-plan.py
Executable file
521
scripts/check-terraform-release-plan.py
Executable file
|
|
@ -0,0 +1,521 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject HCP Terraform plans that are not a frontend content-release update.
|
||||
|
||||
Accepts exactly:
|
||||
- an update of the release pointer (content, plus computed etag/version_id)
|
||||
- an update of the distribution with only origin[*].origin_path changed
|
||||
- exactly one action invocation for the CloudFront invalidation
|
||||
|
||||
after origin_path values must match the expected labels. before origin_path
|
||||
values must match the pointer's prior current/previous. This script may read a
|
||||
local plan JSON file or download plan JSON from the documented HashiCorp
|
||||
endpoint:
|
||||
|
||||
GET https://app.terraform.io/api/v2/plans/:id/json-output
|
||||
|
||||
The download follows exactly one redirect, and only to archivist.terraform.io.
|
||||
It does not create, apply, discard, or poll runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer"
|
||||
DISTRIBUTION_ADDRESS = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
ACTION_ADDRESS = (
|
||||
"module.environment_owned.action.aws_cloudfront_create_invalidation.release"
|
||||
)
|
||||
API_HOST = "app.terraform.io"
|
||||
ARCHIVE_HOST = "archivist.terraform.io"
|
||||
PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$")
|
||||
VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$")
|
||||
IGNORED_ACTIONS = {"no-op", "read"}
|
||||
UNSAFE_ACTIONS = {"create", "delete"}
|
||||
POINTER_UNKNOWN_ATTRIBUTES = frozenset({"etag", "version_id"})
|
||||
DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset(
|
||||
{
|
||||
"etag",
|
||||
"last_modified_time",
|
||||
"status",
|
||||
"in_progress_validation_batches",
|
||||
}
|
||||
)
|
||||
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
||||
|
||||
UrlOpen = Callable[..., Any]
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Return the redirect response instead of following it."""
|
||||
|
||||
def http_error_301(self, req, fp, code, msg, headers):
|
||||
return self._capture(req, fp, code, headers)
|
||||
|
||||
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
|
||||
|
||||
@staticmethod
|
||||
def _capture(req, fp, code, headers):
|
||||
response = urllib.response.addinfourl(fp, headers, req.full_url, code=code)
|
||||
response.msg = "Redirect"
|
||||
return response
|
||||
|
||||
|
||||
def _urlopen_without_redirects(
|
||||
*handlers: urllib.request.BaseHandler,
|
||||
) -> UrlOpen:
|
||||
context = ssl.create_default_context()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=context),
|
||||
_NoRedirectHandler,
|
||||
*handlers,
|
||||
)
|
||||
return opener.open
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument(
|
||||
"plan_json",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
help="Local Terraform plan JSON. Mutually exclusive with --plan-id.",
|
||||
)
|
||||
source.add_argument(
|
||||
"--plan-id",
|
||||
help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-version-label",
|
||||
required=True,
|
||||
help="Immutable current release the plan must apply. Empty string is the legacy root.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-previous-version-label",
|
||||
default="",
|
||||
help="Previous release label the origin group must fail over to.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evidence-out",
|
||||
type=Path,
|
||||
help="Write machine-readable proof after every assertion passes.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def download_plan_json(
|
||||
plan_id: str,
|
||||
token: str,
|
||||
*,
|
||||
urlopen: UrlOpen | None = None,
|
||||
handlers: tuple[urllib.request.BaseHandler, ...] = (),
|
||||
) -> dict[str, Any]:
|
||||
if not PLAN_ID_RE.fullmatch(plan_id):
|
||||
raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id")
|
||||
if not token:
|
||||
raise ValueError("TF_API_TOKEN is required to download plan JSON")
|
||||
|
||||
opener = urlopen or _urlopen_without_redirects(*handlers)
|
||||
api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output"
|
||||
request = urllib.request.Request(
|
||||
api_url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
first = _open_pinned(opener, request, allowed_host=API_HOST)
|
||||
try:
|
||||
if first.status == 204:
|
||||
raise ValueError(
|
||||
"plan JSON is not ready; refusing to poll the plans endpoint"
|
||||
)
|
||||
if first.status not in REDIRECT_STATUSES:
|
||||
raise ValueError(
|
||||
f"expected a redirect from {API_HOST}, got HTTP {first.status}"
|
||||
)
|
||||
location = first.headers.get("Location")
|
||||
if not location:
|
||||
raise ValueError(f"{API_HOST} redirect is missing a Location header")
|
||||
archive = urlparse(location)
|
||||
if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST:
|
||||
raise ValueError(
|
||||
"refusing redirect that is not https://"
|
||||
f"{ARCHIVE_HOST}/"
|
||||
)
|
||||
archive_request = urllib.request.Request(location, method="GET")
|
||||
second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST)
|
||||
try:
|
||||
if second.status in REDIRECT_STATUSES:
|
||||
raise ValueError(
|
||||
f"refusing a second redirect from {ARCHIVE_HOST}"
|
||||
)
|
||||
if second.status != 200:
|
||||
raise ValueError(
|
||||
f"plan JSON download from {ARCHIVE_HOST} returned "
|
||||
f"HTTP {second.status}"
|
||||
)
|
||||
payload = second.read()
|
||||
finally:
|
||||
second.close()
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
plan = json.loads(payload.decode("utf-8"))
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("plan JSON must be an object")
|
||||
return plan
|
||||
|
||||
|
||||
def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str):
|
||||
parsed = urlparse(request.full_url)
|
||||
if parsed.scheme != "https" or parsed.hostname != allowed_host:
|
||||
raise ValueError(
|
||||
f"refusing to contact {parsed.scheme}://{parsed.hostname} "
|
||||
f"(pinned host is {allowed_host})"
|
||||
)
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
return urlopen(request, context=context, timeout=30)
|
||||
except TypeError:
|
||||
return urlopen(request, timeout=30)
|
||||
|
||||
|
||||
def _is_nested_unknown(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def changed_attributes(
|
||||
change: dict[str, Any],
|
||||
*,
|
||||
computed_unknown: frozenset[str],
|
||||
) -> set[str]:
|
||||
before = change.get("before") or {}
|
||||
after = change.get("after") or {}
|
||||
unknown = change.get("after_unknown") or {}
|
||||
keys = set(before) | set(after) | set(unknown)
|
||||
changed: set[str] = set()
|
||||
for key in keys:
|
||||
unknown_value = unknown.get(key)
|
||||
if unknown_value is True:
|
||||
if key in computed_unknown:
|
||||
continue
|
||||
changed.add(key)
|
||||
continue
|
||||
if _is_nested_unknown(unknown_value):
|
||||
changed.add(key)
|
||||
continue
|
||||
if before.get(key) != after.get(key):
|
||||
changed.add(key)
|
||||
return changed
|
||||
|
||||
|
||||
def _label_ok(label: str) -> bool:
|
||||
return label == "" or bool(VERSION_LABEL_RE.fullmatch(label))
|
||||
|
||||
|
||||
def origin_path_for_label(label: str) -> str:
|
||||
return "" if label == "" else f"/releases/{label}"
|
||||
|
||||
|
||||
def _origin_map(origins: Any) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(origins, list):
|
||||
return {}
|
||||
mapped: dict[str, dict[str, Any]] = {}
|
||||
for origin in origins:
|
||||
if not isinstance(origin, dict):
|
||||
continue
|
||||
origin_id = origin.get("origin_id")
|
||||
if not isinstance(origin_id, str) or not origin_id:
|
||||
continue
|
||||
mapped[origin_id] = origin
|
||||
return mapped
|
||||
|
||||
|
||||
def _origin_paths(origins: Any) -> dict[str, str]:
|
||||
return {
|
||||
origin_id: origin.get("origin_path") or ""
|
||||
for origin_id, origin in _origin_map(origins).items()
|
||||
}
|
||||
|
||||
|
||||
def _decode_pointer(content: Any) -> dict[str, str]:
|
||||
if not isinstance(content, str) or not content:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
return {
|
||||
"current": payload.get("current") or "",
|
||||
"previous": payload.get("previous") or "",
|
||||
}
|
||||
|
||||
|
||||
def _validate_pointer(
|
||||
resource: dict[str, Any],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
change = resource.get("change") or {}
|
||||
changed = changed_attributes(change, computed_unknown=POINTER_UNKNOWN_ATTRIBUTES)
|
||||
if changed != {"content"}:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: expected only content to change, found "
|
||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
||||
)
|
||||
after = _decode_pointer((change.get("after") or {}).get("content"))
|
||||
if after.get("current") != expected_current:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: after current {after.get('current')!r} does not match "
|
||||
f"{expected_current!r}"
|
||||
)
|
||||
if after.get("previous") != expected_previous:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: after previous {after.get('previous')!r} does not match "
|
||||
f"{expected_previous!r}"
|
||||
)
|
||||
unknown = change.get("after_unknown") or {}
|
||||
if unknown.get("content") is True:
|
||||
violations.append(f"{POINTER_ADDRESS}: content after value is unknown")
|
||||
return violations
|
||||
|
||||
|
||||
def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool:
|
||||
before_rest = {key: value for key, value in before.items() if key != "origin_path"}
|
||||
after_rest = {key: value for key, value in after.items() if key != "origin_path"}
|
||||
return before_rest != after_rest
|
||||
|
||||
|
||||
def _validate_distribution(
|
||||
resource: dict[str, Any],
|
||||
pointer_before: dict[str, str],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
change = resource.get("change") or {}
|
||||
changed = changed_attributes(
|
||||
change, computed_unknown=DISTRIBUTION_UNKNOWN_ATTRIBUTES
|
||||
)
|
||||
if changed != {"origin"}:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: expected only origin to change, found "
|
||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
||||
)
|
||||
return violations
|
||||
|
||||
before_origins = _origin_map((change.get("before") or {}).get("origin"))
|
||||
after_origins = _origin_map((change.get("after") or {}).get("origin"))
|
||||
if set(before_origins) != set(after_origins):
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: origin IDs changed "
|
||||
f"from {sorted(before_origins)} to {sorted(after_origins)}"
|
||||
)
|
||||
return violations
|
||||
|
||||
for origin_id, before_origin in before_origins.items():
|
||||
if _origin_non_path_fields_changed(before_origin, after_origins[origin_id]):
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: origin {origin_id!r} changed a field other than origin_path"
|
||||
)
|
||||
|
||||
after_paths = sorted(_origin_paths((change.get("after") or {}).get("origin")).values())
|
||||
expected_after = sorted(
|
||||
[
|
||||
origin_path_for_label(expected_current),
|
||||
origin_path_for_label(expected_previous),
|
||||
]
|
||||
)
|
||||
if after_paths != expected_after:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: after origin_path {after_paths} does not match "
|
||||
f"{expected_after}"
|
||||
)
|
||||
|
||||
before_paths = sorted(_origin_paths((change.get("before") or {}).get("origin")).values())
|
||||
expected_before = sorted(
|
||||
[
|
||||
origin_path_for_label(pointer_before.get("current", "")),
|
||||
origin_path_for_label(pointer_before.get("previous", "")),
|
||||
]
|
||||
)
|
||||
if before_paths != expected_before:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: before origin_path {before_paths} does not match "
|
||||
f"pointer prior values {expected_before}"
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_actions(plan: dict[str, Any]) -> list[str]:
|
||||
invocations = plan.get("action_invocations")
|
||||
if invocations is None:
|
||||
return ["plan is missing action_invocations"]
|
||||
if not isinstance(invocations, list):
|
||||
return ["action_invocations must be a list"]
|
||||
addresses = [
|
||||
item.get("address")
|
||||
for item in invocations
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
if addresses != [ACTION_ADDRESS]:
|
||||
return [
|
||||
"expected exactly one action_invocations entry "
|
||||
f"{ACTION_ADDRESS}, found {addresses}"
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def validate_plan(
|
||||
plan: dict[str, Any],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
if not _label_ok(expected_current):
|
||||
violations.append(
|
||||
"expected version label must be empty or <full-sha>-<run-id>-<attempt>"
|
||||
)
|
||||
return violations
|
||||
if not _label_ok(expected_previous):
|
||||
violations.append(
|
||||
"expected previous version label must be empty or <full-sha>-<run-id>-<attempt>"
|
||||
)
|
||||
return violations
|
||||
|
||||
updates: dict[str, dict[str, Any]] = {}
|
||||
for resource in plan.get("resource_changes", []):
|
||||
if resource.get("mode", "managed") != "managed":
|
||||
continue
|
||||
address = resource.get("address", "<unknown>")
|
||||
change = resource.get("change") or {}
|
||||
actions = list(change.get("actions") or [])
|
||||
action_set = set(actions)
|
||||
if action_set <= IGNORED_ACTIONS:
|
||||
continue
|
||||
|
||||
if change.get("importing"):
|
||||
violations.append(f"{address}: import actions are not allowed")
|
||||
|
||||
unsafe = sorted(action_set & UNSAFE_ACTIONS)
|
||||
if unsafe:
|
||||
violations.append(f"{address}: unsafe actions {unsafe}")
|
||||
if "replace" in action_set or actions in (
|
||||
["delete", "create"],
|
||||
["create", "delete"],
|
||||
):
|
||||
violations.append(f"{address}: replacement is not allowed")
|
||||
|
||||
if "update" in action_set:
|
||||
updates[address] = resource
|
||||
if action_set != {"update"}:
|
||||
violations.append(
|
||||
f"{address}: update must be the only action, got {actions}"
|
||||
)
|
||||
|
||||
if address not in {POINTER_ADDRESS, DISTRIBUTION_ADDRESS} and (
|
||||
action_set - IGNORED_ACTIONS
|
||||
):
|
||||
violations.append(
|
||||
f"{address}: managed address is outside the content-release update"
|
||||
)
|
||||
|
||||
if set(updates) != {POINTER_ADDRESS, DISTRIBUTION_ADDRESS}:
|
||||
violations.append(
|
||||
"expected exactly the pointer and distribution updates, found "
|
||||
f"{sorted(updates)}"
|
||||
)
|
||||
violations.extend(_validate_actions(plan))
|
||||
return violations
|
||||
|
||||
pointer_change = updates[POINTER_ADDRESS].get("change") or {}
|
||||
pointer_before = _decode_pointer((pointer_change.get("before") or {}).get("content"))
|
||||
violations.extend(
|
||||
_validate_pointer(updates[POINTER_ADDRESS], expected_current, expected_previous)
|
||||
)
|
||||
violations.extend(
|
||||
_validate_distribution(
|
||||
updates[DISTRIBUTION_ADDRESS],
|
||||
pointer_before,
|
||||
expected_current,
|
||||
expected_previous,
|
||||
)
|
||||
)
|
||||
violations.extend(_validate_actions(plan))
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.plan_id:
|
||||
try:
|
||||
plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", ""))
|
||||
except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
|
||||
print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
if args.plan_json is None:
|
||||
print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr)
|
||||
return 1
|
||||
plan = json.loads(args.plan_json.read_text(encoding="utf-8"))
|
||||
|
||||
violations = validate_plan(
|
||||
plan,
|
||||
args.expected_version_label,
|
||||
args.expected_previous_version_label,
|
||||
)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not a content-release update", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.evidence_out:
|
||||
evidence = {
|
||||
"pointer_address": POINTER_ADDRESS,
|
||||
"distribution_address": DISTRIBUTION_ADDRESS,
|
||||
"action_address": ACTION_ADDRESS,
|
||||
"expected_version_label": args.expected_version_label,
|
||||
"expected_previous_version_label": args.expected_previous_version_label,
|
||||
"managed_updates": 2,
|
||||
"action_invocations": 1,
|
||||
"creates": 0,
|
||||
"deletes": 0,
|
||||
"replacements": 0,
|
||||
}
|
||||
args.evidence_out.write_text(
|
||||
json.dumps(evidence, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
"PASS: content-release plan updates "
|
||||
f"{POINTER_ADDRESS} and {DISTRIBUTION_ADDRESS} to "
|
||||
f"{args.expected_version_label} (previous {args.expected_previous_version_label!r})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -21,9 +21,12 @@ 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"],
|
||||
["CDK build, tests, and synth", "test:infra"],
|
||||
["HCP run guard", "test:hcp-run-guard"],
|
||||
["CloudFront release verify", "test:cloudfront-release-verify"],
|
||||
["GitHub workflow shell", "test:github-workflows"],
|
||||
];
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
|
|
|
|||
207
scripts/hcp-run-guard.py
Executable file
207
scripts/hcp-run-guard.py
Executable file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Guard HCP Terraform runs used by GitHub content CD.
|
||||
|
||||
Subcommands:
|
||||
check-and-discard Refuse unsafe workspace settings. Discard a blocking
|
||||
non-speculative VCS run so GitHub CD can create-run.
|
||||
reconcile-apply Treat an HCP run whose status is already ``applied`` as
|
||||
success when the GitHub apply-run step reported failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
API = "https://app.terraform.io/api/v2"
|
||||
DEFAULT_WORKSPACE = "shoc-frontend-new-dev"
|
||||
EXPECTED_TRIGGER_PATTERNS = [
|
||||
"terraform/live/dev/**",
|
||||
"terraform/live/modules/**",
|
||||
]
|
||||
DISCARDABLE = {
|
||||
"pending",
|
||||
"planned",
|
||||
"cost_estimated",
|
||||
"policy_checked",
|
||||
"policy_override",
|
||||
}
|
||||
APPLYING = {"applying", "apply_queued"}
|
||||
|
||||
HttpGet = Callable[[str], dict[str, Any]]
|
||||
HttpPost = Callable[[str, dict[str, Any]], int]
|
||||
|
||||
|
||||
class GuardError(Exception):
|
||||
"""Refused to continue."""
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
}
|
||||
|
||||
|
||||
def default_get(token: str) -> HttpGet:
|
||||
def get(url: str) -> dict[str, Any]:
|
||||
request = urllib.request.Request(url, headers=_headers(token))
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
return get
|
||||
|
||||
|
||||
def default_post(token: str) -> HttpPost:
|
||||
def post(url: str, payload: dict[str, Any]) -> int:
|
||||
data = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(
|
||||
url, data=data, method="POST", headers=_headers(token)
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return int(response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (409, 404):
|
||||
body = exc.read().decode("utf-8", "replace")
|
||||
print(f"discard returned HTTP {exc.code}: {body}")
|
||||
return exc.code
|
||||
raise
|
||||
|
||||
return post
|
||||
|
||||
|
||||
def require_token(token: str) -> str:
|
||||
if not token:
|
||||
raise GuardError("TF_API_TOKEN is required")
|
||||
return token
|
||||
|
||||
|
||||
def check_invariants(attrs: dict[str, Any], workspace: str) -> None:
|
||||
if attrs.get("auto-apply") is True:
|
||||
raise GuardError(f"{workspace} auto-apply is on; refuse to continue")
|
||||
if not attrs.get("speculative-enabled"):
|
||||
raise GuardError("speculative plans are off; refuse to continue")
|
||||
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
|
||||
raise GuardError("tag-based VCS triggering is set; refuse to continue")
|
||||
if attrs.get("trigger-patterns") != EXPECTED_TRIGGER_PATTERNS:
|
||||
raise GuardError(
|
||||
"trigger-patterns must be "
|
||||
f"{EXPECTED_TRIGGER_PATTERNS}; got {attrs.get('trigger-patterns')}"
|
||||
)
|
||||
|
||||
|
||||
def check_and_discard(
|
||||
*,
|
||||
workspace: str,
|
||||
token: str,
|
||||
get: HttpGet | None = None,
|
||||
post: HttpPost | None = None,
|
||||
) -> int:
|
||||
token = require_token(token)
|
||||
get = get or default_get(token)
|
||||
post = post or default_post(token)
|
||||
workspace_payload = get(
|
||||
f"{API}/organizations/seahaven/workspaces/{workspace}"
|
||||
)["data"]
|
||||
attrs = workspace_payload["attributes"]
|
||||
check_invariants(attrs, workspace)
|
||||
if not attrs.get("locked"):
|
||||
print("workspace is unlocked")
|
||||
return 0
|
||||
|
||||
current = (
|
||||
workspace_payload.get("relationships", {})
|
||||
.get("current-run", {})
|
||||
.get("data")
|
||||
)
|
||||
if not current:
|
||||
raise GuardError("workspace is locked without a current run")
|
||||
run_id = current["id"]
|
||||
run = get(f"{API}/runs/{run_id}")["data"]
|
||||
run_attrs = run["attributes"]
|
||||
status = run_attrs.get("status")
|
||||
plan_only = run_attrs.get("plan-only")
|
||||
print(f"current run {run_id} status={status} plan-only={plan_only}")
|
||||
if plan_only:
|
||||
print("speculative run does not block GitHub CD")
|
||||
return 0
|
||||
if status in APPLYING:
|
||||
raise GuardError(f"{run_id} is {status}; wait, do not discard an apply")
|
||||
if status not in DISCARDABLE:
|
||||
raise GuardError(f"{run_id} status {status} is not discardable")
|
||||
code = post(
|
||||
f"{API}/runs/{run_id}/actions/discard",
|
||||
{
|
||||
"comment": (
|
||||
"Discarded so GitHub CD can create the content-release applyable run"
|
||||
)
|
||||
},
|
||||
)
|
||||
print(f"discarded {run_id} http={code}")
|
||||
return 0
|
||||
|
||||
|
||||
def reconcile_apply(
|
||||
*,
|
||||
run_id: str,
|
||||
apply_outcome: str,
|
||||
token: str,
|
||||
get: HttpGet | None = None,
|
||||
) -> int:
|
||||
token = require_token(token)
|
||||
if not run_id:
|
||||
raise GuardError("run id is required")
|
||||
if apply_outcome == "success":
|
||||
print("Apply succeeded.")
|
||||
return 0
|
||||
get = get or default_get(token)
|
||||
status = get(f"{API}/runs/{run_id}")["data"]["attributes"]["status"]
|
||||
print(f"HCP run {run_id} status={status}")
|
||||
if status == "applied":
|
||||
return 0
|
||||
raise GuardError(
|
||||
f"Apply failed: GitHub outcome={apply_outcome} HCP status={status}"
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
check = sub.add_parser("check-and-discard")
|
||||
check.add_argument("--workspace", default=DEFAULT_WORKSPACE)
|
||||
check.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
|
||||
|
||||
reconcile = sub.add_parser("reconcile-apply")
|
||||
reconcile.add_argument("--run-id", required=True)
|
||||
reconcile.add_argument(
|
||||
"--apply-outcome",
|
||||
default=os.environ.get("APPLY_OUTCOME", ""),
|
||||
)
|
||||
reconcile.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
if args.command == "check-and-discard":
|
||||
return check_and_discard(workspace=args.workspace, token=args.token)
|
||||
return reconcile_apply(
|
||||
run_id=args.run_id,
|
||||
apply_outcome=args.apply_outcome,
|
||||
token=args.token,
|
||||
)
|
||||
except GuardError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
29
scripts/read-release-pointer.py
Executable file
29
scripts/read-release-pointer.py
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read .release/current JSON from stdin and write GitHub Actions outputs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = sys.stdin.read().strip()
|
||||
data = json.loads(raw) if raw else {}
|
||||
current = data.get("current") or ""
|
||||
previous = data.get("previous") or ""
|
||||
output_path = os.environ["GITHUB_OUTPUT"]
|
||||
with open(output_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(f"live_current={current}\n")
|
||||
handle.write(f"live_previous={previous}\n")
|
||||
print(
|
||||
"Pointer live current="
|
||||
+ (current or "<empty>")
|
||||
+ " previous="
|
||||
+ (previous or "<empty>")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
23
scripts/summarize-cloudfront-live-state.sh
Executable file
23
scripts/summarize-cloudfront-live-state.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
# Print pointer body, origin paths, distribution status, and served index hash.
|
||||
# Used by deploy.yml's always() summary. Never fails the job on a missing pointer.
|
||||
set -u
|
||||
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}"
|
||||
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}"
|
||||
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
|
||||
echo "=== CloudFront live state ==="
|
||||
echo "pointer:"
|
||||
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)"
|
||||
echo
|
||||
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
|
||||
import json, sys
|
||||
payload = json.load(sys.stdin)
|
||||
dist = payload.get("Distribution") or {}
|
||||
config = dist.get("DistributionConfig") or {}
|
||||
print("status:", dist.get("Status"))
|
||||
for origin in ((config.get("Origins") or {}).get("Items") or []):
|
||||
print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or ""))
|
||||
'
|
||||
echo
|
||||
echo -n "served index sha256: "
|
||||
curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
|
||||
0
scripts/terraform_import_plan_resources.py
Normal file → Executable file
0
scripts/terraform_import_plan_resources.py
Normal file → Executable file
243
scripts/test-hcp-run-guard.py
Executable file
243
scripts/test-hcp-run-guard.py
Executable file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for every hcp-run-guard refusal, exit-0, discard, and reconcile case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT = Path(__file__).with_name("hcp-run-guard.py")
|
||||
WORKSPACE = "shoc-frontend-new-dev"
|
||||
PATTERNS = [
|
||||
"terraform/live/dev/**",
|
||||
"terraform/live/modules/**",
|
||||
]
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("hcp_run_guard", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def workspace_payload(
|
||||
*,
|
||||
auto_apply: bool = False,
|
||||
speculative: bool = True,
|
||||
tags_regex: str | None = None,
|
||||
trigger_patterns: list[str] | None = None,
|
||||
locked: bool = False,
|
||||
current_run: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"data": {
|
||||
"attributes": {
|
||||
"auto-apply": auto_apply,
|
||||
"speculative-enabled": speculative,
|
||||
"vcs-repo": {"tags-regex": tags_regex},
|
||||
"trigger-patterns": PATTERNS if trigger_patterns is None else trigger_patterns,
|
||||
"locked": locked,
|
||||
},
|
||||
"relationships": {
|
||||
"current-run": {"data": current_run},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def run_payload(*, status: str, plan_only: bool = False) -> dict[str, Any]:
|
||||
return {"data": {"attributes": {"status": status, "plan-only": plan_only}}}
|
||||
|
||||
|
||||
def check(module, payloads: dict[str, Any], posts: list | None = None):
|
||||
calls: list[str] = []
|
||||
|
||||
def get(url: str) -> dict[str, Any]:
|
||||
calls.append(url)
|
||||
if url not in payloads:
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
return payloads[url]
|
||||
|
||||
recorded: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def post(url: str, payload: dict[str, Any]) -> int:
|
||||
recorded.append((url, payload))
|
||||
if posts:
|
||||
return posts.pop(0)
|
||||
return 202
|
||||
|
||||
try:
|
||||
code = module.check_and_discard(
|
||||
workspace=WORKSPACE,
|
||||
token="test-token",
|
||||
get=get,
|
||||
post=post,
|
||||
)
|
||||
return code, None, calls, recorded
|
||||
except module.GuardError as exc:
|
||||
return 1, str(exc), calls, recorded
|
||||
|
||||
|
||||
def reconcile(module, outcome: str, payloads: dict[str, Any], run_id: str = "run-1"):
|
||||
def get(url: str) -> dict[str, Any]:
|
||||
if url not in payloads:
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
return payloads[url]
|
||||
|
||||
try:
|
||||
code = module.reconcile_apply(
|
||||
run_id=run_id,
|
||||
apply_outcome=outcome,
|
||||
token="test-token",
|
||||
get=get,
|
||||
)
|
||||
return code, None
|
||||
except module.GuardError as exc:
|
||||
return 1, str(exc)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
module = load_module()
|
||||
ws = f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{WORKSPACE}"
|
||||
run_url = "https://app.terraform.io/api/v2/runs/run-1"
|
||||
discard_url = f"{run_url}/actions/discard"
|
||||
failures: list[str] = []
|
||||
|
||||
def expect_refuse(name: str, payloads: dict[str, Any], fragment: str) -> None:
|
||||
code, error, _, recorded = check(module, payloads)
|
||||
if code != 1 or not error or fragment not in error:
|
||||
failures.append(f"{name}: expected refuse containing {fragment!r}, got {code} {error}")
|
||||
if recorded:
|
||||
failures.append(f"{name}: discard was posted on a refusal")
|
||||
|
||||
expect_refuse(
|
||||
"auto-apply",
|
||||
{ws: workspace_payload(auto_apply=True)},
|
||||
"auto-apply is on",
|
||||
)
|
||||
expect_refuse(
|
||||
"speculative-off",
|
||||
{ws: workspace_payload(speculative=False)},
|
||||
"speculative plans are off",
|
||||
)
|
||||
expect_refuse(
|
||||
"tags-regex",
|
||||
{ws: workspace_payload(tags_regex="^v")},
|
||||
"tag-based VCS triggering",
|
||||
)
|
||||
expect_refuse(
|
||||
"wrong-patterns",
|
||||
{ws: workspace_payload(trigger_patterns=["terraform/**"])},
|
||||
"trigger-patterns must be",
|
||||
)
|
||||
expect_refuse(
|
||||
"locked-without-run",
|
||||
{ws: workspace_payload(locked=True, current_run=None)},
|
||||
"locked without a current run",
|
||||
)
|
||||
expect_refuse(
|
||||
"applying",
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="applying"),
|
||||
},
|
||||
"wait, do not discard an apply",
|
||||
)
|
||||
expect_refuse(
|
||||
"not-discardable",
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="errored"),
|
||||
},
|
||||
"is not discardable",
|
||||
)
|
||||
|
||||
code, error, _, recorded = check(module, {ws: workspace_payload(locked=False)})
|
||||
if code != 0 or error is not None or recorded:
|
||||
failures.append(f"unlocked: expected exit 0, got {code} {error} {recorded}")
|
||||
|
||||
code, error, _, recorded = check(
|
||||
module,
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="planned", plan_only=True),
|
||||
},
|
||||
)
|
||||
if code != 0 or recorded:
|
||||
failures.append(f"plan-only: expected exit 0 without discard, got {code} {recorded}")
|
||||
|
||||
code, error, _, recorded = check(
|
||||
module,
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="planned"),
|
||||
},
|
||||
)
|
||||
if code != 0 or error is not None:
|
||||
failures.append(f"discard: expected exit 0, got {code} {error}")
|
||||
if not recorded or recorded[0][0] != discard_url:
|
||||
failures.append(f"discard: posted {recorded}")
|
||||
|
||||
code, error, _, recorded = check(
|
||||
module,
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="policy_checked"),
|
||||
},
|
||||
posts=[409],
|
||||
)
|
||||
if code != 0:
|
||||
failures.append(f"discard-409: expected exit 0, got {code} {error}")
|
||||
|
||||
try:
|
||||
module.check_and_discard(workspace=WORKSPACE, token="", get=lambda _url: {})
|
||||
failures.append("missing-token: accepted empty token")
|
||||
except module.GuardError:
|
||||
pass
|
||||
|
||||
code, error = reconcile(module, "success", {})
|
||||
if code != 0:
|
||||
failures.append(f"reconcile-success: expected 0, got {code} {error}")
|
||||
|
||||
code, error = reconcile(
|
||||
module,
|
||||
"failure",
|
||||
{run_url: run_payload(status="applied")},
|
||||
)
|
||||
if code != 0:
|
||||
failures.append(f"reconcile-applied: expected 0, got {code} {error}")
|
||||
|
||||
code, error = reconcile(
|
||||
module,
|
||||
"failure",
|
||||
{run_url: run_payload(status="errored")},
|
||||
)
|
||||
if code != 1 or not error or "errored" not in error:
|
||||
failures.append(f"reconcile-errored: expected refuse, got {code} {error}")
|
||||
|
||||
try:
|
||||
module.reconcile_apply(run_id="", apply_outcome="failure", token="test-token")
|
||||
failures.append("reconcile-missing-run: accepted empty run id")
|
||||
except module.GuardError:
|
||||
pass
|
||||
|
||||
try:
|
||||
module.reconcile_apply(run_id="run-1", apply_outcome="failure", token="")
|
||||
failures.append("reconcile-missing-token: accepted empty token")
|
||||
except module.GuardError:
|
||||
pass
|
||||
|
||||
if failures:
|
||||
print("FAIL: hcp-run-guard cases failed", file=__import__("sys").stderr)
|
||||
for item in failures:
|
||||
print(f" - {item}", file=__import__("sys").stderr)
|
||||
return 1
|
||||
print("PASS: HCP run guard checks")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
14
scripts/test-terraform-import-plan-check.py
Normal file → Executable file
14
scripts/test-terraform-import-plan-check.py
Normal file → Executable file
|
|
@ -452,15 +452,23 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
|
||||
def test_github_deploy_policy_stays_byte_identical(self) -> None:
|
||||
def test_github_deploy_policy_is_release_prefix_only(self) -> None:
|
||||
source = (
|
||||
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("AssumeCdkBootstrapRoles", document)
|
||||
self.assertIn("DescribeStack", document)
|
||||
self.assertIn("ListReleasePrefixes", document)
|
||||
self.assertIn("PublishReleasePrefix", document)
|
||||
self.assertIn("ReadReleasePointer", document)
|
||||
self.assertIn("ReadDistribution", document)
|
||||
self.assertIn("cloudfront:GetDistribution", document)
|
||||
self.assertIn("cloudfront:GetDistributionConfig", document)
|
||||
self.assertIn("releases/*", document)
|
||||
self.assertNotIn("AssumeCdkBootstrapRoles", document)
|
||||
self.assertNotIn("DescribeStack", document)
|
||||
self.assertNotIn("CreateInvalidation", document)
|
||||
self.assertNotIn("ReadDeploymentBucket", document)
|
||||
self.assertNotIn("PublishAndRollbackSiteObjects", document)
|
||||
self.assertNotIn(
|
||||
|
|
|
|||
331
scripts/test-terraform-release-plan-check.py
Executable file
331
scripts/test-terraform-release-plan-check.py
Executable file
|
|
@ -0,0 +1,331 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for check-terraform-release-plan.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from urllib.request import Request
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py")
|
||||
FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans"
|
||||
EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
EXPECTED_PREVIOUS = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
PLAN_ID = "plan-8F5JFydVYAmtTjET"
|
||||
POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer"
|
||||
|
||||
|
||||
def run_case(
|
||||
fixture_name: str,
|
||||
*,
|
||||
expected_label: str = EXPECTED_LABEL,
|
||||
expected_previous: str = EXPECTED_PREVIOUS,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(FIXTURES / fixture_name),
|
||||
"--expected-version-label",
|
||||
expected_label,
|
||||
"--expected-previous-version-label",
|
||||
expected_previous,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
status: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: bytes = b"",
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self._body = body
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def load_check_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"check_terraform_release_plan", SCRIPT
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_download_pinning() -> list[str]:
|
||||
module = load_check_module()
|
||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_urlopen(request: Request, **_kwargs):
|
||||
url = request.full_url
|
||||
calls.append(url)
|
||||
host = request.host if hasattr(request, "host") else ""
|
||||
if url.startswith("https://app.terraform.io/api/v2/plans/"):
|
||||
if request.get_header("Authorization") != "Bearer test-token":
|
||||
raise AssertionError("API request is missing the bearer token")
|
||||
if "/runs" in url or "/apply" in url or "/discard" in url:
|
||||
raise AssertionError(f"download contacted a run-control path: {url}")
|
||||
return FakeResponse(
|
||||
url=url,
|
||||
status=307,
|
||||
headers={"Location": archive_url},
|
||||
)
|
||||
if url == archive_url:
|
||||
if request.get_header("Authorization"):
|
||||
raise AssertionError("archivist request must not send TF_API_TOKEN")
|
||||
return FakeResponse(url=url, status=200, body=fixture)
|
||||
raise AssertionError(f"unexpected URL {url} host={host}")
|
||||
|
||||
plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen)
|
||||
failures: list[str] = []
|
||||
addresses = [item["address"] for item in plan["resource_changes"]]
|
||||
if POINTER_ADDRESS not in addresses:
|
||||
failures.append("download did not return the version-only fixture")
|
||||
if calls != [
|
||||
f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output",
|
||||
archive_url,
|
||||
]:
|
||||
failures.append(f"download URLs were {calls}")
|
||||
|
||||
try:
|
||||
module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen)
|
||||
failures.append("invalid plan id was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def redirect_elsewhere(request: Request, **_kwargs):
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": "https://evil.example/plan.json"},
|
||||
)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere)
|
||||
failures.append("redirect to a non-archivist host was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def double_redirect(request: Request, **_kwargs):
|
||||
if request.full_url.startswith("https://app.terraform.io/"):
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": archive_url},
|
||||
)
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": "https://archivist.terraform.io/v1/object/other"},
|
||||
)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect)
|
||||
failures.append("second archivist redirect was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def not_ready(request: Request, **_kwargs):
|
||||
return FakeResponse(url=request.full_url, status=204)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready)
|
||||
failures.append("HTTP 204 was polled or accepted")
|
||||
except ValueError as exc:
|
||||
if "poll" not in str(exc):
|
||||
failures.append(f"HTTP 204 error was {exc}")
|
||||
|
||||
source = SCRIPT.read_text(encoding="utf-8")
|
||||
for banned in ("/apply", "/discard", "/runs"):
|
||||
if banned in source:
|
||||
failures.append(f"download client contains run-control path {banned}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def _scripted_https_handler(fixture: bytes, archive_url: str):
|
||||
calls: list[str] = []
|
||||
api_prefix = "https://app.terraform.io/api/v2/plans/"
|
||||
|
||||
class ScriptedHTTPSHandler(urllib.request.BaseHandler):
|
||||
handler_order = 100
|
||||
|
||||
def https_open(self, req: Request):
|
||||
url = req.full_url
|
||||
calls.append(url)
|
||||
headers = EmailMessage()
|
||||
if url.startswith(api_prefix):
|
||||
headers["Location"] = archive_url
|
||||
body = b""
|
||||
status = 307
|
||||
msg = "Temporary Redirect"
|
||||
elif url == archive_url:
|
||||
body = fixture
|
||||
status = 200
|
||||
msg = "OK"
|
||||
else:
|
||||
raise AssertionError(f"unexpected URL {url}")
|
||||
response = urllib.response.addinfourl(
|
||||
io.BytesIO(body),
|
||||
headers,
|
||||
url,
|
||||
code=status,
|
||||
)
|
||||
response.msg = msg
|
||||
return response
|
||||
|
||||
return ScriptedHTTPSHandler(), calls
|
||||
|
||||
|
||||
def test_download_standard_opener_redirect() -> list[str]:
|
||||
"""urllib follows the HCP 307; the guard must still inspect that first hop."""
|
||||
module = load_check_module()
|
||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
||||
api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output"
|
||||
failures: list[str] = []
|
||||
|
||||
following_handler, following_calls = _scripted_https_handler(fixture, archive_url)
|
||||
followed = urllib.request.build_opener(following_handler).open(api_url)
|
||||
try:
|
||||
if followed.status != 200:
|
||||
failures.append(
|
||||
f"standard opener first status was {followed.status}, not 200"
|
||||
)
|
||||
if following_calls != [api_url, archive_url]:
|
||||
failures.append(f"standard opener URLs were {following_calls}")
|
||||
finally:
|
||||
followed.close()
|
||||
|
||||
guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url)
|
||||
try:
|
||||
plan = module.download_plan_json(
|
||||
PLAN_ID,
|
||||
"test-token",
|
||||
handlers=(guard_handler,),
|
||||
)
|
||||
except ValueError as exc:
|
||||
failures.append(f"no-redirect download failed: {exc}")
|
||||
return failures
|
||||
|
||||
addresses = [item["address"] for item in plan["resource_changes"]]
|
||||
if POINTER_ADDRESS not in addresses:
|
||||
failures.append("no-redirect download did not return the version-only fixture")
|
||||
if guard_calls != [api_url, archive_url]:
|
||||
failures.append(f"no-redirect download URLs were {guard_calls}")
|
||||
|
||||
following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url)
|
||||
following_urlopen = urllib.request.build_opener(following_urlopen_handler).open
|
||||
try:
|
||||
module.download_plan_json(
|
||||
PLAN_ID,
|
||||
"test-token",
|
||||
urlopen=following_urlopen,
|
||||
)
|
||||
failures.append("redirect-following urlopen was accepted as the first hop")
|
||||
except ValueError as exc:
|
||||
if "expected a redirect" not in str(exc):
|
||||
failures.append(f"following urlopen error was {exc}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def test_deploy_workflow_uses_script_flags() -> list[str]:
|
||||
workflow = (
|
||||
Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
if workflow.count("--expected-version-label") < 2:
|
||||
failures.append(
|
||||
"deploy.yml must pass --expected-version-label on release and rollback"
|
||||
)
|
||||
if workflow.count("--expected-previous-version-label") < 2:
|
||||
failures.append(
|
||||
"deploy.yml must pass --expected-previous-version-label on release and rollback"
|
||||
)
|
||||
for forbidden in (
|
||||
"--expected-current-label",
|
||||
"--expected-previous-label",
|
||||
"--before-current-label",
|
||||
"--before-previous-label",
|
||||
"--current-origin-id",
|
||||
"--previous-origin-id",
|
||||
"CURRENT_ORIGIN_ID",
|
||||
):
|
||||
if forbidden in workflow:
|
||||
failures.append(f"deploy.yml still passes unknown flag {forbidden}")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cases = [
|
||||
("version-only", run_case("version-only.json"), 0),
|
||||
("wrong-label", run_case("wrong-label.json"), 1),
|
||||
("wrong-before", run_case("wrong-before.json"), 1),
|
||||
("extra-origin-change", run_case("extra-origin-change.json"), 1),
|
||||
("iam-update", run_case("iam-update.json"), 1),
|
||||
("dns-update", run_case("dns-update.json"), 1),
|
||||
("create", run_case("create.json"), 1),
|
||||
("delete", run_case("delete.json"), 1),
|
||||
("replace", run_case("replace.json"), 1),
|
||||
("multiple-updates", run_case("multiple-updates.json"), 1),
|
||||
("nested-unknown", run_case("nested-unknown.json"), 1),
|
||||
("unknown-only", run_case("unknown-only.json"), 1),
|
||||
("empty", run_case("empty.json"), 1),
|
||||
("missing-action", run_case("missing-action.json"), 1),
|
||||
("extra-action", run_case("extra-action.json"), 1),
|
||||
]
|
||||
failures = [
|
||||
(name, result, expected)
|
||||
for name, result, expected in cases
|
||||
if result.returncode != expected
|
||||
]
|
||||
download_failures = test_download_pinning()
|
||||
redirect_failures = test_download_standard_opener_redirect()
|
||||
download_failures.extend(redirect_failures)
|
||||
download_failures.extend(test_deploy_workflow_uses_script_flags())
|
||||
if failures or download_failures:
|
||||
if failures:
|
||||
print(
|
||||
"FAIL: release plan-check cases failed: "
|
||||
+ ", ".join(name for name, _, _ in failures),
|
||||
file=sys.stderr,
|
||||
)
|
||||
for name, result, expected in failures:
|
||||
print(
|
||||
f"{name}: expected {expected}, got {result.returncode}\n"
|
||||
f"{result.stdout}{result.stderr}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for item in download_failures:
|
||||
print(f"FAIL: {item}", file=sys.stderr)
|
||||
return 1
|
||||
print("PASS: Terraform release plan safety checks")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
251
scripts/test-verify-cloudfront-release.sh
Executable file
251
scripts/test-verify-cloudfront-release.sh
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/env bash
|
||||
# Stubbed aws/curl tests for scripts/verify-cloudfront-release.sh.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh"
|
||||
CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111"
|
||||
OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
INDEX_HTML='<!doctype html><html><head></head><body><script src="/assets/app.js"></script>api.dev.seahaven.com</body></html>'
|
||||
INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
|
||||
failures=0
|
||||
assert_exit() {
|
||||
local name="$1" expected="$2" got="$3" log="$4"
|
||||
if [[ "${got}" != "${expected}" ]]; then
|
||||
echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2
|
||||
sed -n '1,80p' "${log}" >&2
|
||||
failures=$((failures + 1))
|
||||
else
|
||||
echo "PASS: ${name}"
|
||||
fi
|
||||
}
|
||||
|
||||
make_stubs() {
|
||||
local bin="$1"
|
||||
mkdir -p "${bin}"
|
||||
cat > "${bin}/aws" << 'AWS'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
state_dir="${STUB_STATE}"
|
||||
if [[ "${1:-}" == "s3" ]]; then
|
||||
cat "${state_dir}/pointer.json"
|
||||
exit 0
|
||||
fi
|
||||
cat "${state_dir}/distribution.json"
|
||||
AWS
|
||||
cat > "${bin}/curl" << 'CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
state_dir="${STUB_STATE}"
|
||||
method="GET"
|
||||
url=""
|
||||
dump=""
|
||||
output=""
|
||||
write_out=""
|
||||
args=("$@")
|
||||
i=0
|
||||
while [[ $i -lt ${#args[@]} ]]; do
|
||||
arg="${args[$i]}"
|
||||
case "${arg}" in
|
||||
-X) i=$((i + 1)); method="${args[$i]}" ;;
|
||||
-D) i=$((i + 1)); dump="${args[$i]}" ;;
|
||||
-o) i=$((i + 1)); output="${args[$i]}" ;;
|
||||
-w) i=$((i + 1)); write_out="${args[$i]}" ;;
|
||||
-H|--max-time|-s|-S|-f|-fsS|-sS) ;;
|
||||
http*) url="${arg}" ;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [[ "${method}" == "OPTIONS" ]]; then
|
||||
[[ -n "${dump}" ]] && printf 'HTTP/1.1 204 No Content\nAccess-Control-Allow-Origin: https://dev.seahaven.com\n\n' > "${dump}"
|
||||
[[ -n "${write_out}" ]] && printf '204'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${url}" == *"/assets/"* ]]; then
|
||||
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: public,max-age=31536000,immutable\n\n' > "${dump}"
|
||||
[[ -n "${output}" ]] && printf 'asset' > "${output}"
|
||||
[[ -z "${output}" ]] && printf 'asset'
|
||||
exit 0
|
||||
fi
|
||||
body="$(cat "${state_dir}/index.html")"
|
||||
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
|
||||
if [[ -n "${output}" ]]; then
|
||||
printf '%s' "${body}" > "${output}"
|
||||
else
|
||||
printf '%s' "${body}"
|
||||
fi
|
||||
exit 0
|
||||
CURL
|
||||
chmod +x "${bin}/aws" "${bin}/curl"
|
||||
}
|
||||
|
||||
dist_json() {
|
||||
local status="$1" current_path="$2"
|
||||
python3 -c 'import json,sys
|
||||
status, path = sys.argv[1], sys.argv[2]
|
||||
print(json.dumps({
|
||||
"Distribution": {
|
||||
"Status": status,
|
||||
"DistributionConfig": {
|
||||
"Origins": {"Items": [
|
||||
{"Id": "current", "OriginPath": path},
|
||||
{"Id": "previous", "OriginPath": ""},
|
||||
]}
|
||||
}
|
||||
}
|
||||
}))' "${status}" "${current_path}"
|
||||
}
|
||||
|
||||
pointer_json() {
|
||||
python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local name="$1"
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}"
|
||||
export PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
|
||||
export PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=3
|
||||
export INTERVAL=0
|
||||
local log="${dir}/log.txt"
|
||||
set +e
|
||||
bash "${VERIFY}" > "${log}" 2>&1
|
||||
local code=$?
|
||||
set -e
|
||||
assert_exit "${name}" "$2" "${code}" "${log}"
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 1. Right config, then propagates (InProgress -> Deployed, hash already matches).
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
printf 'InProgress\n' > "${dir}/status"
|
||||
cat > "${dir}/bin/aws" << AWS
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "\${1:-}" == "s3" ]]; then
|
||||
cat "${dir}/pointer.json"
|
||||
exit 0
|
||||
fi
|
||||
status="\$(cat "${dir}/status")"
|
||||
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}"
|
||||
echo Deployed > "${dir}/status"
|
||||
AWS
|
||||
chmod +x "${dir}/bin/aws"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=5 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt"
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 2. Right config never propagates (Deployed, stale hash).
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf 'stale' > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 3. Wrong origin path fails fast.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 4. Wrong pointer fails fast.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 5. Never Deployed.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "never-deployed" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: never-deployed missing last observed state" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
if [[ "${failures}" -ne 0 ]]; then
|
||||
echo "FAIL: ${failures} verify-cloudfront-release cases failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: CloudFront release verify checks"
|
||||
94
scripts/testdata/terraform-release-plans/create.json
vendored
Normal file
94
scripts/testdata/terraform-release-plans/create.json
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["create"],
|
||||
"before": null,
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
94
scripts/testdata/terraform-release-plans/delete.json
vendored
Normal file
94
scripts/testdata/terraform-release-plans/delete.json
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["delete"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}"
|
||||
},
|
||||
"after": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
116
scripts/testdata/terraform-release-plans/dns-update.json
vendored
Normal file
116
scripts/testdata/terraform-release-plans/dns-update.json
vendored
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_route53_record.site_a",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_record",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"ttl": 60
|
||||
},
|
||||
"after": {
|
||||
"ttl": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
scripts/testdata/terraform-release-plans/empty.json
vendored
Normal file
9
scripts/testdata/terraform-release-plans/empty.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"resource_changes": [],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
106
scripts/testdata/terraform-release-plans/extra-action.json
vendored
Normal file
106
scripts/testdata/terraform-release-plans/extra-action.json
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release_extra",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/extra-origin-change.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/extra-origin-change.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 20,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
116
scripts/testdata/terraform-release-plans/iam-update.json
vendored
Normal file
116
scripts/testdata/terraform-release-plans/iam-update.json
vendored
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"policy": "{}"
|
||||
},
|
||||
"after": {
|
||||
"policy": "{\"Version\":\"2012-10-17\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
97
scripts/testdata/terraform-release-plans/missing-action.json
vendored
Normal file
97
scripts/testdata/terraform-release-plans/missing-action.json
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": []
|
||||
}
|
||||
130
scripts/testdata/terraform-release-plans/multiple-updates.json
vendored
Normal file
130
scripts/testdata/terraform-release-plans/multiple-updates.json
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"policy": "{}"
|
||||
},
|
||||
"after": {
|
||||
"policy": "{\"Version\":\"2012-10-17\"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_route53_record.site_a",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_record",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"ttl": 60
|
||||
},
|
||||
"after": {
|
||||
"ttl": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
105
scripts/testdata/terraform-release-plans/nested-unknown.json
vendored
Normal file
105
scripts/testdata/terraform-release-plans/nested-unknown.json
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true,
|
||||
"tags": {
|
||||
"Environment": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
58
scripts/testdata/terraform-release-plans/replace.json
vendored
Normal file
58
scripts/testdata/terraform-release-plans/replace.json
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["delete", "create"],
|
||||
"before": {
|
||||
"origin": []
|
||||
},
|
||||
"after": {
|
||||
"origin": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
103
scripts/testdata/terraform-release-plans/unknown-only.json
vendored
Normal file
103
scripts/testdata/terraform-release-plans/unknown-only.json
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true,
|
||||
"comment": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/version-only.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/version-only.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/wrong-before.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/wrong-before.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/wrong-label.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/wrong-label.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"cccccccccccccccccccccccccccccccccccccccc-3-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/cccccccccccccccccccccccccccccccccccccccc-3-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -6,14 +6,18 @@ set -euo pipefail
|
|||
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
|
||||
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
|
||||
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
RELEASE_LABEL="${SENTRY_RELEASE:-${RELEASE_LABEL:-}}"
|
||||
|
||||
if [[ -n "${RELEASE_LABEL}" ]]; then
|
||||
RELEASE="${RELEASE_LABEL}"
|
||||
else
|
||||
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE="shoc-frontend@${COMMIT_SHA}"
|
||||
fi
|
||||
|
||||
npm exec --no -- sentry-cli sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
|
|
|
|||
177
scripts/verify-cloudfront-release.sh
Executable file
177
scripts/verify-cloudfront-release.sh
Executable file
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env bash
|
||||
# Verify a CloudFront content release or rollback.
|
||||
#
|
||||
# Fail fast when origin_path or .release/current is the wrong label.
|
||||
# Poll while the distribution is InProgress or the served index.html hash
|
||||
# still matches the previous release. On timeout, print last observed state.
|
||||
set -euo pipefail
|
||||
|
||||
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
|
||||
EXPECTED_LABEL="${EXPECTED_LABEL:-}"
|
||||
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
|
||||
SITE_URL="${SITE_URL:-}"
|
||||
SITE_BUCKET="${SITE_BUCKET:-}"
|
||||
PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}"
|
||||
API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
|
||||
BUDGET="${BUDGET:-40}"
|
||||
INTERVAL="${INTERVAL:-15}"
|
||||
|
||||
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then
|
||||
echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
if [[ -n "${EXPECTED_LABEL}" ]]; then
|
||||
EXPECTED_PATH="/releases/${EXPECTED_LABEL}"
|
||||
else
|
||||
EXPECTED_PATH=""
|
||||
fi
|
||||
|
||||
sha256_of() {
|
||||
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
|
||||
}
|
||||
|
||||
read_pointer() {
|
||||
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true
|
||||
}
|
||||
|
||||
read_distribution_json() {
|
||||
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
|
||||
}
|
||||
|
||||
parse_distribution() {
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
payload = json.load(sys.stdin)
|
||||
dist = payload.get("Distribution") or payload
|
||||
status = dist.get("Status") or "Unknown"
|
||||
config = dist.get("DistributionConfig") or {}
|
||||
origins = ((config.get("Origins") or {}).get("Items")) or []
|
||||
paths = [origin.get("OriginPath") or "" for origin in origins]
|
||||
expected = os.environ["EXPECTED_PATH"]
|
||||
print(status)
|
||||
print("\x1f".join(paths))
|
||||
print("yes" if expected in paths else "no")
|
||||
'
|
||||
}
|
||||
|
||||
pointer_current() {
|
||||
POINTER_BODY="$1" python3 -c '
|
||||
import json, os
|
||||
raw = os.environ.get("POINTER_BODY", "").strip()
|
||||
if not raw:
|
||||
print("")
|
||||
raise SystemExit
|
||||
print(json.loads(raw).get("current") or "")
|
||||
'
|
||||
}
|
||||
|
||||
last_status="Unknown"
|
||||
last_paths="Unknown"
|
||||
last_pointer="Unknown"
|
||||
last_hash="Unknown"
|
||||
last_path_ok="no"
|
||||
|
||||
observe() {
|
||||
last_pointer="$(read_pointer)"
|
||||
local parsed
|
||||
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)"
|
||||
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
|
||||
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
|
||||
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')"
|
||||
local body
|
||||
body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)"
|
||||
if [[ -n "${body}" ]]; then
|
||||
last_hash="$(printf '%s' "${body}" | sha256_of)"
|
||||
else
|
||||
last_hash="unreachable"
|
||||
fi
|
||||
}
|
||||
|
||||
report_state() {
|
||||
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}"
|
||||
}
|
||||
|
||||
fail_fast_if_misconfigured() {
|
||||
local current
|
||||
current="$(pointer_current "${last_pointer}")"
|
||||
if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then
|
||||
echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2
|
||||
report_state >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${last_path_ok}" != "yes" ]]; then
|
||||
echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2
|
||||
report_state >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
observe
|
||||
fail_fast_if_misconfigured
|
||||
|
||||
attempt=0
|
||||
while [[ "${attempt}" -lt "${BUDGET}" ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
|
||||
fail_fast_if_misconfigured
|
||||
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep "${INTERVAL}"
|
||||
observe
|
||||
done
|
||||
|
||||
if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA256}" ]]; then
|
||||
echo "FAIL: release did not converge within the budget." >&2
|
||||
report_state >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp}"' EXIT
|
||||
|
||||
curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers"
|
||||
curl -fsS --max-time 30 "${SITE_URL}/login" -o "${tmp}/login.html"
|
||||
curl -fsS --max-time 30 "${SITE_URL}/work-orders" -o "${tmp}/route.html"
|
||||
if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then
|
||||
echo "FAIL: HTML Cache-Control is missing no-store." >&2
|
||||
exit 1
|
||||
fi
|
||||
for forbidden in api.staging.seahaven.com localhost:5141; do
|
||||
if grep -Fq "${forbidden}" "${tmp}/index.html"; then
|
||||
echo "FAIL: served index contains forbidden URL ${forbidden}." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/index.html"; then
|
||||
echo "FAIL: served index is missing the dev API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
asset_path="$(python3 -c 'import re,sys; html=open(sys.argv[1],encoding="utf-8").read(); m=re.search(r"(/assets/[^\"'\'']+)", html); print(m.group(1) if m else "")' "${tmp}/index.html")"
|
||||
if [[ -z "${asset_path}" ]]; then
|
||||
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
|
||||
exit 1
|
||||
fi
|
||||
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" -o /dev/null -D "${tmp}/asset.headers"
|
||||
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
|
||||
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cors_code="$(curl -sS --max-time 30 -o /dev/null -D "${tmp}/cors.headers" -w '%{http_code}' -X OPTIONS "${API_URL}" \
|
||||
-H "Origin: ${SITE_URL}" \
|
||||
-H "Access-Control-Request-Method: GET")"
|
||||
if [[ "${cors_code}" != "200" && "${cors_code}" != "204" ]]; then
|
||||
echo "FAIL: CORS preflight returned HTTP ${cors_code}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
|
||||
echo "FAIL: CORS preflight is missing Access-Control-Allow-Origin." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean."
|
||||
report_state
|
||||
|
|
@ -5,10 +5,10 @@ into HCP Terraform without recreating them. It mirrors the backend adoption
|
|||
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs:
|
||||
|
||||
| PR | Branch | Change |
|
||||
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| A | `feature/frontend-terraform-adoption` | Merged. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. |
|
||||
| B | `feature/terraform-dev-adoption` | This PR. `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
|
||||
| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. |
|
||||
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. |
|
||||
| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
|
||||
| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, rollback. |
|
||||
|
||||
Creating these files, formatting them, initializing with `-backend=false`, and
|
||||
validating them does not authorize an AWS, HCP Terraform, GitHub,
|
||||
|
|
@ -42,7 +42,7 @@ before the first release after any Terraform merge:
|
|||
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
|
||||
prefixes, no tags regex. Do not switch to tag-based triggering.
|
||||
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
|
||||
`>= 1.9.0, < 2.0.0`; CI validates with `1.16.0`).
|
||||
`>= 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`
|
||||
|
|
@ -54,7 +54,8 @@ before the first release after any Terraform merge:
|
|||
|
||||
## Ownership boundary
|
||||
|
||||
`live/modules/environment-owned` owns exactly these 13 addresses:
|
||||
`live/modules/environment-owned` owns these 14 addresses (13 imported hosting
|
||||
resources plus the release pointer created in Phase 3):
|
||||
|
||||
1. `module.environment_owned.aws_s3_bucket.site`
|
||||
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
|
||||
|
|
@ -69,7 +70,10 @@ before the first release after any Terraform merge:
|
|||
11. `module.environment_owned.aws_route53_record.site_aaaa`
|
||||
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
|
||||
|
|
@ -135,15 +139,8 @@ Each step is gated. State the impact, get the go, act, read back, record.
|
|||
1. **Workspace invariants.** Set the invariants above on
|
||||
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
|
||||
PR.
|
||||
2. **CDK retain deploy.** From the reviewed PR head, with administrator
|
||||
credentials:
|
||||
|
||||
```bash
|
||||
cd infra/cdk && npm ci
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=true
|
||||
```
|
||||
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
|
||||
|
|
@ -175,7 +172,7 @@ Each step is gated. State the impact, get the go, act, read back, record.
|
|||
After Phase 1 CloudFormation still owns every resource. Terraform holds state
|
||||
for them and nothing else.
|
||||
|
||||
## Phase 2: controlled ownership transfer (this PR)
|
||||
## Phase 2: controlled ownership transfer (merged #178)
|
||||
|
||||
PR B pins `adoption_complete = true`. The controlled apply may update only:
|
||||
|
||||
|
|
@ -201,23 +198,58 @@ python3 scripts/check-terraform-import-plan.py plan.json --environment dev \
|
|||
--allow-update-address module.environment_owned.aws_iam_role.github_deploy
|
||||
```
|
||||
|
||||
After the apply and a no-op plan, deploy the same reviewed CDK SHA with
|
||||
`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the
|
||||
13 transferred resources and the custom resource. Never deploy with
|
||||
`ManageSiteInfrastructure=true` again after that. See
|
||||
[`infra/cdk/README.md`](../infra/cdk/README.md).
|
||||
After the apply and a no-op plan, the CDK stack was relinquished with
|
||||
`ManageSiteInfrastructure=false`. Never deploy that stack with
|
||||
`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C.
|
||||
|
||||
Confirm `dev.seahaven.com` still serves and that a manual `workflow_dispatch`
|
||||
of `deploy.yml` can still upload with the unchanged GitHub content policy.
|
||||
Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual
|
||||
`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged
|
||||
GitHub content policy. PR C replaces that policy with the release-prefix
|
||||
document during bootstrap.
|
||||
|
||||
## Phase 3: content CD through Terraform (PR C)
|
||||
## Phase 3: content CD through Terraform (this PR)
|
||||
|
||||
Summary only; PR C carries the full design. GitHub builds and uploads to an
|
||||
immutable `releases/<sha>-<run>-<attempt>/` prefix. Terraform owns the
|
||||
`.release/current` pointer, both origin paths of a CloudFront origin group,
|
||||
and the invalidation action. Rollback is one guarded Terraform run swapping
|
||||
the labels. Push-to-`dev` releases return behind the repository variable
|
||||
`TERRAFORM_CONTENT_CD_ENABLED`.
|
||||
GitHub builds the SPA and uploads only `releases/<sha>-<run>-<attempt>/`.
|
||||
The GitHub role may `GetObject` on `.release/current` and read the exact
|
||||
distribution (`GetDistribution` / `GetDistributionConfig`) so verify and
|
||||
live-state summary can observe origin paths. It cannot invalidate or write
|
||||
the pointer. Terraform owns `.release/current`, both origin paths of the
|
||||
CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one
|
||||
guarded Terraform run that swaps the labels. Push-to-`dev` stays off until
|
||||
`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls
|
||||
`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287).
|
||||
|
||||
Release vars `release_version_label` and `previous_release_version_label` are
|
||||
nullable, default null, and must not be set on the workspace or in tfvars.
|
||||
Null VCS plans read the pointer back from S3. Empty string is the legacy root
|
||||
layout.
|
||||
|
||||
Per GitHub content release after bootstrap: exactly two managed updates plus
|
||||
one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py`
|
||||
accepts a plan that updates only the pointer `content` and
|
||||
`origin[*].origin_path`, with `after` equal to the expected labels, `before`
|
||||
equal to the pointer's prior values, and exactly one invalidation
|
||||
`action_invocations` entry.
|
||||
|
||||
The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates
|
||||
`.release/current` (legacy empty labels), adds the previous origin and origin
|
||||
group, switches the default behavior to the group, replaces the GitHub inline
|
||||
policy with the release-prefix document, and invokes invalidation. A human
|
||||
confirms that apply. GitHub CD starts only after bootstrap is applied.
|
||||
|
||||
Activation (each step gated; do not run without an explicit go):
|
||||
|
||||
1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard
|
||||
the HCP VCS run. Apply bootstrap as a human-confirmed controlled update.
|
||||
2. Re-read workspace invariants (auto-apply off, speculative on, trigger
|
||||
patterns only, no prefixes, no tags-regex).
|
||||
3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation,
|
||||
smoke, and rollback readiness from the live-state summary.
|
||||
4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner
|
||||
approval.
|
||||
5. Confirm the first push-to-`dev` run. Close SH-300 on that proof.
|
||||
|
||||
A red job does not mean the site is down. Read the live-state summary first.
|
||||
|
||||
## Operational rules
|
||||
|
||||
|
|
@ -240,9 +272,9 @@ the labels. Push-to-`dev` releases return behind the repository variable
|
|||
workspace.
|
||||
- **Re-read the workspace invariants** before the first release after any
|
||||
Terraform merge or workspace settings change.
|
||||
- **A red job does not mean the site is down.** Read the live state first
|
||||
(served `index.html`, distribution status, pointer body once PR C lands),
|
||||
then triage.
|
||||
- **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.
|
||||
|
||||
|
|
@ -254,8 +286,11 @@ From the repository root (also run by `npm run verify` through
|
|||
```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:infra # CDK build, template tests, synth in both modes
|
||||
npm run test:hcp-run-guard # workspace invariant and apply reconcile
|
||||
npm run test:cloudfront-release-verify
|
||||
npm run test:github-workflows # bash -n and actionlint
|
||||
```
|
||||
|
||||
`terraform init -backend=false -lockfile=readonly` may download the provider
|
||||
|
|
|
|||
|
|
@ -90,4 +90,6 @@ module "environment_owned" {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,11 @@ output "distribution_id" {
|
|||
output "deploy_role_arn" {
|
||||
value = module.environment_owned.deploy_role_arn
|
||||
}
|
||||
|
||||
output "current_origin_id" {
|
||||
value = module.environment_owned.current_origin_id
|
||||
}
|
||||
|
||||
output "previous_origin_id" {
|
||||
value = module.environment_owned.previous_origin_id
|
||||
}
|
||||
|
|
|
|||
33
terraform/live/dev/variables.tf
Normal file
33
terraform/live/dev/variables.tf
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable content release label. Null VCS plans read the live pointer from S3."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
var.release_version_label == "" ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
|
||||
variable "previous_release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.previous_release_version_label == null ||
|
||||
var.previous_release_version_label == "" ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label))
|
||||
)
|
||||
error_message = "previous_release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
terraform {
|
||||
required_version = ">= 1.9.0, < 2.0.0"
|
||||
required_version = ">= 1.14.0, < 2.0.0"
|
||||
|
||||
cloud {
|
||||
organization = "seahaven"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@ locals {
|
|||
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
|
||||
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
|
||||
github_subject_operator = var.pre_adoption_github_subject_operator
|
||||
previous_origin_id = "${var.origin_id}-previous"
|
||||
origin_group_id = "${var.origin_id}-group"
|
||||
pointer_key = ".release/current"
|
||||
pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {})
|
||||
# coalesce() skips empty strings, so a null var plus a missing pointer
|
||||
# would error. Empty string is the legacy root layout and must be valid.
|
||||
current_label = (
|
||||
var.release_version_label != null
|
||||
? var.release_version_label
|
||||
: try(local.pointer_body.current, "")
|
||||
)
|
||||
previous_label = (
|
||||
var.previous_release_version_label != null
|
||||
? var.previous_release_version_label
|
||||
: try(local.pointer_body.previous, "")
|
||||
)
|
||||
current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}"
|
||||
previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}"
|
||||
|
||||
spa_rewrite_code = join("\n", [
|
||||
"function handler(event) {",
|
||||
|
|
@ -19,6 +37,17 @@ locals {
|
|||
])
|
||||
}
|
||||
|
||||
data "aws_s3_objects" "release_prefix" {
|
||||
bucket = aws_s3_bucket.site.bucket
|
||||
prefix = ".release/"
|
||||
}
|
||||
|
||||
data "aws_s3_object" "release_pointer" {
|
||||
count = contains(coalesce(data.aws_s3_objects.release_prefix.keys, []), local.pointer_key) ? 1 : 0
|
||||
bucket = aws_s3_bucket.site.bucket
|
||||
key = local.pointer_key
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "site_bucket" {
|
||||
dynamic "statement" {
|
||||
for_each = var.adoption_complete ? [] : [1]
|
||||
|
|
@ -109,53 +138,48 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
|||
}
|
||||
|
||||
data "aws_iam_policy_document" "github_deploy" {
|
||||
# Byte-identical to the live GitHub content policy through Phase 2 so
|
||||
# aws_iam_role_policy.github_deploy stays no-op. Phase 3 replaces this
|
||||
# with the release-prefix policy.
|
||||
dynamic "statement" {
|
||||
for_each = var.environment == "dev" ? [1] : []
|
||||
|
||||
content {
|
||||
sid = "AssumeCdkBootstrapRoles"
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
resources = ["arn:aws:iam::${var.aws_account_id}:role/cdk-hnb659fds-*"]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "DescribeStack"
|
||||
effect = "Allow"
|
||||
actions = ["cloudformation:DescribeStacks"]
|
||||
resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ListReleasePrefixes"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:GetBucketLocation",
|
||||
"s3:ListBucket",
|
||||
]
|
||||
resources = [local.bucket_arn]
|
||||
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "s3:prefix"
|
||||
values = [
|
||||
"releases/",
|
||||
"releases/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "PublishReleasePrefix"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
]
|
||||
resources = [
|
||||
local.bucket_arn,
|
||||
"${local.bucket_arn}/*",
|
||||
]
|
||||
resources = ["${local.bucket_arn}/releases/*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "InvalidateDistribution"
|
||||
sid = "ReadReleasePointer"
|
||||
effect = "Allow"
|
||||
actions = ["s3:GetObject"]
|
||||
resources = ["${local.bucket_arn}/${local.pointer_key}"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ReadDistribution"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
"cloudfront:GetDistribution",
|
||||
"cloudfront:GetDistributionConfig",
|
||||
]
|
||||
resources = [local.distribution_arn]
|
||||
}
|
||||
|
|
@ -233,6 +257,20 @@ resource "aws_s3_bucket_policy" "site" {
|
|||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_object" "release_pointer" {
|
||||
bucket = aws_s3_bucket.site.bucket
|
||||
key = local.pointer_key
|
||||
content_type = "application/json"
|
||||
content = jsonencode({
|
||||
current = local.current_label
|
||||
previous = local.previous_label
|
||||
})
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_cloudfront_origin_access_control" "site" {
|
||||
name = var.origin_access_control_name
|
||||
description = var.origin_access_control_description
|
||||
|
|
@ -275,6 +313,32 @@ resource "aws_cloudfront_distribution" "site" {
|
|||
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||
origin_id = var.origin_id
|
||||
origin_path = local.current_origin_path
|
||||
}
|
||||
|
||||
origin {
|
||||
connection_attempts = 3
|
||||
connection_timeout = 10
|
||||
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||
origin_id = local.previous_origin_id
|
||||
origin_path = local.previous_origin_path
|
||||
}
|
||||
|
||||
origin_group {
|
||||
origin_id = local.origin_group_id
|
||||
|
||||
failover_criteria {
|
||||
status_codes = [403, 404]
|
||||
}
|
||||
|
||||
member {
|
||||
origin_id = var.origin_id
|
||||
}
|
||||
|
||||
member {
|
||||
origin_id = local.previous_origin_id
|
||||
}
|
||||
}
|
||||
|
||||
default_cache_behavior {
|
||||
|
|
@ -282,7 +346,7 @@ resource "aws_cloudfront_distribution" "site" {
|
|||
cache_policy_id = var.cache_policy_id
|
||||
cached_methods = ["GET", "HEAD"]
|
||||
compress = true
|
||||
target_origin_id = var.origin_id
|
||||
target_origin_id = local.origin_group_id
|
||||
viewer_protocol_policy = "redirect-to-https"
|
||||
|
||||
function_association {
|
||||
|
|
@ -305,6 +369,18 @@ 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 = ["/*"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,33 @@ output "deploy_role_arn" {
|
|||
value = aws_iam_role.github_deploy.arn
|
||||
description = "Imported GitHub deployment role ARN."
|
||||
}
|
||||
|
||||
output "current_release_label" {
|
||||
value = local.current_label
|
||||
description = "Pointer current release label. Empty string is the legacy root layout."
|
||||
}
|
||||
|
||||
output "previous_release_label" {
|
||||
value = local.previous_label
|
||||
description = "Pointer previous release label. Empty string is the legacy root layout."
|
||||
}
|
||||
|
||||
output "current_origin_path" {
|
||||
value = local.current_origin_path
|
||||
description = "CloudFront origin_path for the current member of the origin group."
|
||||
}
|
||||
|
||||
output "previous_origin_path" {
|
||||
value = local.previous_origin_path
|
||||
description = "CloudFront origin_path for the previous member of the origin group."
|
||||
}
|
||||
|
||||
output "current_origin_id" {
|
||||
value = var.origin_id
|
||||
description = "CloudFront origin ID for the current release."
|
||||
}
|
||||
|
||||
output "previous_origin_id" {
|
||||
value = local.previous_origin_id
|
||||
description = "CloudFront origin ID for the previous release."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,44 @@ variable "environment" {
|
|||
|
||||
variable "adoption_complete" {
|
||||
type = bool
|
||||
description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy. The GitHub deploy inline policy stays byte-identical to live until the content-CD PR."
|
||||
description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy."
|
||||
default = false
|
||||
}
|
||||
|
||||
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."
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue