feat(terraform): complete dev environment adoption (SH-300)

This commit is contained in:
Adam Moussa 2026-09-11 11:22:28 -04:00
parent 8b5281d357
commit f532aa6059
No known key found for this signature in database
7 changed files with 110 additions and 377 deletions

View file

@ -18,17 +18,11 @@ from terraform_import_plan_resources import (
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,
}
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS}
OWNERSHIP_TAGS = {
"Environment": None,
"ManagedBy": "terraform",
@ -255,113 +249,6 @@ def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str,
)
def _expected_pre_adoption_deploy_policy(
environment: str,
distribution_id: str,
) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
)
statements: list[dict[str, Any]] = []
if environment == "dev":
statements.append(
{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
}
)
statements.extend(
[
{
"Sid": "DescribeStack",
"Effect": "Allow",
"Action": "cloudformation:DescribeStacks",
"Resource": (
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
f"{config['cloudformation_stack_name']}/*"
),
},
{
"Effect": "Allow",
"Action": [
"s3:Abort*",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
]
)
return _canonical({"Version": "2012-10-17", "Statement": statements})
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],
@ -432,16 +319,10 @@ def _validate_policy_update(
f"{address}: cannot verify policy without the pinned distribution ID"
)
return violations
expected_before = (
_expected_pre_adoption_bucket_policy(environment, distribution_id)
if address == BUCKET_POLICY_ADDRESS
else _expected_pre_adoption_deploy_policy(environment, distribution_id)
)
expected_after = (
_expected_bucket_policy(environment, distribution_id)
if address == BUCKET_POLICY_ADDRESS
else _expected_deploy_policy(environment, distribution_id)
expected_before = _expected_pre_adoption_bucket_policy(
environment, distribution_id
)
expected_after = _expected_bucket_policy(environment, distribution_id)
if before_policy is not None and before_policy != expected_before:
violations.append(f"{address}: pre-adoption policy semantics are not exact")
if after_policy is not None and after_policy != expected_after:
@ -467,7 +348,7 @@ def _validate_controlled_update(
return [*violations, f"{address}: controlled update requires before/after objects"]
if address in TAG_UPDATE_ADDRESSES:
violations.extend(_validate_tag_update(address, before, after, environment))
elif address in {BUCKET_POLICY_ADDRESS, DEPLOY_POLICY_ADDRESS}:
elif address == BUCKET_POLICY_ADDRESS:
violations.extend(
_validate_policy_update(
address,

View file

@ -45,7 +45,6 @@ CONTROLLED_UPDATE_ADDRESSES = frozenset(
"module.environment_owned.aws_cloudfront_distribution.site",
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
"module.environment_owned.aws_iam_role.github_deploy",
"module.environment_owned.aws_iam_role_policy.github_deploy",
}
)

View file

@ -27,7 +27,7 @@ BUCKET = "module.environment_owned.aws_s3_bucket.site"
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY}
def import_id(environment: str, address: str) -> str:
@ -111,110 +111,6 @@ def bucket_policy(environment: str) -> dict[str, Any]:
}
def pre_adoption_deploy_policy(environment: str) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
statements: list[dict[str, Any]] = []
if environment == "dev":
statements.append(
{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
}
)
statements.extend(
[
{
"Sid": "DescribeStack",
"Effect": "Allow",
"Action": "cloudformation:DescribeStacks",
"Resource": (
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
f"{config['cloudformation_stack_name']}/*"
),
},
{
"Effect": "Allow",
"Action": [
"s3:Abort*",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
]
)
return {"Version": "2012-10-17", "Statement": statements}
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"]
@ -250,20 +146,12 @@ def tag_change(environment: str, address: str) -> dict[str, Any]:
def policy_change(environment: str, address: str) -> dict[str, Any]:
before_policy = (
pre_adoption_bucket_policy(environment)
if address == BUCKET_POLICY
else pre_adoption_deploy_policy(environment)
)
after_policy = (
bucket_policy(environment)
if address == BUCKET_POLICY
else deploy_policy(environment)
)
if address != BUCKET_POLICY:
raise AssertionError(f"{address} is not a reviewed policy update")
return {
"actions": ["update"],
"before": {"policy": json.dumps(before_policy)},
"after": {"policy": json.dumps(after_policy)},
"before": {"policy": json.dumps(pre_adoption_bucket_policy(environment))},
"after": {"policy": json.dumps(bucket_policy(environment))},
}
@ -395,9 +283,9 @@ class ImportPlanCheckerTests(unittest.TestCase):
)
self.assertEqual(["dev"], live_roots)
def test_dev_root_pins_import_phase_in_code(self) -> None:
def test_dev_root_pins_adoption_complete_in_code(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
self.assertRegex(source, r"\n\s+adoption_complete\s+= false\n")
self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n")
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
self.assertNotIn('variable "adoption_complete"', source)
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
@ -564,54 +452,53 @@ class ImportPlanCheckerTests(unittest.TestCase):
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_github_deploy_policy_stays_byte_identical(self) -> None:
source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
document = document.split("resource ", 1)[0]
self.assertNotIn("var.adoption_complete", document)
self.assertIn("AssumeCdkBootstrapRoles", document)
self.assertIn("DescribeStack", document)
self.assertNotIn("ReadDeploymentBucket", document)
self.assertNotIn("PublishAndRollbackSiteObjects", document)
self.assertNotIn(
"module.environment_owned.aws_iam_role_policy.github_deploy",
CONTROLLED_UPDATE_ADDRESSES,
)
def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates=set())
self.assert_fails(plan, "dev", DEPLOY_POLICY)
plan = make_plan("dev", mode="controlled", controlled_updates=set())
resource(plan, DEPLOY_POLICY)["change"] = {
"actions": ["update"],
"before": {"policy": "{}"},
"after": {"policy": '{"Version":"2012-10-17"}'},
}
self.assert_fails(plan, "dev", DEPLOY_POLICY)
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
for environment in REQUIRED_RESOURCES:
for address in (BUCKET_POLICY, DEPLOY_POLICY):
plan = make_plan(
environment,
mode="controlled",
controlled_updates={address},
)
change = resource(plan, address)["change"]
before = json.loads(change["before"]["policy"])
before["Statement"].append(
{
"Sid": "UnexpectedDrift",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
}
)
change["before"]["policy"] = json.dumps(before)
with self.subTest(environment=environment, address=address):
self.assert_fails(plan, environment, address)
plan = make_plan(
environment,
mode="controlled",
controlled_updates={BUCKET_POLICY},
)
change = resource(plan, BUCKET_POLICY)["change"]
before = json.loads(change["before"]["policy"])
before["Statement"].append(
{
"Sid": "UnexpectedDrift",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
}
)
change["before"]["policy"] = json.dumps(before)
with self.subTest(environment=environment):
self.assert_fails(plan, environment, BUCKET_POLICY)
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
for field, value in (

View file

@ -4,11 +4,11 @@ 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` | This PR. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. |
| B | `feature/terraform-dev-adoption` | `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant, CloudFormation detaches. |
| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. |
| PR | Branch | Change |
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| A | `feature/frontend-terraform-adoption` | Merged. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. |
| B | `feature/terraform-dev-adoption` | This PR. `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. |
Creating these files, formatting them, initializing with `-backend=false`, and
validating them does not authorize an AWS, HCP Terraform, GitHub,
@ -45,9 +45,8 @@ before the first release after any Terraform merge:
`>= 1.9.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 the `seahaven-org-baseline` roles
`hcptf-shoc-frontend-new-dev-plan` and `hcptf-shoc-frontend-new-dev`. No
access keys.
`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`
@ -86,7 +85,6 @@ The following remain outside state:
- `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
(`seahaven-org-baseline` owns them)
## Exact live inventory (dev)
@ -130,7 +128,7 @@ 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 (this PR)
## Phase 1: import-first adoption (merged)
Each step is gated. State the impact, get the go, act, read back, record.
@ -177,7 +175,7 @@ Each step is gated. State the impact, get the go, act, read back, record.
After Phase 1 CloudFormation still owns every resource. Terraform holds state
for them and nothing else.
## Phase 2: controlled ownership transfer (PR B)
## Phase 2: controlled ownership transfer (this PR)
PR B pins `adoption_complete = true`. The controlled apply may update only:
@ -189,15 +187,19 @@ PR B pins `adoption_complete = true`. The controlled apply may update only:
- `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 post-adoption inline policy byte-identical to live so the
policy address 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.
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:
Dependency: `hcptf-shoc-frontend-new-dev` currently lacks
`cloudfront:UpdateDistribution` and `cloudfront:UpdateFunction`. Codify the
expansion in `seahaven-org-baseline` (cross-family plus security review) and
deploy it before the controlled apply.
```bash
python3 scripts/check-terraform-import-plan.py plan.json --environment dev \
--allow-update-address module.environment_owned.aws_s3_bucket.site \
--allow-update-address module.environment_owned.aws_s3_bucket_policy.site \
--allow-update-address module.environment_owned.aws_cloudfront_distribution.site \
--allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \
--allow-update-address module.environment_owned.aws_iam_role.github_deploy
```
After the apply and a no-op plan, deploy the same reviewed CDK SHA with
`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the
@ -205,6 +207,9 @@ After the apply and a no-op plan, deploy the same reviewed CDK SHA with
`ManageSiteInfrastructure=true` again after that. See
[`infra/cdk/README.md`](../infra/cdk/README.md).
Confirm `dev.seahaven.com` still serves and that a manual `workflow_dispatch`
of `deploy.yml` can still upload with the unchanged GitHub content policy.
## Phase 3: content CD through Terraform (PR C)
Summary only; PR C carries the full design. GitHub builds and uploads to an

View file

@ -1,7 +1,6 @@
locals {
# Import-first phase. Pinned in code, never a workspace variable: the
# controlled ownership transfer flips this to true in its own reviewed PR.
adoption_complete = false
# Controlled ownership transfer. Pinned in code, never a workspace variable.
adoption_complete = true
environment = "dev"
workspace_name = "shoc-frontend-new-dev"

View file

@ -109,8 +109,11 @@ data "aws_iam_policy_document" "github_deploy_assume" {
}
data "aws_iam_policy_document" "github_deploy" {
# Byte-identical to the live GitHub content policy through Phase 2 so
# aws_iam_role_policy.github_deploy stays no-op. Phase 3 replaces this
# with the release-prefix policy.
dynamic "statement" {
for_each = !var.adoption_complete && var.environment == "dev" ? [1] : []
for_each = var.environment == "dev" ? [1] : []
content {
sid = "AssumeCdkBootstrapRoles"
@ -120,72 +123,31 @@ data "aws_iam_policy_document" "github_deploy" {
}
}
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}/*"]
}
statement {
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 {
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}/*",
]
}
statement {

View file

@ -10,7 +10,7 @@ variable "environment" {
variable "adoption_complete" {
type = bool
description = "Switches only ownership tags and the deploy policy to their adopted values."
description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy. The GitHub deploy inline policy stays byte-identical to live until the content-CD PR."
default = false
}