From 78398482cfc5800c80534cb590fb794e46aa52ee Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:15:09 -0400 Subject: [PATCH 1/8] 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. --- .gitignore | 12 + scripts/check-terraform-import-plan.py | 628 +++++++++++++++++ scripts/terraform_import_plan_resources.py | 131 ++++ scripts/test-terraform-import-plan-check.py | 638 ++++++++++++++++++ terraform/README.md | 295 ++++++++ terraform/live/dev/.terraform.lock.hcl | 27 + terraform/live/dev/imports.tf | 64 ++ terraform/live/dev/main.tf | 94 +++ terraform/live/dev/outputs.tf | 11 + terraform/live/dev/providers.tf | 3 + terraform/live/dev/versions.tf | 19 + .../modules/environment-inventory/main.tf | 65 ++ .../modules/environment-inventory/outputs.tf | 19 + .../environment-inventory/variables.tf | 46 ++ .../live/modules/environment-owned/main.tf | 403 +++++++++++ .../live/modules/environment-owned/outputs.tf | 14 + .../modules/environment-owned/variables.tf | 160 +++++ 17 files changed, 2629 insertions(+) create mode 100644 scripts/check-terraform-import-plan.py create mode 100644 scripts/terraform_import_plan_resources.py create mode 100644 scripts/test-terraform-import-plan-check.py create mode 100644 terraform/README.md create mode 100644 terraform/live/dev/.terraform.lock.hcl create mode 100644 terraform/live/dev/imports.tf create mode 100644 terraform/live/dev/main.tf create mode 100644 terraform/live/dev/outputs.tf create mode 100644 terraform/live/dev/providers.tf create mode 100644 terraform/live/dev/versions.tf create mode 100644 terraform/live/modules/environment-inventory/main.tf create mode 100644 terraform/live/modules/environment-inventory/outputs.tf create mode 100644 terraform/live/modules/environment-inventory/variables.tf create mode 100644 terraform/live/modules/environment-owned/main.tf create mode 100644 terraform/live/modules/environment-owned/outputs.tf create mode 100644 terraform/live/modules/environment-owned/variables.tf diff --git a/.gitignore b/.gitignore index aabcecf0..5e4ce209 100644 --- a/.gitignore +++ b/.gitignore @@ -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] diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py new file mode 100644 index 00000000..28bd0dce --- /dev/null +++ b/scripts/check-terraform-import-plan.py @@ -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': }}"] + + 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(": 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(": 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()) diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py new file mode 100644 index 00000000..5d558b02 --- /dev/null +++ b/scripts/terraform_import_plan_resources.py @@ -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" + ), + }, +} diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py new file mode 100644 index 00000000..5046ccad --- /dev/null +++ b/scripts/test-terraform-import-plan-check.py @@ -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() diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 00000000..18bd7b2a --- /dev/null +++ b/terraform/README.md @@ -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/--/` 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 diff --git a/terraform/live/dev/.terraform.lock.hcl b/terraform/live/dev/.terraform.lock.hcl new file mode 100644 index 00000000..b3827528 --- /dev/null +++ b/terraform/live/dev/.terraform.lock.hcl @@ -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", + ] +} diff --git a/terraform/live/dev/imports.tf b/terraform/live/dev/imports.tf new file mode 100644 index 00000000..8f5e3e3f --- /dev/null +++ b/terraform/live/dev/imports.tf @@ -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}" +} diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf new file mode 100644 index 00000000..37ed5472 --- /dev/null +++ b/terraform/live/dev/main.tf @@ -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) +} diff --git a/terraform/live/dev/outputs.tf b/terraform/live/dev/outputs.tf new file mode 100644 index 00000000..726ee1e8 --- /dev/null +++ b/terraform/live/dev/outputs.tf @@ -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 +} diff --git a/terraform/live/dev/providers.tf b/terraform/live/dev/providers.tf new file mode 100644 index 00000000..b6c81d54 --- /dev/null +++ b/terraform/live/dev/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = local.aws_region +} diff --git a/terraform/live/dev/versions.tf b/terraform/live/dev/versions.tf new file mode 100644 index 00000000..9e341837 --- /dev/null +++ b/terraform/live/dev/versions.tf @@ -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" + } + } +} diff --git a/terraform/live/modules/environment-inventory/main.tf b/terraform/live/modules/environment-inventory/main.tf new file mode 100644 index 00000000..d0639b77 --- /dev/null +++ b/terraform/live/modules/environment-inventory/main.tf @@ -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." + } + } +} diff --git a/terraform/live/modules/environment-inventory/outputs.tf b/terraform/live/modules/environment-inventory/outputs.tf new file mode 100644 index 00000000..3223842d --- /dev/null +++ b/terraform/live/modules/environment-inventory/outputs.tf @@ -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." +} diff --git a/terraform/live/modules/environment-inventory/variables.tf b/terraform/live/modules/environment-inventory/variables.tf new file mode 100644 index 00000000..e76ae6dd --- /dev/null +++ b/terraform/live/modules/environment-inventory/variables.tf @@ -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" +} diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf new file mode 100644 index 00000000..3ddb5fdb --- /dev/null +++ b/terraform/live/modules/environment-owned/main.tf @@ -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 + } +} diff --git a/terraform/live/modules/environment-owned/outputs.tf b/terraform/live/modules/environment-owned/outputs.tf new file mode 100644 index 00000000..44f519a7 --- /dev/null +++ b/terraform/live/modules/environment-owned/outputs.tf @@ -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." +} diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf new file mode 100644 index 00000000..6a135679 --- /dev/null +++ b/terraform/live/modules/environment-owned/variables.tf @@ -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." +} From 82361e14b5378b8900cdaef7db012e58fa891e08 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:15:11 -0400 Subject: [PATCH 2/8] feat(cdk): add Terraform adoption retain mode retainForTerraformAdoption=true adds the required ManageSiteInfrastructure parameter, conditions the 13 transferred resources and the S3 auto-delete custom resource on it, applies Retain policies, pins the live dev origin ID, attaches the deploy boundary and HcpTerraformWorkspace tag, and narrows the OIDC subject to StringEquals. Normal synthesis is unchanged; template tests cover both modes. --- infra/cdk/README.md | 162 ++++++---- infra/cdk/bin/app.ts | 7 + infra/cdk/lib/frontend-stack.ts | 298 +++++++++++++++--- .../cdk/lib/retain-for-terraform-adoption.ts | 63 ++++ infra/cdk/package.json | 2 + infra/cdk/test/frontend-stack.test.mjs | 225 +++++++++++++ 6 files changed, 653 insertions(+), 104 deletions(-) create mode 100644 infra/cdk/lib/retain-for-terraform-adoption.ts create mode 100644 infra/cdk/test/frontend-stack.test.mjs diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 364780af..ba46bf4b 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -1,34 +1,45 @@ # Infrastructure & CI/CD — Sea Haven SHOC frontend -AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo, -deployed through the org's **reusable** GitHub Actions workflow. +AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo. +Infrastructure deploys are administrator-run; GitHub Actions publishes content +only. + +> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack +> `shoc-frontend-dev` is in the retain/transfer sequence described under +> [Terraform adoption mode](#terraform-adoption-mode) and in +> [`terraform/README.md`](../terraform/README.md). Do not run a plain +> `cdk deploy` against dev while that sequence is in progress. Staging is +> unaffected and stays on this CDK path (SH-287 tracks its cutover). - **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias). - **API:** the SPA calls the backend **directly** over HTTPS at `https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend allows CORS). CloudFront serves static content only — no `/api` proxy. -- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy` - picks them up with no flags. `VITE_API_URL` is baked into the build, so it's +- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks + them up with no flags. `VITE_API_URL` is baked into the build, so it's per-environment (see the note under "Adding staging / prod"). - **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys) -- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's - `Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy` - (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). +- **Content workflows:** `.github/workflows/deploy.yml` (dev, + `workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to + `staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy + role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was + retired with the adoption PR. - **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is created by this stack, not added to the central `oidc-deploy-roles.yaml`. -- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and - `staging` (push to `staging`, via the standalone `deploy-staging.yml`). ``` infra/cdk/ - bin/app.ts entry point (reads -c context) - lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role -scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation + bin/app.ts entry point (reads -c context) + lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role + lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition) + test/frontend-stack.test.mjs template assertions for both modes +scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation .github/workflows/ - ci.yaml quality gates (lint / build / test / e2e) - deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) - deploy-staging.yml standalone staging deploy (push to staging) + ci.yaml quality gates (lint / build / test / governance) + terraform-isolation.yaml PRs may not mix terraform/** with app code + deploy.yml dev content publish (workflow_dispatch on dev) + deploy-staging.yml standalone staging deploy (push to staging) ``` ## What the stack creates @@ -40,12 +51,67 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation | CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) | | IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | -The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on -`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's -pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`), -and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a -singleton account resource — the stack only _imports_ it (created in step 2), -so `cdk destroy` can't delete a resource shared by other roles. +The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants: +`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`, +read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It +is left byte-identical on purpose so the Terraform import is a no-op; the +Terraform content-CD change narrows it. The OIDC **provider** is a singleton +account resource — the stack only _imports_ it (created in step 2), so +`cdk destroy` can't delete a resource shared by other roles. + +## Terraform adoption mode + +`-c retainForTerraformAdoption=true` switches the stack into the safety mode +used only while HCP Terraform adopts the dev resources. It is off by default +and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts +both). In adoption mode the stack: + +- pins the origin ID CloudFormation generated for the live distribution + (`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is + metadata-only; environments without a verified value fail synthesis +- attaches the `seahaven-org-baseline` permissions boundary + `shoc-frontend-new-dev-deploy-boundary` and the + `HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role +- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the + same exact value +- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13 + transferred resources (bucket, bucket policy, distribution, OAC, SPA + function, A and AAAA records, deploy role, inline policy) and to + `SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider + Lambda and role stay unretained +- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no + default) and conditions those same resources and every output on it +- emits `TerraformImport*` outputs carrying the exact import IDs + +`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must +state the ownership phase: + +```bash +cd infra/cdk && npm ci + +# Phase 1, before the Terraform import: keep the resources in the stack and +# install Retain on them. Update-only change set. +npx cdk deploy shoc-frontend-dev \ + -c retainForTerraformAdoption=true \ + --parameters ManageSiteInfrastructure=true + +# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish +# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource. +npx cdk deploy shoc-frontend-dev \ + -c retainForTerraformAdoption=true \ + --parameters ManageSiteInfrastructure=false +``` + +Both deploys must use the same reviewed SHA. Review the change set before +confirming: Phase 1 must show no create, delete, or replace. After the +`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used +again. If the `true` deploy rolls back, inspect the stack resources and the +live bucket before retrying; retained resources can outlive a failed update and +must not be cleaned up automatically. Never delete the auto-delete custom +resource while its handler can still empty the versioned bucket. + +Local checks (`npm run test:infra` from the repo root) build the app, run the +template assertions, and synthesize both modes. --- @@ -102,48 +168,31 @@ cd infra/cdk npx cdk deploy ``` -Note the `DeployRoleArn` output. Then push the first content (or just push to -`dev` and let CI do everything from here on): +Note the `DeployRoleArn` output. Then publish the first content manually: ```bash # from repo root, optional manual first content publish: STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh ``` -### 6. Set the one GitHub secret +### 6. Content deploys -`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable): - -```bash -REPO=Sea-Haven-Industries/shoc-frontend-new -gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \ - --body "arn:aws:iam:::role/githubdeploy-shoc-frontend-new-dev" -``` - -(Or **Settings → Secrets and variables → Actions → Secrets**.) - -### 7. From now on: push to `dev` - -```bash -git push origin dev -``` - -`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs -`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open -the `SiteUrl` output. - -> First-run verification: this first push is what actually exercises the role's -> permissions and the OIDC trust through the reusable workflow (the local -> bootstrap used admin creds and tested none of that). Watch for -> credential/OIDC errors and a green post-deploy step. +The deploy role ARN is deterministic and pinned in +`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the +Terraform adoption, dev content deploys run only through **Actions → Deploy dev +content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes +`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the +pinned bucket and distribution, uploads source maps, and verifies the served +`index.html` matches the build. Automatic push-to-`dev` releases return with the +Terraform content-CD change. --- ## Staging environment (same account, exact OIDC subject) -Staging lives in the same AWS account (396287094661) but deploys through its -own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org -reusable `cd-cdk.yaml`: +Staging lives in the same AWS account (396287094661) and deploys through its +own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to +`staging`: - **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub @@ -212,10 +261,11 @@ for prod. - **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. -- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging - differs only in that its CD workflow also runs `npm run verify` itself - before deploying); a red-CI commit still deploys on `dev` (matches the - org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part - of enabling CICD. + Never run it against dev during or after the Terraform adoption: the + adoption-mode stack retains the transferred resources, and after Phase 2 + Terraform owns them. +- **CI and staging CD both fire on push to `staging`** in parallel; the + staging CD workflow runs `npm run verify` itself before deploying. Dev has + no push-triggered deploy during the adoption. - **npm is pinned to v11.16.0**; the committed `package-lock.json` uses lockfileVersion 3, matching the Node 24 / npm 11 CI environment. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 876463fe..afb51ff9 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -25,6 +25,12 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; +// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required +// ManageSiteInfrastructure parameter and Retain policies on the transferred +// resources. Off by default so ordinary synthesis is unchanged. +const retainForTerraformAdoption = + String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true"; + // Staging and beyond protect their stacks from accidental deletion; dev // stays teardown-friendly (its artifacts are reproducible). CDK applies this // at deploy time — it is not part of the synthesized template. @@ -40,6 +46,7 @@ const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { certificateArn, hostedZoneId, hostedZoneName, + retainForTerraformAdoption, env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index dda5f67e..bce19e98 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -1,4 +1,16 @@ -import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib"; +import { + Aspects, + CfnCondition, + CfnOutput, + CfnParameter, + CfnResource, + Duration, + Fn, + RemovalPolicy, + Stack, + StackProps, + Tags, +} from "aws-cdk-lib"; import { Construct } from "constructs"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; @@ -7,6 +19,7 @@ import * as iam from "aws-cdk-lib/aws-iam"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as targets from "aws-cdk-lib/aws-route53-targets"; +import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption"; export interface FrontendStackProps extends StackProps { /** Environment label, e.g. "dev". Used in names/tags. */ @@ -42,6 +55,11 @@ export interface FrontendStackProps extends StackProps { readonly hostedZoneId: string; /** Name of the hosted zone above, e.g. "dev.seahaven.com". */ readonly hostedZoneName: string; + /** + * Opt-in safety mode used only during the reviewed Terraform adoption. + * Normal dev/staging synthesis remains unchanged when false. + */ + readonly retainForTerraformAdoption?: boolean; } /** @@ -50,11 +68,10 @@ export interface FrontendStackProps extends StackProps { * - CloudFront distribution (HTTPS, SPA deep-link fallback) * - a GitHub Actions OIDC deploy role * - * Content (the built `dist/`) is NOT uploaded here. The org's reusable - * `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to - * build the SPA, sync it to this bucket, and invalidate CloudFront — so this - * stack only owns the infrastructure, and the deploy role carries the - * permissions those post-deploy steps need. + * Content (the built `dist/`) is NOT uploaded here. Manual environment + * workflows run `scripts/deploy-web.sh` independently of infrastructure + * changes, so this stack only owns infrastructure and the deploy role carries + * content-publication permissions. */ export class FrontendStack extends Stack { constructor(scope: Construct, id: string, props: FrontendStackProps) { @@ -69,8 +86,23 @@ export class FrontendStack extends Stack { certificateArn, hostedZoneId, hostedZoneName, + retainForTerraformAdoption = false, } = props; + const manageSiteInfrastructureCondition = retainForTerraformAdoption + ? new CfnCondition(this, "ManageSiteInfrastructureCondition", { + expression: Fn.conditionEquals( + new CfnParameter(this, "ManageSiteInfrastructure", { + type: "String", + allowedValues: ["true", "false"], + description: + "Set true only before Terraform adoption. After ownership transfer, always reuse false.", + }).valueAsString, + "true", + ), + }) + : undefined; + const hasCustomDomain = domainNames.length > 0; if (hasCustomDomain && !certificateArn) { throw new Error( @@ -114,6 +146,16 @@ export class FrontendStack extends Stack { // --- CloudFront: serves the static SPA from S3 ------------------------- // The SPA calls the backend directly at its absolute HTTPS URL // (VITE_API_URL, cross-origin), so CloudFront hosts only static content. + // Adoption mode pins the origin ID CloudFormation generated for the live + // distribution so the retention deploy is a metadata-only update. Only + // environments with a read-back-verified value may enter adoption mode. + const adoptionOriginIds: Record = { + dev: "shocfrontenddevDistributionOrigin10CCD0EE1", + }; + const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined; + if (retainForTerraformAdoption && !originId) { + throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`); + } const distribution = new cloudfront.Distribution(this, "Distribution", { comment: `SeaHaven SHOC frontend (${envName})`, defaultRootObject: "index.html", @@ -130,7 +172,9 @@ export class FrontendStack extends Stack { : undefined, defaultBehavior: { // withOriginAccessControl wires up OAC + the bucket policy automatically. - origin: origins.S3BucketOrigin.withOriginAccessControl(bucket), + origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, { + originId, + }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, @@ -158,9 +202,9 @@ export class FrontendStack extends Stack { // Trust conditions for the OIDC principal. With a GitHub environment // (staging): exact StringEquals match on both aud and the environment // subject — the staging workflow declares `environment: staging`, so only - // runs in that environment can assume the role. Without one (dev): keep - // the branch-ref trust, where StringLike scopes `sub` to pushes on the - // deploy branch (reusable-workflow runs still carry the caller-based sub). + // runs in that environment can assume the role. Normal dev synthesis keeps + // the current branch-ref StringLike trust. The adoption prerequisite + // narrows that already-exact value to StringEquals before Terraform import. const oidcConditions = githubEnvironment ? { StringEquals: { @@ -168,30 +212,48 @@ export class FrontendStack extends Stack { "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, }, } - : { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - }, - StringLike: { - // Tightly scoped: only pushes to this repo's deploy branch. For a - // reusable-workflow run the OIDC `sub` is still caller-based, so this - // matches even though the deploy job lives in the `.github` repo. - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, - }, - }; + : retainForTerraformAdoption + ? { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + } + : { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + // Tightly scoped: only pushes to this repo's deploy branch. For a + // reusable-workflow run the OIDC `sub` is still caller-based, so this + // matches even though the deploy job lives in the `.github` repo. + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + }; + + const deployPermissionsBoundary = retainForTerraformAdoption + ? iam.ManagedPolicy.fromManagedPolicyArn( + this, + "GithubDeployPermissionsBoundary", + `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, + ) + : undefined; const deployRole = new iam.Role(this, "GithubDeployRole", { roleName: `githubdeploy-shoc-frontend-new-${envName}`, description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, maxSessionDuration: Duration.hours(1), assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions), + permissionsBoundary: deployPermissionsBoundary, }); + if (retainForTerraformAdoption) { + Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`); + } - // Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is - // intentionally narrower: its recurring promotion workflow only publishes - // application assets to this stack's bucket/distribution. Infrastructure - // changes remain an administrator-run CDK operation, so the staging OIDC - // role cannot inherit the bootstrap roles' account-wide deployment power. + // Preserve dev's legacy CDK capability until the reviewed adoption update + // replaces this inline policy. Staging is intentionally narrower: its + // content role only publishes application assets to this stack's + // bucket/distribution. Infrastructure changes remain administrator-run. if (!githubEnvironment) { deployRole.addToPolicy( new iam.PolicyStatement({ @@ -224,6 +286,8 @@ export class FrontendStack extends Stack { // --- DNS: point the custom domain at CloudFront ------------------------ // Only when a hosted zone is supplied (it must be in THIS account). Creates // A + AAAA aliases; for the zone apex, recordName is the zone itself. + let aliasA: route53.ARecord | undefined; + let aliasAaaa: route53.AaaaRecord | undefined; if (hostedZoneId && hasCustomDomain) { const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", { hostedZoneId, @@ -233,31 +297,169 @@ export class FrontendStack extends Stack { // apex record when the domain equals the zone name. const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0]; - new route53.ARecord(this, "AliasA", { zone, recordName, target }); - new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target }); + aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target }); + aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", { + zone, + recordName, + target, + }); } + const gateOutput = (output: CfnOutput): CfnOutput => { + if (manageSiteInfrastructureCondition) { + output.condition = manageSiteInfrastructureCondition; + } + return output; + }; + // --- Outputs ----------------------------------------------------------- // scripts/deploy-web.sh reads BucketName + DistributionId from these. - new CfnOutput(this, "SiteUrl", { - value: hasCustomDomain - ? `https://${domainNames[0]}` - : `https://${distribution.distributionDomainName}`, - description: "Public URL of the deployed SPA", - }); - new CfnOutput(this, "DistributionDomainName", { - value: distribution.distributionDomainName, - description: "CloudFront domain — point the custom-domain DNS record here", - }); - new CfnOutput(this, "BucketName", { - value: bucket.bucketName, - }); - new CfnOutput(this, "DistributionId", { - value: distribution.distributionId, - }); - new CfnOutput(this, "DeployRoleArn", { - value: deployRole.roleArn, - description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN", - }); + gateOutput( + new CfnOutput(this, "SiteUrl", { + value: hasCustomDomain + ? `https://${domainNames[0]}` + : `https://${distribution.distributionDomainName}`, + description: "Public URL of the deployed SPA", + }), + ); + gateOutput( + new CfnOutput(this, "DistributionDomainName", { + value: distribution.distributionDomainName, + description: "CloudFront domain — point the custom-domain DNS record here", + }), + ); + gateOutput( + new CfnOutput(this, "BucketName", { + value: bucket.bucketName, + }), + ); + gateOutput( + new CfnOutput(this, "DistributionId", { + value: distribution.distributionId, + }), + ); + gateOutput( + new CfnOutput(this, "DeployRoleArn", { + value: deployRole.roleArn, + description: "Pinned GitHub OIDC content-deployment role", + }), + ); + + if (retainForTerraformAdoption) { + const originAccessControl = distribution.node + .findAll() + .find( + (node): node is cloudfront.CfnOriginAccessControl => + node instanceof cloudfront.CfnOriginAccessControl, + ); + if (!originAccessControl || !aliasA || !aliasAaaa) { + throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records."); + } + const originAccessControlConfig = + originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty; + + const rolePolicy = deployRole.node + .findAll() + .find((node): node is iam.Policy => node instanceof iam.Policy); + const autoDeleteProviderRole = this.node + .findAll() + .find( + (node): node is CfnResource => + node instanceof CfnResource && + node.cfnResourceType === "AWS::IAM::Role" && + node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"), + ); + if (!rolePolicy || !autoDeleteProviderRole) { + throw new Error("Terraform adoption outputs require deploy and auto-delete roles."); + } + + const recordName = domainNames[0]; + gateOutput( + new CfnOutput(this, "TerraformWorkspaceTag", { + value: `shoc-frontend-new-${envName}`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDeployBoundaryArn", { + value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, + }), + ); + gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName })); + gateOutput( + new CfnOutput(this, "TerraformImportBucketPolicy", { + value: bucket.bucketName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDistribution", { + value: distribution.distributionId, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportOriginAccessControl", { + value: originAccessControl.attrId, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformOriginAccessControlName", { + value: originAccessControlConfig.name, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformOriginAccessControlDescription", { + value: "EMPTY_STRING", + description: "Use an empty Terraform string because the generated OAC has no description", + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDistributionOriginId", { + value: originId!, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportSpaRewriteFunction", { + value: spaRewrite.functionName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportAliasA", { + value: `${hostedZoneId}_${recordName}_A`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportAliasAAAA", { + value: `${hostedZoneId}_${recordName}_AAAA`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDeployRole", { + value: deployRole.roleName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDeployRolePolicy", { + value: `${deployRole.roleName}:${rolePolicy.policyName}`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDeployInlinePolicyName", { + value: rolePolicy.policyName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", { + value: autoDeleteProviderRole.getAtt("Arn").toString(), + }), + ); + gateOutput( + new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", { + value: "SiteBucket/AutoDeleteObjectsCustomResource", + description: + "CloudFormation custom resource retained to prevent bucket emptying during detachment", + }), + ); + + Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition)); + } } } diff --git a/infra/cdk/lib/retain-for-terraform-adoption.ts b/infra/cdk/lib/retain-for-terraform-adoption.ts new file mode 100644 index 00000000..c02aa3e9 --- /dev/null +++ b/infra/cdk/lib/retain-for-terraform-adoption.ts @@ -0,0 +1,63 @@ +import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib"; +import { IConstruct } from "constructs"; + +const TRANSFERRED_RESOURCE_TYPES = new Set([ + "AWS::S3::Bucket", + "AWS::S3::BucketPolicy", + "AWS::CloudFront::Distribution", + "AWS::CloudFront::Function", + "AWS::CloudFront::OriginAccessControl", + "AWS::Route53::RecordSet", +]); + +function isTransferredResource(resource: CfnResource): boolean { + if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) { + return true; + } + + if ( + resource.cfnResourceType === "Custom::S3AutoDeleteObjects" && + resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource") + ) { + return true; + } + + return ( + (resource.cfnResourceType === "AWS::IAM::Role" || + resource.cfnResourceType === "AWS::IAM::Policy") && + resource.node.path.includes("/GithubDeployRole") + ); +} + +/** + * Retains only the resources in the approved Terraform transfer set. + * + * The bucket auto-delete custom resource is intentionally retained while the + * generated provider Lambda, role, log group, and CDK metadata remain excluded. + * When a management condition is supplied, those same resources share it so + * CloudFormation can later relinquish them without deleting them. + */ +export class RetainForTerraformAdoption implements IAspect { + constructor(private readonly manageCondition?: CfnCondition) {} + + public visit(node: IConstruct): void { + if (!(node instanceof CfnResource) || !isTransferredResource(node)) { + return; + } + + // Keep the L2 bucket's configured DESTROY policy visible to its + // AutoDeleteObjects validator while overriding the emitted CloudFormation + // resource. This preserves the custom resource and retains both together. + if (node.cfnResourceType === "AWS::S3::Bucket") { + node.addOverride("DeletionPolicy", "Retain"); + node.addOverride("UpdateReplacePolicy", "Retain"); + } else { + node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN; + node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN; + } + + if (this.manageCondition) { + node.cfnOptions.condition = this.manageCondition; + } + } +} diff --git a/infra/cdk/package.json b/infra/cdk/package.json index b490e706..4529259f 100644 --- a/infra/cdk/package.json +++ b/infra/cdk/package.json @@ -11,7 +11,9 @@ }, "scripts": { "build": "tsc", + "test": "npm run build && node --test test/*.test.mjs", "synth": "cdk synth", + "synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true", "diff": "cdk diff", "deploy": "cdk deploy" }, diff --git a/infra/cdk/test/frontend-stack.test.mjs b/infra/cdk/test/frontend-stack.test.mjs new file mode 100644 index 00000000..f285557d --- /dev/null +++ b/infra/cdk/test/frontend-stack.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; + +const require = createRequire(import.meta.url); +const { App } = require("aws-cdk-lib"); +const { Template } = require("aws-cdk-lib/assertions"); +const { FrontendStack } = require("../lib/frontend-stack.js"); + +const account = "396287094661"; +const region = "us-east-1"; +const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev"; +const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1"; +const CONDITION = "ManageSiteInfrastructureCondition"; + +const RETAINED_TYPES = new Set([ + "AWS::S3::Bucket", + "AWS::S3::BucketPolicy", + "AWS::CloudFront::Distribution", + "AWS::CloudFront::Function", + "AWS::CloudFront::OriginAccessControl", + "AWS::Route53::RecordSet", + "Custom::S3AutoDeleteObjects", +]); + +function devTemplate(retainForTerraformAdoption, overrides = {}) { + const app = new App(); + const stack = new FrontendStack(app, "shoc-frontend-dev", { + envName: "dev", + githubRepo: "Sea-Haven-Industries/shoc-frontend-new", + deployBranch: "dev", + domainNames: ["dev.seahaven.com"], + certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`, + hostedZoneId: "Z07671212N75U4YLPWZR8", + hostedZoneName: "dev.seahaven.com", + retainForTerraformAdoption, + env: { account, region }, + ...overrides, + }); + return Template.fromStack(stack).toJSON(); +} + +function entriesByType(template, type) { + return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type); +} + +function isTransferred(logicalId, resource) { + const isDeployRoleResource = + (resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) || + (resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole")); + return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource; +} + +test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => { + const template = devTemplate(true); + assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1); + assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1); + assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2); + assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1); + const transferred = Object.entries(template.Resources).filter(([id, resource]) => + isTransferred(id, resource), + ); + // Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline + // policy = 9 CloudFormation resources (Terraform splits the bucket into 6 + // addresses) plus the retained custom resource. + assert.equal(transferred.length, 10); +}); + +test("adoption mode preserves the live dev identifiers", () => { + const template = devTemplate(true); + const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; + assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev"); + assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled"); + assert.ok( + bucket.Properties.Tags.some( + (tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true", + ), + ); + + const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1]; + assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID); + assert.equal( + distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId, + DEV_ORIGIN_ID, + ); + + const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( + ([, resource]) => resource.Properties.RoleName === DEV_ROLE, + ); + assert.equal( + deployRole.Properties.PermissionsBoundary, + `arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`, + ); + assert.ok( + deployRole.Properties.Tags.some( + (tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev", + ), + ); + const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; + assert.equal( + condition.StringEquals["token.actions.githubusercontent.com:sub"], + "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", + ); + assert.equal(condition.StringLike, undefined); + + // Legacy inline policy stays byte-compatible with the live document. + const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) => + id.startsWith("GithubDeployRole"), + ); + const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid); + assert.deepEqual(sids, [ + "AssumeCdkBootstrapRoles", + "DescribeStack", + undefined, + "InvalidateDistribution", + ]); + + for (const output of [ + "TerraformWorkspaceTag", + "TerraformDeployBoundaryArn", + "TerraformImportBucket", + "TerraformImportBucketPolicy", + "TerraformImportDistribution", + "TerraformImportOriginAccessControl", + "TerraformOriginAccessControlName", + "TerraformOriginAccessControlDescription", + "TerraformDistributionOriginId", + "TerraformImportSpaRewriteFunction", + "TerraformImportAliasA", + "TerraformImportAliasAAAA", + "TerraformImportDeployRole", + "TerraformImportDeployRolePolicy", + "TerraformDeployInlinePolicyName", + "TerraformBucketAutoDeleteHelperRoleArn", + "TerraformRetainedAutoDeleteCustomResource", + ]) { + assert.ok(template.Outputs[output], `missing output ${output}`); + } + assert.equal( + template.Outputs.TerraformImportAliasA.Value, + "Z07671212N75U4YLPWZR8_dev.seahaven.com_A", + ); + assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID); +}); + +test("adoption mode retains exactly the transferred resources", () => { + const template = devTemplate(true); + for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (isTransferred(logicalId, resource)) { + assert.equal(resource.DeletionPolicy, "Retain", logicalId); + assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId); + } else { + assert.notEqual(resource.DeletionPolicy, "Retain", logicalId); + assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId); + } + } + // The auto-delete provider Lambda, role, and log group stay unretained. + for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) { + for (const [, resource] of entriesByType(template, type)) { + assert.notEqual(resource.DeletionPolicy, "Retain"); + } + } + const providerRoles = entriesByType(template, "AWS::IAM::Role").filter( + ([, resource]) => resource.Properties.RoleName !== DEV_ROLE, + ); + assert.equal(providerRoles.length, 1); + assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain"); +}); + +test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => { + const template = devTemplate(true); + const parameter = template.Parameters.ManageSiteInfrastructure; + assert.ok(parameter); + assert.equal(parameter.Type, "String"); + assert.deepEqual(parameter.AllowedValues, ["true", "false"]); + assert.equal(parameter.Default, undefined); + assert.ok(template.Conditions[CONDITION]); + + for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (isTransferred(logicalId, resource)) { + assert.equal(resource.Condition, CONDITION, logicalId); + } else { + assert.notEqual(resource.Condition, CONDITION, logicalId); + } + } + for (const [outputName, output] of Object.entries(template.Outputs)) { + assert.equal(output.Condition, CONDITION, outputName); + } +}); + +test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => { + const template = devTemplate(false); + const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; + assert.equal(bucket.DeletionPolicy, "Delete"); + assert.equal(bucket.UpdateReplacePolicy, "Delete"); + const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1]; + assert.notEqual(customResource.DeletionPolicy, "Retain"); + + const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( + ([, resource]) => resource.Properties.RoleName === DEV_ROLE, + ); + assert.equal(deployRole.Properties.PermissionsBoundary, undefined); + assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace")); + const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; + assert.equal( + condition.StringLike["token.actions.githubusercontent.com:sub"], + "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", + ); + assert.equal(template.Outputs.TerraformWorkspaceTag, undefined); + assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined); + assert.equal(template.Conditions?.[CONDITION], undefined); + for (const resource of Object.values(template.Resources)) { + assert.equal(resource.Condition, undefined); + } +}); + +test("adoption mode refuses an environment without a verified origin ID", () => { + assert.throws( + () => devTemplate(true, { envName: "staging" }), + /No verified Terraform adoption origin ID exists for staging/, + ); +}); From 87e79072ad6dcfa04abacfac5414161958b4a0dd Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:15:12 -0400 Subject: [PATCH 3/8] ci(deploy): make dev content deploy workflow_dispatch only Remove the push-to-dev trigger and the org cd-cdk.yaml caller so CI no longer runs cdk deploy during the adoption. The workflow assumes the pinned dev role and runs the simple scripts/deploy-web.sh against a pinned bucket and distribution, which keeps content deploys working after CloudFormation relinquishes the stack outputs. Staging is untouched. --- .github/workflows/deploy.yml | 104 +++++++++++++++++++++++++---------- scripts/deploy-web.sh | 52 +++++++++++------- 2 files changed, 108 insertions(+), 48 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6ad9bde2..099b55de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,22 +1,22 @@ -name: Deploy +name: Deploy dev content -# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`. +# Manual dev content deployment during the Terraform adoption (SH-300). # -# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs -# `cdk deploy` (provisioning the infra in infra/cdk) and then the -# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates -# CloudFront. Both run as the OIDC deploy role created by the stack. +# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are +# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are +# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the +# resources, and move to HCP Terraform (`terraform/README.md`) as adoption +# completes. Automatic push-to-`dev` releases return with the Terraform +# content-CD change, gated on a repository variable. # -# When staging/prod accounts exist, add jobs keyed to their branches and their -# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow. +# This workflow publishes only content: verify, build, `aws s3 sync`, and a +# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC +# deploy role. The bucket and distribution are pinned here so a content deploy +# keeps working after CloudFormation relinquishes the stack outputs. on: - push: - branches: [dev] workflow_dispatch: {} -# OIDC needs id-token: write — it is never in the default token set and cannot -# be granted to the reusable workflow unless the caller has it. permissions: id-token: write contents: read @@ -27,22 +27,13 @@ concurrency: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 - with: - node-version: "24" - region: us-east-1 - cdk-dir: infra/cdk - stack-name: shoc-frontend-dev - post-deploy-script: scripts/deploy-web.sh - secrets: - deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} - - upload-sourcemaps: - name: Upload private source maps - needs: deploy + name: Publish content to dev + # Deploy only the exact dev branch ref: workflow_dispatch can be invoked + # from arbitrary refs, and the deploy role trusts only refs/heads/dev. if: github.ref == 'refs/heads/dev' runs-on: ubuntu-latest env: + AWS_REGION: us-east-1 VITE_APP_COMMIT_SHA: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -52,9 +43,66 @@ jobs: with: node-version: "24" cache: npm - - name: Build exact deployed release - run: npm ci && npm run build - - name: Upload source maps to Sentry + - name: Set up Terraform + # Required by `npm run verify` (governance runs terraform fmt/validate). + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false + - name: Quality gates (full verify before any deploy) + run: npm ci && npm run verify + env: + GOVERNANCE_BASE: origin/dev + + - name: Assume dev deploy role (OIDC) + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev + aws-region: us-east-1 + + # Builds with the dev values committed in .env.production (VITE_API_URL, + # Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront. + - name: Build and publish SPA + run: bash scripts/deploy-web.sh + env: + SITE_BUCKET: seahaven-shoc-frontend-dev + CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P + WAIT_FOR_INVALIDATION: "true" + + - name: Upload private source maps run: bash scripts/upload-sourcemaps.sh env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + + - name: Verify deployment + run: | + set -euo pipefail + SITE_URL="https://dev.seahaven.com" + if grep -Rq "api.staging.seahaven.com" dist/; then + echo "::error::Built assets contain the staging API URL." >&2 + exit 1 + fi + grep -Rq "api.dev.seahaven.com" dist/ + echo "Built assets reference the dev API URL." + + # The invalidation has completed, but give edges a short window to + # converge before calling the served index.html wrong. + remote_dir="$(mktemp -d)" + trap 'rm -rf "${remote_dir}"' EXIT + matched=false + for i in 1 2 3 4 5 6; do + if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \ + && cmp -s dist/index.html "${remote_dir}/index.html"; then + matched=true + break + fi + echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..." + sleep 20 + done + if [[ "${matched}" != "true" ]]; then + echo "::error::Served index.html does not match the build just published." >&2 + exit 1 + fi + echo "Served index.html matches the published build." + curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login" + echo "Extensionless SPA route serves." diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 2dfb66a2..ec64a742 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash # -# Post-deploy step for the org reusable workflow `cd-cdk.yaml` -# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`). +# Content publish step for the environment deploy workflows +# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`). # -# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub -# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with -# the right cache headers, and invalidates CloudFront. +# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the +# environment's S3 bucket with the right cache headers, and invalidates +# CloudFront. It never touches infrastructure. # -# Runs from the repo root. Reads the bucket + distribution from stack outputs, -# so it has no hardcoded resource IDs. +# Runs from the repo root. The target is resolved from, in order: +# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by +# dev, whose CloudFormation outputs disappear during Terraform adoption) +# 2. the BucketName/DistributionId outputs of STACK_NAME (staging) set -euo pipefail STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" @@ -20,21 +22,31 @@ export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" npm ci npm run build -echo "Reading stack outputs from ${STACK_NAME}..." -stack_output() { - aws cloudformation describe-stacks \ - --stack-name "${STACK_NAME}" \ - --region "${REGION}" \ - --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ - --output text -} +BUCKET="${SITE_BUCKET:-}" +DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}" -BUCKET="$(stack_output BucketName)" -DIST_ID="$(stack_output DistributionId)" - -if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then - echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 +if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then + echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}." +elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then + echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2 exit 1 +else + echo "Reading stack outputs from ${STACK_NAME}..." + stack_output() { + aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text + } + + BUCKET="$(stack_output BucketName)" + DIST_ID="$(stack_output DistributionId)" + + if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then + echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2 + exit 1 + fi fi echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." From 08da408a13bb803be19b83bfdea118bedc258e85 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:15:13 -0400 Subject: [PATCH 4/8] ci(governance): wire Terraform and CDK gates and isolate Terraform PRs Governance now runs the import-plan checker tests, Terraform fmt and validate for terraform/live/dev, the isolation gate tests, and the CDK build, tests, and synth in both modes. A new terraform-isolation workflow fails PRs that change terraform/** together with application code; the terraform-isolation-override label is the reviewed exception. Renovate gains the terraform manager. --- .github/renovate.json | 8 +- .github/workflows/ci.yaml | 15 ++- .github/workflows/terraform-isolation.yaml | 35 ++++++ package.json | 4 + scripts/check-terraform-isolation.mjs | 120 +++++++++++++++++++++ scripts/check-terraform-isolation.test.mjs | 115 ++++++++++++++++++++ scripts/governance-check.mjs | 33 ++++++ scripts/terraform-validate.mjs | 35 ++++++ 8 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/terraform-isolation.yaml create mode 100644 scripts/check-terraform-isolation.mjs create mode 100644 scripts/check-terraform-isolation.test.mjs create mode 100644 scripts/terraform-validate.mjs diff --git a/.github/renovate.json b/.github/renovate.json index af6c5ee0..38bd081a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "enabledManagers": ["npm", "custom.regex"], + "enabledManagers": ["npm", "custom.regex", "terraform"], "minimumReleaseAge": "3 days", "internalChecksFilter": "strict", "customManagers": [ @@ -17,6 +17,12 @@ } ], "packageRules": [ + { + "description": ["Group non-major Terraform provider updates"], + "matchManagers": ["terraform"], + "matchUpdateTypes": ["minor", "patch"], + "groupName": "terraform minor and patch" + }, { "description": ["Do not open major or replacement PRs until approved on the dashboard"], "matchUpdateTypes": ["major", "replacement"], diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2222ef4f..9a39ad3b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,9 +23,11 @@ jobs: # repository, independent of (and in addition to) the reusable workflow. # `npm run verify` is the single command that chains: format check, lint # (--max-warnings=0), type-check + build, unit tests, then the governance - # checks in scripts/governance-check.mjs (godfile ratchet + changed-file - # maintainability gate). If the reusable workflow is later confirmed to run - # every gate, this job can be slimmed to `npm run governance`. + # checks in scripts/governance-check.mjs (godfile ratchet, changed-file + # maintainability gate, Terraform fmt/validate, Terraform import-plan guard + # tests, Terraform isolation gate tests, CDK build/test/synth). If the + # reusable workflow is later confirmed to run every gate, this job can be + # slimmed to `npm run governance`. # # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) @@ -53,6 +55,13 @@ jobs: base="origin/dev" fi printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - name: Set up Terraform + # Same minor as the HCP workspace (1.16.x) so fmt/validate see what + # the remote run will see. + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" diff --git a/.github/workflows/terraform-isolation.yaml b/.github/workflows/terraform-isolation.yaml new file mode 100644 index 00000000..14c53730 --- /dev/null +++ b/.github/workflows/terraform-isolation.yaml @@ -0,0 +1,35 @@ +name: Terraform isolation + +# Fails a pull request that changes `terraform/**` together with deployable +# application code (see scripts/check-terraform-isolation.mjs). A merge that +# does both queues an HCP VCS run and a content release at the same time, and +# the two race for the workspace lock. +# +# Runs on label events too, so adding or removing the +# `terraform-isolation-override` label re-evaluates the gate without a push. + +on: + pull_request: + branches: [main, dev, staging] + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +jobs: + terraform-isolation: + name: Terraform and application changes are isolated + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Check changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }} + run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}" diff --git a/package.json b/package.json index ed95210d..50346ac3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "test:e2e": "playwright test", "test:e2e:visual": "playwright test --config playwright.visual.config.ts", "test:e2e:ui": "playwright test --ui", + "test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py", + "test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs", + "test:terraform": "node scripts/terraform-validate.mjs", + "test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", diff --git a/scripts/check-terraform-isolation.mjs b/scripts/check-terraform-isolation.mjs new file mode 100644 index 00000000..7b28c149 --- /dev/null +++ b/scripts/check-terraform-isolation.mjs @@ -0,0 +1,120 @@ +// Terraform/application change isolation gate. +// +// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run +// on the workspace. If the same merge also changes deployable application +// code, the content release and the VCS run race for the workspace lock +// (backend incident, 2026-09-04). This gate fails a pull request that mixes the +// two, so Terraform changes ship in their own PR and their VCS run is confirmed +// or discarded by a human before the next content release. +// +// Files that may accompany a Terraform change without triggering a release: +// the Terraform tree itself, its plan-guard tooling, and documentation. +// +// Usage: +// node scripts/check-terraform-isolation.mjs --base --head +// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin +// +// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets +// it only when the PR carries the `terraform-isolation-override` label, which +// reviewers grant to the rare change that must introduce Terraform variables +// together with the workflow that consumes them. +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const OVERRIDE_LABEL = "terraform-isolation-override"; + +export function isTerraformPath(file) { + return file.startsWith("terraform/"); +} + +export function mayAccompanyTerraform(file) { + if (isTerraformPath(file)) return true; + if (file.endsWith(".md")) return true; + if (file.startsWith("docs/")) return true; + if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true; + return false; +} + +/** + * @param {string[]} files changed paths relative to the repository root + * @returns {{ terraform: string[], application: string[], mixed: boolean }} + */ +export function classifyChangedFiles(files) { + const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort(); + const terraform = unique.filter(isTerraformPath); + const application = unique.filter((file) => !mayAccompanyTerraform(file)); + return { + terraform, + application, + mixed: terraform.length > 0 && application.length > 0, + }; +} + +function changedFilesFromGit(base, head) { + const mergeBase = execFileSync("git", ["merge-base", base, head], { + cwd: ROOT, + encoding: "utf8", + }).trim(); + return execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head], + { cwd: ROOT, encoding: "utf8" }, + ) + .split("\n") + .filter(Boolean); +} + +function parseArgs(argv) { + const options = { base: null, head: "HEAD", stdin: false }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--base") options.base = argv[++index]; + else if (argument === "--head") options.head = argv[++index]; + else if (argument === "--stdin") options.stdin = true; + else throw new Error(`unknown argument: ${argument}`); + } + if (!options.stdin && !options.base) { + throw new Error("provide --base (and optionally --head ) or --stdin"); + } + return options; +} + +function main(argv) { + const options = parseArgs(argv); + const files = options.stdin + ? readFileSync(0, "utf8").split("\n") + : changedFilesFromGit(options.base, options.head); + const result = classifyChangedFiles(files); + const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true"; + + console.log("─".repeat(64)); + console.log( + `terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`, + ); + if (!result.mixed) { + console.log(" PASS: Terraform and application changes are not mixed"); + return 0; + } + console.log(" Terraform files:"); + for (const file of result.terraform) console.log(` ${file}`); + console.log(" Application files that cannot ship in the same PR:"); + for (const file of result.application) console.log(` ${file}`); + if (override) { + console.log( + ` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`, + ); + return 0; + } + console.log( + ` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`, + ); + return 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/check-terraform-isolation.test.mjs b/scripts/check-terraform-isolation.test.mjs new file mode 100644 index 00000000..24c6c87d --- /dev/null +++ b/scripts/check-terraform-isolation.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + OVERRIDE_LABEL, + classifyChangedFiles, + mayAccompanyTerraform, +} from "./check-terraform-isolation.mjs"; + +const SCRIPT = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "check-terraform-isolation.mjs", +); + +function runGate(files, env = {}) { + return spawnSync(process.execPath, [SCRIPT, "--stdin"], { + input: `${files.join("\n")}\n`, + encoding: "utf8", + env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env }, + }); +} + +test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => { + for (const file of [ + "terraform/live/dev/main.tf", + "terraform/live/modules/environment-owned/main.tf", + "terraform/README.md", + "README.md", + "docs/adr/0003-terraform.md", + "scripts/check-terraform-import-plan.py", + "scripts/terraform_import_plan_resources.py", + "scripts/test-terraform-import-plan-check.py", + "scripts/terraform-validate.mjs", + "scripts/check-terraform-isolation.mjs", + ]) { + assert.equal(mayAccompanyTerraform(file), true, file); + } +}); + +test("application, workflow, CDK, and dependency files count as application changes", () => { + for (const file of [ + "src/App.tsx", + "public/favicon.ico", + "index.html", + "package.json", + "package-lock.json", + ".env.production", + "vite.config.ts", + ".github/workflows/deploy.yml", + "infra/cdk/lib/frontend-stack.ts", + "scripts/deploy-web.sh", + "scripts/governance-check.mjs", + "e2e/login.spec.ts", + ]) { + assert.equal(mayAccompanyTerraform(file), false, file); + } +}); + +test("terraform-only and application-only changes are not mixed", () => { + assert.equal( + classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed, + false, + ); + assert.equal( + classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed, + false, + ); + assert.equal(classifyChangedFiles([]).mixed, false); +}); + +test("terraform plus application is mixed and lists the offending files", () => { + const result = classifyChangedFiles([ + "terraform/live/dev/main.tf", + "src/App.tsx", + "README.md", + " ", + "src/App.tsx", + ]); + assert.equal(result.mixed, true); + assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]); + assert.deepEqual(result.application, ["src/App.tsx"]); +}); + +test("CLI exits 1 on a mixed change and 0 when isolated", () => { + const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]); + assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr); + assert.match(mixed.stdout, /FAIL/); + assert.match(mixed.stdout, /src\/App\.tsx/); + + const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]); + assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr); + assert.match(isolated.stdout, /PASS/); +}); + +test("CLI override downgrades a mixed change to a warning that names the label", () => { + const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], { + TERRAFORM_ISOLATION_OVERRIDE: "true", + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /WARNING/); + assert.match(result.stdout, new RegExp(OVERRIDE_LABEL)); + + const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], { + TERRAFORM_ISOLATION_OVERRIDE: "yes", + }); + assert.equal(notTrue.status, 1); +}); + +test("CLI refuses to run without a base ref or --stdin", () => { + const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" }); + assert.notEqual(result.status, 0); +}); diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 8cabc70f..f777ce09 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -17,6 +17,14 @@ const MAINTAINABILITY_RULES = [ const GOVERNED_ROOTS = ["src/", "config/"]; const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//; const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/; +// Repository-level gates that run after the source gates. Each is an npm +// script so it can also be run on its own. +const REPOSITORY_GATES = [ + ["Terraform import-plan contract", "test:terraform-import-plan"], + ["Terraform isolation gate", "test:terraform-isolation"], + ["Terraform formatting and validation", "test:terraform"], + ["CDK build, tests, and synth", "test:infra"], +]; function isGoverned(relativePath) { return ( @@ -194,6 +202,20 @@ function plural(count, word) { return `${count} ${word}${count === 1 ? "" : "s"}`; } +function runRepositoryGate(label, script) { + // Reuse the npm that launched us when available (matches its version and + // config); fall back to PATH for direct `node scripts/governance-check.mjs`. + const npmCli = process.env.npm_execpath; + const executable = npmCli ? process.execPath : "npm"; + const args = npmCli ? [npmCli, "run", script] : ["run", script]; + const result = spawnSync(executable, args, { + cwd: ROOT, + encoding: "utf8", + stdio: "inherit", + }); + return { label, status: result.status, error: result.error }; +} + function main() { const failures = []; const baseRef = resolveBaseRef(); @@ -281,6 +303,17 @@ function main() { } } + for (const [label, script] of REPOSITORY_GATES) { + console.log("─".repeat(64)); + console.log(`${label}: npm run ${script}`); + const gate = runRepositoryGate(label, script); + if (gate.error) { + failures.push(`${label}: could not start: ${gate.error.message}`); + } else if (gate.status !== 0) { + failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`); + } + } + console.log("─".repeat(64)); if (failures.length > 0) { console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`); diff --git a/scripts/terraform-validate.mjs b/scripts/terraform-validate.mjs new file mode 100644 index 00000000..59ee4140 --- /dev/null +++ b/scripts/terraform-validate.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const TERRAFORM = process.env.TERRAFORM_BIN || "terraform"; +// Only dev has a live root. Staging adoption (SH-287) adds its own root here. +const ENVIRONMENTS = ["dev"]; +const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment)); + +function run(args, cwd = ROOT) { + const result = spawnSync(TERRAFORM, args, { + cwd, + encoding: "utf8", + stdio: "inherit", + }); + if (result.error) { + throw new Error(`could not start Terraform: ${result.error.message}`, { + cause: result.error, + }); + } + if (result.status !== 0) { + throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`); + } +} + +run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]); +for (const root of ROOTS) { + // -backend=false never touches HCP state; -lockfile=readonly refuses to + // silently rewrite the committed provider lock. + run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root); + run(["validate", "-no-color"], root); +} + +console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`); From b71c0ad87b9194f1a29a58df79333553ad3fc099 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:15:14 -0400 Subject: [PATCH 5/8] docs: document the dev Terraform adoption runbook and gates Two-phase runbook, ownership boundary, workspace invariants, rollback per phase, and operational rules in terraform/README.md; CDK adoption mode and the retired cd-cdk path in infra/cdk/README.md; gate matrix and deployment section updates. --- QUALITY_GATES.md | 30 ++++++++++-- README.md | 121 +++++++++++++++++++++++++++++------------------ 2 files changed, 100 insertions(+), 51 deletions(-) diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index b28babd0..78b5dcec 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -7,7 +7,10 @@ npm run verify ``` `verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) → -`test` (`vitest run`) → `governance`. A task is not done until this is green. +`test` (`vitest run`) → `governance`. Governance also runs the repository +gates: the Terraform import-plan checker tests, the Terraform isolation gate +tests, Terraform formatting and validation, and the CDK build, template tests, +and synthesis. A task is not done until this is green. ## Gate matrix @@ -23,6 +26,11 @@ npm run verify | Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | | Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | | Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | +| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | +| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier | +| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` | +| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes | +| Terraform/app change isolation | `.github/workflows/terraform-isolation.yaml` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | ## No-false-pass guarantees @@ -36,6 +44,14 @@ npm run verify - **Changed-file maintainability fails closed without a valid base** — in CI the base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before` (push). An absent or unresolvable base is a failure, not a pass. +- **Terraform gates never touch live state** — `terraform init -backend=false +-lockfile=readonly` and `validate` run offline; the plan checker is tested + against synthetic plan JSON. Real import and controlled-update plans from HCP + are migration evidence reviewed by a human before an approved apply + (`terraform/README.md`). +- **The isolation gate re-evaluates on label changes** — the + `terraform-isolation-override` label is the only way to merge a mixed + Terraform/application PR, and the gate logs the override on the run. ## Where the gates run @@ -44,11 +60,17 @@ npm run verify - **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs format/lint/build/tests, **and** a repo-owned `governance` job runs - `npm run verify` so the maintainability ratchets are guaranteed from this - repository regardless of the reusable workflow. + `npm run verify` (with Terraform 1.16.0 installed) so the maintainability + ratchets and repository gates are guaranteed from this repository regardless + of the reusable workflow. +- **CI ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):** + on every PR (including label events), fails when `terraform/**` and + application code change together. ## Toolchain pin Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use `corepack npm …` if your default `npm` is older). The lockfile is -`package-lock.json` v3; install with `npm ci`. +`package-lock.json` v3; install with `npm ci`. Governance also needs +`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and +`python3` (3.10+) on `PATH`. diff --git a/README.md b/README.md index 51fc21d2..1fc10a6c 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,15 @@ graph LR CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev] CF -.->|viewer-request fn| FN[SPA rewrite → /index.html] U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API] - GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev] - ROLE -->|cdk deploy + s3 sync + invalidation| S3 + GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev] + ROLE -->|s3 sync + invalidation| S3 + TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3 ``` +Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see +[`terraform/README.md`](terraform/README.md) for the phase runbook and the +current ownership state. + Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack Query, React Router (via `@generouted/react-router`), React Hook Form + Zod, Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the @@ -57,12 +62,13 @@ No Lambdas, queues, or databases — this stack is static hosting only. ### Secrets -No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions -repo secret**: +No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role +ARNs are deterministic and pinned in the workflows. The one **GitHub Actions +repo secret** is: -| Secret | Purpose | -| --------------------- | ----------------------------------------------------------------------------------- | -| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow | +| Secret | Purpose | +| ------------------- | ------------------------------------------------------------------ | +| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy | ### Environment variables (build-time, `VITE_*`) @@ -78,7 +84,8 @@ build otherwise. See [`.env.example`](.env.example), [`.env.development`](.env.development), and [`.env.production`](.env.production). CDK context (domain, certificate ARN, hosted zone) lives in -[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags. +[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs +`cdk deploy` with no flags. ## Local Development @@ -95,17 +102,22 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`; override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use the deployed dev API). -| Command | Description | -| ------------------------------------------ | -------------------------------------------------------- | -| `npm run dev` | Start Vite dev server on port 3000 | -| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` | -| `npm run preview` | Preview the production build locally | -| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) | -| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) | -| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) | -| `npm run format` / `npm run format:check` | Prettier (write / check) | -| `npm run governance` | Frontend governance checks (godfile + maintainability) | -| `npm run verify` | **All gates**: format + lint + build + test + governance | +| Command | Description | +| ------------------------------------------ | ------------------------------------------------------------ | +| `npm run dev` | Start Vite dev server on port 3000 | +| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` | +| `npm run preview` | Preview the production build locally | +| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) | +| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) | +| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) | +| `npm run format` / `npm run format:check` | Prettier (write / check) | +| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) | +| `npm run verify` | **All gates**: format + lint + build + test + governance | + +`npm run governance` needs `terraform` and `python3` on `PATH` for the +Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`, +`npm run test:terraform-isolation`) and installs `infra/cdk` for +`npm run test:infra`. Husky + lint-staged run ESLint and Prettier on staged files at commit; commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or @@ -123,66 +135,81 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or a green CI run and an approving review from a code owner (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals. Merged branches are deleted automatically. -- Promotion flow: `feature/* → dev` (auto-deployed and verified on - `dev.seahaven.com`) `→ main` (production promotion — no prod environment - exists yet). +- A PR that changes `terraform/**` may not also change application code + (`.github/workflows/terraform-isolation.yaml`); ship Terraform in its own PR. +- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the + **Deploy dev content** workflow while the Terraform adoption is in progress) + `→ main` (production promotion — no prod environment exists yet). ## Deployment -CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only): +No stored AWS keys — OIDC only. Infrastructure and content deploy separately: - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push - and PRs to `main`/`dev`, calls + and PRs to `main`/`dev`/`staging`, calls `Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24): format check, lint, build, tests; **and** runs a repo-owned `governance` job that calls `npm run verify` so every gate (including the maintainability - ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is - guaranteed from this repository. Conventions and gates are documented under + ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs), + the Terraform gates, and the CDK template tests) is guaranteed from this + repository. Conventions and gates are documented under [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). -- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on - push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which - runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`) - and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, +- **Terraform isolation** + ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)) + — fails a PR that mixes `terraform/**` with application code, so a Terraform + merge never races a content release for the HCP workspace. +- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) + — `workflow_dispatch` on `dev` only while the Terraform adoption is in + progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`, + and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, `aws s3 sync dist/` (hashed assets immutable, `index.html` never cached), - CloudFront invalidation. Both run as the OIDC deploy role. + CloudFront invalidation, then uploads source maps and checks the served + `index.html` matches the build. Push-to-`dev` releases return with the + Terraform content-CD change. +- **Staging content** + ([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml)) + — on push to `staging`, unchanged. +- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence + and the HCP Terraform workspace `shoc-frontend-new-dev` + ([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy` + ([`infra/cdk/README.md`](infra/cdk/README.md)). -One-time provisioning (OIDC provider, CDK bootstrap, first local deploy, -setting `AWS_DEPLOY_ROLE_ARN`) is documented in -[`infra/cdk/README.md`](infra/cdk/README.md). - -Manual deploy (emergency/reference only — needs credentials for the -external-dev AWS account; the normal path is push to `dev`): +Manual content deploy (emergency/reference only — needs credentials for the +external-dev AWS account): ```bash -(cd infra/cdk && npx cdk deploy) -STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh +SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \ + AWS_REGION=us-east-1 bash scripts/deploy-web.sh ``` ## Operations -- **Verify:** open after a green **Deploy** run in - the Actions tab; confirm a deep link (e.g. a work-orders route) loads - directly and API calls succeed. +- **Verify:** open after a green **Deploy dev + content** run in the Actions tab; confirm a deep link (e.g. a work-orders + route) loads directly and API calls succeed. - **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There are no CloudWatch application logs — the stack is static hosting; runtime errors surface in the browser and on the backend API's side. - **Common failure modes:** - _Stale content after deploy_ — the CloudFront invalidation step failed or is still propagating; re-run the Deploy workflow or invalidate `/*` manually. - - _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev` - on this repo; deploys from other branches/repos are rejected by design. + - _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref + on this repo; dispatching the workflow from another branch is rejected by + design. - _Broken API requests after a build_ — `VITE_API_URL` missing the `/api` suffix or carrying the wrong environment's host (it is baked in at build time). - _CORS errors_ — the backend must allow the frontend origin; CloudFront does not proxy `/api`. -- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still - deploys (matches the org's push-time-CD model; gating deploy on CI is known - follow-up work). +- **Dev has no push-triggered deploy during the adoption.** Merging to `dev` + runs CI only; publish through the **Deploy dev content** workflow. Merging a + `terraform/**` change also queues an HCP Terraform run that a human confirms + or discards (see the operational rules in `terraform/README.md`). ## Documentation - Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md) +- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md) - Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md); design system and UI docs under [`docs/`](docs/) From 0bc7e22884ae328d1508b5bfd6e175cfa7ca8269 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:22:43 -0400 Subject: [PATCH 6/8] docs(terraform): mark the isolation override as temporary --- terraform/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/terraform/README.md b/terraform/README.md index 18bd7b2a..1c97c9bf 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -222,7 +222,9 @@ the labels. Push-to-`dev` releases return behind the repository variable 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. + approval record. The override is temporary: a follow-up PR after PR C + removes the label path from the checker and workflow so the gate has no + exception. - **Every Terraform merge produces a VCS run.** A human confirms or discards it before the next content release. Do not leave a pending run on the workspace. From 7ab6fa30e7ba91a4078ab1da6e000949f526440c Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:34:10 -0400 Subject: [PATCH 7/8] fix(terraform): lock provider hashes for linux and darwin platforms --- terraform/README.md | 10 ++++++++++ terraform/live/dev/.terraform.lock.hcl | 3 +++ 2 files changed, 13 insertions(+) diff --git a/terraform/README.md b/terraform/README.md index 1c97c9bf..1f436b17 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -252,6 +252,16 @@ npm run test:infra # CDK build, template tests, synth in both m but never contacts HCP state or plans against AWS. Only HCP runs plan against the account. +The lock file must carry `h1:` hashes for every platform that runs the gate +(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the +provider version, refresh them with: + +```bash +terraform -chdir=terraform/live/dev providers lock \ + -platform=linux_amd64 -platform=linux_arm64 \ + -platform=darwin_amd64 -platform=darwin_arm64 +``` + ## Import plan safety Import mode requires exactly the canonical 13 addresses and AWS types, valid diff --git a/terraform/live/dev/.terraform.lock.hcl b/terraform/live/dev/.terraform.lock.hcl index b3827528..7f171232 100644 --- a/terraform/live/dev/.terraform.lock.hcl +++ b/terraform/live/dev/.terraform.lock.hcl @@ -5,8 +5,11 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.62.0" constraints = "~> 6.57" hashes = [ + "h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=", "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=", "h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=", + "h1:yOSEz5G8b/n5uhFCZ0gbEsKkAQATtVuhXJEXR3OM5qs=", "zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5", "zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd", "zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010", From 8ec91f0dac9b5cac7a4fe634ce509cd7db6c2bec Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:37:27 -0400 Subject: [PATCH 8/8] ci: run the Terraform isolation gate as a job in the CI workflow --- .github/workflows/ci.yaml | 24 +++++++++++++++ .github/workflows/terraform-isolation.yaml | 35 ---------------------- QUALITY_GATES.md | 14 ++++----- README.md | 12 ++++---- infra/cdk/README.md | 3 +- terraform/README.md | 11 +++---- 6 files changed, 44 insertions(+), 55 deletions(-) delete mode 100644 .github/workflows/terraform-isolation.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9a39ad3b..53262ad1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -71,6 +71,30 @@ jobs: env: GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} + terraform-isolation: + # Fails a pull request that changes `terraform/**` together with deployable + # application code (scripts/check-terraform-isolation.mjs). A merge that + # does both queues an HCP VCS run and a content release at the same time, + # and the two race for the workspace lock. The + # `terraform-isolation-override` label is the reviewed exception; it is + # read when the job runs, so re-run this workflow after labeling. + name: Terraform and application changes are isolated + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Check changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }} + run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}" + visual-regression: name: Visual regression runs-on: ubuntu-latest diff --git a/.github/workflows/terraform-isolation.yaml b/.github/workflows/terraform-isolation.yaml deleted file mode 100644 index 14c53730..00000000 --- a/.github/workflows/terraform-isolation.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: Terraform isolation - -# Fails a pull request that changes `terraform/**` together with deployable -# application code (see scripts/check-terraform-isolation.mjs). A merge that -# does both queues an HCP VCS run and a content release at the same time, and -# the two race for the workspace lock. -# -# Runs on label events too, so adding or removing the -# `terraform-isolation-override` label re-evaluates the gate without a push. - -on: - pull_request: - branches: [main, dev, staging] - types: [opened, synchronize, reopened, labeled, unlabeled] - -permissions: - contents: read - -jobs: - terraform-isolation: - name: Terraform and application changes are isolated - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - - name: Check changed files - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }} - run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}" diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 78b5dcec..60ed9970 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -30,7 +30,7 @@ and synthesis. A task is not done until this is green. | Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier | | Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` | | CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes | -| Terraform/app change isolation | `.github/workflows/terraform-isolation.yaml` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | +| Terraform/app change isolation | `ci.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | ## No-false-pass guarantees @@ -49,9 +49,10 @@ and synthesis. A task is not done until this is green. against synthetic plan JSON. Real import and controlled-update plans from HCP are migration evidence reviewed by a human before an approved apply (`terraform/README.md`). -- **The isolation gate re-evaluates on label changes** — the +- **The isolation gate reads the override when it runs** — the `terraform-isolation-override` label is the only way to merge a mixed - Terraform/application PR, and the gate logs the override on the run. + Terraform/application PR, the gate logs the override on the run, and the + workflow must be re-run after the label is added or removed. ## Where the gates run @@ -62,10 +63,9 @@ and synthesis. A task is not done until this is green. format/lint/build/tests, **and** a repo-owned `governance` job runs `npm run verify` (with Terraform 1.16.0 installed) so the maintainability ratchets and repository gates are guaranteed from this repository regardless - of the reusable workflow. -- **CI ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):** - on every PR (including label events), fails when `terraform/**` and - application code change together. + of the reusable workflow. On pull requests the same workflow's + `terraform-isolation` job fails when `terraform/**` and application code + change together. ## Toolchain pin diff --git a/README.md b/README.md index 1fc10a6c..9a4bc04a 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,8 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or a green CI run and an approving review from a code owner (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals. Merged branches are deleted automatically. -- A PR that changes `terraform/**` may not also change application code - (`.github/workflows/terraform-isolation.yaml`); ship Terraform in its own PR. +- A PR that changes `terraform/**` may not also change application code (the + `terraform-isolation` CI job); ship Terraform in its own PR. - Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the **Deploy dev content** workflow while the Terraform adoption is in progress) `→ main` (production promotion — no prod environment exists yet). @@ -156,10 +156,10 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately: [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). -- **Terraform isolation** - ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)) - — fails a PR that mixes `terraform/**` with application code, so a Terraform - merge never races a content release for the HCP workspace. +- **Terraform isolation** (the `terraform-isolation` job in + [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — fails a PR that + mixes `terraform/**` with application code, so a Terraform merge never races + a content release for the HCP workspace. - **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — `workflow_dispatch` on `dev` only while the Terraform adoption is in progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`, diff --git a/infra/cdk/README.md b/infra/cdk/README.md index ba46bf4b..cf7d466f 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -36,8 +36,7 @@ infra/cdk/ test/frontend-stack.test.mjs template assertions for both modes scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation .github/workflows/ - ci.yaml quality gates (lint / build / test / governance) - terraform-isolation.yaml PRs may not mix terraform/** with app code + ci.yaml quality gates (lint / build / test / governance / terraform isolation) deploy.yml dev content publish (workflow_dispatch on dev) deploy-staging.yml standalone staging deploy (push to staging) ``` diff --git a/terraform/README.md b/terraform/README.md index 1f436b17..a20e97cc 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -217,11 +217,12 @@ the labels. Push-to-`dev` releases return behind the repository variable ## 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 + deployable application code. The `terraform-isolation` job in + `.github/workflows/ci.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), then re-run the workflow so the job reads the label. The label is the approval record. The override is temporary: a follow-up PR after PR C removes the label path from the checker and workflow so the gate has no exception.