feat(terraform): add dev root and import guard for HCP adoption

Port the reviewed dev root and environment-owned/inventory modules from
111eb556 with the 13 pinned dev identifiers. adoption_complete is pinned
to false in code; the root has no variables so a workspace variable
cannot change what applies. The tf-poc root, staging root, and tf-poc
map entries are dropped; staging constants stay only for the checker's
cross-environment negative tests.
This commit is contained in:
Adam Moussa 2026-09-10 19:15:09 -04:00
parent 73346371e5
commit 78398482cf
No known key found for this signature in database
17 changed files with 2629 additions and 0 deletions

12
.gitignore vendored
View file

@ -47,3 +47,15 @@ infra/cdk/bin/*.d.ts
infra/cdk/bin/*.js
infra/cdk/lib/*.d.ts
infra/cdk/lib/*.js
# terraform (the provider lock file is committed)
**/.terraform/*
*.tfstate
*.tfstate.*
*.tfplan
*.tfvars
*.tfvars.json
# python
__pycache__/
*.py[cod]

View file

@ -0,0 +1,628 @@
#!/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_pre_adoption_bucket_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}"
)
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": config["bucket_auto_delete_helper_role_arn"]
},
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"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_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_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],
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_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)
)
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:
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())

View file

@ -0,0 +1,131 @@
"""Canonical frontend Terraform ownership and import-ID maps.
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
are kept so the checker can prove that a dev plan carrying a staging identifier
is rejected; they do not authorize a staging import.
"""
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")
}
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",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
),
"cloudformation_stack_name": "shoc-frontend-dev",
"distribution_id": "E2CWLM1AFB964P",
"workspace_name": "shoc-frontend-new-dev",
},
"staging": {
"bucket_name": "seahaven-shoc-frontend-staging",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
),
"cloudformation_stack_name": "shoc-frontend-staging",
"distribution_id": "E2JDVEZ6EGD49J",
"workspace_name": "shoc-frontend-new-staging",
},
}
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"
),
},
}

View file

@ -0,0 +1,638 @@
#!/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]
assert expected is not None, f"{environment} must pin an import ID for {address}"
return expected
def distribution_id(environment: str) -> str:
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
return configured
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
source = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": config["bucket_auto_delete_helper_role_arn"]
},
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"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 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 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"]
}
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]:
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)
)
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_only_dev_has_a_live_root(self) -> None:
live_roots = sorted(
path.name
for path in (REPOSITORY / "terraform/live").iterdir()
if path.is_dir() and path.name != "modules"
)
self.assertEqual(["dev"], live_roots)
def test_dev_root_pins_import_phase_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"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"):
self.assertNotIn(
"variable ",
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
root_file,
)
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",
),
}
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 environment in REQUIRED_RESOURCES:
for address in CONTROLLED_UPDATE_ADDRESSES:
with self.subTest(environment=environment, address=address):
self.assert_passes(
make_plan(
environment,
mode="controlled",
controlled_updates={address},
),
environment,
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_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)
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()

295
terraform/README.md Normal file
View file

@ -0,0 +1,295 @@
# 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` | 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. |
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.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.
- **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 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 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
(`seahaven-org-baseline` owns them)
## 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 (this PR)
Each step is gated. State the impact, get the go, act, read back, record.
1. **Workspace invariants.** Set the invariants above on
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
PR.
2. **CDK retain deploy.** From the reviewed PR head, with administrator
credentials:
```bash
cd infra/cdk && npm ci
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=true
```
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 (PR B)
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 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.
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.
After the apply and a no-op plan, deploy the same reviewed CDK SHA with
`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the
13 transferred resources and the custom resource. Never deploy with
`ManageSiteInfrastructure=true` again after that. See
[`infra/cdk/README.md`](../infra/cdk/README.md).
## Phase 3: content CD through Terraform (PR C)
Summary only; PR C carries the full design. GitHub builds and uploads to an
immutable `releases/<sha>-<run>-<attempt>/` prefix. Terraform owns the
`.release/current` pointer, both origin paths of a CloudFront origin group,
and the invalidation action. Rollback is one guarded Terraform run swapping
the labels. Push-to-`dev` releases return behind the repository variable
`TERRAFORM_CONTENT_CD_ENABLED`.
## Operational rules
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
deployable application code. `.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 A and PR C). The label is the
approval record.
- **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 first
(served `index.html`, distribution status, pointer body once PR C lands),
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-isolation # isolation gate unit tests
npm run test:infra # CDK build, template tests, synth in both modes
```
`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.
## 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

27
terraform/live/dev/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,27 @@
# 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.57"
hashes = [
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
"h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=",
"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",
]
}

View 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}"
}

View file

@ -0,0 +1,94 @@
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
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 = local.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)
}

View 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
}

View file

@ -0,0 +1,3 @@
provider "aws" {
region = local.aws_region
}

View 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"
}
}
}

View 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."
}
}
}

View 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."
}

View 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"
}

View 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
}
}

View 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."
}

View file

@ -0,0 +1,160 @@
variable "environment" {
type = string
description = "Environment name."
validation {
condition = contains(["dev", "staging"], var.environment)
error_message = "environment must be 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."
}