mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +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"
|
||||
fi
|
||||
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
|
||||
with:
|
||||
node-version: "24"
|
||||
|
|
|
|||
93
.github/workflows/deploy-staging.yml
vendored
93
.github/workflows/deploy-staging.yml
vendored
|
|
@ -1,17 +1,6 @@
|
|||
name: Deploy staging
|
||||
|
||||
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
|
||||
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
|
||||
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
|
||||
# job to declare `environment: staging` and run in this repo, with the
|
||||
# non-secret role ARN pinned below (created by the staging stack itself).
|
||||
#
|
||||
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
|
||||
# No secrets are used — OIDC + the static role ARN are the only credentials.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
|
|
@ -32,6 +21,14 @@ jobs:
|
|||
environment: staging
|
||||
env:
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -51,6 +48,10 @@ jobs:
|
|||
base="origin/dev"
|
||||
fi
|
||||
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
|
||||
with:
|
||||
node-version: "24"
|
||||
|
|
@ -68,73 +69,5 @@ jobs:
|
|||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds the SPA with the staging VITE_API_URL (process env overrides the
|
||||
# dev value committed in .env.production), syncs to the staging bucket,
|
||||
# and invalidates CloudFront.
|
||||
- name: Build and publish SPA
|
||||
- name: Build, publish, and verify SPA
|
||||
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
|
||||
|
||||
# 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:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch: {}
|
||||
|
||||
# OIDC needs id-token: write — it is never in the default token set and cannot
|
||||
# be granted to the reusable workflow unless the caller has it.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
|
@ -27,12 +13,36 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||
with:
|
||||
node-version: "24"
|
||||
region: us-east-1
|
||||
cdk-dir: infra/cdk
|
||||
stack-name: shoc-frontend-dev
|
||||
post-deploy-script: scripts/deploy-web.sh
|
||||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
name: Deploy to dev
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||
EXPECTED_API_URL: https://api.dev.seahaven.com/api
|
||||
FORBIDDEN_API_URLS: https://api.staging.seahaven.com/api,https://api.tf-poc.seahaven.com/api
|
||||
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/lib/*.d.ts
|
||||
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`) →
|
||||
`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
|
||||
|
||||
|
|
@ -23,6 +25,10 @@ npm run verify
|
|||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform 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
|
||||
|
||||
|
|
@ -36,6 +42,10 @@ npm run verify
|
|||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
- **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
|
||||
|
||||
|
|
|
|||
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).
|
||||
|
||||
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
|
||||
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
|
||||
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
|
||||
- **Hosted at:** <https://dev.seahaven.com> and <https://staging.seahaven.com>
|
||||
- **Backend APIs:** matching `api.<environment>.seahaven.com/api` endpoints,
|
||||
called directly from the browser
|
||||
|
||||
## Architecture
|
||||
|
||||
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
|
||||
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
|
||||
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
|
||||
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
|
||||
Static SPA hosting on AWS. CloudFront serves the built `dist/` from a private,
|
||||
versioned S3 bucket; the SPA calls the backend directly over HTTPS at
|
||||
`VITE_API_URL`. The live stacks remain CDK/CloudFormation-owned while the
|
||||
import-first Terraform transfer is rehearsed and reviewed. See
|
||||
[`terraform/README.md`](terraform/README.md).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
|
|
@ -29,8 +31,8 @@ graph LR
|
|||
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|cdk deploy + s3 sync + invalidation| S3
|
||||
GH[Manual GitHub deployment] -->|OIDC| ROLE[Environment deploy role]
|
||||
ROLE -->|content publish + invalidation| S3
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
|
||||
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
||||
Stacks **`shoc-frontend-dev`** and **`shoc-frontend-staging`** are currently
|
||||
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 |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
|
||||
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
||||
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
|
||||
| Resource | Name | Purpose |
|
||||
| ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| S3 bucket | `seahaven-shoc-frontend-{dev,staging}` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||
| 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) |
|
||||
| IAM role | `githubdeploy-shoc-frontend-new-{dev,staging}` | Environment-scoped GitHub OIDC content deploy role |
|
||||
| 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.
|
||||
|
||||
|
|
@ -57,12 +60,9 @@ No Lambdas, queues, or databases — this stack is static hosting only.
|
|||
|
||||
### Secrets
|
||||
|
||||
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
|
||||
repo secret**:
|
||||
|
||||
| Secret | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
|
||||
No Secrets Manager, SSM parameters, AWS access keys, or deploy-role repo secret
|
||||
are used. Content workflows assume their pinned environment role through
|
||||
GitHub OIDC.
|
||||
|
||||
### Environment variables (build-time, `VITE_*`)
|
||||
|
||||
|
|
@ -77,8 +77,9 @@ repo secret**:
|
|||
build otherwise. See [`.env.example`](.env.example),
|
||||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||
|
||||
CDK context (domain, certificate ARN, hosted zone) lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
|
||||
CDK context for normal dev synthesis lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json). CDK deployment is no longer part of
|
||||
recurring content releases during the ownership transfer.
|
||||
|
||||
## Local Development
|
||||
|
||||
|
|
@ -114,7 +115,7 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
## Contributing
|
||||
|
||||
- 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`).
|
||||
- Commit messages follow
|
||||
[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
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
|
||||
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
|
||||
exists yet).
|
||||
- Promotion flow during migration: `feature/* → dev`, then an explicitly
|
||||
approved manual dev deployment and verification on `dev.seahaven.com`.
|
||||
Staging promotion and deployment are separate approvals. No production
|
||||
environment exists yet.
|
||||
|
||||
## 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
|
||||
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),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
|
||||
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
|
||||
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
|
||||
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
||||
CloudFront invalidation. Both run as the OIDC deploy role.
|
||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml),
|
||||
[`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml),
|
||||
and [`.github/workflows/deploy-tf-poc.yml`](.github/workflows/deploy-tf-poc.yml))
|
||||
is manual-only during migration. [`scripts/deploy-web.sh`](scripts/deploy-web.sh)
|
||||
publishes to pinned targets, verifies cache/API/routing behavior, retains two
|
||||
release manifests, and restores the prior versioned index on verification
|
||||
failure.
|
||||
|
||||
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
|
||||
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
|
||||
[`infra/cdk/README.md`](infra/cdk/README.md).
|
||||
The isolated rehearsal, retention mechanism, and deployment prerequisites are
|
||||
documented in [`infra/cdk/README.md`](infra/cdk/README.md). Terraform ownership,
|
||||
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
|
||||
external-dev AWS account; the normal path is push to `dev`):
|
||||
|
||||
```bash
|
||||
(cd infra/cdk && npx cdk deploy)
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
Infrastructure changes and ownership transfer remain separate reviewed
|
||||
administrator actions. Content workflows never run `cdk deploy` or Terraform
|
||||
apply.
|
||||
|
||||
## 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).
|
||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||
not proxy `/api`.
|
||||
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
|
||||
deploys (matches the org's push-time-CD model; gating deploy on CI is known
|
||||
follow-up work).
|
||||
- **Deploy workflow is unavailable on an arbitrary ref** — each manual workflow
|
||||
checks its exact branch or protected GitHub environment before assuming AWS
|
||||
credentials.
|
||||
|
||||
## 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,
|
||||
deployed through the org's **reusable** GitHub Actions workflow.
|
||||
This CDK app describes the existing Sea Haven SHOC SPA hosting and an isolated,
|
||||
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
|
||||
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`).
|
||||
## Existing environments
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
Normal synthesis remains unchanged when the adoption flag is off:
|
||||
|
||||
## 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 |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
|
||||
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
|
||||
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
|
||||
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
Dev remains the default context in `cdk.json`. Staging uses explicit context
|
||||
arguments. During migration, both content workflows are manual-only and use
|
||||
fixed environment configuration rather than discovering deployment targets
|
||||
from CloudFormation.
|
||||
|
||||
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
|
||||
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
|
||||
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
|
||||
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
|
||||
singleton account resource — the stack only _imports_ it (created in step 2),
|
||||
so `cdk destroy` can't delete a resource shared by other roles.
|
||||
## Terraform POC
|
||||
|
||||
---
|
||||
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
|
||||
aws configure # or: aws sso login --profile <admin>
|
||||
aws sts get-caller-identity # confirm the right account + region (us-east-1)
|
||||
```
|
||||
- bucket: `seahaven-shoc-frontend-tf-poc`
|
||||
- role: `githubdeploy-shoc-frontend-new-tf-poc`
|
||||
- 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)
|
||||
|
||||
```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)
|
||||
The shared stack is intentionally staged. The zone must exist and be delegated
|
||||
before ACM can validate a certificate inside it:
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
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 |
|
||||
| --------------------------------- | ------------------------------------------------------------ |
|
||||
| `domainNames` | `dev.seahaven.com` |
|
||||
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
|
||||
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
|
||||
## Adoption retention
|
||||
|
||||
The stack creates the apex A/AAAA alias in the hosted zone (in this account,
|
||||
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
|
||||
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
|
||||
baked into the build. Per-environment; override for staging/prod.
|
||||
`-c retainForTerraformAdoption=true` is deliberately opt-in. Keep it enabled
|
||||
from the reviewed retention deployment through CloudFormation ownership
|
||||
detachment.
|
||||
|
||||
### 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
|
||||
locally. This provisions infra + the role:
|
||||
- site bucket and bucket policy
|
||||
- distribution, OAC, and SPA rewrite function
|
||||
- A and AAAA records
|
||||
- GitHub deploy role and its inline policy
|
||||
- `SiteBucket/AutoDeleteObjectsCustomResource`
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy
|
||||
```
|
||||
The bucket remains configured with `autoDeleteObjects: true`. The emitted
|
||||
bucket and its matching custom resource are both retained, so deleting the
|
||||
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
|
||||
`dev` and let CI do everything from here on):
|
||||
In adoption mode, the deploy role also receives:
|
||||
|
||||
```bash
|
||||
# from repo root, optional manual first content publish:
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
- tag `HcpTerraformWorkspace=shoc-frontend-new-{env}`
|
||||
- permissions boundary
|
||||
`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
|
||||
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"
|
||||
```
|
||||
`scripts/deploy-web.sh` requires explicit target and expectation variables:
|
||||
|
||||
(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
|
||||
git push origin dev
|
||||
```
|
||||
If verification fails after publishing the index, the previous index version
|
||||
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
|
||||
`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open
|
||||
the `SiteUrl` output.
|
||||
## Manual workflows
|
||||
|
||||
> First-run verification: this first push is what actually exercises the role's
|
||||
> permissions and the OIDC trust through the reusable workflow (the local
|
||||
> bootstrap used admin creds and tested none of that). Watch for
|
||||
> credential/OIDC errors and a green post-deploy step.
|
||||
- `.github/workflows/deploy.yml`: dev content deployment
|
||||
- `.github/workflows/deploy-staging.yml`: staging content deployment
|
||||
- `.github/workflows/deploy-tf-poc.yml`: isolated POC content deployment
|
||||
|
||||
---
|
||||
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)
|
||||
|
||||
Staging lives in the same AWS account (396287094661) but deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
|
||||
reusable `cd-cdk.yaml`:
|
||||
|
||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||
environment subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
|
||||
(`StringEquals` on both `aud` and `sub`). The workflow declares
|
||||
`environment: staging`, so only runs in that environment can assume the role.
|
||||
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
|
||||
unchanged.
|
||||
- **No secret:** the role ARN is static (the role name is deterministic), so
|
||||
the workflow pins
|
||||
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
|
||||
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
|
||||
- **Gates first:** the workflow runs the full `npm run verify` before assuming
|
||||
the staging role, then runs `scripts/deploy-web.sh` with
|
||||
`STACK_NAME=shoc-frontend-staging`,
|
||||
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
|
||||
CloudFront invalidation to complete.
|
||||
- **Application-only role:** the recurring staging workflow can describe only
|
||||
its exact stack, publish only to its exact bucket, and invalidate only its
|
||||
exact distribution. It cannot assume the shared CDK bootstrap roles or
|
||||
modify infrastructure. Staging infrastructure changes use the Administrator
|
||||
command below.
|
||||
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
|
||||
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
|
||||
contain the staging API URL and no dev API URL. (Not browser QA.)
|
||||
|
||||
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
|
||||
|
||||
1. **GitHub Admin — create the `staging` environment** (Settings →
|
||||
Environments → New environment → `staging`). Add protection rules as
|
||||
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
|
||||
the environment does not exist, GitHub creates it unprotected on first use.
|
||||
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
|
||||
OIDC provider and bootstrap already exist in this account):
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy shoc-frontend-staging \
|
||||
-c envName=staging \
|
||||
-c deployBranch=staging \
|
||||
-c githubEnvironment=staging \
|
||||
-c domainNames=staging.seahaven.com \
|
||||
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
|
||||
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
|
||||
-c hostedZoneName=staging.seahaven.com
|
||||
```
|
||||
|
||||
The `DeployRoleArn` output must match the ARN pinned in
|
||||
`deploy-staging.yml` (it will — the role name is deterministic).
|
||||
|
||||
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
|
||||
allow the `https://staging.seahaven.com` origin.
|
||||
4. Push to `staging` — `ci.yaml` runs the quality gates and
|
||||
`deploy-staging.yml` deploys.
|
||||
|
||||
### Adding prod later
|
||||
|
||||
Same pattern: a prod account/stack with its own contexts and, ideally, its own
|
||||
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
|
||||
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
|
||||
for prod.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
||||
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
|
||||
differs only in that its CD workflow also runs `npm run verify` itself
|
||||
before deploying); a red-CI commit still deploys on `dev` (matches the
|
||||
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
|
||||
of enabling CICD.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
Infrastructure creation, parent-zone delegation, Terraform imports, and
|
||||
ownership detachment remain separate administrator actions. None of these
|
||||
workflows performs them.
|
||||
|
|
|
|||
|
|
@ -1,51 +1,102 @@
|
|||
#!/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 { TfPocCertificateStack, TfPocZoneStack } from "../lib/tf-poc-shared-stack";
|
||||
|
||||
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.
|
||||
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") ?? "";
|
||||
if (tfPoc) {
|
||||
const pocEnv = { account: "396287094661", region: "us-east-1" };
|
||||
const tfPocPhase = String(app.node.tryGetContext("tfPocPhase") ?? "").toLowerCase();
|
||||
if (!["zone", "environment"].includes(tfPocPhase)) {
|
||||
throw new Error("tfPocPhase must be set explicitly to 'zone' or 'environment'.");
|
||||
}
|
||||
const createEnvironment = tfPocPhase === "environment";
|
||||
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
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||
.split(",")
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d: string) => d.length > 0);
|
||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||
const stacks: Stack[] = [zoneStack];
|
||||
if (createEnvironment) {
|
||||
const certificateStack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", {
|
||||
env: pocEnv,
|
||||
terminationProtection: true,
|
||||
hostedZone: zoneStack.hostedZone,
|
||||
});
|
||||
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.
|
||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
for (const stack of stacks) {
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
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
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
const terminationProtection = envName !== "dev";
|
||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||
.split(",")
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d: string) => d.length > 0);
|
||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment,
|
||||
terminationProtection,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
},
|
||||
});
|
||||
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
const retainForTerraformAdoption =
|
||||
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() ===
|
||||
"true";
|
||||
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", envName);
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
const terminationProtection = envName !== "dev";
|
||||
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment,
|
||||
terminationProtection,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
},
|
||||
});
|
||||
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", envName);
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@
|
|||
"context": {
|
||||
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true,
|
||||
"@aws-cdk/core:defaultCrossStackReferences": "strong",
|
||||
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
|
||||
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
|
||||
|
||||
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
|
||||
"//": "Dev synthesis defaults for account 396287094661. Infrastructure deployment is administrator-run.",
|
||||
"domainNames": "dev.seahaven.com",
|
||||
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
|
||||
"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 * as s3 from "aws-cdk-lib/aws-s3";
|
||||
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 route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
||||
import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption";
|
||||
|
||||
export interface FrontendStackProps extends StackProps {
|
||||
/** Environment label, e.g. "dev". Used in names/tags. */
|
||||
|
|
@ -42,6 +52,11 @@ export interface FrontendStackProps extends StackProps {
|
|||
readonly hostedZoneId: string;
|
||||
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
||||
readonly hostedZoneName: string;
|
||||
/**
|
||||
* Opt-in safety mode used only during the reviewed Terraform adoption.
|
||||
* Normal dev/staging synthesis remains unchanged when false.
|
||||
*/
|
||||
readonly retainForTerraformAdoption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,11 +65,10 @@ export interface FrontendStackProps extends StackProps {
|
|||
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
||||
* - a GitHub Actions OIDC deploy role
|
||||
*
|
||||
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
|
||||
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
|
||||
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
|
||||
* stack only owns the infrastructure, and the deploy role carries the
|
||||
* permissions those post-deploy steps need.
|
||||
* Content (the built `dist/`) is NOT uploaded here. Manual environment
|
||||
* workflows run `scripts/deploy-web.sh` independently of infrastructure
|
||||
* changes, so this stack only owns infrastructure and the deploy role carries
|
||||
* content-publication permissions.
|
||||
*/
|
||||
export class FrontendStack extends Stack {
|
||||
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
||||
|
|
@ -69,6 +83,7 @@ export class FrontendStack extends Stack {
|
|||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption = false,
|
||||
} = props;
|
||||
|
||||
const hasCustomDomain = domainNames.length > 0;
|
||||
|
|
@ -114,6 +129,15 @@ export class FrontendStack extends Stack {
|
|||
// --- CloudFront: serves the static SPA from S3 -------------------------
|
||||
// The SPA calls the backend directly at its absolute HTTPS URL
|
||||
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
||||
const 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", {
|
||||
comment: `SeaHaven SHOC frontend (${envName})`,
|
||||
defaultRootObject: "index.html",
|
||||
|
|
@ -130,7 +154,9 @@ export class FrontendStack extends Stack {
|
|||
: undefined,
|
||||
defaultBehavior: {
|
||||
// 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,
|
||||
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
||||
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
|
||||
// (staging): exact StringEquals match on both aud and the environment
|
||||
// subject — the staging workflow declares `environment: staging`, so only
|
||||
// runs in that environment can assume the role. Without one (dev): keep
|
||||
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
|
||||
// deploy branch (reusable-workflow runs still carry the caller-based sub).
|
||||
// runs in that environment can assume the role. Normal dev synthesis keeps
|
||||
// the current branch-ref StringLike trust. The adoption prerequisite
|
||||
// narrows that already-exact value to StringEquals before Terraform import.
|
||||
const oidcConditions = githubEnvironment
|
||||
? {
|
||||
StringEquals: {
|
||||
|
|
@ -168,30 +194,48 @@ export class FrontendStack extends Stack {
|
|||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
: retainForTerraformAdoption
|
||||
? {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
|
||||
const deployPermissionsBoundary = retainForTerraformAdoption
|
||||
? iam.ManagedPolicy.fromManagedPolicyArn(
|
||||
this,
|
||||
"GithubDeployPermissionsBoundary",
|
||||
`arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||
maxSessionDuration: Duration.hours(1),
|
||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||
permissionsBoundary: deployPermissionsBoundary,
|
||||
});
|
||||
if (retainForTerraformAdoption) {
|
||||
Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`);
|
||||
}
|
||||
|
||||
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
||||
// intentionally narrower: its recurring promotion workflow only publishes
|
||||
// application assets to this stack's bucket/distribution. Infrastructure
|
||||
// changes remain an administrator-run CDK operation, so the staging OIDC
|
||||
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
||||
// Preserve dev's legacy CDK capability until the reviewed adoption update
|
||||
// replaces this inline policy. Staging is intentionally narrower: its
|
||||
// content role only publishes application assets to this stack's
|
||||
// bucket/distribution. Infrastructure changes remain administrator-run.
|
||||
if (!githubEnvironment) {
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
|
|
@ -224,6 +268,8 @@ export class FrontendStack extends Stack {
|
|||
// --- DNS: point the custom domain at CloudFront ------------------------
|
||||
// Only when a hosted zone is supplied (it must be in THIS account). Creates
|
||||
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
|
||||
let aliasA: route53.ARecord | undefined;
|
||||
let aliasAaaa: route53.AaaaRecord | undefined;
|
||||
if (hostedZoneId && hasCustomDomain) {
|
||||
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
||||
hostedZoneId,
|
||||
|
|
@ -233,8 +279,12 @@ export class FrontendStack extends Stack {
|
|||
// apex record when the domain equals the zone name.
|
||||
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
||||
|
||||
new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
|
||||
aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", {
|
||||
zone,
|
||||
recordName,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Outputs -----------------------------------------------------------
|
||||
|
|
@ -257,7 +307,92 @@ export class FrontendStack extends Stack {
|
|||
});
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
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": {
|
||||
"build": "tsc",
|
||||
"test": "npm run build && node --test test/*.test.mjs",
|
||||
"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",
|
||||
"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:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"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:fix": "eslint . --fix --max-warnings=0",
|
||||
"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
|
||||
#
|
||||
# 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
|
||||
set -Eeuo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
REGION="${AWS_REGION:-us-east-1}"
|
||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
required_vars=(
|
||||
SITE_BUCKET
|
||||
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)..."
|
||||
npm ci
|
||||
npm run build
|
||||
DEPLOY_RELEASE_ID="${DEPLOY_RELEASE_ID:-${GITHUB_SHA:-}}"
|
||||
EXTENSIONLESS_SMOKE_PATH="${EXTENSIONLESS_SMOKE_PATH:-/deployment-smoke}"
|
||||
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}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
if [[ "${SITE_BUCKET}" != "${EXPECTED_SITE_BUCKET}" ]]; then
|
||||
echo "::error::SITE_BUCKET does not match EXPECTED_SITE_BUCKET." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${VITE_API_URL:-}" != "${EXPECTED_API_URL}" ]]; then
|
||||
echo "::error::VITE_API_URL must exactly match EXPECTED_API_URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${DEPLOY_RELEASE_ID}" =~ ^[A-Za-z0-9._-]{7,128}$ ]]; then
|
||||
echo "::error::DEPLOY_RELEASE_ID is missing or unsafe." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${SITE_URL}" =~ ^https://[^/]+/?$ || ! "${EXPECTED_API_URL}" =~ ^https:// ]]; then
|
||||
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
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
# Everything except index.html: long-lived + immutable, prune stale objects.
|
||||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
work_dir="$(mktemp -d)"
|
||||
published_index_version=""
|
||||
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" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
|
||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--paths "/*" \
|
||||
--query 'Invalidation.Id' \
|
||||
--output text)"
|
||||
|
||||
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
|
||||
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--id "${INVALIDATION_ID}"
|
||||
published_index_version="$(
|
||||
aws s3api put-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key index.html \
|
||||
--body dist/index.html \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html" \
|
||||
--query VersionId \
|
||||
--output text
|
||||
)"
|
||||
if [[ -z "${published_index_version}" || "${published_index_version}" == "None" ]]; then
|
||||
echo "::error::Index upload did not return a version ID." >&2
|
||||
exit 1
|
||||
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 EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -194,6 +200,22 @@ function plural(count, word) {
|
|||
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() {
|
||||
const failures = [];
|
||||
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));
|
||||
if (failures.length > 0) {
|
||||
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