mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
* feat(terraform): ship dev content CD through Terraform (SH-300) GitHub uploads immutable release prefixes; Terraform owns live publish. Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set. * fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300)
352 lines
17 KiB
Markdown
352 lines
17 KiB
Markdown
# Frontend Terraform adoption runbook (dev)
|
|
|
|
This tree adopts the existing Sea Haven SHOC frontend dev hosting resources
|
|
into HCP Terraform without recreating them. It mirrors the backend adoption
|
|
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs:
|
|
|
|
| PR | Branch | Change |
|
|
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
| A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. |
|
|
| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
|
|
| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, rollback. |
|
|
|
|
Creating these files, formatting them, initializing with `-backend=false`, and
|
|
validating them does not authorize an AWS, HCP Terraform, GitHub,
|
|
CloudFormation, DNS, or deployment mutation. Every live step below is gated on
|
|
an explicit go from the owner, with the production impact stated first.
|
|
|
|
Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked
|
|
separately (SH-287) and adds its own root under `live/staging` when it starts.
|
|
The `staging` constants in `scripts/terraform_import_plan_resources.py` exist
|
|
only so the checker can prove a dev plan carrying a staging identifier fails.
|
|
|
|
## Fixed targets
|
|
|
|
- AWS account: `396287094661`
|
|
- AWS region: `us-east-1`
|
|
- HCP organization: `seahaven`
|
|
- HCP project: `seahaven-external-dev`
|
|
- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working
|
|
directory `terraform/live/dev`
|
|
- Site: `dev.seahaven.com`
|
|
- API build value: `https://api.dev.seahaven.com/api`
|
|
|
|
## Workspace invariants
|
|
|
|
Set before any Terraform lands on `dev`, read back after setting, and re-read
|
|
before the first release after any Terraform merge:
|
|
|
|
- Auto-apply **off**. GitHub or a human applies every run.
|
|
- Automatic speculative plans **on** (PR plans are read-only evidence).
|
|
- Automatic run triggering: **patterns**
|
|
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
|
|
prefixes, no tags regex. Do not switch to tag-based triggering.
|
|
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
|
|
`>= 1.14.0, < 2.0.0`; CI validates with `1.16.0`).
|
|
- Dynamic AWS credentials only: environment variables
|
|
`TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and
|
|
`TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan`
|
|
and `hcptf-shoc-frontend-new-dev`. No access keys.
|
|
- **No** `adoption_complete` workspace variable. The dev root pins it in code
|
|
(`local.adoption_complete`) so the value under review is the value that
|
|
applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable`
|
|
block reappears in the root.
|
|
|
|
## Ownership boundary
|
|
|
|
`live/modules/environment-owned` owns these 14 addresses (13 imported hosting
|
|
resources plus the release pointer created in Phase 3):
|
|
|
|
1. `module.environment_owned.aws_s3_bucket.site`
|
|
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
|
|
3. `module.environment_owned.aws_s3_bucket_ownership_controls.site`
|
|
4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site`
|
|
5. `module.environment_owned.aws_s3_bucket_versioning.site`
|
|
6. `module.environment_owned.aws_s3_bucket_policy.site`
|
|
7. `module.environment_owned.aws_cloudfront_distribution.site`
|
|
8. `module.environment_owned.aws_cloudfront_origin_access_control.site`
|
|
9. `module.environment_owned.aws_cloudfront_function.spa_rewrite`
|
|
10. `module.environment_owned.aws_route53_record.site_a`
|
|
11. `module.environment_owned.aws_route53_record.site_aaaa`
|
|
12. `module.environment_owned.aws_iam_role.github_deploy`
|
|
13. `module.environment_owned.aws_iam_role_policy.github_deploy`
|
|
14. `module.environment_owned.aws_s3_object.release_pointer`
|
|
|
|
The CloudFront invalidation is a Terraform action
|
|
(`action.aws_cloudfront_create_invalidation.release`), not a managed resource.
|
|
Every managed resource has `prevent_destroy = true`.
|
|
|
|
`live/modules/environment-inventory` is data-only. It resolves and checks the
|
|
caller account, provider region, public hosted zone, ACM certificate, account
|
|
GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache
|
|
policy against pinned values, and fails the plan on any mismatch.
|
|
|
|
The following remain outside state:
|
|
|
|
- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate
|
|
- the account-global GitHub OIDC provider
|
|
- the AWS managed CloudFront cache policy
|
|
- `CDKToolkit` resources and CDK metadata
|
|
- the S3 auto-delete custom resource, its provider Lambda and role
|
|
- the HCP plan/apply roles and the deploy-role permissions boundary
|
|
|
|
## Exact live inventory (dev)
|
|
|
|
- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev`
|
|
- Distribution: `E2CWLM1AFB964P`
|
|
- OAC: `E30VSIK87N8H64`, name
|
|
`shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`,
|
|
description modeled as `""`
|
|
- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1`
|
|
- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8`
|
|
- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A`
|
|
- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA`
|
|
- Deploy role: `githubdeploy-shoc-frontend-new-dev`
|
|
- Inline policy import ID:
|
|
`githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1`
|
|
- Hosted zone: `Z07671212N75U4YLPWZR8`
|
|
- Certificate:
|
|
`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`
|
|
- Legacy stack: `shoc-frontend-dev`
|
|
- Auto-delete helper role:
|
|
`arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV`
|
|
- Permissions boundary:
|
|
`arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary`
|
|
|
|
With `adoption_complete = false` the root declares the configuration observed
|
|
after the CDK retain deploy (Phase 1, step 2), not the configuration live
|
|
today:
|
|
|
|
- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the
|
|
S3-only `aws-cdk:auto-delete-objects=true` tag
|
|
- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag
|
|
- the permissions boundary attached to the deploy role
|
|
- `StringEquals` on the OIDC subject
|
|
`repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev`
|
|
- the legacy bucket policy including the auto-delete helper grant
|
|
- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`,
|
|
bucket read/write, `InvalidateDistribution`)
|
|
|
|
The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing.
|
|
If read-back after that deploy differs from the root in any other way, update
|
|
the root to the observed value and prove a zero-change import plan. Do not
|
|
approve drift through the controlled-update checker.
|
|
|
|
## Phase 1: import-first adoption (merged)
|
|
|
|
Each step is gated. State the impact, get the go, act, read back, record.
|
|
|
|
1. **Workspace invariants.** Set the invariants above on
|
|
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
|
|
PR.
|
|
2. **CDK retain deploy.** Completed from the reviewed PR A head. The CDK app
|
|
is no longer in this repository.
|
|
|
|
Expected: an update-only change set (no create, no delete, no replace)
|
|
that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the
|
|
13 transferred resources and the `Custom::S3AutoDeleteObjects` resource,
|
|
attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows
|
|
the trust operator. Read back the role, bucket policy, and stack resources
|
|
as JSON and attach it to the PR.
|
|
|
|
3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply
|
|
off). Download the plan JSON and run the guard:
|
|
|
|
```bash
|
|
python3 scripts/check-terraform-import-plan.py plan.json --environment dev
|
|
```
|
|
|
|
Confirm the apply only when the plan is exactly 13 imports, 0 create,
|
|
0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the
|
|
run and fix the root in a new PR.
|
|
|
|
4. **Post-import no-op.** Queue a plan and require it to be no-op:
|
|
|
|
```bash
|
|
python3 scripts/check-terraform-import-plan.py post-import.json \
|
|
--environment dev --post-import-no-op
|
|
```
|
|
|
|
Post the run URLs and the guard output on SH-300.
|
|
|
|
After Phase 1 CloudFormation still owns every resource. Terraform holds state
|
|
for them and nothing else.
|
|
|
|
## Phase 2: controlled ownership transfer (merged #178)
|
|
|
|
PR B pins `adoption_complete = true`. The controlled apply may update only:
|
|
|
|
- `module.environment_owned.aws_s3_bucket.site` (tags)
|
|
- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the
|
|
auto-delete helper grant)
|
|
- `module.environment_owned.aws_cloudfront_distribution.site` (tags)
|
|
- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags)
|
|
- `module.environment_owned.aws_iam_role.github_deploy` (tags)
|
|
|
|
The OAC, both Route 53 records, and the deploy inline policy must be no-op.
|
|
PR B keeps the GitHub deploy inline policy byte-identical to live so
|
|
`aws_iam_role_policy.github_deploy` does not appear in the plan. Run the
|
|
checker with one `--allow-update-address` per updating address; it rejects
|
|
unused allowlist entries, unknown values, and replacements:
|
|
|
|
```bash
|
|
python3 scripts/check-terraform-import-plan.py plan.json --environment dev \
|
|
--allow-update-address module.environment_owned.aws_s3_bucket.site \
|
|
--allow-update-address module.environment_owned.aws_s3_bucket_policy.site \
|
|
--allow-update-address module.environment_owned.aws_cloudfront_distribution.site \
|
|
--allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \
|
|
--allow-update-address module.environment_owned.aws_iam_role.github_deploy
|
|
```
|
|
|
|
After the apply and a no-op plan, the CDK stack was relinquished with
|
|
`ManageSiteInfrastructure=false`. Never deploy that stack with
|
|
`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C.
|
|
|
|
Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual
|
|
`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged
|
|
GitHub content policy. PR C replaces that policy with the release-prefix
|
|
document during bootstrap.
|
|
|
|
## Phase 3: content CD through Terraform (this PR)
|
|
|
|
GitHub builds the SPA and uploads only `releases/<sha>-<run>-<attempt>/`.
|
|
The GitHub role may `GetObject` on `.release/current` and read the exact
|
|
distribution (`GetDistribution` / `GetDistributionConfig`) so verify and
|
|
live-state summary can observe origin paths. It cannot invalidate or write
|
|
the pointer. Terraform owns `.release/current`, both origin paths of the
|
|
CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one
|
|
guarded Terraform run that swaps the labels. Push-to-`dev` stays off until
|
|
`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls
|
|
`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287).
|
|
|
|
Release vars `release_version_label` and `previous_release_version_label` are
|
|
nullable, default null, and must not be set on the workspace or in tfvars.
|
|
Null VCS plans read the pointer back from S3. Empty string is the legacy root
|
|
layout.
|
|
|
|
Per GitHub content release after bootstrap: exactly two managed updates plus
|
|
one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py`
|
|
accepts a plan that updates only the pointer `content` and
|
|
`origin[*].origin_path`, with `after` equal to the expected labels, `before`
|
|
equal to the pointer's prior values, and exactly one invalidation
|
|
`action_invocations` entry.
|
|
|
|
The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates
|
|
`.release/current` (legacy empty labels), adds the previous origin and origin
|
|
group, switches the default behavior to the group, replaces the GitHub inline
|
|
policy with the release-prefix document, and invokes invalidation. A human
|
|
confirms that apply. GitHub CD starts only after bootstrap is applied.
|
|
|
|
Activation (each step gated; do not run without an explicit go):
|
|
|
|
1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard
|
|
the HCP VCS run. Apply bootstrap as a human-confirmed controlled update.
|
|
2. Re-read workspace invariants (auto-apply off, speculative on, trigger
|
|
patterns only, no prefixes, no tags-regex).
|
|
3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation,
|
|
smoke, and rollback readiness from the live-state summary.
|
|
4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner
|
|
approval.
|
|
5. Confirm the first push-to-`dev` run. Close SH-300 on that proof.
|
|
|
|
A red job does not mean the site is down. Read the live-state summary first.
|
|
|
|
## Operational rules
|
|
|
|
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
|
|
deployable application code. The `terraform-isolation` job in
|
|
`.github/workflows/terraform-isolation.yaml` enforces this; documentation and the
|
|
`scripts/*terraform*` tooling are allowed alongside. A reviewer may add the
|
|
`terraform-isolation-override` label for the rare change that must introduce
|
|
Terraform variables together with the workflow that consumes them (PR C).
|
|
Adding or removing that label re-runs only that workflow against the labels
|
|
currently on the PR; Frontend checks does not start a new run. Removing the
|
|
label fails a mixed PR that had previously passed with the override, so a
|
|
stale green check cannot merge. Markdown under `terraform/` does not count as a Terraform
|
|
change for this gate; it does not match the workspace trigger patterns.
|
|
The label is the approval record. The override is temporary:
|
|
a follow-up PR after PR C removes the label path from the checker and
|
|
workflow so the gate has no exception.
|
|
- **Every Terraform merge produces a VCS run.** A human confirms or discards
|
|
it before the next content release. Do not leave a pending run on the
|
|
workspace.
|
|
- **Re-read the workspace invariants** before the first release after any
|
|
Terraform merge or workspace settings change.
|
|
- **A red job does not mean the site is down.** Read the live-state summary
|
|
first (served `index.html` hash, distribution status, pointer body, both
|
|
origin paths), then triage.
|
|
- **Exact-head evidence.** Every live step records the run URL, the SHA, and a
|
|
machine-readable read-back on the PR or SH-300.
|
|
|
|
## Local validation
|
|
|
|
From the repository root (also run by `npm run verify` through
|
|
`scripts/governance-check.mjs`):
|
|
|
|
```bash
|
|
npm run test:terraform # fmt -check, init -backend=false, validate
|
|
npm run test:terraform-import-plan # checker unit tests against synthetic plans
|
|
npm run test:terraform-release-plan # content-release plan guard
|
|
npm run test:terraform-isolation # isolation gate unit tests
|
|
npm run test:hcp-run-guard # workspace invariant and apply reconcile
|
|
npm run test:cloudfront-release-verify
|
|
npm run test:github-workflows # bash -n and actionlint
|
|
```
|
|
|
|
`terraform init -backend=false -lockfile=readonly` may download the provider
|
|
but never contacts HCP state or plans against AWS. Only HCP runs plan against
|
|
the account.
|
|
|
|
The lock file must carry `h1:` hashes for every platform that runs the gate
|
|
(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the
|
|
provider version, refresh them with:
|
|
|
|
```bash
|
|
terraform -chdir=terraform/live/dev providers lock \
|
|
-platform=linux_amd64 -platform=linux_arm64 \
|
|
-platform=darwin_amd64 -platform=darwin_arm64
|
|
```
|
|
|
|
## Import plan safety
|
|
|
|
Import mode requires exactly the canonical 13 addresses and AWS types, valid
|
|
import metadata for every resource, the exact dev import IDs (a staging ID in a
|
|
dev plan fails), and zero create, update, delete, or replace actions.
|
|
|
|
Post-import mode requires all 13 resources to be no-op and rejects any
|
|
remaining import metadata.
|
|
|
|
Controlled mode permits only in-place updates to the addresses explicitly
|
|
listed with `--allow-update-address`, verifies `before` against the exact
|
|
pre-adoption policies and tags and `after` against the exact adopted values,
|
|
and rejects create, delete, replace, import metadata, unknown values,
|
|
unapproved addresses, and unused allowlist entries.
|
|
|
|
## Rollback
|
|
|
|
- Before import apply: discard the run and correct the root.
|
|
- After import, before the controlled update (end of Phase 1): remove only the
|
|
13 imported addresses from state under a separately reviewed state
|
|
operation. CloudFormation remains authoritative; a
|
|
`ManageSiteInfrastructure=true` stack is unchanged by this.
|
|
- After the controlled update, before detachment: either complete the reviewed
|
|
detachment or restore the exact pre-adoption policy and tags under a
|
|
separate approval. Do not remove state or redeploy CloudFormation blindly.
|
|
- After detachment: Terraform is authoritative. Restore content from the
|
|
versioned bucket. Re-establishing CloudFormation ownership requires a
|
|
reviewed `IMPORT` change set, never an ordinary update.
|
|
|
|
Any replacement, destroy, cross-environment ID, missing import, broad policy
|
|
change, or failed smoke check is a hard stop.
|
|
|
|
## Evidence per phase
|
|
|
|
- HCP run URL and the workspace settings read-back
|
|
- plan JSON and checker output
|
|
- `terraform state list` showing exactly the 13 addresses
|
|
- read-only inventory before and after each mutation
|
|
- synthesized CloudFormation template, change set, and stack events
|
|
- deploy, invalidation, and smoke output
|
|
- the post-action no-op plan
|
|
- phase close-out on SH-300: completed work, validation, risks, deviations,
|
|
remaining work
|