mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 11:39:00 +00:00
feat(terraform): adopt live deployment roles safely
This commit is contained in:
parent
8d26a07fa4
commit
8380548917
49 changed files with 4422 additions and 463 deletions
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -53,6 +53,10 @@ jobs:
|
||||||
base="origin/dev"
|
base="origin/dev"
|
||||||
fi
|
fi
|
||||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||||
|
- name: Set up Terraform
|
||||||
|
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
|
with:
|
||||||
|
terraform_version: "1.9.8"
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
|
||||||
93
.github/workflows/deploy-staging.yml
vendored
93
.github/workflows/deploy-staging.yml
vendored
|
|
@ -1,17 +1,6 @@
|
||||||
name: Deploy staging
|
name: Deploy staging
|
||||||
|
|
||||||
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
|
|
||||||
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
|
|
||||||
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
|
|
||||||
# job to declare `environment: staging` and run in this repo, with the
|
|
||||||
# non-secret role ARN pinned below (created by the staging stack itself).
|
|
||||||
#
|
|
||||||
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
|
|
||||||
# No secrets are used — OIDC + the static role ARN are the only credentials.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [staging]
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -32,6 +21,14 @@ jobs:
|
||||||
environment: staging
|
environment: staging
|
||||||
env:
|
env:
|
||||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||||
|
EXPECTED_API_URL: https://api.staging.seahaven.com/api
|
||||||
|
FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.tf-poc.seahaven.com/api
|
||||||
|
SITE_URL: https://staging.seahaven.com
|
||||||
|
API_SMOKE_URL: https://api.staging.seahaven.com/swagger/v1/swagger.json
|
||||||
|
SITE_BUCKET: seahaven-shoc-frontend-staging
|
||||||
|
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-staging
|
||||||
|
CLOUDFRONT_DISTRIBUTION_ID: E2JDVEZ6EGD49J
|
||||||
|
DEPLOY_RELEASE_ID: ${{ github.sha }}
|
||||||
AWS_REGION: us-east-1
|
AWS_REGION: us-east-1
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
@ -51,6 +48,10 @@ jobs:
|
||||||
base="origin/dev"
|
base="origin/dev"
|
||||||
fi
|
fi
|
||||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||||
|
- name: Set up Terraform
|
||||||
|
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
|
with:
|
||||||
|
terraform_version: "1.9.8"
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
@ -68,73 +69,5 @@ jobs:
|
||||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||||
aws-region: us-east-1
|
aws-region: us-east-1
|
||||||
|
|
||||||
# Builds the SPA with the staging VITE_API_URL (process env overrides the
|
- name: Build, publish, and verify SPA
|
||||||
# dev value committed in .env.production), syncs to the staging bucket,
|
|
||||||
# and invalidates CloudFront.
|
|
||||||
- name: Build and publish SPA
|
|
||||||
run: bash scripts/deploy-web.sh
|
run: bash scripts/deploy-web.sh
|
||||||
env:
|
|
||||||
STACK_NAME: shoc-frontend-staging
|
|
||||||
WAIT_FOR_INVALIDATION: "true"
|
|
||||||
|
|
||||||
- name: Verify deployment
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
stack_output() {
|
|
||||||
aws cloudformation describe-stacks \
|
|
||||||
--stack-name shoc-frontend-staging \
|
|
||||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
|
||||||
--output text
|
|
||||||
}
|
|
||||||
BUCKET="$(stack_output BucketName)"
|
|
||||||
DIST_ID="$(stack_output DistributionId)"
|
|
||||||
DIST_DOMAIN="$(stack_output DistributionDomainName)"
|
|
||||||
SITE_URL="$(stack_output SiteUrl)"
|
|
||||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then
|
|
||||||
echo "::error::Could not resolve bucket/distribution from stack outputs." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}"
|
|
||||||
|
|
||||||
aws s3api head-bucket --bucket "${BUCKET}"
|
|
||||||
echo "Bucket exists."
|
|
||||||
# The distribution is proven to exist and serve by the HTTPS check
|
|
||||||
# below: the custom domain is an alias to this distribution, and the
|
|
||||||
# deploy role deliberately carries no cloudfront:GetDistribution
|
|
||||||
# (least privilege; the dev template is shared and must not drift).
|
|
||||||
|
|
||||||
if grep -Rq "api.dev.seahaven.com" dist/; then
|
|
||||||
echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2
|
|
||||||
grep -Rl "api.dev.seahaven.com" dist/ >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Built assets carry no dev API URL."
|
|
||||||
grep -Rq "api.staging.seahaven.com" dist/
|
|
||||||
echo "Built assets reference the staging API URL."
|
|
||||||
|
|
||||||
# Verify the actual post-invalidation HTML and its referenced assets,
|
|
||||||
# not only the local build or a generic endpoint response.
|
|
||||||
remote_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "${remote_dir}"' EXIT
|
|
||||||
for i in 1 2 3 4 5 6; do
|
|
||||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Endpoint not ready (attempt ${i}); retrying in 20s..."
|
|
||||||
sleep 20
|
|
||||||
done
|
|
||||||
test -s "${remote_dir}/index.html"
|
|
||||||
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \
|
|
||||||
| sed -E 's/^(src|href)="([^"]+)"$/\2/' \
|
|
||||||
| sort -u > "${remote_dir}/asset-paths.txt"
|
|
||||||
test -s "${remote_dir}/asset-paths.txt"
|
|
||||||
while IFS= read -r asset_path; do
|
|
||||||
curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \
|
|
||||||
>> "${remote_dir}/assets.txt"
|
|
||||||
done < "${remote_dir}/asset-paths.txt"
|
|
||||||
if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then
|
|
||||||
echo "::error::Deployed assets contain the dev API URL." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt"
|
|
||||||
echo "Deployed staging assets reference only the staging API URL."
|
|
||||||
|
|
|
||||||
53
.github/workflows/deploy-tf-poc.yml
vendored
Normal file
53
.github/workflows/deploy-tf-poc.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
name: Deploy Terraform POC
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-tf-poc
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy to tf-poc
|
||||||
|
if: github.ref == 'refs/heads/feature/terraform-cd-poc'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: tf-poc
|
||||||
|
env:
|
||||||
|
AWS_REGION: us-east-1
|
||||||
|
VITE_API_URL: https://api.tf-poc.seahaven.com/api
|
||||||
|
EXPECTED_API_URL: https://api.tf-poc.seahaven.com/api
|
||||||
|
FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.staging.seahaven.com/api
|
||||||
|
SITE_URL: https://frontend-tf-poc.seahaven.com
|
||||||
|
SITE_BUCKET: seahaven-shoc-frontend-tf-poc
|
||||||
|
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-tf-poc
|
||||||
|
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
|
||||||
|
API_SMOKE_URL: https://api.tf-poc.seahaven.com/swagger/v1/swagger.json
|
||||||
|
DEPLOY_RELEASE_ID: ${{ github.sha }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Set up Terraform
|
||||||
|
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
|
with:
|
||||||
|
terraform_version: "1.9.8"
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
- name: Quality gates
|
||||||
|
run: npm ci && npm run verify
|
||||||
|
env:
|
||||||
|
GOVERNANCE_BASE: origin/dev
|
||||||
|
- name: Assume tf-poc deploy role (OIDC)
|
||||||
|
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-tf-poc
|
||||||
|
aws-region: us-east-1
|
||||||
|
- name: Build, publish, and verify SPA
|
||||||
|
run: bash scripts/deploy-web.sh
|
||||||
56
.github/workflows/deploy.yml
vendored
56
.github/workflows/deploy.yml
vendored
|
|
@ -1,22 +1,8 @@
|
||||||
name: Deploy
|
name: Deploy
|
||||||
|
|
||||||
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
|
|
||||||
#
|
|
||||||
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
|
|
||||||
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
|
|
||||||
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
|
|
||||||
# CloudFront. Both run as the OIDC deploy role created by the stack.
|
|
||||||
#
|
|
||||||
# When staging/prod accounts exist, add jobs keyed to their branches and their
|
|
||||||
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [dev]
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
# OIDC needs id-token: write — it is never in the default token set and cannot
|
|
||||||
# be granted to the reusable workflow unless the caller has it.
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -27,12 +13,36 @@ concurrency:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
name: Deploy to dev
|
||||||
with:
|
if: github.ref == 'refs/heads/dev'
|
||||||
node-version: "24"
|
runs-on: ubuntu-latest
|
||||||
region: us-east-1
|
env:
|
||||||
cdk-dir: infra/cdk
|
AWS_REGION: us-east-1
|
||||||
stack-name: shoc-frontend-dev
|
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||||
post-deploy-script: scripts/deploy-web.sh
|
EXPECTED_API_URL: https://api.dev.seahaven.com/api
|
||||||
secrets:
|
FORBIDDEN_API_URLS: https://api.staging.seahaven.com/api,https://api.tf-poc.seahaven.com/api
|
||||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
SITE_URL: https://dev.seahaven.com
|
||||||
|
API_SMOKE_URL: https://api.dev.seahaven.com/swagger/v1/swagger.json
|
||||||
|
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||||
|
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||||
|
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||||
|
DEPLOY_RELEASE_ID: ${{ github.sha }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
- name: Set up Terraform
|
||||||
|
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
|
with:
|
||||||
|
terraform_version: "1.9.8"
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
- name: Quality gates
|
||||||
|
run: npm ci && npm run verify
|
||||||
|
- name: Assume dev deploy role (OIDC)
|
||||||
|
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
|
||||||
|
aws-region: us-east-1
|
||||||
|
- name: Build, publish, and verify SPA
|
||||||
|
run: bash scripts/deploy-web.sh
|
||||||
|
|
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -47,3 +47,16 @@ infra/cdk/bin/*.d.ts
|
||||||
infra/cdk/bin/*.js
|
infra/cdk/bin/*.js
|
||||||
infra/cdk/lib/*.d.ts
|
infra/cdk/lib/*.d.ts
|
||||||
infra/cdk/lib/*.js
|
infra/cdk/lib/*.js
|
||||||
|
|
||||||
|
# terraform
|
||||||
|
**/.terraform/*
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
*.tfplan
|
||||||
|
*.tfvars
|
||||||
|
*.tfvars.json
|
||||||
|
!*.tfvars.example
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ npm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||||
`test` (`vitest run`) → `governance`. A task is not done until this is green.
|
`test` (`vitest run`) → `governance`. Governance also runs the Terraform
|
||||||
|
import-plan contract, Terraform formatting and validation, the web deployment
|
||||||
|
contract, and CDK build/synth. A task is not done until this is green.
|
||||||
|
|
||||||
## Gate matrix
|
## Gate matrix
|
||||||
|
|
||||||
|
|
@ -23,6 +25,10 @@ npm run verify
|
||||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||||
|
| Terraform plan-checker contract | `npm run test:terraform-import-plan` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||||
|
| Terraform formatting/validation | `npm run test:terraform` | `governance` + CI | tf-poc, dev, and staging roots |
|
||||||
|
| Web deployment/rollback contract | `npm run test:deploy-web` | `governance` + CI | `scripts/deploy-web.sh` |
|
||||||
|
| CDK compile and synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**` |
|
||||||
|
|
||||||
## No-false-pass guarantees
|
## No-false-pass guarantees
|
||||||
|
|
||||||
|
|
@ -36,6 +42,10 @@ npm run verify
|
||||||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||||
(push). An absent or unresolvable base is a failure, not a pass.
|
(push). An absent or unresolvable base is a failure, not a pass.
|
||||||
|
- **Real import and controlled-update plans remain migration evidence** — CI
|
||||||
|
tests the checker and validates configuration, but it cannot evaluate live
|
||||||
|
AWS/HCP state. Each environment requires a saved `terraform show -json` plan
|
||||||
|
and checker output before an approved apply.
|
||||||
|
|
||||||
## Where the gates run
|
## Where the gates run
|
||||||
|
|
||||||
|
|
|
||||||
98
README.md
98
README.md
|
|
@ -13,15 +13,17 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
|
||||||
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
||||||
|
|
||||||
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
|
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
|
||||||
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
|
- **Hosted at:** <https://dev.seahaven.com> and <https://staging.seahaven.com>
|
||||||
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
|
- **Backend APIs:** matching `api.<environment>.seahaven.com/api` endpoints,
|
||||||
|
called directly from the browser
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
|
Static SPA hosting on AWS. CloudFront serves the built `dist/` from a private,
|
||||||
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
|
versioned S3 bucket; the SPA calls the backend directly over HTTPS at
|
||||||
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
|
`VITE_API_URL`. The live stacks remain CDK/CloudFormation-owned while the
|
||||||
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
|
import-first Terraform transfer is rehearsed and reviewed. See
|
||||||
|
[`terraform/README.md`](terraform/README.md).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph LR
|
graph LR
|
||||||
|
|
@ -29,8 +31,8 @@ graph LR
|
||||||
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||||
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
GH[Manual GitHub deployment] -->|OIDC| ROLE[Environment deploy role]
|
||||||
ROLE -->|cdk deploy + s3 sync + invalidation| S3
|
ROLE -->|content publish + invalidation| S3
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||||
|
|
@ -40,16 +42,17 @@ architecture plan for the keep/discard migration matrix).
|
||||||
|
|
||||||
## AWS Resources
|
## AWS Resources
|
||||||
|
|
||||||
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
|
Stacks **`shoc-frontend-dev`** and **`shoc-frontend-staging`** are currently
|
||||||
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
CDK-owned in account `396287094661`, region `us-east-1`. They are defined in
|
||||||
|
[`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
||||||
|
|
||||||
| Resource | Name | Purpose |
|
| Resource | Name | Purpose |
|
||||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
| S3 bucket | `seahaven-shoc-frontend-{dev,staging}` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||||
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
|
| CloudFront distribution | `E2CWLM1AFB964P` / `E2JDVEZ6EGD49J` | HTTPS static hosting on the matching environment domain |
|
||||||
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
||||||
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
| IAM role | `githubdeploy-shoc-frontend-new-{dev,staging}` | Environment-scoped GitHub OIDC content deploy role |
|
||||||
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
|
| Route 53 records | A/AAAA aliases in the dev and staging hosted zones | Point each custom domain at its CloudFront distribution |
|
||||||
|
|
||||||
No Lambdas, queues, or databases — this stack is static hosting only.
|
No Lambdas, queues, or databases — this stack is static hosting only.
|
||||||
|
|
||||||
|
|
@ -57,12 +60,9 @@ No Lambdas, queues, or databases — this stack is static hosting only.
|
||||||
|
|
||||||
### Secrets
|
### Secrets
|
||||||
|
|
||||||
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
|
No Secrets Manager, SSM parameters, AWS access keys, or deploy-role repo secret
|
||||||
repo secret**:
|
are used. Content workflows assume their pinned environment role through
|
||||||
|
GitHub OIDC.
|
||||||
| Secret | Purpose |
|
|
||||||
| --------------------- | ----------------------------------------------------------------------------------- |
|
|
||||||
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
|
|
||||||
|
|
||||||
### Environment variables (build-time, `VITE_*`)
|
### Environment variables (build-time, `VITE_*`)
|
||||||
|
|
||||||
|
|
@ -77,8 +77,9 @@ repo secret**:
|
||||||
build otherwise. See [`.env.example`](.env.example),
|
build otherwise. See [`.env.example`](.env.example),
|
||||||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||||
|
|
||||||
CDK context (domain, certificate ARN, hosted zone) lives in
|
CDK context for normal dev synthesis lives in
|
||||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
|
[`infra/cdk/cdk.json`](infra/cdk/cdk.json). CDK deployment is no longer part of
|
||||||
|
recurring content releases during the ownership transfer.
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
|
|
@ -114,7 +115,7 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
- Branch from `dev` with a kebab-case description and a prefix matching the
|
- Branch from `dev` with a kebab-case description and a prefix matching the
|
||||||
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||||
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
|
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
|
||||||
- Commit messages follow
|
- Commit messages follow
|
||||||
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
|
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
|
||||||
|
|
@ -123,13 +124,14 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||||
a green CI run and an approving review from a code owner
|
a green CI run and an approving review from a code owner
|
||||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||||
Merged branches are deleted automatically.
|
Merged branches are deleted automatically.
|
||||||
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
|
- Promotion flow during migration: `feature/* → dev`, then an explicitly
|
||||||
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
|
approved manual dev deployment and verification on `dev.seahaven.com`.
|
||||||
exists yet).
|
Staging promotion and deployment are separate approvals. No production
|
||||||
|
environment exists yet.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
|
CI/CD uses OIDC and stores no AWS access keys:
|
||||||
|
|
||||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||||
and PRs to `main`/`dev`, calls
|
and PRs to `main`/`dev`, calls
|
||||||
|
|
@ -141,24 +143,22 @@ CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
|
||||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
|
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml),
|
||||||
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
|
[`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml),
|
||||||
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
|
and [`.github/workflows/deploy-tf-poc.yml`](.github/workflows/deploy-tf-poc.yml))
|
||||||
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
is manual-only during migration. [`scripts/deploy-web.sh`](scripts/deploy-web.sh)
|
||||||
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
publishes to pinned targets, verifies cache/API/routing behavior, retains two
|
||||||
CloudFront invalidation. Both run as the OIDC deploy role.
|
release manifests, and restores the prior versioned index on verification
|
||||||
|
failure.
|
||||||
|
|
||||||
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
|
The isolated rehearsal, retention mechanism, and deployment prerequisites are
|
||||||
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
|
documented in [`infra/cdk/README.md`](infra/cdk/README.md). Terraform ownership,
|
||||||
[`infra/cdk/README.md`](infra/cdk/README.md).
|
HCP configuration, import gates, evidence, and rollback are documented in
|
||||||
|
[`terraform/README.md`](terraform/README.md).
|
||||||
|
|
||||||
Manual deploy (emergency/reference only — needs credentials for the
|
Infrastructure changes and ownership transfer remain separate reviewed
|
||||||
external-dev AWS account; the normal path is push to `dev`):
|
administrator actions. Content workflows never run `cdk deploy` or Terraform
|
||||||
|
apply.
|
||||||
```bash
|
|
||||||
(cd infra/cdk && npx cdk deploy)
|
|
||||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
|
@ -177,9 +177,9 @@ STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||||
suffix or carrying the wrong environment's host (it is baked in at build time).
|
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||||
not proxy `/api`.
|
not proxy `/api`.
|
||||||
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
|
- **Deploy workflow is unavailable on an arbitrary ref** — each manual workflow
|
||||||
deploys (matches the org's push-time-CD model; gating deploy on CI is known
|
checks its exact branch or protected GitHub environment before assuming AWS
|
||||||
follow-up work).
|
credentials.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,221 +1,136 @@
|
||||||
# Infrastructure & CI/CD — Sea Haven SHOC frontend
|
# Frontend infrastructure and migration rehearsal
|
||||||
|
|
||||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
|
This CDK app describes the existing Sea Haven SHOC SPA hosting and an isolated,
|
||||||
deployed through the org's **reusable** GitHub Actions workflow.
|
production-shaped Terraform adoption rehearsal. It performs no content upload.
|
||||||
|
Content-only deployment is handled by `scripts/deploy-web.sh`.
|
||||||
|
|
||||||
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
|
## Existing environments
|
||||||
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
|
|
||||||
- **API:** the SPA calls the backend **directly** over HTTPS at
|
|
||||||
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
|
|
||||||
allows CORS). CloudFront serves static content only — no `/api` proxy.
|
|
||||||
- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy`
|
|
||||||
picks them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
|
||||||
per-environment (see the note under "Adding staging / prod").
|
|
||||||
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
|
|
||||||
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
|
|
||||||
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
|
|
||||||
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
|
|
||||||
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
|
|
||||||
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
|
|
||||||
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
|
|
||||||
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
|
|
||||||
|
|
||||||
```
|
Normal synthesis remains unchanged when the adoption flag is off:
|
||||||
infra/cdk/
|
|
||||||
bin/app.ts entry point (reads -c context)
|
|
||||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
|
||||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
|
||||||
.github/workflows/
|
|
||||||
ci.yaml quality gates (lint / build / test / e2e)
|
|
||||||
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
|
|
||||||
deploy-staging.yml standalone staging deploy (push to staging)
|
|
||||||
```
|
|
||||||
|
|
||||||
## What the stack creates
|
- private, versioned S3 bucket with CDK auto-delete cleanup
|
||||||
|
- CloudFront distribution and origin access control
|
||||||
|
- viewer-request function that rewrites extensionless SPA routes
|
||||||
|
- optional Route 53 A and AAAA aliases
|
||||||
|
- GitHub Actions OIDC deploy role
|
||||||
|
|
||||||
| Resource | Purpose |
|
Dev remains the default context in `cdk.json`. Staging uses explicit context
|
||||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
arguments. During migration, both content workflows are manual-only and use
|
||||||
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
|
fixed environment configuration rather than discovering deployment targets
|
||||||
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
|
from CloudFormation.
|
||||||
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
|
|
||||||
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
|
||||||
|
|
||||||
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
|
## Terraform POC
|
||||||
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
|
|
||||||
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
|
|
||||||
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
|
|
||||||
singleton account resource — the stack only _imports_ it (created in step 2),
|
|
||||||
so `cdk destroy` can't delete a resource shared by other roles.
|
|
||||||
|
|
||||||
---
|
The POC is isolated in account `396287094661`, region `us-east-1`, and is
|
||||||
|
created only with `-c tfPoc=true` plus an explicit `tfPocPhase`. It uses three
|
||||||
|
ownership scopes:
|
||||||
|
|
||||||
## One-time setup (run by a human with admin AWS creds)
|
1. `shoc-frontend-tf-poc-shared`: a dedicated public hosted zone for
|
||||||
|
`frontend-tf-poc.seahaven.com`.
|
||||||
|
2. `shoc-frontend-tf-poc-certificate`: the DNS-validated ACM certificate.
|
||||||
|
3. `shoc-frontend-tf-poc`: the private versioned bucket, CloudFront OAC,
|
||||||
|
distribution, SPA function, A/AAAA aliases, and GitHub OIDC content deploy
|
||||||
|
role.
|
||||||
|
|
||||||
### 1. Authenticate to the AWS account
|
Fixed application values:
|
||||||
|
|
||||||
```bash
|
- bucket: `seahaven-shoc-frontend-tf-poc`
|
||||||
aws configure # or: aws sso login --profile <admin>
|
- role: `githubdeploy-shoc-frontend-new-tf-poc`
|
||||||
aws sts get-caller-identity # confirm the right account + region (us-east-1)
|
- GitHub environment: `tf-poc`
|
||||||
```
|
- site: `https://frontend-tf-poc.seahaven.com`
|
||||||
|
- API: `https://api.tf-poc.seahaven.com/api`
|
||||||
|
|
||||||
### 2. Ensure the GitHub OIDC provider exists (once per account)
|
The shared stack is intentionally staged. The zone must exist and be delegated
|
||||||
|
before ACM can validate a certificate inside it:
|
||||||
```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
|
```bash
|
||||||
cd infra/cdk
|
cd infra/cdk
|
||||||
npm ci
|
npm ci
|
||||||
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
|
npm test
|
||||||
|
npm run synth:tf-poc-zone
|
||||||
|
npm run synth:tf-poc-environment
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Domain, cert, and API URL (already wired for dev)
|
After approval, deploy only `shoc-frontend-tf-poc-shared` with
|
||||||
|
`tfPocPhase=zone`. Its outputs provide the child name servers. Create the
|
||||||
|
parent NS record as a separate approved change and verify public delegation.
|
||||||
|
Only then use `tfPocPhase=environment` to deploy the separate certificate and
|
||||||
|
site stacks. The zone stack never contains the certificate, so re-running the
|
||||||
|
zone phase cannot remove a certificate created by the environment phase.
|
||||||
|
Omitting `tfPocPhase` fails closed.
|
||||||
|
|
||||||
Domain/cert/zone are set in `cdk.json` context (account `396287094661`):
|
The stacks output the hosted zone ID, certificate ARN, delegation evidence,
|
||||||
|
workspace tag, boundary ARN, and import IDs for the bucket, bucket policy,
|
||||||
|
distribution, OAC, SPA function, A/AAAA records, deploy role, and inline role
|
||||||
|
policy. They also emit the generated OAC name/description, deterministic origin
|
||||||
|
ID, and inline policy name required by the tf-poc Terraform configuration.
|
||||||
|
|
||||||
| Context key | Value |
|
## Adoption retention
|
||||||
| --------------------------------- | ------------------------------------------------------------ |
|
|
||||||
| `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,
|
`-c retainForTerraformAdoption=true` is deliberately opt-in. Keep it enabled
|
||||||
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
|
from the reviewed retention deployment through CloudFormation ownership
|
||||||
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
|
detachment.
|
||||||
baked into the build. Per-environment; override for staging/prod.
|
|
||||||
|
|
||||||
### 5. First deploy (locally, with admin creds)
|
The emitted template applies both `DeletionPolicy: Retain` and
|
||||||
|
`UpdateReplacePolicy: Retain` to:
|
||||||
|
|
||||||
The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it
|
- site bucket and bucket policy
|
||||||
locally. This provisions infra + the role:
|
- distribution, OAC, and SPA rewrite function
|
||||||
|
- A and AAAA records
|
||||||
|
- GitHub deploy role and its inline policy
|
||||||
|
- `SiteBucket/AutoDeleteObjectsCustomResource`
|
||||||
|
|
||||||
```bash
|
The bucket remains configured with `autoDeleteObjects: true`. The emitted
|
||||||
cd infra/cdk
|
bucket and its matching custom resource are both retained, so deleting the
|
||||||
npx cdk deploy
|
stack cannot invoke that custom resource to empty the versioned bucket.
|
||||||
```
|
Generated provider Lambda resources, provider IAM resources, provider logs,
|
||||||
|
and CDK metadata are intentionally excluded. Template tests enforce this exact
|
||||||
|
boundary.
|
||||||
|
|
||||||
Note the `DeployRoleArn` output. Then push the first content (or just push to
|
In adoption mode, the deploy role also receives:
|
||||||
`dev` and let CI do everything from here on):
|
|
||||||
|
|
||||||
```bash
|
- tag `HcpTerraformWorkspace=shoc-frontend-new-{env}`
|
||||||
# from repo root, optional manual first content publish:
|
- permissions boundary
|
||||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
`arn:aws:iam::<account>:policy/shoc-frontend-new-{env}-deploy-boundary`
|
||||||
```
|
- exact `StringEquals` OIDC subject matching; for dev this narrows the current
|
||||||
|
no-wildcard `StringLike` subject before Terraform import
|
||||||
|
|
||||||
### 6. Set the one GitHub secret
|
These changes are absent when the flag is off.
|
||||||
|
|
||||||
`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable):
|
## Content deployment safeguards
|
||||||
|
|
||||||
```bash
|
`scripts/deploy-web.sh` requires explicit target and expectation variables:
|
||||||
REPO=Sea-Haven-Industries/shoc-frontend-new
|
|
||||||
gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \
|
|
||||||
--body "arn:aws:iam::<acct>:role/githubdeploy-shoc-frontend-new-dev"
|
|
||||||
```
|
|
||||||
|
|
||||||
(Or **Settings → Secrets and variables → Actions → Secrets**.)
|
- `SITE_BUCKET` and matching `EXPECTED_SITE_BUCKET`
|
||||||
|
- `CLOUDFRONT_DISTRIBUTION_ID`
|
||||||
|
- `SITE_URL`
|
||||||
|
- `VITE_API_URL` and matching `EXPECTED_API_URL`
|
||||||
|
- `DEPLOY_RELEASE_ID` or `GITHUB_SHA`
|
||||||
|
- optional comma-separated `FORBIDDEN_API_URLS`
|
||||||
|
- optional `API_SMOKE_URL` and `API_CORS_ORIGIN`
|
||||||
|
|
||||||
### 7. From now on: push to `dev`
|
The script verifies bucket versioning, builds the app, publishes immutable
|
||||||
|
assets and a no-cache index, records a release manifest, invalidates and waits,
|
||||||
|
then checks `/`, `/login`, an extensionless route, asset references, API URLs,
|
||||||
|
and cache headers. Optional API preflight checks verify CORS.
|
||||||
|
|
||||||
```bash
|
If verification fails after publishing the index, the previous index version
|
||||||
git push origin dev
|
is restored and invalidated. Pruning starts only after successful remote
|
||||||
```
|
verification. Current versions needed by the latest two release manifests are
|
||||||
|
kept; unreferenced object versions are deleted.
|
||||||
|
|
||||||
`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs
|
## Manual workflows
|
||||||
`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open
|
|
||||||
the `SiteUrl` output.
|
|
||||||
|
|
||||||
> First-run verification: this first push is what actually exercises the role's
|
- `.github/workflows/deploy.yml`: dev content deployment
|
||||||
> permissions and the OIDC trust through the reusable workflow (the local
|
- `.github/workflows/deploy-staging.yml`: staging content deployment
|
||||||
> bootstrap used admin creds and tested none of that). Watch for
|
- `.github/workflows/deploy-tf-poc.yml`: isolated POC content deployment
|
||||||
> credential/OIDC errors and a green post-deploy step.
|
|
||||||
|
|
||||||
---
|
All are `workflow_dispatch` only. Staging and tf-poc retain their GitHub
|
||||||
|
environment protection and exact environment-scoped OIDC trust. Each dev and
|
||||||
|
staging distribution ID is pinned in its workflow. The tf-poc environment
|
||||||
|
must define its generated `CLOUDFRONT_DISTRIBUTION_ID` as a protected variable.
|
||||||
|
Each workflow pins its environment's Swagger URL for API CORS/preflight checks.
|
||||||
|
|
||||||
## Staging environment (same account, exact OIDC subject)
|
Infrastructure creation, parent-zone delegation, Terraform imports, and
|
||||||
|
ownership detachment remain separate administrator actions. None of these
|
||||||
Staging lives in the same AWS account (396287094661) but deploys through its
|
workflows performs them.
|
||||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
|
|
||||||
reusable `cd-cdk.yaml`:
|
|
||||||
|
|
||||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
|
||||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
|
||||||
environment subject
|
|
||||||
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
|
|
||||||
(`StringEquals` on both `aud` and `sub`). The workflow declares
|
|
||||||
`environment: staging`, so only runs in that environment can assume the role.
|
|
||||||
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
|
|
||||||
unchanged.
|
|
||||||
- **No secret:** the role ARN is static (the role name is deterministic), so
|
|
||||||
the workflow pins
|
|
||||||
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
|
|
||||||
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
|
|
||||||
- **Gates first:** the workflow runs the full `npm run verify` before assuming
|
|
||||||
the staging role, then runs `scripts/deploy-web.sh` with
|
|
||||||
`STACK_NAME=shoc-frontend-staging`,
|
|
||||||
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
|
|
||||||
CloudFront invalidation to complete.
|
|
||||||
- **Application-only role:** the recurring staging workflow can describe only
|
|
||||||
its exact stack, publish only to its exact bucket, and invalidate only its
|
|
||||||
exact distribution. It cannot assume the shared CDK bootstrap roles or
|
|
||||||
modify infrastructure. Staging infrastructure changes use the Administrator
|
|
||||||
command below.
|
|
||||||
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
|
|
||||||
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
|
|
||||||
contain the staging API URL and no dev API URL. (Not browser QA.)
|
|
||||||
|
|
||||||
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
|
|
||||||
|
|
||||||
1. **GitHub Admin — create the `staging` environment** (Settings →
|
|
||||||
Environments → New environment → `staging`). Add protection rules as
|
|
||||||
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
|
|
||||||
the environment does not exist, GitHub creates it unprotected on first use.
|
|
||||||
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
|
|
||||||
OIDC provider and bootstrap already exist in this account):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd infra/cdk
|
|
||||||
npx cdk deploy shoc-frontend-staging \
|
|
||||||
-c envName=staging \
|
|
||||||
-c deployBranch=staging \
|
|
||||||
-c githubEnvironment=staging \
|
|
||||||
-c domainNames=staging.seahaven.com \
|
|
||||||
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
|
|
||||||
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
|
|
||||||
-c hostedZoneName=staging.seahaven.com
|
|
||||||
```
|
|
||||||
|
|
||||||
The `DeployRoleArn` output must match the ARN pinned in
|
|
||||||
`deploy-staging.yml` (it will — the role name is deterministic).
|
|
||||||
|
|
||||||
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
|
|
||||||
allow the `https://staging.seahaven.com` origin.
|
|
||||||
4. Push to `staging` — `ci.yaml` runs the quality gates and
|
|
||||||
`deploy-staging.yml` deploys.
|
|
||||||
|
|
||||||
### Adding prod later
|
|
||||||
|
|
||||||
Same pattern: a prod account/stack with its own contexts and, ideally, its own
|
|
||||||
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
|
|
||||||
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
|
|
||||||
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
|
|
||||||
for prod.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
|
||||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
|
||||||
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
|
|
||||||
differs only in that its CD workflow also runs `npm run verify` itself
|
|
||||||
before deploying); a red-CI commit still deploys on `dev` (matches the
|
|
||||||
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
|
|
||||||
of enabling CICD.
|
|
||||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
|
||||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,102 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { App, Tags } from "aws-cdk-lib";
|
import { App, Stack, Tags } from "aws-cdk-lib";
|
||||||
import { FrontendStack } from "../lib/frontend-stack";
|
import { FrontendStack } from "../lib/frontend-stack";
|
||||||
|
import { TfPocCertificateStack, TfPocZoneStack } from "../lib/tf-poc-shared-stack";
|
||||||
|
|
||||||
const app = new App();
|
const app = new App();
|
||||||
|
const tfPoc = String(app.node.tryGetContext("tfPoc") ?? "false").toLowerCase() === "true";
|
||||||
|
|
||||||
// Defaults match the dev setup; override via `-c key=value` on the CLI.
|
if (tfPoc) {
|
||||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
const pocEnv = { account: "396287094661", region: "us-east-1" };
|
||||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
const tfPocPhase = String(app.node.tryGetContext("tfPocPhase") ?? "").toLowerCase();
|
||||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
if (!["zone", "environment"].includes(tfPocPhase)) {
|
||||||
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
|
throw new Error("tfPocPhase must be set explicitly to 'zone' or 'environment'.");
|
||||||
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
|
}
|
||||||
// branch-ref trust.
|
const createEnvironment = tfPocPhase === "environment";
|
||||||
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
|
const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
|
||||||
|
env: pocEnv,
|
||||||
|
terminationProtection: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
const stacks: Stack[] = [zoneStack];
|
||||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
if (createEnvironment) {
|
||||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
const certificateStack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", {
|
||||||
.split(",")
|
env: pocEnv,
|
||||||
.map((d: string) => d.trim())
|
terminationProtection: true,
|
||||||
.filter((d: string) => d.length > 0);
|
hostedZone: zoneStack.hostedZone,
|
||||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
});
|
||||||
|
const environmentStack = new FrontendStack(app, "shoc-frontend-tf-poc", {
|
||||||
|
envName: "tf-poc",
|
||||||
|
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||||
|
deployBranch: "tf-poc",
|
||||||
|
githubEnvironment: "tf-poc",
|
||||||
|
terminationProtection: true,
|
||||||
|
domainNames: [zoneStack.hostedZoneName],
|
||||||
|
certificateArn: certificateStack.certificateArn,
|
||||||
|
hostedZoneId: zoneStack.hostedZoneId,
|
||||||
|
hostedZoneName: zoneStack.hostedZoneName,
|
||||||
|
retainForTerraformAdoption: true,
|
||||||
|
env: pocEnv,
|
||||||
|
});
|
||||||
|
certificateStack.addDependency(zoneStack);
|
||||||
|
environmentStack.addDependency(certificateStack);
|
||||||
|
stacks.push(certificateStack, environmentStack);
|
||||||
|
}
|
||||||
|
|
||||||
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
for (const stack of stacks) {
|
||||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
Tags.of(stack).add("Project", "shoc-frontend");
|
||||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
Tags.of(stack).add("Environment", "tf-poc");
|
||||||
|
Tags.of(stack).add("ManagedBy", "cdk");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 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") ?? "";
|
||||||
|
|
||||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||||
// at deploy time — it is not part of the synthesized template.
|
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||||
const terminationProtection = envName !== "dev";
|
.split(",")
|
||||||
|
.map((d: string) => d.trim())
|
||||||
|
.filter((d: string) => d.length > 0);
|
||||||
|
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||||
|
|
||||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
||||||
envName,
|
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||||
githubRepo,
|
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||||
deployBranch,
|
const retainForTerraformAdoption =
|
||||||
githubEnvironment,
|
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() ===
|
||||||
terminationProtection,
|
"true";
|
||||||
domainNames,
|
|
||||||
certificateArn,
|
|
||||||
hostedZoneId,
|
|
||||||
hostedZoneName,
|
|
||||||
env: {
|
|
||||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
|
||||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Tags.of(stack).add("Project", "shoc-frontend");
|
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||||
Tags.of(stack).add("Environment", envName);
|
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||||
Tags.of(stack).add("ManagedBy", "cdk");
|
// 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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@
|
||||||
"context": {
|
"context": {
|
||||||
"@aws-cdk/aws-iam:minimizePolicies": true,
|
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||||
"@aws-cdk/core:checkSecretUsage": true,
|
"@aws-cdk/core:checkSecretUsage": true,
|
||||||
|
"@aws-cdk/core:defaultCrossStackReferences": "strong",
|
||||||
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
|
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
|
||||||
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": 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.",
|
"//": "Dev synthesis defaults for account 396287094661. Infrastructure deployment is administrator-run.",
|
||||||
"domainNames": "dev.seahaven.com",
|
"domainNames": "dev.seahaven.com",
|
||||||
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
|
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
|
||||||
"hostedZoneId": "Z07671212N75U4YLPWZR8",
|
"hostedZoneId": "Z07671212N75U4YLPWZR8",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
|
import {
|
||||||
|
Aspects,
|
||||||
|
CfnOutput,
|
||||||
|
CfnResource,
|
||||||
|
Duration,
|
||||||
|
RemovalPolicy,
|
||||||
|
Stack,
|
||||||
|
StackProps,
|
||||||
|
Tags,
|
||||||
|
} from "aws-cdk-lib";
|
||||||
import { Construct } from "constructs";
|
import { Construct } from "constructs";
|
||||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||||
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
||||||
|
|
@ -7,6 +16,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
||||||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||||
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
||||||
|
import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption";
|
||||||
|
|
||||||
export interface FrontendStackProps extends StackProps {
|
export interface FrontendStackProps extends StackProps {
|
||||||
/** Environment label, e.g. "dev". Used in names/tags. */
|
/** Environment label, e.g. "dev". Used in names/tags. */
|
||||||
|
|
@ -42,6 +52,11 @@ export interface FrontendStackProps extends StackProps {
|
||||||
readonly hostedZoneId: string;
|
readonly hostedZoneId: string;
|
||||||
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
||||||
readonly hostedZoneName: string;
|
readonly hostedZoneName: string;
|
||||||
|
/**
|
||||||
|
* Opt-in safety mode used only during the reviewed Terraform adoption.
|
||||||
|
* Normal dev/staging synthesis remains unchanged when false.
|
||||||
|
*/
|
||||||
|
readonly retainForTerraformAdoption?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,11 +65,10 @@ export interface FrontendStackProps extends StackProps {
|
||||||
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
||||||
* - a GitHub Actions OIDC deploy role
|
* - a GitHub Actions OIDC deploy role
|
||||||
*
|
*
|
||||||
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
|
* Content (the built `dist/`) is NOT uploaded here. Manual environment
|
||||||
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
|
* workflows run `scripts/deploy-web.sh` independently of infrastructure
|
||||||
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
|
* changes, so this stack only owns infrastructure and the deploy role carries
|
||||||
* stack only owns the infrastructure, and the deploy role carries the
|
* content-publication permissions.
|
||||||
* permissions those post-deploy steps need.
|
|
||||||
*/
|
*/
|
||||||
export class FrontendStack extends Stack {
|
export class FrontendStack extends Stack {
|
||||||
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
||||||
|
|
@ -69,6 +83,7 @@ export class FrontendStack extends Stack {
|
||||||
certificateArn,
|
certificateArn,
|
||||||
hostedZoneId,
|
hostedZoneId,
|
||||||
hostedZoneName,
|
hostedZoneName,
|
||||||
|
retainForTerraformAdoption = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const hasCustomDomain = domainNames.length > 0;
|
const hasCustomDomain = domainNames.length > 0;
|
||||||
|
|
@ -114,6 +129,15 @@ export class FrontendStack extends Stack {
|
||||||
// --- CloudFront: serves the static SPA from S3 -------------------------
|
// --- CloudFront: serves the static SPA from S3 -------------------------
|
||||||
// The SPA calls the backend directly at its absolute HTTPS URL
|
// The SPA calls the backend directly at its absolute HTTPS URL
|
||||||
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
||||||
|
const adoptionOriginIds: Record<string, string> = {
|
||||||
|
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||||
|
staging: "shocfrontendstagingDistributionOrigin16E4628FC",
|
||||||
|
"tf-poc": "shoc-frontend-tf-poc-origin",
|
||||||
|
};
|
||||||
|
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", {
|
const distribution = new cloudfront.Distribution(this, "Distribution", {
|
||||||
comment: `SeaHaven SHOC frontend (${envName})`,
|
comment: `SeaHaven SHOC frontend (${envName})`,
|
||||||
defaultRootObject: "index.html",
|
defaultRootObject: "index.html",
|
||||||
|
|
@ -130,7 +154,9 @@ export class FrontendStack extends Stack {
|
||||||
: undefined,
|
: undefined,
|
||||||
defaultBehavior: {
|
defaultBehavior: {
|
||||||
// withOriginAccessControl wires up OAC + the bucket policy automatically.
|
// withOriginAccessControl wires up OAC + the bucket policy automatically.
|
||||||
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, {
|
||||||
|
originId,
|
||||||
|
}),
|
||||||
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
||||||
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
||||||
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
||||||
|
|
@ -158,9 +184,9 @@ export class FrontendStack extends Stack {
|
||||||
// Trust conditions for the OIDC principal. With a GitHub environment
|
// Trust conditions for the OIDC principal. With a GitHub environment
|
||||||
// (staging): exact StringEquals match on both aud and the environment
|
// (staging): exact StringEquals match on both aud and the environment
|
||||||
// subject — the staging workflow declares `environment: staging`, so only
|
// subject — the staging workflow declares `environment: staging`, so only
|
||||||
// runs in that environment can assume the role. Without one (dev): keep
|
// runs in that environment can assume the role. Normal dev synthesis keeps
|
||||||
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
|
// the current branch-ref StringLike trust. The adoption prerequisite
|
||||||
// deploy branch (reusable-workflow runs still carry the caller-based sub).
|
// narrows that already-exact value to StringEquals before Terraform import.
|
||||||
const oidcConditions = githubEnvironment
|
const oidcConditions = githubEnvironment
|
||||||
? {
|
? {
|
||||||
StringEquals: {
|
StringEquals: {
|
||||||
|
|
@ -168,30 +194,48 @@ export class FrontendStack extends Stack {
|
||||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: retainForTerraformAdoption
|
||||||
StringEquals: {
|
? {
|
||||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
StringEquals: {
|
||||||
},
|
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||||
StringLike: {
|
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||||
// 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}`,
|
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", {
|
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||||
maxSessionDuration: Duration.hours(1),
|
maxSessionDuration: Duration.hours(1),
|
||||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||||
|
permissionsBoundary: deployPermissionsBoundary,
|
||||||
});
|
});
|
||||||
|
if (retainForTerraformAdoption) {
|
||||||
|
Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
// Preserve dev's legacy CDK capability until the reviewed adoption update
|
||||||
// intentionally narrower: its recurring promotion workflow only publishes
|
// replaces this inline policy. Staging is intentionally narrower: its
|
||||||
// application assets to this stack's bucket/distribution. Infrastructure
|
// content role only publishes application assets to this stack's
|
||||||
// changes remain an administrator-run CDK operation, so the staging OIDC
|
// bucket/distribution. Infrastructure changes remain administrator-run.
|
||||||
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
|
||||||
if (!githubEnvironment) {
|
if (!githubEnvironment) {
|
||||||
deployRole.addToPolicy(
|
deployRole.addToPolicy(
|
||||||
new iam.PolicyStatement({
|
new iam.PolicyStatement({
|
||||||
|
|
@ -224,6 +268,8 @@ export class FrontendStack extends Stack {
|
||||||
// --- DNS: point the custom domain at CloudFront ------------------------
|
// --- DNS: point the custom domain at CloudFront ------------------------
|
||||||
// Only when a hosted zone is supplied (it must be in THIS account). Creates
|
// 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.
|
// 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) {
|
if (hostedZoneId && hasCustomDomain) {
|
||||||
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
||||||
hostedZoneId,
|
hostedZoneId,
|
||||||
|
|
@ -233,8 +279,12 @@ export class FrontendStack extends Stack {
|
||||||
// apex record when the domain equals the zone name.
|
// apex record when the domain equals the zone name.
|
||||||
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
||||||
|
|
||||||
new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||||
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
|
aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", {
|
||||||
|
zone,
|
||||||
|
recordName,
|
||||||
|
target,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Outputs -----------------------------------------------------------
|
// --- Outputs -----------------------------------------------------------
|
||||||
|
|
@ -257,7 +307,92 @@ export class FrontendStack extends Stack {
|
||||||
});
|
});
|
||||||
new CfnOutput(this, "DeployRoleArn", {
|
new CfnOutput(this, "DeployRoleArn", {
|
||||||
value: deployRole.roleArn,
|
value: deployRole.roleArn,
|
||||||
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
|
description: "Pinned GitHub OIDC content-deployment role",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (retainForTerraformAdoption) {
|
||||||
|
const originAccessControl = distribution.node
|
||||||
|
.findAll()
|
||||||
|
.find(
|
||||||
|
(node): node is cloudfront.CfnOriginAccessControl =>
|
||||||
|
node instanceof cloudfront.CfnOriginAccessControl,
|
||||||
|
);
|
||||||
|
if (!originAccessControl || !aliasA || !aliasAaaa) {
|
||||||
|
throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records.");
|
||||||
|
}
|
||||||
|
const originAccessControlConfig =
|
||||||
|
originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty;
|
||||||
|
|
||||||
|
const rolePolicy = deployRole.node
|
||||||
|
.findAll()
|
||||||
|
.find((node): node is iam.Policy => node instanceof iam.Policy);
|
||||||
|
const autoDeleteProviderRole = this.node
|
||||||
|
.findAll()
|
||||||
|
.find(
|
||||||
|
(node): node is CfnResource =>
|
||||||
|
node instanceof CfnResource &&
|
||||||
|
node.cfnResourceType === "AWS::IAM::Role" &&
|
||||||
|
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"),
|
||||||
|
);
|
||||||
|
if (!rolePolicy || !autoDeleteProviderRole) {
|
||||||
|
throw new Error("Terraform adoption outputs require deploy and auto-delete roles.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordName = domainNames[0];
|
||||||
|
new CfnOutput(this, "TerraformWorkspaceTag", {
|
||||||
|
value: `shoc-frontend-new-${envName}`,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformDeployBoundaryArn", {
|
||||||
|
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName });
|
||||||
|
new CfnOutput(this, "TerraformImportBucketPolicy", {
|
||||||
|
value: bucket.bucketName,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportDistribution", {
|
||||||
|
value: distribution.distributionId,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportOriginAccessControl", {
|
||||||
|
value: originAccessControl.attrId,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformOriginAccessControlName", {
|
||||||
|
value: originAccessControlConfig.name,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
|
||||||
|
value: "EMPTY_STRING",
|
||||||
|
description: "Use an empty Terraform string because the generated OAC has no description",
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformDistributionOriginId", {
|
||||||
|
value: originId!,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
|
||||||
|
value: spaRewrite.functionName,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportAliasA", {
|
||||||
|
value: `${hostedZoneId}_${recordName}_A`,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportAliasAAAA", {
|
||||||
|
value: `${hostedZoneId}_${recordName}_AAAA`,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportDeployRole", {
|
||||||
|
value: deployRole.roleName,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
|
||||||
|
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
|
||||||
|
value: rolePolicy.policyName,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
|
||||||
|
value: autoDeleteProviderRole.getAtt("Arn").toString(),
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
|
||||||
|
value: "SiteBucket/AutoDeleteObjectsCustomResource",
|
||||||
|
description:
|
||||||
|
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
|
||||||
|
});
|
||||||
|
|
||||||
|
Aspects.of(this).add(new RetainForTerraformAdoption());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
infra/cdk/lib/retain-for-terraform-adoption.ts
Normal file
56
infra/cdk/lib/retain-for-terraform-adoption.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { 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.
|
||||||
|
*/
|
||||||
|
export class RetainForTerraformAdoption implements IAspect {
|
||||||
|
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");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
|
||||||
|
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
infra/cdk/lib/tf-poc-shared-stack.ts
Normal file
75
infra/cdk/lib/tf-poc-shared-stack.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { CfnOutput, Fn, Stack, StackProps } from "aws-cdk-lib";
|
||||||
|
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||||
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||||
|
import { Construct } from "constructs";
|
||||||
|
|
||||||
|
const TF_POC_DOMAIN = "frontend-tf-poc.seahaven.com";
|
||||||
|
|
||||||
|
export interface TfPocCertificateStackProps extends StackProps {
|
||||||
|
readonly hostedZone: route53.IHostedZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary, isolated DNS zone for the production-shaped Terraform POC.
|
||||||
|
* Parent-zone delegation is deliberately excluded from this stack.
|
||||||
|
*/
|
||||||
|
export class TfPocZoneStack extends Stack {
|
||||||
|
public readonly hostedZone: route53.IHostedZone;
|
||||||
|
public readonly hostedZoneId: string;
|
||||||
|
public readonly hostedZoneName = TF_POC_DOMAIN;
|
||||||
|
|
||||||
|
public constructor(scope: Construct, id: string, props: StackProps) {
|
||||||
|
super(scope, id, props);
|
||||||
|
|
||||||
|
this.hostedZone = new route53.PublicHostedZone(this, "HostedZone", {
|
||||||
|
zoneName: TF_POC_DOMAIN,
|
||||||
|
comment: "Temporary isolated hosted zone for frontend Terraform adoption rehearsal",
|
||||||
|
});
|
||||||
|
this.hostedZoneId = this.hostedZone.hostedZoneId;
|
||||||
|
|
||||||
|
const nameServers = this.hostedZone.hostedZoneNameServers;
|
||||||
|
if (!nameServers) {
|
||||||
|
throw new Error("Public hosted zone must expose delegation name servers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
new CfnOutput(this, "HostedZoneId", {
|
||||||
|
value: this.hostedZone.hostedZoneId,
|
||||||
|
description: "Terraform aws_route53_zone import ID",
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "HostedZoneName", { value: TF_POC_DOMAIN });
|
||||||
|
new CfnOutput(this, "DelegationNameServers", {
|
||||||
|
value: Fn.join(",", nameServers),
|
||||||
|
description:
|
||||||
|
"Evidence only. Add these NS values to the seahaven.com parent zone in a separately approved change.",
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "DelegationRecordName", {
|
||||||
|
value: TF_POC_DOMAIN,
|
||||||
|
});
|
||||||
|
new CfnOutput(this, "DelegationRequiredAction", {
|
||||||
|
value:
|
||||||
|
"SEPARATE APPROVAL REQUIRED: create an NS record for frontend-tf-poc.seahaven.com in the parent seahaven.com zone",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate is isolated so re-running the zone phase cannot remove it.
|
||||||
|
*/
|
||||||
|
export class TfPocCertificateStack extends Stack {
|
||||||
|
public readonly certificateArn: string;
|
||||||
|
|
||||||
|
public constructor(scope: Construct, id: string, props: TfPocCertificateStackProps) {
|
||||||
|
super(scope, id, props);
|
||||||
|
|
||||||
|
const certificate = new acm.Certificate(this, "Certificate", {
|
||||||
|
domainName: TF_POC_DOMAIN,
|
||||||
|
validation: acm.CertificateValidation.fromDns(props.hostedZone),
|
||||||
|
});
|
||||||
|
this.certificateArn = certificate.certificateArn;
|
||||||
|
|
||||||
|
new CfnOutput(this, "CertificateArn", {
|
||||||
|
value: certificate.certificateArn,
|
||||||
|
description: "Inventory-only certificate ARN for the frontend tf-poc root",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,10 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test": "npm run build && node --test test/*.test.mjs",
|
||||||
"synth": "cdk synth",
|
"synth": "cdk synth",
|
||||||
|
"synth:tf-poc-zone": "cdk synth -c tfPoc=true -c tfPocPhase=zone",
|
||||||
|
"synth:tf-poc-environment": "cdk synth -c tfPoc=true -c tfPocPhase=environment",
|
||||||
"diff": "cdk diff",
|
"diff": "cdk diff",
|
||||||
"deploy": "cdk deploy"
|
"deploy": "cdk deploy"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
204
infra/cdk/test/frontend-stack.test.mjs
Normal file
204
infra/cdk/test/frontend-stack.test.mjs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
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 { TfPocCertificateStack, TfPocZoneStack } = require("../lib/tf-poc-shared-stack.js");
|
||||||
|
|
||||||
|
const account = "396287094661";
|
||||||
|
const region = "us-east-1";
|
||||||
|
|
||||||
|
function frontendTemplate(retainForTerraformAdoption) {
|
||||||
|
const app = new App();
|
||||||
|
const stack = new FrontendStack(app, "shoc-frontend-tf-poc", {
|
||||||
|
envName: "tf-poc",
|
||||||
|
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||||
|
deployBranch: "tf-poc",
|
||||||
|
githubEnvironment: "tf-poc",
|
||||||
|
domainNames: ["frontend-tf-poc.seahaven.com"],
|
||||||
|
certificateArn: `arn:aws:acm:${region}:${account}:certificate/test`,
|
||||||
|
hostedZoneId: "ZTESTPOC",
|
||||||
|
hostedZoneName: "frontend-tf-poc.seahaven.com",
|
||||||
|
retainForTerraformAdoption,
|
||||||
|
env: { account, region },
|
||||||
|
});
|
||||||
|
return Template.fromStack(stack).toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
function entriesByType(template, type) {
|
||||||
|
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tf-poc has fixed production-shaped resources and adoption metadata", () => {
|
||||||
|
const template = frontendTemplate(true);
|
||||||
|
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||||
|
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-tf-poc");
|
||||||
|
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 [deployRole] = entriesByType(template, "AWS::IAM::Role").filter(
|
||||||
|
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc",
|
||||||
|
);
|
||||||
|
assert.ok(deployRole);
|
||||||
|
assert.equal(
|
||||||
|
deployRole[1].Properties.PermissionsBoundary,
|
||||||
|
`arn:aws:iam::${account}:policy/shoc-frontend-new-tf-poc-deploy-boundary`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
deployRole[1].Properties.Tags.some(
|
||||||
|
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-tf-poc",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
|
||||||
|
assert.equal(
|
||||||
|
entriesByType(template, "AWS::CloudFront::Distribution")[0][1].Properties.DistributionConfig
|
||||||
|
.Origins[0].Id,
|
||||||
|
"shoc-frontend-tf-poc-origin",
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adoption mode retains exactly the transferred resources", () => {
|
||||||
|
const template = frontendTemplate(true);
|
||||||
|
const retainedTypes = new Set([
|
||||||
|
"AWS::S3::Bucket",
|
||||||
|
"AWS::S3::BucketPolicy",
|
||||||
|
"AWS::CloudFront::Distribution",
|
||||||
|
"AWS::CloudFront::Function",
|
||||||
|
"AWS::CloudFront::OriginAccessControl",
|
||||||
|
"AWS::Route53::RecordSet",
|
||||||
|
"Custom::S3AutoDeleteObjects",
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||||
|
const isDeployRoleResource =
|
||||||
|
(resource.Type === "AWS::IAM::Role" &&
|
||||||
|
resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc") ||
|
||||||
|
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
|
||||||
|
const shouldRetain = retainedTypes.has(resource.Type) || isDeployRoleResource;
|
||||||
|
if (shouldRetain) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
|
||||||
|
assert.equal(customResource.DeletionPolicy, "Retain");
|
||||||
|
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
|
||||||
|
for (const [, resource] of entriesByType(template, type)) {
|
||||||
|
assert.notEqual(resource.DeletionPolicy, "Retain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normal mode preserves destructive cleanup and has no adoption boundary or tag", () => {
|
||||||
|
const template = frontendTemplate(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 === "githubdeploy-shoc-frontend-new-tf-poc",
|
||||||
|
)[1];
|
||||||
|
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
|
||||||
|
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
|
||||||
|
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dev adoption prerequisite preserves origin ID and narrows exact trust", () => {
|
||||||
|
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/test`,
|
||||||
|
hostedZoneId: "Z07671212N75U4YLPWZR8",
|
||||||
|
hostedZoneName: "dev.seahaven.com",
|
||||||
|
retainForTerraformAdoption: true,
|
||||||
|
env: { account, region },
|
||||||
|
});
|
||||||
|
const template = Template.fromStack(stack).toJSON();
|
||||||
|
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
|
||||||
|
assert.equal(
|
||||||
|
distribution.Properties.DistributionConfig.Origins[0].Id,
|
||||||
|
"shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||||
|
);
|
||||||
|
|
||||||
|
const deployRole = entriesByType(template, "AWS::IAM::Role").find(
|
||||||
|
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-dev",
|
||||||
|
)[1];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zone stack never owns a certificate or parent delegation", () => {
|
||||||
|
const app = new App();
|
||||||
|
const stack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
|
||||||
|
env: { account, region },
|
||||||
|
});
|
||||||
|
const template = Template.fromStack(stack).toJSON();
|
||||||
|
assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 1);
|
||||||
|
assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 0);
|
||||||
|
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 0);
|
||||||
|
assert.ok(template.Outputs.DelegationNameServers);
|
||||||
|
assert.equal(template.Outputs.CertificateArn, undefined);
|
||||||
|
assert.match(template.Outputs.DelegationRequiredAction.Value, /SEPARATE APPROVAL REQUIRED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("environment phase creates its certificate in a separate stack", () => {
|
||||||
|
const app = new App();
|
||||||
|
const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
|
||||||
|
env: { account, region },
|
||||||
|
});
|
||||||
|
const stack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", {
|
||||||
|
env: { account, region },
|
||||||
|
hostedZone: zoneStack.hostedZone,
|
||||||
|
});
|
||||||
|
const template = Template.fromStack(stack).toJSON();
|
||||||
|
assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 0);
|
||||||
|
assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 1);
|
||||||
|
assert.ok(template.Outputs.CertificateArn);
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:deploy-web": "node scripts/deploy-web.test.mjs",
|
||||||
|
"test:terraform-import-plan": "python scripts/test-terraform-import-plan-check.py",
|
||||||
|
"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:tf-poc-zone && npm --prefix infra/cdk run synth:tf-poc-environment",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|
|
||||||
514
scripts/check-terraform-import-plan.py
Normal file
514
scripts/check-terraform-import-plan.py
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reject plans that violate the frontend Terraform adoption boundary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from terraform_import_plan_resources import (
|
||||||
|
CONTROLLED_UPDATE_ADDRESSES,
|
||||||
|
ENVIRONMENT_CONFIG,
|
||||||
|
REQUIRED_IMPORT_IDS,
|
||||||
|
REQUIRED_RESOURCES,
|
||||||
|
)
|
||||||
|
|
||||||
|
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||||
|
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
|
||||||
|
DEPLOY_POLICY_ADDRESS = (
|
||||||
|
"module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||||
|
)
|
||||||
|
DISTRIBUTION_ADDRESS = (
|
||||||
|
"module.environment_owned.aws_cloudfront_distribution.site"
|
||||||
|
)
|
||||||
|
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
|
||||||
|
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
|
||||||
|
BUCKET_POLICY_ADDRESS,
|
||||||
|
DEPLOY_POLICY_ADDRESS,
|
||||||
|
}
|
||||||
|
OWNERSHIP_TAGS = {
|
||||||
|
"Environment": None,
|
||||||
|
"ManagedBy": "terraform",
|
||||||
|
"Ownership": "terraform",
|
||||||
|
"Project": "shoc-frontend",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("plan_json", type=Path)
|
||||||
|
parser.add_argument(
|
||||||
|
"--environment",
|
||||||
|
required=True,
|
||||||
|
choices=sorted(REQUIRED_RESOURCES),
|
||||||
|
help="Exact environment ownership boundary expected in the plan.",
|
||||||
|
)
|
||||||
|
modes = parser.add_mutually_exclusive_group()
|
||||||
|
modes.add_argument(
|
||||||
|
"--post-import-no-op",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Require all managed resources to be no-op after import and forbid "
|
||||||
|
"import metadata."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
modes.add_argument(
|
||||||
|
"--allow-update-address",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="ADDRESS",
|
||||||
|
help=(
|
||||||
|
"Enter controlled-update mode and allow one exact reviewed address. "
|
||||||
|
"Repeat for every expected update."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_plan(path: Path) -> dict[str, Any]:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("plan JSON root must be an object")
|
||||||
|
if not isinstance(value.get("resource_changes"), list):
|
||||||
|
raise ValueError("plan JSON must contain a resource_changes array")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_import_metadata(
|
||||||
|
*,
|
||||||
|
address: str,
|
||||||
|
change: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
) -> list[str]:
|
||||||
|
importing = change.get("importing")
|
||||||
|
if not isinstance(importing, dict) or set(importing) != {"id"}:
|
||||||
|
return [f"{address}: import metadata must be exactly {{'id': <string>}}"]
|
||||||
|
|
||||||
|
import_id = importing.get("id")
|
||||||
|
if not isinstance(import_id, str) or not import_id.strip():
|
||||||
|
return [f"{address}: import ID must be a non-empty string"]
|
||||||
|
if import_id.startswith("REPLACE_WITH_"):
|
||||||
|
return [f"{address}: import ID is still a placeholder"]
|
||||||
|
|
||||||
|
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||||
|
if expected is not None and import_id != expected:
|
||||||
|
return [f"{address}: expected import ID {expected!r}, got {import_id!r}"]
|
||||||
|
|
||||||
|
other_environment_ids = {
|
||||||
|
imports[address]
|
||||||
|
for name, imports in REQUIRED_IMPORT_IDS.items()
|
||||||
|
if name != environment and imports[address] is not None
|
||||||
|
}
|
||||||
|
if import_id in other_environment_ids:
|
||||||
|
return [f"{address}: import ID belongs to another environment"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_unknown(value: Any) -> bool:
|
||||||
|
if value is True:
|
||||||
|
return True
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(_contains_unknown(item) for item in value.values())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(_contains_unknown(item) for item in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_leaf_paths(
|
||||||
|
before: Any,
|
||||||
|
after: Any,
|
||||||
|
path: tuple[str, ...] = (),
|
||||||
|
) -> set[tuple[str, ...]]:
|
||||||
|
if isinstance(before, dict) and isinstance(after, dict):
|
||||||
|
result: set[tuple[str, ...]] = set()
|
||||||
|
for key in set(before) | set(after):
|
||||||
|
result.update(
|
||||||
|
_changed_leaf_paths(
|
||||||
|
before.get(key),
|
||||||
|
after.get(key),
|
||||||
|
(*path, str(key)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if before != after:
|
||||||
|
return {path}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _canonical(value[key]) for key in sorted(value)}
|
||||||
|
if isinstance(value, list):
|
||||||
|
items = [_canonical(item) for item in value]
|
||||||
|
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_policy(value: Any, address: str, side: str) -> tuple[Any, list[str]]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None, [f"{address}: {side} policy must be a JSON string"]
|
||||||
|
try:
|
||||||
|
document = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, [f"{address}: {side} policy is not valid JSON"]
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
return None, [f"{address}: {side} policy must be a JSON object"]
|
||||||
|
return _canonical(document), []
|
||||||
|
|
||||||
|
|
||||||
|
def _distribution_id(
|
||||||
|
plan: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
) -> str | None:
|
||||||
|
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||||
|
if isinstance(configured, str):
|
||||||
|
return configured
|
||||||
|
for resource in plan["resource_changes"]:
|
||||||
|
if not isinstance(resource, dict) or resource.get("address") != DISTRIBUTION_ADDRESS:
|
||||||
|
continue
|
||||||
|
after = resource.get("change", {}).get("after")
|
||||||
|
if isinstance(after, dict):
|
||||||
|
identifier = after.get("id")
|
||||||
|
if isinstance(identifier, str) and identifier.strip():
|
||||||
|
return identifier
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||||
|
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||||
|
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||||
|
distribution_arn = (
|
||||||
|
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||||
|
)
|
||||||
|
return _canonical(
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": f"{bucket_arn}/*",
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Principal": {"AWS": "*"},
|
||||||
|
"Action": "s3:*",
|
||||||
|
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||||
|
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||||
|
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||||
|
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||||
|
distribution_arn = (
|
||||||
|
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||||
|
)
|
||||||
|
return _canonical(
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "ReadDeploymentBucket",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:GetBucketLocation",
|
||||||
|
"s3:GetBucketVersioning",
|
||||||
|
"s3:ListBucket",
|
||||||
|
"s3:ListBucketVersions",
|
||||||
|
],
|
||||||
|
"Resource": bucket_arn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "PublishAndRollbackSiteObjects",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:DeleteObject",
|
||||||
|
"s3:DeleteObjectVersion",
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:GetObjectVersion",
|
||||||
|
"s3:PutObject",
|
||||||
|
],
|
||||||
|
"Resource": f"{bucket_arn}/*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "InvalidateDistribution",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"cloudfront:CreateInvalidation",
|
||||||
|
"cloudfront:GetInvalidation",
|
||||||
|
],
|
||||||
|
"Resource": distribution_arn,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tag_update(
|
||||||
|
address: str,
|
||||||
|
before: dict[str, Any],
|
||||||
|
after: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
) -> list[str]:
|
||||||
|
changed = _changed_leaf_paths(before, after)
|
||||||
|
invalid = {
|
||||||
|
path
|
||||||
|
for path in changed
|
||||||
|
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
|
||||||
|
}
|
||||||
|
violations = [
|
||||||
|
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
|
||||||
|
for path in sorted(invalid)
|
||||||
|
]
|
||||||
|
expected = {**OWNERSHIP_TAGS, "Environment": environment}
|
||||||
|
if address == ROLE_ADDRESS:
|
||||||
|
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
|
||||||
|
"workspace_name"
|
||||||
|
]
|
||||||
|
if address == BUCKET_ADDRESS:
|
||||||
|
expected["aws-cdk:auto-delete-objects"] = None
|
||||||
|
expected_after = {
|
||||||
|
key: value for key, value in expected.items() if value is not None
|
||||||
|
}
|
||||||
|
for tag_attribute in ("tags", "tags_all"):
|
||||||
|
if after.get(tag_attribute) != expected_after:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||||
|
)
|
||||||
|
for path in sorted(changed - invalid):
|
||||||
|
key = path[1]
|
||||||
|
if key not in expected:
|
||||||
|
violations.append(f"{address}: tag {key!r} is not an ownership tag")
|
||||||
|
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
|
||||||
|
violations.append(
|
||||||
|
f"{address}: legacy auto-delete ownership tag was not removed"
|
||||||
|
)
|
||||||
|
elif after.get(path[0], {}).get(key) != expected[key]:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: tag {key!r} does not have its expected adopted value"
|
||||||
|
)
|
||||||
|
if not changed:
|
||||||
|
violations.append(f"{address}: update has no changed leaf values")
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_policy_update(
|
||||||
|
address: str,
|
||||||
|
before: dict[str, Any],
|
||||||
|
after: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
distribution_id: str | None,
|
||||||
|
) -> list[str]:
|
||||||
|
changed = _changed_leaf_paths(before, after)
|
||||||
|
if changed != {("policy",)}:
|
||||||
|
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
|
||||||
|
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
|
||||||
|
after_policy, after_violations = _parse_policy(
|
||||||
|
after.get("policy"), address, "after"
|
||||||
|
)
|
||||||
|
violations.extend(after_violations)
|
||||||
|
if before_policy == after_policy:
|
||||||
|
violations.append(f"{address}: policy semantics did not change")
|
||||||
|
if distribution_id is None:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: cannot verify policy without the pinned distribution ID"
|
||||||
|
)
|
||||||
|
return violations
|
||||||
|
expected = (
|
||||||
|
_expected_bucket_policy(environment, distribution_id)
|
||||||
|
if address == BUCKET_POLICY_ADDRESS
|
||||||
|
else _expected_deploy_policy(environment, distribution_id)
|
||||||
|
)
|
||||||
|
if after_policy is not None and after_policy != expected:
|
||||||
|
violations.append(f"{address}: post-adoption policy semantics are not exact")
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_controlled_update(
|
||||||
|
address: str,
|
||||||
|
change: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
distribution_id: str | None,
|
||||||
|
) -> list[str]:
|
||||||
|
violations: list[str] = []
|
||||||
|
replace_paths = change.get("replace_paths", [])
|
||||||
|
if replace_paths not in (None, []):
|
||||||
|
violations.append(f"{address}: replace_paths must be empty")
|
||||||
|
if _contains_unknown(change.get("after_unknown", {})):
|
||||||
|
violations.append(f"{address}: controlled update contains unknown values")
|
||||||
|
before = change.get("before")
|
||||||
|
after = change.get("after")
|
||||||
|
if not isinstance(before, dict) or not isinstance(after, dict):
|
||||||
|
return [*violations, f"{address}: controlled update requires before/after objects"]
|
||||||
|
if address in TAG_UPDATE_ADDRESSES:
|
||||||
|
violations.extend(_validate_tag_update(address, before, after, environment))
|
||||||
|
elif address in {BUCKET_POLICY_ADDRESS, DEPLOY_POLICY_ADDRESS}:
|
||||||
|
violations.extend(
|
||||||
|
_validate_policy_update(
|
||||||
|
address,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
environment,
|
||||||
|
distribution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_plan(
|
||||||
|
plan: dict[str, Any],
|
||||||
|
*,
|
||||||
|
environment: str,
|
||||||
|
mode: str,
|
||||||
|
allowed_updates: set[str],
|
||||||
|
) -> list[str]:
|
||||||
|
violations: list[str] = []
|
||||||
|
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
|
||||||
|
for address in sorted(invalid_allowed):
|
||||||
|
violations.append(
|
||||||
|
f"{address}: address is not eligible for the controlled adoption update"
|
||||||
|
)
|
||||||
|
|
||||||
|
distribution_id = _distribution_id(plan, environment)
|
||||||
|
seen_addresses: set[str] = set()
|
||||||
|
seen_updates: set[str] = set()
|
||||||
|
required_resources = REQUIRED_RESOURCES[environment]
|
||||||
|
for resource in plan["resource_changes"]:
|
||||||
|
if not isinstance(resource, dict):
|
||||||
|
violations.append("<unknown>: resource change must be an object")
|
||||||
|
continue
|
||||||
|
if resource.get("mode", "managed") != "managed":
|
||||||
|
continue
|
||||||
|
address = resource.get("address")
|
||||||
|
if not isinstance(address, str):
|
||||||
|
violations.append("<unknown>: managed resource has no valid address")
|
||||||
|
continue
|
||||||
|
if address in seen_addresses:
|
||||||
|
violations.append(f"{address}: duplicate managed resource change")
|
||||||
|
seen_addresses.add(address)
|
||||||
|
|
||||||
|
expected_type = required_resources.get(address)
|
||||||
|
if expected_type is None:
|
||||||
|
violations.append(f"{address}: managed address is outside the ownership boundary")
|
||||||
|
elif resource.get("type") != expected_type:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: expected managed type {expected_type!r}, "
|
||||||
|
f"got {resource.get('type')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
change = resource.get("change")
|
||||||
|
if not isinstance(change, dict):
|
||||||
|
violations.append(f"{address}: missing change object")
|
||||||
|
continue
|
||||||
|
actions = change.get("actions")
|
||||||
|
if not isinstance(actions, list) or not all(
|
||||||
|
isinstance(action, str) for action in actions
|
||||||
|
):
|
||||||
|
violations.append(f"{address}: actions must be a string array")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if change.get("replace_paths") not in (None, []):
|
||||||
|
violations.append(f"{address}: replace_paths must be empty")
|
||||||
|
|
||||||
|
if mode == "import":
|
||||||
|
if actions != ["no-op"]:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: import mode requires no-op, got {actions!r}"
|
||||||
|
)
|
||||||
|
if expected_type is not None:
|
||||||
|
violations.extend(
|
||||||
|
_validate_import_metadata(
|
||||||
|
address=address,
|
||||||
|
change=change,
|
||||||
|
environment=environment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif mode == "post-import":
|
||||||
|
if actions != ["no-op"]:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: post-import mode requires no-op, got {actions!r}"
|
||||||
|
)
|
||||||
|
if "importing" in change:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: import metadata is forbidden in post-import mode"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if "importing" in change:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: import metadata is forbidden in controlled-update mode"
|
||||||
|
)
|
||||||
|
if actions == ["update"]:
|
||||||
|
seen_updates.add(address)
|
||||||
|
if address not in allowed_updates:
|
||||||
|
violations.append(f"{address}: update is not explicitly allowlisted")
|
||||||
|
else:
|
||||||
|
violations.extend(
|
||||||
|
_validate_controlled_update(
|
||||||
|
address,
|
||||||
|
change,
|
||||||
|
environment,
|
||||||
|
distribution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif actions != ["no-op"]:
|
||||||
|
violations.append(f"{address}: unsafe controlled actions {actions!r}")
|
||||||
|
|
||||||
|
for missing in sorted(set(required_resources) - seen_addresses):
|
||||||
|
violations.append(f"{missing}: required managed resource is absent")
|
||||||
|
for unused in sorted(allowed_updates - seen_updates):
|
||||||
|
violations.append(f"{unused}: allowlisted update address is not updating")
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
plan = _load_plan(args.plan_json)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
allowed_updates = set(args.allow_update_address or [])
|
||||||
|
if args.post_import_no_op:
|
||||||
|
mode = "post-import"
|
||||||
|
elif allowed_updates:
|
||||||
|
mode = "controlled"
|
||||||
|
else:
|
||||||
|
mode = "import"
|
||||||
|
violations = check_plan(
|
||||||
|
plan,
|
||||||
|
environment=args.environment,
|
||||||
|
mode=mode,
|
||||||
|
allowed_updates=allowed_updates,
|
||||||
|
)
|
||||||
|
if violations:
|
||||||
|
print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr)
|
||||||
|
for violation in violations:
|
||||||
|
print(f" - {violation}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
label = {
|
||||||
|
"import": "zero-change import",
|
||||||
|
"post-import": "post-import no-op",
|
||||||
|
"controlled": "controlled update",
|
||||||
|
}[mode]
|
||||||
|
print(
|
||||||
|
f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} "
|
||||||
|
f"managed resources and {len(allowed_updates)} exact updates"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,65 +1,436 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
set -Eeuo pipefail
|
||||||
# Post-deploy step for the org reusable workflow `cd-cdk.yaml`
|
|
||||||
# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`).
|
|
||||||
#
|
|
||||||
# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub
|
|
||||||
# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with
|
|
||||||
# the right cache headers, and invalidates CloudFront.
|
|
||||||
#
|
|
||||||
# Runs from the repo root. Reads the bucket + distribution from stack outputs,
|
|
||||||
# so it has no hardcoded resource IDs.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
required_vars=(
|
||||||
REGION="${AWS_REGION:-us-east-1}"
|
SITE_BUCKET
|
||||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
EXPECTED_SITE_BUCKET
|
||||||
|
CLOUDFRONT_DISTRIBUTION_ID
|
||||||
|
SITE_URL
|
||||||
|
EXPECTED_API_URL
|
||||||
|
)
|
||||||
|
for name in "${required_vars[@]}"; do
|
||||||
|
if [[ -z "${!name:-}" ]]; then
|
||||||
|
echo "::error::${name} must be set explicitly." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
DEPLOY_RELEASE_ID="${DEPLOY_RELEASE_ID:-${GITHUB_SHA:-}}"
|
||||||
npm ci
|
EXTENSIONLESS_SMOKE_PATH="${EXTENSIONLESS_SMOKE_PATH:-/deployment-smoke}"
|
||||||
npm run build
|
FORBIDDEN_API_URLS="${FORBIDDEN_API_URLS:-}"
|
||||||
|
API_SMOKE_URL="${API_SMOKE_URL:-}"
|
||||||
|
API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}"
|
||||||
|
|
||||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
if [[ "${SITE_BUCKET}" != "${EXPECTED_SITE_BUCKET}" ]]; then
|
||||||
stack_output() {
|
echo "::error::SITE_BUCKET does not match EXPECTED_SITE_BUCKET." >&2
|
||||||
aws cloudformation describe-stacks \
|
exit 1
|
||||||
--stack-name "${STACK_NAME}" \
|
fi
|
||||||
--region "${REGION}" \
|
if [[ "${VITE_API_URL:-}" != "${EXPECTED_API_URL}" ]]; then
|
||||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
echo "::error::VITE_API_URL must exactly match EXPECTED_API_URL." >&2
|
||||||
--output text
|
exit 1
|
||||||
}
|
fi
|
||||||
|
if [[ ! "${DEPLOY_RELEASE_ID}" =~ ^[A-Za-z0-9._-]{7,128}$ ]]; then
|
||||||
BUCKET="$(stack_output BucketName)"
|
echo "::error::DEPLOY_RELEASE_ID is missing or unsafe." >&2
|
||||||
DIST_ID="$(stack_output DistributionId)"
|
exit 1
|
||||||
|
fi
|
||||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
if [[ ! "${SITE_URL}" =~ ^https://[^/]+/?$ || ! "${EXPECTED_API_URL}" =~ ^https:// ]]; then
|
||||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
echo "::error::SITE_URL and EXPECTED_API_URL must be HTTPS URLs." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "${EXTENSIONLESS_SMOKE_PATH}" != /* || "${EXTENSIONLESS_SMOKE_PATH}" == *.* ]]; then
|
||||||
|
echo "::error::EXTENSIONLESS_SMOKE_PATH must be an extensionless absolute path." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
SITE_URL="${SITE_URL%/}"
|
||||||
# Everything except index.html: long-lived + immutable, prune stale objects.
|
work_dir="$(mktemp -d)"
|
||||||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
published_index_version=""
|
||||||
--delete \
|
prior_index_version=""
|
||||||
|
deployment_verified="false"
|
||||||
|
|
||||||
|
invalidate_and_wait() {
|
||||||
|
local invalidation_id
|
||||||
|
invalidation_id="$(
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||||
|
--paths "/*" \
|
||||||
|
--query "Invalidation.Id" \
|
||||||
|
--output text
|
||||||
|
)"
|
||||||
|
test -n "${invalidation_id}"
|
||||||
|
aws cloudfront wait invalidation-completed \
|
||||||
|
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||||
|
--id "${invalidation_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_index() {
|
||||||
|
[[ -n "${published_index_version}" ]] || return 0
|
||||||
|
echo "::warning::Verification failed. Restoring the prior index version." >&2
|
||||||
|
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
|
||||||
|
aws s3api copy-object \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--key index.html \
|
||||||
|
--copy-source "${SITE_BUCKET}/index.html?versionId=${prior_index_version}" \
|
||||||
|
--cache-control "no-cache,no-store,must-revalidate" \
|
||||||
|
--content-type "text/html" \
|
||||||
|
--metadata-directive REPLACE >/dev/null
|
||||||
|
else
|
||||||
|
aws s3api delete-object \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--key index.html \
|
||||||
|
--version-id "${published_index_version}" >/dev/null
|
||||||
|
fi
|
||||||
|
invalidate_and_wait || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
if [[ "${status}" -ne 0 && "${deployment_verified}" != "true" ]]; then
|
||||||
|
rollback_index
|
||||||
|
fi
|
||||||
|
rm -rf "${work_dir}"
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
versioning_status="$(
|
||||||
|
aws s3api get-bucket-versioning \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--query Status \
|
||||||
|
--output text
|
||||||
|
)"
|
||||||
|
if [[ "${versioning_status}" != "Enabled" ]]; then
|
||||||
|
echo "::error::The target bucket must have versioning enabled." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! prior_index_version="$(
|
||||||
|
aws s3api head-object \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--key index.html \
|
||||||
|
--query VersionId \
|
||||||
|
--output text 2>"${work_dir}/prior-index.error"
|
||||||
|
)"; then
|
||||||
|
if grep -Eqi "(404|Not Found|NoSuchKey)" "${work_dir}/prior-index.error"; then
|
||||||
|
prior_index_version=""
|
||||||
|
else
|
||||||
|
cat "${work_dir}/prior-index.error" >&2
|
||||||
|
echo "::error::Could not inspect the current index version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
: >"${work_dir}/prior-asset-versions.tsv"
|
||||||
|
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
|
||||||
|
prior_manifest_key="$(
|
||||||
|
aws s3api list-objects-v2 \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--prefix ".deploy/releases/" \
|
||||||
|
--query "reverse(sort_by(Contents,&LastModified))[0].Key" \
|
||||||
|
--output text
|
||||||
|
)"
|
||||||
|
if [[ -n "${prior_manifest_key}" && "${prior_manifest_key}" != "None" ]]; then
|
||||||
|
aws s3 cp "s3://${SITE_BUCKET}/${prior_manifest_key}" \
|
||||||
|
"${work_dir}/prior-manifest.json" --quiet
|
||||||
|
node - "${work_dir}/prior-manifest.json" \
|
||||||
|
>"${work_dir}/prior-asset-versions.tsv" <<'NODE'
|
||||||
|
const manifest = require(process.argv[2]);
|
||||||
|
for (const asset of manifest.assets ?? []) {
|
||||||
|
if (typeof asset === "object" && asset.key && asset.versionId) {
|
||||||
|
console.log(`${asset.key}\t${asset.versionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
else
|
||||||
|
aws s3api list-objects-v2 \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--query "Contents[].Key" \
|
||||||
|
--output text | tr "\t" "\n" \
|
||||||
|
| awk '$0 != "index.html" && $0 !~ /^\.deploy\// && $0 != "None"' \
|
||||||
|
| sort -u >"${work_dir}/prior-asset-keys.txt"
|
||||||
|
while IFS= read -r prior_asset_key; do
|
||||||
|
[[ -n "${prior_asset_key}" ]] || continue
|
||||||
|
prior_asset_version="$(
|
||||||
|
aws s3api head-object \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--key "${prior_asset_key}" \
|
||||||
|
--query VersionId \
|
||||||
|
--output text
|
||||||
|
)"
|
||||||
|
if [[ -z "${prior_asset_version}" || "${prior_asset_version}" == "None" ]]; then
|
||||||
|
echo "::error::Prior asset ${prior_asset_key} did not resolve to a version ID." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf "%s\t%s\n" "${prior_asset_key}" "${prior_asset_version}" \
|
||||||
|
>>"${work_dir}/prior-asset-versions.tsv"
|
||||||
|
done <"${work_dir}/prior-asset-keys.txt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building SPA for ${EXPECTED_API_URL}..."
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
test -s dist/index.html
|
||||||
|
if ! grep -RqsF -- "${EXPECTED_API_URL}" dist; then
|
||||||
|
echo "::error::Built output does not contain EXPECTED_API_URL." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
|
||||||
|
if [[ -n "${forbidden_url}" ]] && grep -RqsF -- "${forbidden_url}" dist; then
|
||||||
|
echo "::error::Built output contains forbidden API URL ${forbidden_url}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Publishing immutable release assets..."
|
||||||
|
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
|
||||||
--exclude "index.html" \
|
--exclude "index.html" \
|
||||||
--cache-control "public,max-age=31536000,immutable"
|
--cache-control "public,max-age=31536000,immutable"
|
||||||
|
|
||||||
echo "Uploading index.html (never cached)..."
|
published_index_version="$(
|
||||||
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
aws s3api put-object \
|
||||||
--cache-control "no-cache,no-store,must-revalidate" \
|
--bucket "${SITE_BUCKET}" \
|
||||||
--content-type "text/html"
|
--key index.html \
|
||||||
|
--body dist/index.html \
|
||||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
--cache-control "no-cache,no-store,must-revalidate" \
|
||||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
--content-type "text/html" \
|
||||||
--distribution-id "${DIST_ID}" \
|
--query VersionId \
|
||||||
--paths "/*" \
|
--output text
|
||||||
--query 'Invalidation.Id' \
|
)"
|
||||||
--output text)"
|
if [[ -z "${published_index_version}" || "${published_index_version}" == "None" ]]; then
|
||||||
|
echo "::error::Index upload did not return a version ID." >&2
|
||||||
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
|
exit 1
|
||||||
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
|
|
||||||
aws cloudfront wait invalidation-completed \
|
|
||||||
--distribution-id "${DIST_ID}" \
|
|
||||||
--id "${INVALIDATION_ID}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Web deploy complete."
|
node - >"${work_dir}/asset-keys.txt" <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
function files(directory, prefix = "") {
|
||||||
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const relative = path.posix.join(prefix, entry.name);
|
||||||
|
return entry.isDirectory()
|
||||||
|
? files(path.join(directory, entry.name), relative)
|
||||||
|
: [relative];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const file of files("dist").filter((entry) => entry !== "index.html").sort()) {
|
||||||
|
console.log(file);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
: >"${work_dir}/asset-versions.tsv"
|
||||||
|
while IFS= read -r asset_key; do
|
||||||
|
asset_version="$(
|
||||||
|
aws s3api head-object \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--key "${asset_key}" \
|
||||||
|
--query VersionId \
|
||||||
|
--output text
|
||||||
|
)"
|
||||||
|
if [[ -z "${asset_version}" || "${asset_version}" == "None" ]]; then
|
||||||
|
echo "::error::Asset ${asset_key} did not resolve to a version ID." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf "%s\t%s\n" "${asset_key}" "${asset_version}" >>"${work_dir}/asset-versions.tsv"
|
||||||
|
done <"${work_dir}/asset-keys.txt"
|
||||||
|
|
||||||
|
node - "${DEPLOY_RELEASE_ID}" "${published_index_version}" "${prior_index_version}" \
|
||||||
|
"${work_dir}/asset-versions.tsv" "${work_dir}/prior-asset-versions.tsv" \
|
||||||
|
>"${work_dir}/manifest.json" <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const [release, indexVersion, priorIndexVersion, versionsPath, priorVersionsPath] =
|
||||||
|
process.argv.slice(2);
|
||||||
|
function readVersions(path) {
|
||||||
|
return fs
|
||||||
|
.readFileSync(path, "utf8")
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [key, versionId] = line.split("\t");
|
||||||
|
return { key, versionId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
release,
|
||||||
|
indexVersion,
|
||||||
|
priorIndexVersion,
|
||||||
|
assets: readVersions(versionsPath),
|
||||||
|
priorAssets: readVersions(priorVersionsPath),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
NODE
|
||||||
|
manifest_key=".deploy/releases/${DEPLOY_RELEASE_ID}.json"
|
||||||
|
|
||||||
|
echo "Invalidating CloudFront and waiting for propagation..."
|
||||||
|
invalidate_and_wait
|
||||||
|
|
||||||
|
fetch_route() {
|
||||||
|
local route="$1"
|
||||||
|
local slug="$2"
|
||||||
|
curl -fsS --max-time 30 \
|
||||||
|
-D "${work_dir}/${slug}.headers" \
|
||||||
|
-o "${work_dir}/${slug}.body" \
|
||||||
|
"${SITE_URL}${route}"
|
||||||
|
grep -qi "^content-type:.*text/html" "${work_dir}/${slug}.headers"
|
||||||
|
grep -qi "^cache-control:.*no-cache" "${work_dir}/${slug}.headers"
|
||||||
|
grep -qi "^cache-control:.*no-store" "${work_dir}/${slug}.headers"
|
||||||
|
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/${slug}.headers"
|
||||||
|
cmp -s "${work_dir}/${slug}.body" "${work_dir}/root.body"
|
||||||
|
}
|
||||||
|
|
||||||
|
curl -fsS --max-time 30 \
|
||||||
|
-D "${work_dir}/root.headers" \
|
||||||
|
-o "${work_dir}/root.body" \
|
||||||
|
"${SITE_URL}/"
|
||||||
|
grep -qi "^content-type:.*text/html" "${work_dir}/root.headers"
|
||||||
|
grep -qi "^cache-control:.*no-cache" "${work_dir}/root.headers"
|
||||||
|
grep -qi "^cache-control:.*no-store" "${work_dir}/root.headers"
|
||||||
|
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/root.headers"
|
||||||
|
fetch_route "/login" "login"
|
||||||
|
fetch_route "${EXTENSIONLESS_SMOKE_PATH}" "extensionless"
|
||||||
|
|
||||||
|
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${work_dir}/root.body" \
|
||||||
|
| awk -F'"' '{ print $2 }' \
|
||||||
|
| sort -u >"${work_dir}/asset-paths.txt"
|
||||||
|
test -s "${work_dir}/asset-paths.txt"
|
||||||
|
: >"${work_dir}/asset-content.txt"
|
||||||
|
while IFS= read -r asset_path; do
|
||||||
|
asset_slug="$(printf "%s" "${asset_path}" | tr "/." "__")"
|
||||||
|
curl -fsS --max-time 30 \
|
||||||
|
-D "${work_dir}/${asset_slug}.headers" \
|
||||||
|
-o "${work_dir}/${asset_slug}.body" \
|
||||||
|
"${SITE_URL}${asset_path}"
|
||||||
|
grep -qi "^cache-control:.*max-age=31536000" "${work_dir}/${asset_slug}.headers"
|
||||||
|
grep -qi "^cache-control:.*immutable" "${work_dir}/${asset_slug}.headers"
|
||||||
|
cat "${work_dir}/${asset_slug}.body" >>"${work_dir}/asset-content.txt"
|
||||||
|
done <"${work_dir}/asset-paths.txt"
|
||||||
|
|
||||||
|
grep -qsF -- "${EXPECTED_API_URL}" "${work_dir}/asset-content.txt"
|
||||||
|
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
|
||||||
|
if [[ -n "${forbidden_url}" ]] &&
|
||||||
|
grep -qsF -- "${forbidden_url}" "${work_dir}/asset-content.txt"; then
|
||||||
|
echo "::error::Deployed assets contain forbidden API URL ${forbidden_url}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${API_SMOKE_URL}" ]]; then
|
||||||
|
api_status="$(
|
||||||
|
curl -sS --max-time 30 \
|
||||||
|
-H "Origin: ${API_CORS_ORIGIN}" \
|
||||||
|
-D "${work_dir}/api.headers" \
|
||||||
|
-o "${work_dir}/api.body" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${API_SMOKE_URL}"
|
||||||
|
)"
|
||||||
|
if [[ "${api_status}" == "000" || "${api_status}" -ge 500 ]]; then
|
||||||
|
echo "::error::API smoke request failed with HTTP ${api_status}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/api.headers"
|
||||||
|
|
||||||
|
curl -fsS --max-time 30 \
|
||||||
|
-X OPTIONS \
|
||||||
|
-H "Origin: ${API_CORS_ORIGIN}" \
|
||||||
|
-H "Access-Control-Request-Method: GET" \
|
||||||
|
-D "${work_dir}/cors.headers" \
|
||||||
|
-o /dev/null \
|
||||||
|
"${API_SMOKE_URL}"
|
||||||
|
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/cors.headers"
|
||||||
|
grep -qi "^access-control-allow-methods:.*GET" "${work_dir}/cors.headers"
|
||||||
|
fi
|
||||||
|
|
||||||
|
aws s3 cp "${work_dir}/manifest.json" "s3://${SITE_BUCKET}/${manifest_key}" \
|
||||||
|
--cache-control "no-cache,no-store,must-revalidate" \
|
||||||
|
--content-type "application/json"
|
||||||
|
|
||||||
|
# Once the manifest is durable, this release and its rollback target are both
|
||||||
|
# protected from pruning. A later cleanup failure must not roll back a release
|
||||||
|
# whose prior assets may already have been pruned.
|
||||||
|
deployment_verified="true"
|
||||||
|
|
||||||
|
# Keep exactly the current and immediately prior release manifests and every
|
||||||
|
# object version they reference. Prune only unreferenced versions, after all
|
||||||
|
# remote checks pass.
|
||||||
|
aws s3api list-objects-v2 \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--prefix ".deploy/releases/" \
|
||||||
|
--query "reverse(sort_by(Contents,&LastModified))[].Key" \
|
||||||
|
--output text | tr "\t" "\n" >"${work_dir}/manifest-keys.txt"
|
||||||
|
printf "%s\n" "${manifest_key}" >"${work_dir}/kept-manifests.txt"
|
||||||
|
awk -v current="${manifest_key}" '$0 != current { print; exit }' \
|
||||||
|
"${work_dir}/manifest-keys.txt" >>"${work_dir}/kept-manifests.txt"
|
||||||
|
: >"${work_dir}/retained-versions.tsv"
|
||||||
|
while IFS= read -r kept_manifest; do
|
||||||
|
[[ -n "${kept_manifest}" ]] || continue
|
||||||
|
aws s3 cp "s3://${SITE_BUCKET}/${kept_manifest}" "${work_dir}/kept.json" --quiet
|
||||||
|
kept_manifest_version="$(
|
||||||
|
aws s3api head-object \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--key "${kept_manifest}" \
|
||||||
|
--query VersionId \
|
||||||
|
--output text
|
||||||
|
)"
|
||||||
|
printf "%s\t%s\n" "${kept_manifest}" "${kept_manifest_version}" \
|
||||||
|
>>"${work_dir}/retained-versions.tsv"
|
||||||
|
is_current_manifest="false"
|
||||||
|
if [[ "${kept_manifest}" == "${manifest_key}" ]]; then
|
||||||
|
is_current_manifest="true"
|
||||||
|
fi
|
||||||
|
node - "${work_dir}/kept.json" "${is_current_manifest}" \
|
||||||
|
>>"${work_dir}/retained-versions.tsv" <<'NODE'
|
||||||
|
const manifest = require(process.argv[2]);
|
||||||
|
const isCurrentManifest = process.argv[3] === "true";
|
||||||
|
if (manifest.indexVersion && manifest.indexVersion !== "None") {
|
||||||
|
console.log(`index.html\t${manifest.indexVersion}`);
|
||||||
|
}
|
||||||
|
if (manifest.priorIndexVersion && manifest.priorIndexVersion !== "None") {
|
||||||
|
console.log(`index.html\t${manifest.priorIndexVersion}`);
|
||||||
|
}
|
||||||
|
for (const asset of manifest.assets) {
|
||||||
|
if (typeof asset === "object" && asset.key && asset.versionId) {
|
||||||
|
console.log(`${asset.key}\t${asset.versionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isCurrentManifest) {
|
||||||
|
for (const asset of manifest.priorAssets ?? []) {
|
||||||
|
if (typeof asset === "object" && asset.key && asset.versionId) {
|
||||||
|
console.log(`${asset.key}\t${asset.versionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
done <"${work_dir}/kept-manifests.txt"
|
||||||
|
sort -u -o "${work_dir}/retained-versions.tsv" "${work_dir}/retained-versions.tsv"
|
||||||
|
|
||||||
|
aws s3api list-object-versions \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--output json >"${work_dir}/object-versions.json"
|
||||||
|
node - "${work_dir}/object-versions.json" >"${work_dir}/prune-candidates.tsv" <<'NODE'
|
||||||
|
const listing = require(process.argv[2]);
|
||||||
|
for (const version of listing.Versions ?? []) {
|
||||||
|
console.log(`version\t${version.Key}\t${version.VersionId}`);
|
||||||
|
}
|
||||||
|
for (const marker of listing.DeleteMarkers ?? []) {
|
||||||
|
console.log(`marker\t${marker.Key}\t${marker.VersionId}`);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r kind object_key version_id; do
|
||||||
|
[[ -n "${object_key}" && -n "${version_id}" ]] || continue
|
||||||
|
if [[ "${kind}" == "version" ]] &&
|
||||||
|
grep -qxF -- "${object_key}"$'\t'"${version_id}" "${work_dir}/retained-versions.tsv"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
aws s3api delete-object \
|
||||||
|
--bucket "${SITE_BUCKET}" \
|
||||||
|
--key "${object_key}" \
|
||||||
|
--version-id "${version_id}" >/dev/null
|
||||||
|
done <"${work_dir}/prune-candidates.tsv"
|
||||||
|
|
||||||
|
echo "Web release ${DEPLOY_RELEASE_ID} deployed and verified."
|
||||||
|
|
|
||||||
100
scripts/deploy-web.test.mjs
Normal file
100
scripts/deploy-web.test.mjs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptPath = new URL("./deploy-web.sh", import.meta.url);
|
||||||
|
const script = readFileSync(scriptPath, "utf8");
|
||||||
|
const windowsGitBash = `${process.env.ProgramFiles ?? "C:\\Program Files"}\\Git\\bin\\bash.exe`;
|
||||||
|
const bash = process.platform === "win32" ? windowsGitBash : "bash";
|
||||||
|
const hasBash = process.platform !== "win32" || existsSync(windowsGitBash);
|
||||||
|
const nativeScriptPath = fileURLToPath(scriptPath);
|
||||||
|
const bashScriptPath =
|
||||||
|
process.platform === "win32"
|
||||||
|
? nativeScriptPath
|
||||||
|
.replace(/^([A-Za-z]):\\/, (_, drive) => `/${drive.toLowerCase()}/`)
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
: nativeScriptPath;
|
||||||
|
|
||||||
|
test("deploy script has valid bash syntax", { skip: !hasBash }, () => {
|
||||||
|
const result = spawnSync(bash, ["-n", bashScriptPath], { encoding: "utf8" });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deploy script fails closed before running tools", { skip: !hasBash }, () => {
|
||||||
|
const result = spawnSync(bash, [bashScriptPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { PATH: process.env.PATH },
|
||||||
|
});
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /SITE_BUCKET must be set explicitly/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("target bucket mismatch fails before publishing", { skip: !hasBash }, () => {
|
||||||
|
const result = spawnSync(bash, [bashScriptPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
SITE_BUCKET: "wrong-bucket",
|
||||||
|
EXPECTED_SITE_BUCKET: "expected-bucket",
|
||||||
|
CLOUDFRONT_DISTRIBUTION_ID: "DIST123",
|
||||||
|
SITE_URL: "https://example.test",
|
||||||
|
EXPECTED_API_URL: "https://api.example.test/api",
|
||||||
|
VITE_API_URL: "https://api.example.test/api",
|
||||||
|
DEPLOY_RELEASE_ID: "1234567",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /SITE_BUCKET does not match EXPECTED_SITE_BUCKET/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content safety contract is present and ordered", () => {
|
||||||
|
for (const required of [
|
||||||
|
"get-bucket-versioning",
|
||||||
|
"head-object",
|
||||||
|
"list-object-versions",
|
||||||
|
"asset-versions.tsv",
|
||||||
|
"prior-asset-versions.tsv",
|
||||||
|
"prior-manifest.json",
|
||||||
|
"priorAssets",
|
||||||
|
"isCurrentManifest",
|
||||||
|
"--version-id",
|
||||||
|
"public,max-age=31536000,immutable",
|
||||||
|
"no-cache,no-store,must-revalidate",
|
||||||
|
"cloudfront wait invalidation-completed",
|
||||||
|
'fetch_route "/login"',
|
||||||
|
'fetch_route "${EXTENSIONLESS_SMOKE_PATH}"',
|
||||||
|
"Access-Control-Request-Method: GET",
|
||||||
|
".deploy/releases/${DEPLOY_RELEASE_ID}.json",
|
||||||
|
]) {
|
||||||
|
assert.ok(script.includes(required), `missing contract: ${required}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
script.indexOf('deployment_verified="true"') < script.indexOf("aws s3api list-object-versions"),
|
||||||
|
"pruning must occur only after remote verification",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
script.indexOf('grep -qi "^access-control-allow-methods:.*GET"') <
|
||||||
|
script.indexOf('aws s3 cp "${work_dir}/manifest.json"'),
|
||||||
|
"failed remote verification must not publish a retention manifest",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
script.indexOf('aws s3 cp "${work_dir}/manifest.json"') <
|
||||||
|
script.indexOf('deployment_verified="true"'),
|
||||||
|
"manifest publication failures must roll back the new index",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
script.indexOf('>"${work_dir}/prior-asset-keys.txt"') <
|
||||||
|
script.indexOf('aws s3 sync dist/ "s3://${SITE_BUCKET}/"'),
|
||||||
|
"the pre-manifest release must be inventoried before new assets publish",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
script,
|
||||||
|
/if \(isCurrentManifest\) \{[\s\S]*manifest\.priorAssets/,
|
||||||
|
"only the current manifest may retain its pre-script rollback assets",
|
||||||
|
);
|
||||||
|
assert.match(script, /Could not inspect the current index version/);
|
||||||
|
assert.doesNotMatch(script, /s3 sync[\s\S]{0,250}--delete/);
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,12 @@ const MAINTAINABILITY_RULES = [
|
||||||
const GOVERNED_ROOTS = ["src/", "config/"];
|
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||||
|
const REPOSITORY_GATES = [
|
||||||
|
["Terraform import-plan contract", "test:terraform-import-plan"],
|
||||||
|
["Terraform formatting and validation", "test:terraform"],
|
||||||
|
["Web deployment contract", "test:deploy-web"],
|
||||||
|
["CDK build and synth", "test:infra"],
|
||||||
|
];
|
||||||
|
|
||||||
function isGoverned(relativePath) {
|
function isGoverned(relativePath) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -194,6 +200,22 @@ function plural(count, word) {
|
||||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runRepositoryGate(label, script) {
|
||||||
|
const npmCli = process.env.npm_execpath;
|
||||||
|
const executable = npmCli ? process.execPath : "npm";
|
||||||
|
const args = npmCli ? [npmCli, "run", script] : ["run", script];
|
||||||
|
const result = spawnSync(executable, args, {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
status: result.status,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const baseRef = resolveBaseRef();
|
const baseRef = resolveBaseRef();
|
||||||
|
|
@ -281,6 +303,17 @@ function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [label, script] of REPOSITORY_GATES) {
|
||||||
|
console.log("─".repeat(64));
|
||||||
|
console.log(`${label}: npm run ${script}`);
|
||||||
|
const gate = runRepositoryGate(label, script);
|
||||||
|
if (gate.error) {
|
||||||
|
failures.push(`${label}: could not start: ${gate.error.message}`);
|
||||||
|
} else if (gate.status !== 0) {
|
||||||
|
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("─".repeat(64));
|
console.log("─".repeat(64));
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||||
|
|
|
||||||
33
scripts/terraform-validate.mjs
Normal file
33
scripts/terraform-validate.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
|
||||||
|
const ROOTS = ["tf-poc", "dev", "staging"].map((environment) =>
|
||||||
|
path.join(ROOT, "terraform", "live", environment),
|
||||||
|
);
|
||||||
|
|
||||||
|
function run(args, cwd = ROOT) {
|
||||||
|
const result = spawnSync(TERRAFORM, args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`could not start Terraform: ${result.error.message}`, {
|
||||||
|
cause: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
run(["init", "-backend=false", "-input=false", "-no-color"], root);
|
||||||
|
run(["validate", "-no-color"], root);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Terraform formatting and validation passed for tf-poc, dev, and staging.");
|
||||||
133
scripts/terraform_import_plan_resources.py
Normal file
133
scripts/terraform_import_plan_resources.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Canonical frontend Terraform ownership and import-ID maps."""
|
||||||
|
|
||||||
|
COMMON_RESOURCES = {
|
||||||
|
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
|
||||||
|
"module.environment_owned.aws_s3_bucket_public_access_block.site": (
|
||||||
|
"aws_s3_bucket_public_access_block"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_s3_bucket_ownership_controls.site": (
|
||||||
|
"aws_s3_bucket_ownership_controls"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": (
|
||||||
|
"aws_s3_bucket_server_side_encryption_configuration"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning",
|
||||||
|
"module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy",
|
||||||
|
"module.environment_owned.aws_cloudfront_distribution.site": (
|
||||||
|
"aws_cloudfront_distribution"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||||
|
"aws_cloudfront_origin_access_control"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||||
|
"aws_cloudfront_function"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_route53_record.site_a": "aws_route53_record",
|
||||||
|
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
|
||||||
|
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
|
||||||
|
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUIRED_RESOURCES = {
|
||||||
|
environment: dict(COMMON_RESOURCES)
|
||||||
|
for environment in ("dev", "staging", "tf-poc")
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTROLLED_UPDATE_ADDRESSES = frozenset(
|
||||||
|
{
|
||||||
|
"module.environment_owned.aws_s3_bucket.site",
|
||||||
|
"module.environment_owned.aws_s3_bucket_policy.site",
|
||||||
|
"module.environment_owned.aws_cloudfront_distribution.site",
|
||||||
|
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
|
||||||
|
"module.environment_owned.aws_iam_role.github_deploy",
|
||||||
|
"module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ENVIRONMENT_CONFIG = {
|
||||||
|
"dev": {
|
||||||
|
"bucket_name": "seahaven-shoc-frontend-dev",
|
||||||
|
"distribution_id": "E2CWLM1AFB964P",
|
||||||
|
"workspace_name": "shoc-frontend-new-dev",
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"bucket_name": "seahaven-shoc-frontend-staging",
|
||||||
|
"distribution_id": "E2JDVEZ6EGD49J",
|
||||||
|
"workspace_name": "shoc-frontend-new-staging",
|
||||||
|
},
|
||||||
|
"tf-poc": {
|
||||||
|
"bucket_name": "seahaven-shoc-frontend-tf-poc",
|
||||||
|
"distribution_id": None,
|
||||||
|
"workspace_name": "shoc-frontend-new-tf-poc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_imports(bucket_name: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
address: bucket_name
|
||||||
|
for address in COMMON_RESOURCES
|
||||||
|
if address.startswith("module.environment_owned.aws_s3_bucket")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
||||||
|
"dev": {
|
||||||
|
**_bucket_imports("seahaven-shoc-frontend-dev"),
|
||||||
|
"module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P",
|
||||||
|
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||||
|
"E30VSIK87N8H64"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||||
|
"us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_route53_record.site_a": (
|
||||||
|
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||||
|
"Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||||
|
"githubdeploy-shoc-frontend-new-dev"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||||
|
"githubdeploy-shoc-frontend-new-dev:"
|
||||||
|
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
**_bucket_imports("seahaven-shoc-frontend-staging"),
|
||||||
|
"module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J",
|
||||||
|
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||||
|
"E1PF5R6QQNBZAI"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||||
|
"us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_route53_record.site_a": (
|
||||||
|
"Z02602739VQWBWCAGXP4_staging.seahaven.com_A"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||||
|
"Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||||
|
"githubdeploy-shoc-frontend-new-staging"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||||
|
"githubdeploy-shoc-frontend-new-staging:"
|
||||||
|
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"tf-poc": {
|
||||||
|
**_bucket_imports("seahaven-shoc-frontend-tf-poc"),
|
||||||
|
"module.environment_owned.aws_cloudfront_distribution.site": None,
|
||||||
|
"module.environment_owned.aws_cloudfront_origin_access_control.site": None,
|
||||||
|
"module.environment_owned.aws_cloudfront_function.spa_rewrite": None,
|
||||||
|
"module.environment_owned.aws_route53_record.site_a": None,
|
||||||
|
"module.environment_owned.aws_route53_record.site_aaaa": None,
|
||||||
|
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||||
|
"githubdeploy-shoc-frontend-new-tf-poc"
|
||||||
|
),
|
||||||
|
"module.environment_owned.aws_iam_role_policy.github_deploy": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
505
scripts/test-terraform-import-plan-check.py
Normal file
505
scripts/test-terraform-import-plan-check.py
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deterministic unit tests for the frontend Terraform plan checker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from terraform_import_plan_resources import (
|
||||||
|
CONTROLLED_UPDATE_ADDRESSES,
|
||||||
|
ENVIRONMENT_CONFIG,
|
||||||
|
REQUIRED_IMPORT_IDS,
|
||||||
|
REQUIRED_RESOURCES,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
|
||||||
|
REPOSITORY = SCRIPT.parent.parent
|
||||||
|
BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||||
|
BUCKET = "module.environment_owned.aws_s3_bucket.site"
|
||||||
|
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||||
|
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
|
||||||
|
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||||
|
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
|
||||||
|
|
||||||
|
|
||||||
|
def import_id(environment: str, address: str) -> str:
|
||||||
|
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||||
|
if expected is not None:
|
||||||
|
return expected
|
||||||
|
suffix = address.rsplit(".", 1)[-1].replace("_", "-")
|
||||||
|
return f"tf-poc-generated-{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def distribution_id(environment: str) -> str:
|
||||||
|
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||||
|
return configured if isinstance(configured, str) else "ETFPOCGENERATED123"
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_policy(environment: str) -> dict[str, Any]:
|
||||||
|
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||||
|
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||||
|
source = (
|
||||||
|
"arn:aws:cloudfront::396287094661:distribution/"
|
||||||
|
f"{distribution_id(environment)}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": f"{bucket_arn}/*",
|
||||||
|
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Principal": {"AWS": "*"},
|
||||||
|
"Action": "s3:*",
|
||||||
|
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||||
|
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_policy(environment: str) -> dict[str, Any]:
|
||||||
|
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||||
|
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||||
|
distribution_arn = (
|
||||||
|
"arn:aws:cloudfront::396287094661:distribution/"
|
||||||
|
f"{distribution_id(environment)}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "ReadDeploymentBucket",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:GetBucketLocation",
|
||||||
|
"s3:GetBucketVersioning",
|
||||||
|
"s3:ListBucket",
|
||||||
|
"s3:ListBucketVersions",
|
||||||
|
],
|
||||||
|
"Resource": bucket_arn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "PublishAndRollbackSiteObjects",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:DeleteObject",
|
||||||
|
"s3:DeleteObjectVersion",
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:GetObjectVersion",
|
||||||
|
"s3:PutObject",
|
||||||
|
],
|
||||||
|
"Resource": f"{bucket_arn}/*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "InvalidateDistribution",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"cloudfront:CreateInvalidation",
|
||||||
|
"cloudfront:GetInvalidation",
|
||||||
|
],
|
||||||
|
"Resource": distribution_arn,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tag_change(environment: str, address: str) -> dict[str, Any]:
|
||||||
|
manager = {
|
||||||
|
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"]
|
||||||
|
}
|
||||||
|
before_tags = {
|
||||||
|
"Environment": environment,
|
||||||
|
"ManagedBy": "cdk",
|
||||||
|
"Project": "shoc-frontend",
|
||||||
|
}
|
||||||
|
after_tags = {
|
||||||
|
"Environment": environment,
|
||||||
|
"ManagedBy": "terraform",
|
||||||
|
"Ownership": "terraform",
|
||||||
|
"Project": "shoc-frontend",
|
||||||
|
}
|
||||||
|
if address == ROLE:
|
||||||
|
before_tags.update(manager)
|
||||||
|
after_tags.update(manager)
|
||||||
|
if address == BUCKET:
|
||||||
|
before_tags["aws-cdk:auto-delete-objects"] = "true"
|
||||||
|
before: dict[str, Any] = {
|
||||||
|
"tags": before_tags,
|
||||||
|
"tags_all": before_tags,
|
||||||
|
}
|
||||||
|
after: dict[str, Any] = {
|
||||||
|
"tags": after_tags,
|
||||||
|
"tags_all": after_tags,
|
||||||
|
}
|
||||||
|
if address == DISTRIBUTION:
|
||||||
|
before["id"] = distribution_id(environment)
|
||||||
|
after["id"] = distribution_id(environment)
|
||||||
|
return {"actions": ["update"], "before": before, "after": after}
|
||||||
|
|
||||||
|
|
||||||
|
def policy_change(environment: str, address: str) -> dict[str, Any]:
|
||||||
|
after_policy = (
|
||||||
|
bucket_policy(environment)
|
||||||
|
if address == BUCKET_POLICY
|
||||||
|
else deploy_policy(environment)
|
||||||
|
)
|
||||||
|
before_policy = {"Version": "2012-10-17", "Statement": []}
|
||||||
|
return {
|
||||||
|
"actions": ["update"],
|
||||||
|
"before": {"policy": json.dumps(before_policy)},
|
||||||
|
"after": {"policy": json.dumps(after_policy)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_plan(
|
||||||
|
environment: str,
|
||||||
|
*,
|
||||||
|
mode: str = "import",
|
||||||
|
controlled_updates: set[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resources: list[dict[str, Any]] = []
|
||||||
|
updates = controlled_updates or set()
|
||||||
|
for address, resource_type in REQUIRED_RESOURCES[environment].items():
|
||||||
|
if mode == "import":
|
||||||
|
change: dict[str, Any] = {
|
||||||
|
"actions": ["no-op"],
|
||||||
|
"importing": {"id": import_id(environment, address)},
|
||||||
|
}
|
||||||
|
elif mode == "post-import":
|
||||||
|
change = {"actions": ["no-op"]}
|
||||||
|
elif address in updates:
|
||||||
|
change = (
|
||||||
|
tag_change(environment, address)
|
||||||
|
if address in TAG_ADDRESSES
|
||||||
|
else policy_change(environment, address)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
change = {"actions": ["no-op"]}
|
||||||
|
if address == DISTRIBUTION:
|
||||||
|
change["after"] = {"id": distribution_id(environment)}
|
||||||
|
resources.append(
|
||||||
|
{
|
||||||
|
"address": address,
|
||||||
|
"mode": "managed",
|
||||||
|
"type": resource_type,
|
||||||
|
"change": change,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"resource_changes": resources}
|
||||||
|
|
||||||
|
|
||||||
|
def resource(plan: dict[str, Any], address: str) -> dict[str, Any]:
|
||||||
|
return next(
|
||||||
|
item for item in plan["resource_changes"] if item["address"] == address
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_checker(
|
||||||
|
plan: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
*allowed_updates: str,
|
||||||
|
post_import: bool = False,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "plan.json"
|
||||||
|
path.write_text(json.dumps(plan), encoding="utf-8")
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
str(SCRIPT),
|
||||||
|
str(path),
|
||||||
|
"--environment",
|
||||||
|
environment,
|
||||||
|
]
|
||||||
|
if post_import:
|
||||||
|
command.append("--post-import-no-op")
|
||||||
|
for address in allowed_updates:
|
||||||
|
command.extend(["--allow-update-address", address])
|
||||||
|
return subprocess.run(
|
||||||
|
command,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPlanCheckerTests(unittest.TestCase):
|
||||||
|
def assert_passes(
|
||||||
|
self,
|
||||||
|
plan: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
*allowed_updates: str,
|
||||||
|
post_import: bool = False,
|
||||||
|
) -> None:
|
||||||
|
result = run_checker(
|
||||||
|
plan,
|
||||||
|
environment,
|
||||||
|
*allowed_updates,
|
||||||
|
post_import=post_import,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
|
||||||
|
def assert_fails(
|
||||||
|
self,
|
||||||
|
plan: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
*allowed_updates: str,
|
||||||
|
post_import: bool = False,
|
||||||
|
) -> None:
|
||||||
|
result = run_checker(
|
||||||
|
plan,
|
||||||
|
environment,
|
||||||
|
*allowed_updates,
|
||||||
|
post_import=post_import,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
|
||||||
|
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
|
||||||
|
source = (
|
||||||
|
REPOSITORY
|
||||||
|
/ "terraform/live/modules/environment-owned/main.tf"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
expected = """ spa_rewrite_code = join("\\n", [
|
||||||
|
"function handler(event) {",
|
||||||
|
" var request = event.request;",
|
||||||
|
" var uri = request.uri;",
|
||||||
|
" // No file extension after the last slash -> a client-side route.",
|
||||||
|
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||||
|
" request.uri = '/index.html';",
|
||||||
|
" }",
|
||||||
|
" return request;",
|
||||||
|
"}",
|
||||||
|
])"""
|
||||||
|
self.assertIn(expected, source)
|
||||||
|
|
||||||
|
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
|
||||||
|
expected = {
|
||||||
|
"dev": (
|
||||||
|
"local.hosted_zone_id",
|
||||||
|
"local.certificate_arn",
|
||||||
|
"local.github_oidc_arn",
|
||||||
|
"local.cache_policy_id",
|
||||||
|
),
|
||||||
|
"staging": (
|
||||||
|
"local.hosted_zone_id",
|
||||||
|
"local.certificate_arn",
|
||||||
|
"local.github_oidc_arn",
|
||||||
|
"local.cache_policy_id",
|
||||||
|
),
|
||||||
|
"tf-poc": (
|
||||||
|
"var.hosted_zone_id",
|
||||||
|
"var.certificate_arn",
|
||||||
|
"local.github_oidc_arn",
|
||||||
|
"local.cache_policy_id",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for environment, values in expected.items():
|
||||||
|
source = (
|
||||||
|
REPOSITORY / f"terraform/live/{environment}/main.tf"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
for name, value in zip(
|
||||||
|
(
|
||||||
|
"hosted_zone_id",
|
||||||
|
"certificate_arn",
|
||||||
|
"github_oidc_provider_arn",
|
||||||
|
"cache_policy_id",
|
||||||
|
),
|
||||||
|
values,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
self.assertIn(f"{name}", source)
|
||||||
|
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
|
||||||
|
self.assertNotRegex(
|
||||||
|
source,
|
||||||
|
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_import_plan_passes_for_every_environment(self) -> None:
|
||||||
|
for environment in REQUIRED_RESOURCES:
|
||||||
|
with self.subTest(environment=environment):
|
||||||
|
self.assert_passes(make_plan(environment), environment)
|
||||||
|
|
||||||
|
def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None:
|
||||||
|
for mutation in ("missing", "extra", "wrong-type", "cross-environment"):
|
||||||
|
plan = make_plan("dev")
|
||||||
|
if mutation == "missing":
|
||||||
|
plan["resource_changes"].pop()
|
||||||
|
elif mutation == "extra":
|
||||||
|
plan["resource_changes"].append(
|
||||||
|
{
|
||||||
|
"address": "module.inventory.aws_route53_zone.site",
|
||||||
|
"mode": "managed",
|
||||||
|
"type": "aws_route53_zone",
|
||||||
|
"change": {
|
||||||
|
"actions": ["no-op"],
|
||||||
|
"importing": {"id": "Z00000000000000000000"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif mutation == "wrong-type":
|
||||||
|
plan["resource_changes"][0]["type"] = "aws_s3_object"
|
||||||
|
else:
|
||||||
|
resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = (
|
||||||
|
REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION]
|
||||||
|
)
|
||||||
|
with self.subTest(mutation=mutation):
|
||||||
|
self.assert_fails(plan, "dev")
|
||||||
|
|
||||||
|
def test_import_rejects_mutation_and_invalid_metadata(self) -> None:
|
||||||
|
for actions in (["create"], ["update"], ["delete"], ["delete", "create"]):
|
||||||
|
plan = make_plan("dev")
|
||||||
|
plan["resource_changes"][0]["change"]["actions"] = actions
|
||||||
|
with self.subTest(actions=actions):
|
||||||
|
self.assert_fails(plan, "dev")
|
||||||
|
plan = make_plan("dev")
|
||||||
|
plan["resource_changes"][0]["change"]["importing"] = {"id": ""}
|
||||||
|
self.assert_fails(plan, "dev")
|
||||||
|
|
||||||
|
def test_post_import_no_op_passes(self) -> None:
|
||||||
|
self.assert_passes(
|
||||||
|
make_plan("staging", mode="post-import"),
|
||||||
|
"staging",
|
||||||
|
post_import=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_import_rejects_import_metadata_and_update(self) -> None:
|
||||||
|
plan = make_plan("dev", mode="post-import")
|
||||||
|
plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"}
|
||||||
|
self.assert_fails(plan, "dev", post_import=True)
|
||||||
|
plan = make_plan("dev", mode="post-import")
|
||||||
|
plan["resource_changes"][0]["change"]["actions"] = ["update"]
|
||||||
|
self.assert_fails(plan, "dev", post_import=True)
|
||||||
|
|
||||||
|
def test_every_allowed_controlled_diff_passes(self) -> None:
|
||||||
|
for address in CONTROLLED_UPDATE_ADDRESSES:
|
||||||
|
with self.subTest(address=address):
|
||||||
|
self.assert_passes(
|
||||||
|
make_plan(
|
||||||
|
"tf-poc",
|
||||||
|
mode="controlled",
|
||||||
|
controlled_updates={address},
|
||||||
|
),
|
||||||
|
"tf-poc",
|
||||||
|
address,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_exact_controlled_allowlist_passes(self) -> None:
|
||||||
|
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
|
||||||
|
self.assert_passes(
|
||||||
|
make_plan(
|
||||||
|
"dev",
|
||||||
|
mode="controlled",
|
||||||
|
controlled_updates=set(addresses),
|
||||||
|
),
|
||||||
|
"dev",
|
||||||
|
*addresses,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
|
||||||
|
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||||
|
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
|
||||||
|
self.assert_fails(plan, "dev", ROLE)
|
||||||
|
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||||
|
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
|
||||||
|
self.assert_fails(plan, "dev", ROLE)
|
||||||
|
|
||||||
|
def test_tag_update_requires_complete_adopted_tag_sets(self) -> None:
|
||||||
|
plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET})
|
||||||
|
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
|
||||||
|
self.assert_fails(plan, "dev", BUCKET)
|
||||||
|
|
||||||
|
def test_role_trust_change_is_rejected(self) -> None:
|
||||||
|
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||||
|
role = resource(plan, ROLE)["change"]
|
||||||
|
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||||
|
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
|
||||||
|
self.assert_fails(plan, "dev", ROLE)
|
||||||
|
|
||||||
|
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
||||||
|
for mutation in ("principal", "extra"):
|
||||||
|
plan = make_plan(
|
||||||
|
"dev",
|
||||||
|
mode="controlled",
|
||||||
|
controlled_updates={BUCKET_POLICY},
|
||||||
|
)
|
||||||
|
policy = copy.deepcopy(bucket_policy("dev"))
|
||||||
|
if mutation == "principal":
|
||||||
|
policy["Statement"][0]["Principal"] = {"AWS": "*"}
|
||||||
|
else:
|
||||||
|
policy["Statement"].append(
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"AWS": "*"},
|
||||||
|
"Action": "s3:*",
|
||||||
|
"Resource": "*",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||||
|
policy
|
||||||
|
)
|
||||||
|
with self.subTest(mutation=mutation):
|
||||||
|
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||||
|
|
||||||
|
def test_deploy_policy_rejects_resource_action_and_extra_statement(self) -> None:
|
||||||
|
for mutation in ("resource", "action", "extra"):
|
||||||
|
plan = make_plan(
|
||||||
|
"staging",
|
||||||
|
mode="controlled",
|
||||||
|
controlled_updates={DEPLOY_POLICY},
|
||||||
|
)
|
||||||
|
policy = copy.deepcopy(deploy_policy("staging"))
|
||||||
|
if mutation == "resource":
|
||||||
|
policy["Statement"][0]["Resource"] = "*"
|
||||||
|
elif mutation == "action":
|
||||||
|
policy["Statement"][0]["Action"].append("iam:PassRole")
|
||||||
|
else:
|
||||||
|
policy["Statement"].append(
|
||||||
|
{
|
||||||
|
"Sid": "Extra",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "s3:*",
|
||||||
|
"Resource": "*",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resource(plan, DEPLOY_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||||
|
policy
|
||||||
|
)
|
||||||
|
with self.subTest(mutation=mutation):
|
||||||
|
self.assert_fails(plan, "staging", DEPLOY_POLICY)
|
||||||
|
|
||||||
|
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
|
||||||
|
for field, value in (
|
||||||
|
("after_unknown", {"tags": {"ManagedBy": True}}),
|
||||||
|
("replace_paths", [["tags"]]),
|
||||||
|
):
|
||||||
|
plan = make_plan(
|
||||||
|
"dev",
|
||||||
|
mode="controlled",
|
||||||
|
controlled_updates={ROLE},
|
||||||
|
)
|
||||||
|
resource(plan, ROLE)["change"][field] = value
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.assert_fails(plan, "dev", ROLE)
|
||||||
|
|
||||||
|
def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None:
|
||||||
|
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||||
|
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||||
|
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||||
|
self.assert_fails(plan, "dev", ROLE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
367
terraform/README.md
Normal file
367
terraform/README.md
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
# Frontend Terraform adoption runbook
|
||||||
|
|
||||||
|
This tree adopts the existing Sea Haven SHOC frontend hosting resources without
|
||||||
|
recreating them. It implements the local configuration and plan-safety tooling
|
||||||
|
only. Creating these files, formatting them, initializing with
|
||||||
|
`-backend=false`, and validating them does not authorize an AWS, HCP Terraform,
|
||||||
|
GitHub, CloudFormation, DNS, or deployment mutation.
|
||||||
|
|
||||||
|
The rollout order is `tf-poc`, dev, then staging. Production and tf-poc teardown
|
||||||
|
are separate follow-up changes.
|
||||||
|
|
||||||
|
## Fixed targets
|
||||||
|
|
||||||
|
- AWS account: `396287094661`
|
||||||
|
- AWS region: `us-east-1`
|
||||||
|
- HCP organization: `seahaven`
|
||||||
|
- HCP project: `seahaven-external-dev`
|
||||||
|
- Workspaces:
|
||||||
|
- `shoc-frontend-new-tf-poc`
|
||||||
|
- `shoc-frontend-new-dev`
|
||||||
|
- `shoc-frontend-new-staging`
|
||||||
|
- HCP auto-apply: off for all three workspaces
|
||||||
|
- tf-poc site: `frontend-tf-poc.seahaven.com`
|
||||||
|
- tf-poc API build value: `https://api.tf-poc.seahaven.com/api`
|
||||||
|
- tf-poc bucket: `seahaven-shoc-frontend-tf-poc`
|
||||||
|
- tf-poc deploy role: `githubdeploy-shoc-frontend-new-tf-poc`
|
||||||
|
- tf-poc GitHub environment: `tf-poc`
|
||||||
|
|
||||||
|
The `cloud` blocks identify the organization, project, and workspace. Auto-apply
|
||||||
|
is an HCP workspace setting and must be verified operationally before connecting
|
||||||
|
VCS or starting a run.
|
||||||
|
|
||||||
|
## Ownership boundary
|
||||||
|
|
||||||
|
`live/modules/environment-owned` owns exactly these 13 addresses:
|
||||||
|
|
||||||
|
1. `module.environment_owned.aws_s3_bucket.site`
|
||||||
|
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
|
||||||
|
3. `module.environment_owned.aws_s3_bucket_ownership_controls.site`
|
||||||
|
4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site`
|
||||||
|
5. `module.environment_owned.aws_s3_bucket_versioning.site`
|
||||||
|
6. `module.environment_owned.aws_s3_bucket_policy.site`
|
||||||
|
7. `module.environment_owned.aws_cloudfront_distribution.site`
|
||||||
|
8. `module.environment_owned.aws_cloudfront_origin_access_control.site`
|
||||||
|
9. `module.environment_owned.aws_cloudfront_function.spa_rewrite`
|
||||||
|
10. `module.environment_owned.aws_route53_record.site_a`
|
||||||
|
11. `module.environment_owned.aws_route53_record.site_aaaa`
|
||||||
|
12. `module.environment_owned.aws_iam_role.github_deploy`
|
||||||
|
13. `module.environment_owned.aws_iam_role_policy.github_deploy`
|
||||||
|
|
||||||
|
Every managed resource has `prevent_destroy = true`.
|
||||||
|
|
||||||
|
`live/modules/environment-inventory` is data-only. It resolves and checks the
|
||||||
|
caller account, provider region, public hosted zone, ACM certificate, account
|
||||||
|
GitHub OIDC provider, and AWS managed `Managed-CachingOptimized` CloudFront
|
||||||
|
cache policy.
|
||||||
|
|
||||||
|
The following remain outside state:
|
||||||
|
|
||||||
|
- public hosted zones and ACM certificates
|
||||||
|
- the account-global GitHub OIDC provider
|
||||||
|
- the AWS managed CloudFront cache policy
|
||||||
|
- `CDKToolkit` resources and CDK metadata
|
||||||
|
- S3 auto-delete custom resources, provider Lambda, provider role, and log group
|
||||||
|
- hosted-zone and ACM validation internals
|
||||||
|
- CloudFront service-generated resources
|
||||||
|
|
||||||
|
## Exact live inventory
|
||||||
|
|
||||||
|
### Dev
|
||||||
|
|
||||||
|
- Bucket and all bucket subresources:
|
||||||
|
`seahaven-shoc-frontend-dev`
|
||||||
|
- Distribution: `E2CWLM1AFB964P`
|
||||||
|
- OAC: `E30VSIK87N8H64`
|
||||||
|
- OAC name:
|
||||||
|
`shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`
|
||||||
|
- OAC description: the API empty value, modeled as `""`
|
||||||
|
- Distribution origin ID:
|
||||||
|
`shocfrontenddevDistributionOrigin10CCD0EE1`
|
||||||
|
- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8`
|
||||||
|
- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A`
|
||||||
|
- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA`
|
||||||
|
- Deploy role: `githubdeploy-shoc-frontend-new-dev`
|
||||||
|
- Inline policy import ID:
|
||||||
|
`githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1`
|
||||||
|
- Hosted zone: `Z07671212N75U4YLPWZR8`
|
||||||
|
- Stack: `shoc-frontend-dev`
|
||||||
|
|
||||||
|
### Staging
|
||||||
|
|
||||||
|
- Bucket and all bucket subresources:
|
||||||
|
`seahaven-shoc-frontend-staging`
|
||||||
|
- Distribution: `E2JDVEZ6EGD49J`
|
||||||
|
- OAC: `E1PF5R6QQNBZAI`
|
||||||
|
- OAC name:
|
||||||
|
`shocfrontendstagingDistributOrigin1S3OriginAccessControl82B1C17D`
|
||||||
|
- OAC description: the API empty value, modeled as `""`
|
||||||
|
- Distribution origin ID:
|
||||||
|
`shocfrontendstagingDistributionOrigin16E4628FC`
|
||||||
|
- Function: `us-east-1shocfrontendstagingSpaRewriteE9C0CBDA`
|
||||||
|
- A import ID:
|
||||||
|
`Z02602739VQWBWCAGXP4_staging.seahaven.com_A`
|
||||||
|
- AAAA import ID:
|
||||||
|
`Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA`
|
||||||
|
- Deploy role: `githubdeploy-shoc-frontend-new-staging`
|
||||||
|
- Inline policy import ID:
|
||||||
|
`githubdeploy-shoc-frontend-new-staging:GithubDeployRoleDefaultPolicyE8F540D1`
|
||||||
|
- Hosted zone: `Z02602739VQWBWCAGXP4`
|
||||||
|
- Stack: `shoc-frontend-staging`
|
||||||
|
|
||||||
|
Both live roots inventory the shared certificate:
|
||||||
|
|
||||||
|
`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`
|
||||||
|
|
||||||
|
The live roots preserve the observed pre-adoption configuration:
|
||||||
|
|
||||||
|
- `adoption_complete = false`
|
||||||
|
- `Environment`, `ManagedBy=cdk`, and `Project=shoc-frontend` tags
|
||||||
|
- the S3-only `aws-cdk:auto-delete-objects=true` tag
|
||||||
|
- the deploy-role-only
|
||||||
|
`HcpTerraformWorkspace=shoc-frontend-new-<environment>` manager tag
|
||||||
|
- current OAC names, empty descriptions, origin IDs, comments, protocols, cache
|
||||||
|
policy, certificate, trust subjects, role descriptions, and legacy deploy
|
||||||
|
policy
|
||||||
|
- the exact legacy bucket-policy grant for the S3 auto-delete helper
|
||||||
|
- the mandatory deterministic permissions boundary
|
||||||
|
`arn:aws:iam::396287094661:policy/shoc-frontend-new-<environment>-deploy-boundary`
|
||||||
|
|
||||||
|
The approved legacy-owner prerequisite narrows the dev role's exact subject
|
||||||
|
from `StringLike` to `StringEquals` before import. All roots therefore use
|
||||||
|
`StringEquals` in both modes. The HCP workspace manager tag remains on the role
|
||||||
|
in both modes and is never added to S3 or CloudFront resources.
|
||||||
|
|
||||||
|
If a prerequisite changes any live metadata before import, update the matching
|
||||||
|
root to the newly observed exact value and prove a zero-change import plan. Do
|
||||||
|
not approve that drift through the controlled-update checker.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before any remote plan:
|
||||||
|
|
||||||
|
1. Confirm the deployment workflow for the target environment is paused while
|
||||||
|
PR validation remains active.
|
||||||
|
2. Confirm no CloudFormation/CDK update or content deployment can race the
|
||||||
|
adoption.
|
||||||
|
3. Confirm the HCP workspace is in the `seahaven-external-dev` project with
|
||||||
|
auto-apply off.
|
||||||
|
4. Confirm the org-baseline plan/apply roles and deploy-role permissions
|
||||||
|
boundary exist with exact workspace trust.
|
||||||
|
5. Attach the root's deterministic boundary through the approved legacy-owner
|
||||||
|
procedure. It is mandatory before the import plan.
|
||||||
|
6. Verify account `396287094661`, region `us-east-1`, all import IDs, current
|
||||||
|
tags, the dev `StringEquals` trust prerequisite, role description, boundary,
|
||||||
|
policies, distribution configuration, OAC configuration, function code, DNS
|
||||||
|
targets, and bucket settings using read-only queries.
|
||||||
|
7. Confirm the creator stack has retention semantics for all 13 transferred
|
||||||
|
resources and the S3 auto-delete custom resource. A synthesized template and
|
||||||
|
reviewed change set are required before mutation.
|
||||||
|
8. Capture a complete object-version inventory and smoke-test baseline.
|
||||||
|
|
||||||
|
Do not remove or replace the S3 auto-delete custom resource casually. Deleting
|
||||||
|
it while its handler is active can empty the versioned bucket. Retain it during
|
||||||
|
ownership transfer and prove the tf-poc path before touching dev.
|
||||||
|
|
||||||
|
## HCP variables
|
||||||
|
|
||||||
|
Configure dynamic AWS credentials in each workspace. Use the exact
|
||||||
|
org-baseline role ARNs for that workspace:
|
||||||
|
|
||||||
|
- environment variable `TFC_AWS_PROVIDER_AUTH=true`
|
||||||
|
- environment variable `TFC_AWS_PLAN_ROLE_ARN`
|
||||||
|
- environment variable `TFC_AWS_APPLY_ROLE_ARN`
|
||||||
|
- Terraform variable `adoption_complete=false`
|
||||||
|
|
||||||
|
Do not store AWS access keys. Mark sensitive values sensitive even when they are
|
||||||
|
not credentials. VCS working directories are:
|
||||||
|
|
||||||
|
- `terraform/live/tf-poc`
|
||||||
|
- `terraform/live/dev`
|
||||||
|
- `terraform/live/staging`
|
||||||
|
|
||||||
|
tf-poc also requires every variable in `terraform.tfvars.example`. Populate
|
||||||
|
them only from creator outputs and read-only verification. The check in the
|
||||||
|
tf-poc root blocks planning while a value is empty or starts with
|
||||||
|
`REPLACE_WITH_`.
|
||||||
|
|
||||||
|
## Local validation
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
terraform fmt -check -recursive terraform
|
||||||
|
python scripts/test-terraform-import-plan-check.py
|
||||||
|
```
|
||||||
|
|
||||||
|
For every root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
terraform -chdir=terraform/live/tf-poc init -backend=false
|
||||||
|
terraform -chdir=terraform/live/tf-poc validate
|
||||||
|
terraform -chdir=terraform/live/dev init -backend=false
|
||||||
|
terraform -chdir=terraform/live/dev validate
|
||||||
|
terraform -chdir=terraform/live/staging init -backend=false
|
||||||
|
terraform -chdir=terraform/live/staging validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialization without the backend may download providers and write lockfiles,
|
||||||
|
but it must not contact HCP state or plan against AWS.
|
||||||
|
|
||||||
|
## tf-poc flow
|
||||||
|
|
||||||
|
1. Deploy only the temporary shared stack with `tfPocPhase=zone` and record its
|
||||||
|
name servers.
|
||||||
|
2. Apply the separately approved parent-zone NS delegation and verify it
|
||||||
|
publicly.
|
||||||
|
3. Use `tfPocPhase=environment` to add the certificate and environment stack.
|
||||||
|
Do not attempt certificate creation before delegation.
|
||||||
|
4. Record creator outputs for the distribution, OAC ID/name, function, zone,
|
||||||
|
certificate, origin ID, role, inline policy, bucket auto-delete helper role,
|
||||||
|
and DNS import IDs.
|
||||||
|
5. With the frontend tf-poc HCP role gate still false, set the five org-baseline
|
||||||
|
tf-poc identifiers from those outputs and deploy the reviewed boundary
|
||||||
|
update. Confirm the creator role's existing boundary now permits only its
|
||||||
|
bucket operations and exact distribution invalidation.
|
||||||
|
6. Deploy the real SPA through GitHub environment `tf-poc`, built with
|
||||||
|
`VITE_API_URL=https://api.tf-poc.seahaven.com/api`.
|
||||||
|
7. Pass HTTPS page load, `/login`, extensionless SPA fallback, asset-reference
|
||||||
|
integrity, expected/forbidden API URL scan, cache headers, invalidation
|
||||||
|
completion, API CORS/preflight connectivity, and index rollback.
|
||||||
|
8. Populate HCP variables. Re-run read-only inventory and compare all declared
|
||||||
|
metadata.
|
||||||
|
9. Produce the import plan, export JSON, and pass the zero-change import gate.
|
||||||
|
10. Review and apply only the imports. Require an immediate second no-op plan.
|
||||||
|
11. Prepare and inspect retention for all transferred resources and the
|
||||||
|
auto-delete custom resource. Do not detach yet.
|
||||||
|
12. Set only tf-poc `adoption_complete=true`. Run the controlled-update gate
|
||||||
|
with the exact addresses below, apply after review, and require a no-op
|
||||||
|
plan. This removes the bucket policy grant while the CDK auto-delete helper
|
||||||
|
role still exists.
|
||||||
|
13. Detach the creator stack with the reviewed retention template. Verify
|
||||||
|
identifiers, every object version, DNS, HTTPS/API smoke checks, deploy-role
|
||||||
|
assumption, and a final no-op plan.
|
||||||
|
|
||||||
|
Stop on a missing output, placeholder, nonzero import action, unexpected
|
||||||
|
address, replacement, inventory mismatch, retention mismatch, or smoke failure.
|
||||||
|
|
||||||
|
## Import plan safety
|
||||||
|
|
||||||
|
Create a saved plan using the approved remote workflow, then export its JSON:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
terraform show -json path\to\saved.plan > path\to\plan.json
|
||||||
|
python scripts/check-terraform-import-plan.py path\to\plan.json --environment tf-poc
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `dev` or `staging` for the corresponding root. Import mode requires:
|
||||||
|
|
||||||
|
- exactly the canonical 13 addresses and AWS types
|
||||||
|
- valid import metadata for every resource
|
||||||
|
- exact known import IDs for dev and staging
|
||||||
|
- populated, non-placeholder creator IDs for tf-poc
|
||||||
|
- zero create, update, delete, or replace actions
|
||||||
|
|
||||||
|
After import apply, export the immediate refresh plan and use the distinct
|
||||||
|
post-import mode. It requires all 13 resources to be no-op and rejects any
|
||||||
|
remaining import metadata:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/check-terraform-import-plan.py path\to\post-import-plan.json `
|
||||||
|
--environment tf-poc `
|
||||||
|
--post-import-no-op
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact controlled-adoption addresses are:
|
||||||
|
|
||||||
|
- `module.environment_owned.aws_s3_bucket.site`
|
||||||
|
- `module.environment_owned.aws_s3_bucket_policy.site`
|
||||||
|
- `module.environment_owned.aws_cloudfront_distribution.site`
|
||||||
|
- `module.environment_owned.aws_cloudfront_function.spa_rewrite`
|
||||||
|
- `module.environment_owned.aws_iam_role.github_deploy`
|
||||||
|
- `module.environment_owned.aws_iam_role_policy.github_deploy`
|
||||||
|
|
||||||
|
The bucket-policy update removes only the retained auto-delete helper grant.
|
||||||
|
The IAM role update changes ownership tags while preserving the exact
|
||||||
|
`StringEquals` subject, boundary, and HCP manager tag.
|
||||||
|
|
||||||
|
Run controlled mode by repeating the exact option:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/check-terraform-import-plan.py path\to\plan.json `
|
||||||
|
--environment tf-poc `
|
||||||
|
--allow-update-address module.environment_owned.aws_s3_bucket.site `
|
||||||
|
--allow-update-address module.environment_owned.aws_s3_bucket_policy.site `
|
||||||
|
--allow-update-address module.environment_owned.aws_cloudfront_distribution.site `
|
||||||
|
--allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite `
|
||||||
|
--allow-update-address module.environment_owned.aws_iam_role.github_deploy `
|
||||||
|
--allow-update-address module.environment_owned.aws_iam_role_policy.github_deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Controlled mode permits only in-place updates to the addresses explicitly
|
||||||
|
listed on that invocation. It rejects create, delete, replace, import metadata,
|
||||||
|
unapproved addresses, and unused allowlist entries. OAC and Route 53 must remain
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
If an ownership-tagged resource does not actually update because its final tags
|
||||||
|
are already present, omit that address from both the plan expectation and the
|
||||||
|
command. Never leave an unused allowlist entry.
|
||||||
|
|
||||||
|
## Dev and staging flow
|
||||||
|
|
||||||
|
Run one live environment at a time.
|
||||||
|
|
||||||
|
For dev:
|
||||||
|
|
||||||
|
1. Keep releases paused.
|
||||||
|
2. Complete and verify boundary, retention, and exact-metadata prerequisites.
|
||||||
|
3. Run and review the zero-change import plan.
|
||||||
|
4. Apply imports and require a second no-op plan.
|
||||||
|
5. Prepare and verify the retention template only after tf-poc evidence is
|
||||||
|
accepted. Do not detach yet.
|
||||||
|
6. Set `adoption_complete=true`, allow only the exact updating addresses from
|
||||||
|
the controlled list, apply after review, and require another no-op plan.
|
||||||
|
7. Detach CloudFormation with the reviewed retention template.
|
||||||
|
8. Verify IDs, object versions, DNS, TLS, API connectivity, content deployment,
|
||||||
|
invalidation, rollback, deploy identity, and a final no-op plan.
|
||||||
|
9. Re-enable dev release only after explicit approval.
|
||||||
|
|
||||||
|
Observe dev for the agreed window. Then repeat the full sequence for staging.
|
||||||
|
Staging termination protection requires a separately reviewed disable
|
||||||
|
immediately before retained stack deletion. Do not carry approval from dev into
|
||||||
|
staging.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Retain for each phase:
|
||||||
|
|
||||||
|
- HCP run URL and workspace settings showing auto-apply off
|
||||||
|
- saved plan JSON and checker output
|
||||||
|
- state list containing exactly the 13 managed addresses
|
||||||
|
- read-only inventory before and after each mutation
|
||||||
|
- synthesized CloudFormation template, reviewed change set, and stack events
|
||||||
|
- object-version inventory
|
||||||
|
- exact DNS, certificate, distribution, OAC, function, role, and policy IDs
|
||||||
|
- deployment, invalidation, smoke, and rollback output
|
||||||
|
- post-action no-op plan
|
||||||
|
- phase close-out with completed work, validation, risks, deviations, and
|
||||||
|
remaining work
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Before import apply: discard the run and correct configuration.
|
||||||
|
- After import but before the controlled ownership update: remove only the
|
||||||
|
imported Terraform state addresses under a separately reviewed state
|
||||||
|
operation. CloudFormation remains authoritative.
|
||||||
|
- After the controlled ownership update but before detachment: do not simply
|
||||||
|
remove Terraform state or redeploy CloudFormation. Either complete the
|
||||||
|
reviewed retained detachment or explicitly restore the exact pre-adoption
|
||||||
|
policy and tags under a separate rollback approval.
|
||||||
|
- After detachment: Terraform remains authoritative. Restore content from the
|
||||||
|
versioned bucket and release manifests. Do not recreate the legacy stack over
|
||||||
|
retained resources.
|
||||||
|
- Re-establishing CloudFormation ownership requires a reviewed CloudFormation
|
||||||
|
`IMPORT` change set. An ordinary create/update is not a rollback.
|
||||||
|
|
||||||
|
Any replacement, destroy, cross-environment ID, missing import, broad policy
|
||||||
|
change, or failed smoke check is a hard stop.
|
||||||
26
terraform/live/dev/.terraform.lock.hcl
generated
Normal file
26
terraform/live/dev/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
|
version = "6.62.0"
|
||||||
|
constraints = "~> 6.0"
|
||||||
|
hashes = [
|
||||||
|
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
|
||||||
|
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
|
||||||
|
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
|
||||||
|
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
|
||||||
|
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
|
||||||
|
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
|
||||||
|
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
|
||||||
|
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
|
||||||
|
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
|
||||||
|
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
|
||||||
|
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||||
|
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
|
||||||
|
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
|
||||||
|
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
|
||||||
|
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
|
||||||
|
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
|
||||||
|
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
|
||||||
|
]
|
||||||
|
}
|
||||||
64
terraform/live/dev/imports.tf
Normal file
64
terraform/live/dev/imports.tf
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_public_access_block.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_ownership_controls.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_versioning.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_policy.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_distribution.site
|
||||||
|
id = local.distribution_id
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_origin_access_control.site
|
||||||
|
id = local.oac_id
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_function.spa_rewrite
|
||||||
|
id = local.function_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_route53_record.site_a
|
||||||
|
id = "${local.hosted_zone_id}_${local.domain_name}_A"
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_route53_record.site_aaaa
|
||||||
|
id = "${local.hosted_zone_id}_${local.domain_name}_AAAA"
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_iam_role.github_deploy
|
||||||
|
id = local.deploy_role_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_iam_role_policy.github_deploy
|
||||||
|
id = "${local.deploy_role_name}:${local.inline_policy}"
|
||||||
|
}
|
||||||
96
terraform/live/dev/main.tf
Normal file
96
terraform/live/dev/main.tf
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
variable "adoption_complete" {
|
||||||
|
type = bool
|
||||||
|
description = "Enable only after import, no-op verification, and ownership transfer approval."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
environment = "dev"
|
||||||
|
workspace_name = "shoc-frontend-new-dev"
|
||||||
|
aws_account_id = "396287094661"
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
bucket_name = "seahaven-shoc-frontend-dev"
|
||||||
|
distribution_id = "E2CWLM1AFB964P"
|
||||||
|
oac_id = "E30VSIK87N8H64"
|
||||||
|
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
|
||||||
|
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
|
||||||
|
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||||
|
domain_name = "dev.seahaven.com"
|
||||||
|
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||||
|
certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||||
|
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
deploy_role_name = "githubdeploy-shoc-frontend-new-dev"
|
||||||
|
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||||
|
stack_name = "shoc-frontend-dev"
|
||||||
|
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||||
|
permissions_boundary_arn = (
|
||||||
|
"arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary"
|
||||||
|
)
|
||||||
|
bucket_auto_delete_helper_role_arn = (
|
||||||
|
"arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
|
||||||
|
)
|
||||||
|
legacy_tags = {
|
||||||
|
Environment = "dev"
|
||||||
|
ManagedBy = "cdk"
|
||||||
|
Project = "shoc-frontend"
|
||||||
|
}
|
||||||
|
legacy_bucket_tags = merge(local.legacy_tags, {
|
||||||
|
"aws-cdk:auto-delete-objects" = "true"
|
||||||
|
})
|
||||||
|
terraform_tags = {
|
||||||
|
Environment = "dev"
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
Ownership = "terraform"
|
||||||
|
Project = "shoc-frontend"
|
||||||
|
}
|
||||||
|
manager_tag = {
|
||||||
|
HcpTerraformWorkspace = local.workspace_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module "inventory" {
|
||||||
|
source = "../modules/environment-inventory"
|
||||||
|
|
||||||
|
aws_account_id = local.aws_account_id
|
||||||
|
aws_region = local.aws_region
|
||||||
|
hosted_zone_name = local.domain_name
|
||||||
|
expected_hosted_zone_id = local.hosted_zone_id
|
||||||
|
certificate_domain = "*.seahaven.com"
|
||||||
|
expected_certificate_arn = local.certificate_arn
|
||||||
|
expected_github_oidc_provider_arn = local.github_oidc_arn
|
||||||
|
expected_cache_policy_id = local.cache_policy_id
|
||||||
|
}
|
||||||
|
|
||||||
|
module "environment_owned" {
|
||||||
|
source = "../modules/environment-owned"
|
||||||
|
|
||||||
|
environment = local.environment
|
||||||
|
adoption_complete = var.adoption_complete
|
||||||
|
aws_account_id = local.aws_account_id
|
||||||
|
aws_region = local.aws_region
|
||||||
|
bucket_name = local.bucket_name
|
||||||
|
distribution_id = local.distribution_id
|
||||||
|
origin_access_control_name = local.oac_name
|
||||||
|
origin_access_control_description = ""
|
||||||
|
origin_id = local.origin_id
|
||||||
|
function_name = local.function_name
|
||||||
|
domain_name = local.domain_name
|
||||||
|
hosted_zone_id = local.hosted_zone_id
|
||||||
|
certificate_arn = local.certificate_arn
|
||||||
|
cache_policy_id = local.cache_policy_id
|
||||||
|
github_oidc_provider_arn = local.github_oidc_arn
|
||||||
|
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev"
|
||||||
|
pre_adoption_github_subject_operator = "StringEquals"
|
||||||
|
post_adoption_github_subject_operator = "StringEquals"
|
||||||
|
deploy_branch = "dev"
|
||||||
|
deploy_role_name = local.deploy_role_name
|
||||||
|
deploy_inline_policy_name = local.inline_policy
|
||||||
|
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||||
|
cloudformation_stack_name = local.stack_name
|
||||||
|
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
|
||||||
|
pre_adoption_tags = local.legacy_tags
|
||||||
|
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||||
|
ownership_tags = local.terraform_tags
|
||||||
|
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||||
|
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||||
|
}
|
||||||
11
terraform/live/dev/outputs.tf
Normal file
11
terraform/live/dev/outputs.tf
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
output "bucket_name" {
|
||||||
|
value = module.environment_owned.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "distribution_id" {
|
||||||
|
value = module.environment_owned.distribution_id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "deploy_role_arn" {
|
||||||
|
value = module.environment_owned.deploy_role_arn
|
||||||
|
}
|
||||||
3
terraform/live/dev/providers.tf
Normal file
3
terraform/live/dev/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
provider "aws" {
|
||||||
|
region = local.aws_region
|
||||||
|
}
|
||||||
19
terraform/live/dev/versions.tf
Normal file
19
terraform/live/dev/versions.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.9.0, < 2.0.0"
|
||||||
|
|
||||||
|
cloud {
|
||||||
|
organization = "seahaven"
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
project = "seahaven-external-dev"
|
||||||
|
name = "shoc-frontend-new-dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 6.57"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
terraform/live/modules/environment-inventory/main.tf
Normal file
65
terraform/live/modules/environment-inventory/main.tf
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
data "aws_caller_identity" "current" {
|
||||||
|
lifecycle {
|
||||||
|
postcondition {
|
||||||
|
condition = self.account_id == var.aws_account_id
|
||||||
|
error_message = "Refusing to inspect resources outside the expected AWS account."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_region" "current" {
|
||||||
|
lifecycle {
|
||||||
|
postcondition {
|
||||||
|
condition = self.region == var.aws_region
|
||||||
|
error_message = "Refusing to inspect resources outside the expected AWS region."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_route53_zone" "site" {
|
||||||
|
name = "${trimsuffix(var.hosted_zone_name, ".")}."
|
||||||
|
private_zone = false
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
postcondition {
|
||||||
|
condition = self.zone_id == var.expected_hosted_zone_id
|
||||||
|
error_message = "The resolved Route 53 zone does not match the pinned hosted zone."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_acm_certificate" "shared" {
|
||||||
|
domain = var.certificate_domain
|
||||||
|
statuses = ["ISSUED"]
|
||||||
|
types = ["AMAZON_ISSUED"]
|
||||||
|
most_recent = true
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
postcondition {
|
||||||
|
condition = self.arn == var.expected_certificate_arn
|
||||||
|
error_message = "The resolved ACM certificate does not match the pinned certificate."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_openid_connect_provider" "github" {
|
||||||
|
url = "https://token.actions.githubusercontent.com"
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
postcondition {
|
||||||
|
condition = self.arn == var.expected_github_oidc_provider_arn
|
||||||
|
error_message = "The GitHub OIDC provider does not match the pinned account provider."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_cloudfront_cache_policy" "managed" {
|
||||||
|
name = var.cache_policy_name
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
postcondition {
|
||||||
|
condition = self.id == var.expected_cache_policy_id
|
||||||
|
error_message = "The AWS managed CloudFront cache policy does not match the pinned ID."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
terraform/live/modules/environment-inventory/outputs.tf
Normal file
19
terraform/live/modules/environment-inventory/outputs.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
output "hosted_zone_id" {
|
||||||
|
value = data.aws_route53_zone.site.zone_id
|
||||||
|
description = "Verified hosted zone ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "certificate_arn" {
|
||||||
|
value = data.aws_acm_certificate.shared.arn
|
||||||
|
description = "Verified ACM certificate ARN."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "github_oidc_provider_arn" {
|
||||||
|
value = data.aws_iam_openid_connect_provider.github.arn
|
||||||
|
description = "Verified GitHub OIDC provider ARN."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cache_policy_id" {
|
||||||
|
value = data.aws_cloudfront_cache_policy.managed.id
|
||||||
|
description = "Verified AWS managed cache policy ID."
|
||||||
|
}
|
||||||
46
terraform/live/modules/environment-inventory/variables.tf
Normal file
46
terraform/live/modules/environment-inventory/variables.tf
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
variable "aws_account_id" {
|
||||||
|
type = string
|
||||||
|
description = "Expected AWS account ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "aws_region" {
|
||||||
|
type = string
|
||||||
|
description = "Expected AWS provider region."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "hosted_zone_name" {
|
||||||
|
type = string
|
||||||
|
description = "Public hosted zone DNS name."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "expected_hosted_zone_id" {
|
||||||
|
type = string
|
||||||
|
description = "Pinned hosted zone ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "certificate_domain" {
|
||||||
|
type = string
|
||||||
|
description = "Domain used to resolve the expected certificate."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "expected_certificate_arn" {
|
||||||
|
type = string
|
||||||
|
description = "Pinned ACM certificate ARN."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "expected_github_oidc_provider_arn" {
|
||||||
|
type = string
|
||||||
|
description = "Pinned account-global GitHub OIDC provider ARN."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cache_policy_name" {
|
||||||
|
type = string
|
||||||
|
description = "AWS managed CloudFront cache policy name."
|
||||||
|
default = "Managed-CachingOptimized"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "expected_cache_policy_id" {
|
||||||
|
type = string
|
||||||
|
description = "Pinned AWS managed CloudFront cache policy ID."
|
||||||
|
default = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||||
|
}
|
||||||
403
terraform/live/modules/environment-owned/main.tf
Normal file
403
terraform/live/modules/environment-owned/main.tf
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
locals {
|
||||||
|
bucket_arn = "arn:aws:s3:::${var.bucket_name}"
|
||||||
|
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
|
||||||
|
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
|
||||||
|
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
|
||||||
|
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
|
||||||
|
github_subject_operator = var.pre_adoption_github_subject_operator
|
||||||
|
|
||||||
|
spa_rewrite_code = join("\n", [
|
||||||
|
"function handler(event) {",
|
||||||
|
" var request = event.request;",
|
||||||
|
" var uri = request.uri;",
|
||||||
|
" // No file extension after the last slash -> a client-side route.",
|
||||||
|
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||||
|
" request.uri = '/index.html';",
|
||||||
|
" }",
|
||||||
|
" return request;",
|
||||||
|
"}",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "site_bucket" {
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = var.adoption_complete ? [] : [1]
|
||||||
|
|
||||||
|
content {
|
||||||
|
effect = "Allow"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "AWS"
|
||||||
|
identifiers = [var.bucket_auto_delete_helper_role_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
"s3:DeleteObject*",
|
||||||
|
"s3:GetBucket*",
|
||||||
|
"s3:List*",
|
||||||
|
"s3:PutBucketPolicy",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
local.bucket_arn,
|
||||||
|
"${local.bucket_arn}/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["cloudfront.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ["s3:GetObject"]
|
||||||
|
resources = ["${local.bucket_arn}/*"]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "AWS:SourceArn"
|
||||||
|
values = [local.distribution_arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
effect = "Deny"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "AWS"
|
||||||
|
identifiers = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ["s3:*"]
|
||||||
|
resources = [
|
||||||
|
local.bucket_arn,
|
||||||
|
"${local.bucket_arn}/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "Bool"
|
||||||
|
variable = "aws:SecureTransport"
|
||||||
|
values = ["false"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Federated"
|
||||||
|
identifiers = [var.github_oidc_provider_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "token.actions.githubusercontent.com:aud"
|
||||||
|
values = ["sts.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = local.github_subject_operator
|
||||||
|
variable = "token.actions.githubusercontent.com:sub"
|
||||||
|
values = [var.github_subject]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "github_deploy" {
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = !var.adoption_complete && var.environment == "dev" ? [1] : []
|
||||||
|
|
||||||
|
content {
|
||||||
|
sid = "AssumeCdkBootstrapRoles"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
resources = ["arn:aws:iam::${var.aws_account_id}:role/cdk-hnb659fds-*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = var.adoption_complete ? [] : [1]
|
||||||
|
|
||||||
|
content {
|
||||||
|
sid = "DescribeStack"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["cloudformation:DescribeStacks"]
|
||||||
|
resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = var.adoption_complete ? [] : [1]
|
||||||
|
|
||||||
|
content {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"s3:Abort*",
|
||||||
|
"s3:DeleteObject*",
|
||||||
|
"s3:GetBucket*",
|
||||||
|
"s3:GetObject*",
|
||||||
|
"s3:List*",
|
||||||
|
"s3:PutObject",
|
||||||
|
"s3:PutObjectLegalHold",
|
||||||
|
"s3:PutObjectRetention",
|
||||||
|
"s3:PutObjectTagging",
|
||||||
|
"s3:PutObjectVersionTagging",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
local.bucket_arn,
|
||||||
|
"${local.bucket_arn}/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = var.adoption_complete ? [1] : []
|
||||||
|
|
||||||
|
content {
|
||||||
|
sid = "ReadDeploymentBucket"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"s3:GetBucketLocation",
|
||||||
|
"s3:GetBucketVersioning",
|
||||||
|
"s3:ListBucket",
|
||||||
|
"s3:ListBucketVersions",
|
||||||
|
]
|
||||||
|
resources = [local.bucket_arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = var.adoption_complete ? [1] : []
|
||||||
|
|
||||||
|
content {
|
||||||
|
sid = "PublishAndRollbackSiteObjects"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"s3:DeleteObject",
|
||||||
|
"s3:DeleteObjectVersion",
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:GetObjectVersion",
|
||||||
|
"s3:PutObject",
|
||||||
|
]
|
||||||
|
resources = ["${local.bucket_arn}/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvalidateDistribution"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"cloudfront:CreateInvalidation",
|
||||||
|
"cloudfront:GetInvalidation",
|
||||||
|
]
|
||||||
|
resources = [local.distribution_arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "site" {
|
||||||
|
bucket = var.bucket_name
|
||||||
|
force_destroy = false
|
||||||
|
tags = local.bucket_tags
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_ownership_controls" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
object_ownership = "BucketOwnerEnforced"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "AES256"
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket_key_enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_versioning" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
|
||||||
|
versioning_configuration {
|
||||||
|
status = "Enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_policy" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
policy = data.aws_iam_policy_document.site_bucket.json
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudfront_origin_access_control" "site" {
|
||||||
|
name = var.origin_access_control_name
|
||||||
|
description = var.origin_access_control_description
|
||||||
|
origin_access_control_origin_type = "s3"
|
||||||
|
signing_behavior = "always"
|
||||||
|
signing_protocol = "sigv4"
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudfront_function" "spa_rewrite" {
|
||||||
|
name = var.function_name
|
||||||
|
runtime = "cloudfront-js-1.0"
|
||||||
|
comment = "SPA routing: rewrite extensionless paths to /index.html"
|
||||||
|
publish = true
|
||||||
|
code = local.spa_rewrite_code
|
||||||
|
tags = local.resource_tags
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
ignore_changes = [publish]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudfront_distribution" "site" {
|
||||||
|
aliases = [var.domain_name]
|
||||||
|
comment = "SeaHaven SHOC frontend (${var.environment})"
|
||||||
|
default_root_object = "index.html"
|
||||||
|
enabled = true
|
||||||
|
http_version = "http2and3"
|
||||||
|
is_ipv6_enabled = true
|
||||||
|
price_class = "PriceClass_100"
|
||||||
|
tags = local.resource_tags
|
||||||
|
|
||||||
|
origin {
|
||||||
|
connection_attempts = 3
|
||||||
|
connection_timeout = 10
|
||||||
|
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||||
|
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||||
|
origin_id = var.origin_id
|
||||||
|
}
|
||||||
|
|
||||||
|
default_cache_behavior {
|
||||||
|
allowed_methods = ["GET", "HEAD", "OPTIONS"]
|
||||||
|
cache_policy_id = var.cache_policy_id
|
||||||
|
cached_methods = ["GET", "HEAD"]
|
||||||
|
compress = true
|
||||||
|
target_origin_id = var.origin_id
|
||||||
|
viewer_protocol_policy = "redirect-to-https"
|
||||||
|
|
||||||
|
function_association {
|
||||||
|
event_type = "viewer-request"
|
||||||
|
function_arn = aws_cloudfront_function.spa_rewrite.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restrictions {
|
||||||
|
geo_restriction {
|
||||||
|
restriction_type = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_certificate {
|
||||||
|
acm_certificate_arn = var.certificate_arn
|
||||||
|
minimum_protocol_version = "TLSv1.2_2021"
|
||||||
|
ssl_support_method = "sni-only"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route53_record" "site_a" {
|
||||||
|
zone_id = var.hosted_zone_id
|
||||||
|
name = var.domain_name
|
||||||
|
type = "A"
|
||||||
|
|
||||||
|
alias {
|
||||||
|
name = aws_cloudfront_distribution.site.domain_name
|
||||||
|
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
||||||
|
evaluate_target_health = false
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route53_record" "site_aaaa" {
|
||||||
|
zone_id = var.hosted_zone_id
|
||||||
|
name = var.domain_name
|
||||||
|
type = "AAAA"
|
||||||
|
|
||||||
|
alias {
|
||||||
|
name = aws_cloudfront_distribution.site.domain_name
|
||||||
|
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
||||||
|
evaluate_target_health = false
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "github_deploy" {
|
||||||
|
name = var.deploy_role_name
|
||||||
|
path = "/"
|
||||||
|
description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
||||||
|
max_session_duration = 3600
|
||||||
|
permissions_boundary = var.deploy_permissions_boundary_arn
|
||||||
|
tags = local.deploy_role_tags
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "github_deploy" {
|
||||||
|
name = var.deploy_inline_policy_name
|
||||||
|
role = aws_iam_role.github_deploy.id
|
||||||
|
policy = data.aws_iam_policy_document.github_deploy.json
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
terraform/live/modules/environment-owned/outputs.tf
Normal file
14
terraform/live/modules/environment-owned/outputs.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
output "bucket_name" {
|
||||||
|
value = aws_s3_bucket.site.id
|
||||||
|
description = "Imported site bucket name."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "distribution_id" {
|
||||||
|
value = aws_cloudfront_distribution.site.id
|
||||||
|
description = "Imported CloudFront distribution ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "deploy_role_arn" {
|
||||||
|
value = aws_iam_role.github_deploy.arn
|
||||||
|
description = "Imported GitHub deployment role ARN."
|
||||||
|
}
|
||||||
160
terraform/live/modules/environment-owned/variables.tf
Normal file
160
terraform/live/modules/environment-owned/variables.tf
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
variable "environment" {
|
||||||
|
type = string
|
||||||
|
description = "Environment name."
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = contains(["tf-poc", "dev", "staging"], var.environment)
|
||||||
|
error_message = "environment must be tf-poc, dev, or staging."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "adoption_complete" {
|
||||||
|
type = bool
|
||||||
|
description = "Switches only ownership tags and the deploy policy to their adopted values."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "aws_account_id" {
|
||||||
|
type = string
|
||||||
|
description = "AWS account containing the resources."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "aws_region" {
|
||||||
|
type = string
|
||||||
|
description = "AWS region used by the environment."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "bucket_name" {
|
||||||
|
type = string
|
||||||
|
description = "Existing private S3 origin bucket."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "distribution_id" {
|
||||||
|
type = string
|
||||||
|
description = "Existing CloudFront distribution ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "origin_access_control_name" {
|
||||||
|
type = string
|
||||||
|
description = "Exact existing CloudFront OAC name."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "origin_access_control_description" {
|
||||||
|
type = string
|
||||||
|
description = "Exact existing CloudFront OAC description."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "origin_id" {
|
||||||
|
type = string
|
||||||
|
description = "Exact origin ID in the existing distribution."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "function_name" {
|
||||||
|
type = string
|
||||||
|
description = "Existing CloudFront Function name."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
type = string
|
||||||
|
description = "Site hostname."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "hosted_zone_id" {
|
||||||
|
type = string
|
||||||
|
description = "Inventory-verified hosted zone ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "certificate_arn" {
|
||||||
|
type = string
|
||||||
|
description = "Inventory-verified ACM certificate ARN."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cache_policy_id" {
|
||||||
|
type = string
|
||||||
|
description = "Inventory-verified AWS managed cache policy ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "github_oidc_provider_arn" {
|
||||||
|
type = string
|
||||||
|
description = "Inventory-verified GitHub OIDC provider ARN."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "github_subject" {
|
||||||
|
type = string
|
||||||
|
description = "Exact GitHub OIDC subject in the existing role."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pre_adoption_github_subject_operator" {
|
||||||
|
type = string
|
||||||
|
description = "Condition operator used by the role before adoption."
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = contains(["StringEquals", "StringLike"], var.pre_adoption_github_subject_operator)
|
||||||
|
error_message = "pre_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "post_adoption_github_subject_operator" {
|
||||||
|
type = string
|
||||||
|
description = "Condition operator used by the role after adoption."
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = contains(["StringEquals", "StringLike"], var.post_adoption_github_subject_operator)
|
||||||
|
error_message = "post_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_branch" {
|
||||||
|
type = string
|
||||||
|
description = "Branch or environment named in the existing role description."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_role_name" {
|
||||||
|
type = string
|
||||||
|
description = "Existing GitHub deployment role name."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_inline_policy_name" {
|
||||||
|
type = string
|
||||||
|
description = "Existing generated inline policy name."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_permissions_boundary_arn" {
|
||||||
|
type = string
|
||||||
|
description = "Exact permissions boundary attached before import."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cloudformation_stack_name" {
|
||||||
|
type = string
|
||||||
|
description = "Legacy CloudFormation stack used by the pre-adoption policy."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "bucket_auto_delete_helper_role_arn" {
|
||||||
|
type = string
|
||||||
|
description = "Exact legacy S3 auto-delete helper role ARN."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pre_adoption_tags" {
|
||||||
|
type = map(string)
|
||||||
|
description = "Exact tags present while CloudFormation still owns the resources."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pre_adoption_bucket_tags" {
|
||||||
|
type = map(string)
|
||||||
|
description = "Exact pre-adoption S3 tags, including the CDK auto-delete marker."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ownership_tags" {
|
||||||
|
type = map(string)
|
||||||
|
description = "Tags applied by the controlled ownership transfer."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pre_adoption_deploy_role_tags" {
|
||||||
|
type = map(string)
|
||||||
|
description = "Exact pre-adoption deploy-role tags, including its HCP manager tag."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "post_adoption_deploy_role_tags" {
|
||||||
|
type = map(string)
|
||||||
|
description = "Exact post-adoption deploy-role tags, preserving its HCP manager tag."
|
||||||
|
}
|
||||||
26
terraform/live/staging/.terraform.lock.hcl
generated
Normal file
26
terraform/live/staging/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
|
version = "6.62.0"
|
||||||
|
constraints = "~> 6.0"
|
||||||
|
hashes = [
|
||||||
|
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
|
||||||
|
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
|
||||||
|
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
|
||||||
|
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
|
||||||
|
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
|
||||||
|
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
|
||||||
|
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
|
||||||
|
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
|
||||||
|
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
|
||||||
|
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
|
||||||
|
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||||
|
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
|
||||||
|
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
|
||||||
|
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
|
||||||
|
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
|
||||||
|
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
|
||||||
|
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
|
||||||
|
]
|
||||||
|
}
|
||||||
64
terraform/live/staging/imports.tf
Normal file
64
terraform/live/staging/imports.tf
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_public_access_block.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_ownership_controls.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_versioning.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_policy.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_distribution.site
|
||||||
|
id = local.distribution_id
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_origin_access_control.site
|
||||||
|
id = local.oac_id
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_function.spa_rewrite
|
||||||
|
id = local.function_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_route53_record.site_a
|
||||||
|
id = "${local.hosted_zone_id}_${local.domain_name}_A"
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_route53_record.site_aaaa
|
||||||
|
id = "${local.hosted_zone_id}_${local.domain_name}_AAAA"
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_iam_role.github_deploy
|
||||||
|
id = local.deploy_role_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_iam_role_policy.github_deploy
|
||||||
|
id = "${local.deploy_role_name}:${local.inline_policy}"
|
||||||
|
}
|
||||||
96
terraform/live/staging/main.tf
Normal file
96
terraform/live/staging/main.tf
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
variable "adoption_complete" {
|
||||||
|
type = bool
|
||||||
|
description = "Enable only after import, no-op verification, and ownership transfer approval."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
environment = "staging"
|
||||||
|
workspace_name = "shoc-frontend-new-staging"
|
||||||
|
aws_account_id = "396287094661"
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
bucket_name = "seahaven-shoc-frontend-staging"
|
||||||
|
distribution_id = "E2JDVEZ6EGD49J"
|
||||||
|
oac_id = "E1PF5R6QQNBZAI"
|
||||||
|
oac_name = "shocfrontendstagingDistributOrigin1S3OriginAccessControl82B1C17D"
|
||||||
|
origin_id = "shocfrontendstagingDistributionOrigin16E4628FC"
|
||||||
|
function_name = "us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
|
||||||
|
domain_name = "staging.seahaven.com"
|
||||||
|
hosted_zone_id = "Z02602739VQWBWCAGXP4"
|
||||||
|
certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||||
|
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
deploy_role_name = "githubdeploy-shoc-frontend-new-staging"
|
||||||
|
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||||
|
stack_name = "shoc-frontend-staging"
|
||||||
|
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||||
|
permissions_boundary_arn = (
|
||||||
|
"arn:aws:iam::396287094661:policy/shoc-frontend-new-staging-deploy-boundary"
|
||||||
|
)
|
||||||
|
bucket_auto_delete_helper_role_arn = (
|
||||||
|
"arn:aws:iam::396287094661:role/shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
|
||||||
|
)
|
||||||
|
legacy_tags = {
|
||||||
|
Environment = "staging"
|
||||||
|
ManagedBy = "cdk"
|
||||||
|
Project = "shoc-frontend"
|
||||||
|
}
|
||||||
|
legacy_bucket_tags = merge(local.legacy_tags, {
|
||||||
|
"aws-cdk:auto-delete-objects" = "true"
|
||||||
|
})
|
||||||
|
terraform_tags = {
|
||||||
|
Environment = "staging"
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
Ownership = "terraform"
|
||||||
|
Project = "shoc-frontend"
|
||||||
|
}
|
||||||
|
manager_tag = {
|
||||||
|
HcpTerraformWorkspace = local.workspace_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module "inventory" {
|
||||||
|
source = "../modules/environment-inventory"
|
||||||
|
|
||||||
|
aws_account_id = local.aws_account_id
|
||||||
|
aws_region = local.aws_region
|
||||||
|
hosted_zone_name = local.domain_name
|
||||||
|
expected_hosted_zone_id = local.hosted_zone_id
|
||||||
|
certificate_domain = "*.seahaven.com"
|
||||||
|
expected_certificate_arn = local.certificate_arn
|
||||||
|
expected_github_oidc_provider_arn = local.github_oidc_arn
|
||||||
|
expected_cache_policy_id = local.cache_policy_id
|
||||||
|
}
|
||||||
|
|
||||||
|
module "environment_owned" {
|
||||||
|
source = "../modules/environment-owned"
|
||||||
|
|
||||||
|
environment = local.environment
|
||||||
|
adoption_complete = var.adoption_complete
|
||||||
|
aws_account_id = local.aws_account_id
|
||||||
|
aws_region = local.aws_region
|
||||||
|
bucket_name = local.bucket_name
|
||||||
|
distribution_id = local.distribution_id
|
||||||
|
origin_access_control_name = local.oac_name
|
||||||
|
origin_access_control_description = ""
|
||||||
|
origin_id = local.origin_id
|
||||||
|
function_name = local.function_name
|
||||||
|
domain_name = local.domain_name
|
||||||
|
hosted_zone_id = local.hosted_zone_id
|
||||||
|
certificate_arn = local.certificate_arn
|
||||||
|
cache_policy_id = local.cache_policy_id
|
||||||
|
github_oidc_provider_arn = local.github_oidc_arn
|
||||||
|
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging"
|
||||||
|
pre_adoption_github_subject_operator = "StringEquals"
|
||||||
|
post_adoption_github_subject_operator = "StringEquals"
|
||||||
|
deploy_branch = "staging"
|
||||||
|
deploy_role_name = local.deploy_role_name
|
||||||
|
deploy_inline_policy_name = local.inline_policy
|
||||||
|
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||||
|
cloudformation_stack_name = local.stack_name
|
||||||
|
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
|
||||||
|
pre_adoption_tags = local.legacy_tags
|
||||||
|
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||||
|
ownership_tags = local.terraform_tags
|
||||||
|
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||||
|
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||||
|
}
|
||||||
11
terraform/live/staging/outputs.tf
Normal file
11
terraform/live/staging/outputs.tf
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
output "bucket_name" {
|
||||||
|
value = module.environment_owned.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "distribution_id" {
|
||||||
|
value = module.environment_owned.distribution_id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "deploy_role_arn" {
|
||||||
|
value = module.environment_owned.deploy_role_arn
|
||||||
|
}
|
||||||
3
terraform/live/staging/providers.tf
Normal file
3
terraform/live/staging/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
provider "aws" {
|
||||||
|
region = local.aws_region
|
||||||
|
}
|
||||||
19
terraform/live/staging/versions.tf
Normal file
19
terraform/live/staging/versions.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.9.0, < 2.0.0"
|
||||||
|
|
||||||
|
cloud {
|
||||||
|
organization = "seahaven"
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
project = "seahaven-external-dev"
|
||||||
|
name = "shoc-frontend-new-staging"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 6.57"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
terraform/live/tf-poc/.terraform.lock.hcl
generated
Normal file
26
terraform/live/tf-poc/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
|
version = "6.62.0"
|
||||||
|
constraints = "~> 6.0"
|
||||||
|
hashes = [
|
||||||
|
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
|
||||||
|
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
|
||||||
|
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
|
||||||
|
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
|
||||||
|
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
|
||||||
|
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
|
||||||
|
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
|
||||||
|
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
|
||||||
|
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
|
||||||
|
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
|
||||||
|
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||||
|
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
|
||||||
|
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
|
||||||
|
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
|
||||||
|
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
|
||||||
|
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
|
||||||
|
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
|
||||||
|
]
|
||||||
|
}
|
||||||
64
terraform/live/tf-poc/imports.tf
Normal file
64
terraform/live/tf-poc/imports.tf
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_public_access_block.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_ownership_controls.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_versioning.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_s3_bucket_policy.site
|
||||||
|
id = local.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_distribution.site
|
||||||
|
id = var.distribution_id
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_origin_access_control.site
|
||||||
|
id = var.origin_access_control_id
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_cloudfront_function.spa_rewrite
|
||||||
|
id = var.function_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_route53_record.site_a
|
||||||
|
id = "${var.hosted_zone_id}_${local.domain_name}_A"
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_route53_record.site_aaaa
|
||||||
|
id = "${var.hosted_zone_id}_${local.domain_name}_AAAA"
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_iam_role.github_deploy
|
||||||
|
id = local.deploy_role_name
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
to = module.environment_owned.aws_iam_role_policy.github_deploy
|
||||||
|
id = "${local.deploy_role_name}:${var.deploy_inline_policy_name}"
|
||||||
|
}
|
||||||
152
terraform/live/tf-poc/main.tf
Normal file
152
terraform/live/tf-poc/main.tf
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
variable "adoption_complete" {
|
||||||
|
type = bool
|
||||||
|
description = "Enable only after import, no-op verification, and ownership transfer approval."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "distribution_id" {
|
||||||
|
type = string
|
||||||
|
description = "CloudFront distribution ID emitted by the tf-poc creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "origin_access_control_id" {
|
||||||
|
type = string
|
||||||
|
description = "CloudFront OAC ID emitted by the tf-poc creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "origin_access_control_name" {
|
||||||
|
type = string
|
||||||
|
description = "Exact CloudFront OAC name emitted by the tf-poc creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "origin_id" {
|
||||||
|
type = string
|
||||||
|
description = "Exact distribution origin ID emitted by the tf-poc creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "function_name" {
|
||||||
|
type = string
|
||||||
|
description = "CloudFront Function name emitted by the tf-poc creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "hosted_zone_id" {
|
||||||
|
type = string
|
||||||
|
description = "Dedicated frontend tf-poc hosted zone ID emitted by the creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "certificate_arn" {
|
||||||
|
type = string
|
||||||
|
description = "Dedicated frontend tf-poc ACM certificate ARN emitted by the creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_inline_policy_name" {
|
||||||
|
type = string
|
||||||
|
description = "Generated inline policy name emitted by the tf-poc creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "bucket_auto_delete_helper_role_arn" {
|
||||||
|
type = string
|
||||||
|
description = "S3 auto-delete helper role ARN emitted by the tf-poc creator."
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
environment = "tf-poc"
|
||||||
|
workspace_name = "shoc-frontend-new-tf-poc"
|
||||||
|
aws_account_id = "396287094661"
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
bucket_name = "seahaven-shoc-frontend-tf-poc"
|
||||||
|
domain_name = "frontend-tf-poc.seahaven.com"
|
||||||
|
api_url = "https://api.tf-poc.seahaven.com/api"
|
||||||
|
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
deploy_role_name = "githubdeploy-shoc-frontend-new-tf-poc"
|
||||||
|
stack_name = "shoc-frontend-tf-poc"
|
||||||
|
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||||
|
permissions_boundary_arn = (
|
||||||
|
"arn:aws:iam::396287094661:policy/shoc-frontend-new-tf-poc-deploy-boundary"
|
||||||
|
)
|
||||||
|
generated_values = {
|
||||||
|
distribution_id = var.distribution_id
|
||||||
|
origin_access_control_id = var.origin_access_control_id
|
||||||
|
origin_access_control_name = var.origin_access_control_name
|
||||||
|
origin_id = var.origin_id
|
||||||
|
function_name = var.function_name
|
||||||
|
hosted_zone_id = var.hosted_zone_id
|
||||||
|
certificate_arn = var.certificate_arn
|
||||||
|
deploy_inline_policy_name = var.deploy_inline_policy_name
|
||||||
|
bucket_auto_delete_helper_role_arn = var.bucket_auto_delete_helper_role_arn
|
||||||
|
}
|
||||||
|
legacy_tags = {
|
||||||
|
Environment = "tf-poc"
|
||||||
|
ManagedBy = "cdk"
|
||||||
|
Project = "shoc-frontend"
|
||||||
|
}
|
||||||
|
legacy_bucket_tags = merge(local.legacy_tags, {
|
||||||
|
"aws-cdk:auto-delete-objects" = "true"
|
||||||
|
})
|
||||||
|
terraform_tags = {
|
||||||
|
Environment = "tf-poc"
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
Ownership = "terraform"
|
||||||
|
Project = "shoc-frontend"
|
||||||
|
}
|
||||||
|
manager_tag = {
|
||||||
|
HcpTerraformWorkspace = local.workspace_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check "creator_outputs_populated" {
|
||||||
|
assert {
|
||||||
|
condition = alltrue([
|
||||||
|
for value in values(local.generated_values) :
|
||||||
|
length(trimspace(value)) > 0 && !startswith(value, "REPLACE_WITH_")
|
||||||
|
])
|
||||||
|
error_message = "Populate every tf-poc generated value from creator outputs before planning."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module "inventory" {
|
||||||
|
source = "../modules/environment-inventory"
|
||||||
|
|
||||||
|
aws_account_id = local.aws_account_id
|
||||||
|
aws_region = local.aws_region
|
||||||
|
hosted_zone_name = local.domain_name
|
||||||
|
expected_hosted_zone_id = var.hosted_zone_id
|
||||||
|
certificate_domain = local.domain_name
|
||||||
|
expected_certificate_arn = var.certificate_arn
|
||||||
|
expected_github_oidc_provider_arn = local.github_oidc_arn
|
||||||
|
expected_cache_policy_id = local.cache_policy_id
|
||||||
|
}
|
||||||
|
|
||||||
|
module "environment_owned" {
|
||||||
|
source = "../modules/environment-owned"
|
||||||
|
|
||||||
|
environment = local.environment
|
||||||
|
adoption_complete = var.adoption_complete
|
||||||
|
aws_account_id = local.aws_account_id
|
||||||
|
aws_region = local.aws_region
|
||||||
|
bucket_name = local.bucket_name
|
||||||
|
distribution_id = var.distribution_id
|
||||||
|
origin_access_control_name = var.origin_access_control_name
|
||||||
|
origin_access_control_description = ""
|
||||||
|
origin_id = var.origin_id
|
||||||
|
function_name = var.function_name
|
||||||
|
domain_name = local.domain_name
|
||||||
|
hosted_zone_id = var.hosted_zone_id
|
||||||
|
certificate_arn = var.certificate_arn
|
||||||
|
cache_policy_id = local.cache_policy_id
|
||||||
|
github_oidc_provider_arn = local.github_oidc_arn
|
||||||
|
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:environment:tf-poc"
|
||||||
|
pre_adoption_github_subject_operator = "StringEquals"
|
||||||
|
post_adoption_github_subject_operator = "StringEquals"
|
||||||
|
deploy_branch = "tf-poc"
|
||||||
|
deploy_role_name = local.deploy_role_name
|
||||||
|
deploy_inline_policy_name = var.deploy_inline_policy_name
|
||||||
|
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||||
|
cloudformation_stack_name = local.stack_name
|
||||||
|
bucket_auto_delete_helper_role_arn = var.bucket_auto_delete_helper_role_arn
|
||||||
|
pre_adoption_tags = local.legacy_tags
|
||||||
|
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||||
|
ownership_tags = local.terraform_tags
|
||||||
|
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||||
|
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||||
|
}
|
||||||
15
terraform/live/tf-poc/outputs.tf
Normal file
15
terraform/live/tf-poc/outputs.tf
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
output "bucket_name" {
|
||||||
|
value = module.environment_owned.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "distribution_id" {
|
||||||
|
value = module.environment_owned.distribution_id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "deploy_role_arn" {
|
||||||
|
value = module.environment_owned.deploy_role_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "api_url" {
|
||||||
|
value = local.api_url
|
||||||
|
}
|
||||||
3
terraform/live/tf-poc/providers.tf
Normal file
3
terraform/live/tf-poc/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
provider "aws" {
|
||||||
|
region = local.aws_region
|
||||||
|
}
|
||||||
12
terraform/live/tf-poc/terraform.tfvars.example
Normal file
12
terraform/live/tf-poc/terraform.tfvars.example
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Copy to a secure, untracked tfvars file or set equivalent HCP variables.
|
||||||
|
# Replace every value only with the exact output from the tf-poc creator.
|
||||||
|
adoption_complete = false
|
||||||
|
distribution_id = "REPLACE_WITH_TF_POC_DISTRIBUTION_ID"
|
||||||
|
origin_access_control_id = "REPLACE_WITH_TF_POC_OAC_ID"
|
||||||
|
origin_access_control_name = "REPLACE_WITH_TF_POC_OAC_NAME"
|
||||||
|
origin_id = "REPLACE_WITH_TF_POC_ORIGIN_ID"
|
||||||
|
function_name = "REPLACE_WITH_TF_POC_FUNCTION_NAME"
|
||||||
|
hosted_zone_id = "REPLACE_WITH_TF_POC_HOSTED_ZONE_ID"
|
||||||
|
certificate_arn = "REPLACE_WITH_TF_POC_CERTIFICATE_ARN"
|
||||||
|
deploy_inline_policy_name = "REPLACE_WITH_TF_POC_INLINE_POLICY_NAME"
|
||||||
|
bucket_auto_delete_helper_role_arn = "REPLACE_WITH_TF_POC_AUTO_DELETE_HELPER_ROLE_ARN"
|
||||||
19
terraform/live/tf-poc/versions.tf
Normal file
19
terraform/live/tf-poc/versions.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.9.0, < 2.0.0"
|
||||||
|
|
||||||
|
cloud {
|
||||||
|
organization = "seahaven"
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
project = "seahaven-external-dev"
|
||||||
|
name = "shoc-frontend-new-tf-poc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 6.57"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue