From 78398482cfc5800c80534cb590fb794e46aa52ee Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:15:09 -0400 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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. From 8b5281d357ae82dcc6e60e66131bf8737123aecc Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:45:49 -0400 Subject: [PATCH 09/16] ci(terraform-isolation): re-evaluate the gate on label changes (#177) * ci(terraform-isolation): re-evaluate the gate on label changes * test(terraform-isolation): lock the ci.yaml label-event contract * fix(terraform-isolation): do not treat terraform markdown as a mixed change * fix(ci): do not skip Frontend checks on isolation label events * ci(terraform-isolation): run label retriggers in a dedicated workflow * fix: apply eslint formatting * fix: apply additional missed eslint formatting --- .github/workflows/ci.yaml | 24 ---------- .github/workflows/terraform-isolation.yaml | 43 +++++++++++++++++ QUALITY_GATES.md | 18 ++++--- README.md | 8 ++-- scripts/check-terraform-isolation.mjs | 13 ++++- scripts/check-terraform-isolation.test.mjs | 56 ++++++++++++++++++++++ terraform/README.md | 16 ++++--- 7 files changed, 135 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/terraform-isolation.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 53262ad1..9a39ad3b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -71,30 +71,6 @@ 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 new file mode 100644 index 00000000..92cc9d38 --- /dev/null +++ b/.github/workflows/terraform-isolation.yaml @@ -0,0 +1,43 @@ +name: Terraform isolation + +# Own workflow so labeled/unlabeled re-evaluate this gate without starting a +# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events +# would report required checks as success and could merge a failing SHA. + +on: + pull_request: + branches: [main, dev, staging] + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + +permissions: + contents: read + +jobs: + terraform-isolation: + # Fails a pull request that changes Terraform infrastructure 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. This + # job is unconditional so adding or removing that label always reads the + # current label set; a previous green check does not survive removal. + 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 60ed9970..64d5292e 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 | `ci.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | +| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | ## No-false-pass guarantees @@ -49,10 +49,13 @@ 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 reads the override when it runs** — the +- **The isolation gate re-evaluates on label changes** — the `terraform-isolation-override` label is the only way to merge a mixed - 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. + Terraform/application PR. `.github/workflows/terraform-isolation.yaml` + runs `terraform-isolation` on `labeled` and `unlabeled` as well as the + default pull-request types, so adding or removing the label re-checks + the current labels without starting a new Frontend checks run. Removing + the label fails a mixed PR that had previously passed with the override. ## Where the gates run @@ -63,9 +66,10 @@ 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. On pull requests the same workflow's - `terraform-isolation` job fails when `terraform/**` and application code - change together. + of the reusable workflow. +- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):** + on pull requests, fails when Terraform infrastructure and application code + change together. Label add/remove re-runs only this workflow. ## Toolchain pin diff --git a/README.md b/README.md index 9a4bc04a..1abeac15 100644 --- a/README.md +++ b/README.md @@ -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** (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. +- **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`, diff --git a/scripts/check-terraform-isolation.mjs b/scripts/check-terraform-isolation.mjs index 7b28c149..484b6a50 100644 --- a/scripts/check-terraform-isolation.mjs +++ b/scripts/check-terraform-isolation.mjs @@ -17,7 +17,9 @@ // 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. +// together with the workflow that consumes them. The checker has no memory of +// a previous pass: the same mixed diff fails again as soon as the override +// env is unset (label removal). import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; @@ -31,6 +33,13 @@ export function isTerraformPath(file) { return file.startsWith("terraform/"); } +// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers +// are terraform/live/dev/** and terraform/live/modules/**), so it is not a +// Terraform change for the mixed-PR check. +export function isTerraformInfrastructurePath(file) { + return isTerraformPath(file) && !file.endsWith(".md"); +} + export function mayAccompanyTerraform(file) { if (isTerraformPath(file)) return true; if (file.endsWith(".md")) return true; @@ -45,7 +54,7 @@ export function mayAccompanyTerraform(file) { */ export function classifyChangedFiles(files) { const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort(); - const terraform = unique.filter(isTerraformPath); + const terraform = unique.filter(isTerraformInfrastructurePath); const application = unique.filter((file) => !mayAccompanyTerraform(file)); return { terraform, diff --git a/scripts/check-terraform-isolation.test.mjs b/scripts/check-terraform-isolation.test.mjs index 24c6c87d..45c18fc8 100644 --- a/scripts/check-terraform-isolation.test.mjs +++ b/scripts/check-terraform-isolation.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; @@ -7,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { OVERRIDE_LABEL, classifyChangedFiles, + isTerraformInfrastructurePath, mayAccompanyTerraform, } from "./check-terraform-isolation.mjs"; @@ -71,6 +73,18 @@ test("terraform-only and application-only changes are not mixed", () => { assert.equal(classifyChangedFiles([]).mixed, false); }); +test("terraform documentation does not mix with application or workflow changes", () => { + assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false); + assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true); + assert.equal( + classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed, + false, + ); + const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]); + assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr); + assert.match(docsOnly.stdout, /PASS/); +}); + test("terraform plus application is mixed and lists the offending files", () => { const result = classifyChangedFiles([ "terraform/live/dev/main.tf", @@ -109,7 +123,49 @@ test("CLI override downgrades a mixed change to a warning that names the label", assert.equal(notTrue.status, 1); }); +test("removing the override fails a mixed change that was previously green", () => { + const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"]; + const previouslyGreen = runGate(files, { + TERRAFORM_ISOLATION_OVERRIDE: "true", + }); + assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr); + assert.match(previouslyGreen.stdout, /WARNING/); + + // CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is + // the string "false" after the label is removed. A stale green check must + // not survive that. + const afterLabelRemoved = runGate(files, { + TERRAFORM_ISOLATION_OVERRIDE: "false", + }); + assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr); + assert.match(afterLabelRemoved.stdout, /FAIL/); + assert.match(afterLabelRemoved.stdout, /deploy\.yml/); +}); + test("CLI refuses to run without a base ref or --stdin", () => { const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" }); assert.notEqual(result.status, 0); }); + +test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => { + const workflows = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ".github/workflows", + ); + const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8"); + const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8"); + + for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) { + assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType); + } + + assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m); + assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m); + assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m); + assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/); + + assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m); + assert.match(isolationYaml, /name: Terraform and application changes are isolated/); + assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/); +}); diff --git a/terraform/README.md b/terraform/README.md index a20e97cc..e0b2b4ab 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -218,14 +218,18 @@ the labels. Push-to-`dev` releases return behind the repository variable - **Terraform-only PRs.** A PR that changes `terraform/**` may not change deployable application code. The `terraform-isolation` job in - `.github/workflows/ci.yaml` enforces this; documentation and the + `.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), 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. + Terraform variables together with the workflow that consumes them (PR C). + Adding or removing that label re-runs only that workflow against the labels + currently on the PR; Frontend checks does not start a new run. Removing the + label fails a mixed PR that had previously passed with the override, so a + stale green check cannot merge. Markdown under `terraform/` does not count as a Terraform + change for this gate; it does not match the workspace trigger patterns. + The label is the approval record. The override is temporary: + a follow-up PR after PR C removes the label path from the checker and + workflow so the gate has no exception. - **Every Terraform merge produces a VCS run.** A human confirms or discards it before the next content release. Do not leave a pending run on the workspace. From f532aa6059bfef1bfbda928f854462609879cd1b Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 11 Sep 2026 11:22:28 -0400 Subject: [PATCH 10/16] feat(terraform): complete dev environment adoption (SH-300) --- scripts/check-terraform-import-plan.py | 129 +---------- scripts/terraform_import_plan_resources.py | 1 - scripts/test-terraform-import-plan-check.py | 215 +++++------------- terraform/README.md | 43 ++-- terraform/live/dev/main.tf | 5 +- .../live/modules/environment-owned/main.tf | 92 +++----- .../modules/environment-owned/variables.tf | 2 +- 7 files changed, 110 insertions(+), 377 deletions(-) diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py index 28bd0dce..1e65685f 100644 --- a/scripts/check-terraform-import-plan.py +++ b/scripts/check-terraform-import-plan.py @@ -18,17 +18,11 @@ from terraform_import_plan_resources import ( BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site" BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site" -DEPLOY_POLICY_ADDRESS = ( - "module.environment_owned.aws_iam_role_policy.github_deploy" -) DISTRIBUTION_ADDRESS = ( "module.environment_owned.aws_cloudfront_distribution.site" ) ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy" -TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - { - BUCKET_POLICY_ADDRESS, - DEPLOY_POLICY_ADDRESS, -} +TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS} OWNERSHIP_TAGS = { "Environment": None, "ManagedBy": "terraform", @@ -255,113 +249,6 @@ def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, ) -def _expected_pre_adoption_deploy_policy( - environment: str, - distribution_id: str, -) -> dict[str, Any]: - config = ENVIRONMENT_CONFIG[environment] - bucket_arn = f"arn:aws:s3:::{config['bucket_name']}" - distribution_arn = ( - f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}" - ) - statements: list[dict[str, Any]] = [] - if environment == "dev": - statements.append( - { - "Sid": "AssumeCdkBootstrapRoles", - "Effect": "Allow", - "Action": "sts:AssumeRole", - "Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", - } - ) - statements.extend( - [ - { - "Sid": "DescribeStack", - "Effect": "Allow", - "Action": "cloudformation:DescribeStacks", - "Resource": ( - "arn:aws:cloudformation:us-east-1:396287094661:stack/" - f"{config['cloudformation_stack_name']}/*" - ), - }, - { - "Effect": "Allow", - "Action": [ - "s3:Abort*", - "s3:DeleteObject*", - "s3:GetBucket*", - "s3:GetObject*", - "s3:List*", - "s3:PutObject", - "s3:PutObjectLegalHold", - "s3:PutObjectRetention", - "s3:PutObjectTagging", - "s3:PutObjectVersionTagging", - ], - "Resource": [bucket_arn, f"{bucket_arn}/*"], - }, - { - "Sid": "InvalidateDistribution", - "Effect": "Allow", - "Action": [ - "cloudfront:CreateInvalidation", - "cloudfront:GetInvalidation", - ], - "Resource": distribution_arn, - }, - ] - ) - return _canonical({"Version": "2012-10-17", "Statement": statements}) - - -def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]: - bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] - bucket_arn = f"arn:aws:s3:::{bucket}" - distribution_arn = ( - f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}" - ) - return _canonical( - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "ReadDeploymentBucket", - "Effect": "Allow", - "Action": [ - "s3:GetBucketLocation", - "s3:GetBucketVersioning", - "s3:ListBucket", - "s3:ListBucketVersions", - ], - "Resource": bucket_arn, - }, - { - "Sid": "PublishAndRollbackSiteObjects", - "Effect": "Allow", - "Action": [ - "s3:DeleteObject", - "s3:DeleteObjectVersion", - "s3:GetObject", - "s3:GetObjectVersion", - "s3:PutObject", - ], - "Resource": f"{bucket_arn}/*", - }, - { - "Sid": "InvalidateDistribution", - "Effect": "Allow", - "Action": [ - "cloudfront:CreateInvalidation", - "cloudfront:GetInvalidation", - ], - "Resource": distribution_arn, - }, - ], - } - ) - - def _validate_tag_update( address: str, before: dict[str, Any], @@ -432,16 +319,10 @@ def _validate_policy_update( f"{address}: cannot verify policy without the pinned distribution ID" ) return violations - expected_before = ( - _expected_pre_adoption_bucket_policy(environment, distribution_id) - if address == BUCKET_POLICY_ADDRESS - else _expected_pre_adoption_deploy_policy(environment, distribution_id) - ) - expected_after = ( - _expected_bucket_policy(environment, distribution_id) - if address == BUCKET_POLICY_ADDRESS - else _expected_deploy_policy(environment, distribution_id) + expected_before = _expected_pre_adoption_bucket_policy( + environment, distribution_id ) + expected_after = _expected_bucket_policy(environment, distribution_id) if before_policy is not None and before_policy != expected_before: violations.append(f"{address}: pre-adoption policy semantics are not exact") if after_policy is not None and after_policy != expected_after: @@ -467,7 +348,7 @@ def _validate_controlled_update( return [*violations, f"{address}: controlled update requires before/after objects"] if address in TAG_UPDATE_ADDRESSES: violations.extend(_validate_tag_update(address, before, after, environment)) - elif address in {BUCKET_POLICY_ADDRESS, DEPLOY_POLICY_ADDRESS}: + elif address == BUCKET_POLICY_ADDRESS: violations.extend( _validate_policy_update( address, diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py index 5d558b02..a5b6db08 100644 --- a/scripts/terraform_import_plan_resources.py +++ b/scripts/terraform_import_plan_resources.py @@ -45,7 +45,6 @@ CONTROLLED_UPDATE_ADDRESSES = frozenset( "module.environment_owned.aws_cloudfront_distribution.site", "module.environment_owned.aws_cloudfront_function.spa_rewrite", "module.environment_owned.aws_iam_role.github_deploy", - "module.environment_owned.aws_iam_role_policy.github_deploy", } ) diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py index 5046ccad..c122ae19 100644 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -27,7 +27,7 @@ BUCKET = "module.environment_owned.aws_s3_bucket.site" DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy" ROLE = "module.environment_owned.aws_iam_role.github_deploy" DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site" -TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY} +TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY} def import_id(environment: str, address: str) -> str: @@ -111,110 +111,6 @@ def bucket_policy(environment: str) -> dict[str, Any]: } -def pre_adoption_deploy_policy(environment: str) -> dict[str, Any]: - config = ENVIRONMENT_CONFIG[environment] - bucket_arn = f"arn:aws:s3:::{config['bucket_name']}" - distribution_arn = ( - "arn:aws:cloudfront::396287094661:distribution/" - f"{distribution_id(environment)}" - ) - statements: list[dict[str, Any]] = [] - if environment == "dev": - statements.append( - { - "Sid": "AssumeCdkBootstrapRoles", - "Effect": "Allow", - "Action": "sts:AssumeRole", - "Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", - } - ) - statements.extend( - [ - { - "Sid": "DescribeStack", - "Effect": "Allow", - "Action": "cloudformation:DescribeStacks", - "Resource": ( - "arn:aws:cloudformation:us-east-1:396287094661:stack/" - f"{config['cloudformation_stack_name']}/*" - ), - }, - { - "Effect": "Allow", - "Action": [ - "s3:Abort*", - "s3:DeleteObject*", - "s3:GetBucket*", - "s3:GetObject*", - "s3:List*", - "s3:PutObject", - "s3:PutObjectLegalHold", - "s3:PutObjectRetention", - "s3:PutObjectTagging", - "s3:PutObjectVersionTagging", - ], - "Resource": [bucket_arn, f"{bucket_arn}/*"], - }, - { - "Sid": "InvalidateDistribution", - "Effect": "Allow", - "Action": [ - "cloudfront:CreateInvalidation", - "cloudfront:GetInvalidation", - ], - "Resource": distribution_arn, - }, - ] - ) - return {"Version": "2012-10-17", "Statement": statements} - - -def deploy_policy(environment: str) -> dict[str, Any]: - bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] - bucket_arn = f"arn:aws:s3:::{bucket}" - distribution_arn = ( - "arn:aws:cloudfront::396287094661:distribution/" - f"{distribution_id(environment)}" - ) - return { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "ReadDeploymentBucket", - "Effect": "Allow", - "Action": [ - "s3:GetBucketLocation", - "s3:GetBucketVersioning", - "s3:ListBucket", - "s3:ListBucketVersions", - ], - "Resource": bucket_arn, - }, - { - "Sid": "PublishAndRollbackSiteObjects", - "Effect": "Allow", - "Action": [ - "s3:DeleteObject", - "s3:DeleteObjectVersion", - "s3:GetObject", - "s3:GetObjectVersion", - "s3:PutObject", - ], - "Resource": f"{bucket_arn}/*", - }, - { - "Sid": "InvalidateDistribution", - "Effect": "Allow", - "Action": [ - "cloudfront:CreateInvalidation", - "cloudfront:GetInvalidation", - ], - "Resource": distribution_arn, - }, - ], - } - - def tag_change(environment: str, address: str) -> dict[str, Any]: manager = { "HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"] @@ -250,20 +146,12 @@ def tag_change(environment: str, address: str) -> dict[str, Any]: def policy_change(environment: str, address: str) -> dict[str, Any]: - before_policy = ( - pre_adoption_bucket_policy(environment) - if address == BUCKET_POLICY - else pre_adoption_deploy_policy(environment) - ) - after_policy = ( - bucket_policy(environment) - if address == BUCKET_POLICY - else deploy_policy(environment) - ) + if address != BUCKET_POLICY: + raise AssertionError(f"{address} is not a reviewed policy update") return { "actions": ["update"], - "before": {"policy": json.dumps(before_policy)}, - "after": {"policy": json.dumps(after_policy)}, + "before": {"policy": json.dumps(pre_adoption_bucket_policy(environment))}, + "after": {"policy": json.dumps(bucket_policy(environment))}, } @@ -395,9 +283,9 @@ class ImportPlanCheckerTests(unittest.TestCase): ) self.assertEqual(["dev"], live_roots) - def test_dev_root_pins_import_phase_in_code(self) -> None: + def test_dev_root_pins_adoption_complete_in_code(self) -> None: source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8") - self.assertRegex(source, r"\n\s+adoption_complete\s+= false\n") + self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n") self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete") self.assertNotIn('variable "adoption_complete"', source) for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"): @@ -564,54 +452,53 @@ class ImportPlanCheckerTests(unittest.TestCase): with self.subTest(mutation=mutation): self.assert_fails(plan, "dev", BUCKET_POLICY) - def test_deploy_policy_rejects_resource_action_and_extra_statement(self) -> None: - for mutation in ("resource", "action", "extra"): - plan = make_plan( - "staging", - mode="controlled", - controlled_updates={DEPLOY_POLICY}, - ) - policy = copy.deepcopy(deploy_policy("staging")) - if mutation == "resource": - policy["Statement"][0]["Resource"] = "*" - elif mutation == "action": - policy["Statement"][0]["Action"].append("iam:PassRole") - else: - policy["Statement"].append( - { - "Sid": "Extra", - "Effect": "Allow", - "Action": "s3:*", - "Resource": "*", - } - ) - resource(plan, DEPLOY_POLICY)["change"]["after"]["policy"] = json.dumps( - policy - ) - with self.subTest(mutation=mutation): - self.assert_fails(plan, "staging", DEPLOY_POLICY) + def test_github_deploy_policy_stays_byte_identical(self) -> None: + source = ( + REPOSITORY / "terraform/live/modules/environment-owned/main.tf" + ).read_text(encoding="utf-8") + document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1] + document = document.split("resource ", 1)[0] + self.assertNotIn("var.adoption_complete", document) + self.assertIn("AssumeCdkBootstrapRoles", document) + self.assertIn("DescribeStack", document) + self.assertNotIn("ReadDeploymentBucket", document) + self.assertNotIn("PublishAndRollbackSiteObjects", document) + self.assertNotIn( + "module.environment_owned.aws_iam_role_policy.github_deploy", + CONTROLLED_UPDATE_ADDRESSES, + ) + + def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None: + plan = make_plan("dev", mode="controlled", controlled_updates=set()) + self.assert_fails(plan, "dev", DEPLOY_POLICY) + plan = make_plan("dev", mode="controlled", controlled_updates=set()) + resource(plan, DEPLOY_POLICY)["change"] = { + "actions": ["update"], + "before": {"policy": "{}"}, + "after": {"policy": '{"Version":"2012-10-17"}'}, + } + self.assert_fails(plan, "dev", DEPLOY_POLICY) def test_policy_updates_require_exact_pre_adoption_state(self) -> None: for environment in REQUIRED_RESOURCES: - for address in (BUCKET_POLICY, DEPLOY_POLICY): - plan = make_plan( - environment, - mode="controlled", - controlled_updates={address}, - ) - change = resource(plan, address)["change"] - before = json.loads(change["before"]["policy"]) - before["Statement"].append( - { - "Sid": "UnexpectedDrift", - "Effect": "Deny", - "Action": "*", - "Resource": "*", - } - ) - change["before"]["policy"] = json.dumps(before) - with self.subTest(environment=environment, address=address): - self.assert_fails(plan, environment, address) + plan = make_plan( + environment, + mode="controlled", + controlled_updates={BUCKET_POLICY}, + ) + change = resource(plan, BUCKET_POLICY)["change"] + before = json.loads(change["before"]["policy"]) + before["Statement"].append( + { + "Sid": "UnexpectedDrift", + "Effect": "Deny", + "Action": "*", + "Resource": "*", + } + ) + change["before"]["policy"] = json.dumps(before) + with self.subTest(environment=environment): + self.assert_fails(plan, environment, BUCKET_POLICY) def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None: for field, value in ( diff --git a/terraform/README.md b/terraform/README.md index e0b2b4ab..190dd53b 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -4,11 +4,11 @@ This tree adopts the existing Sea Haven SHOC frontend dev hosting resources into HCP Terraform without recreating them. It mirrors the backend adoption (`shoc-backend` #94, #98, #99, #102) and lands in three PRs: -| PR | Branch | Change | -| --- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| A | `feature/frontend-terraform-adoption` | This PR. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. | -| B | `feature/terraform-dev-adoption` | `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant, CloudFormation detaches. | -| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. | +| PR | Branch | Change | +| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| A | `feature/frontend-terraform-adoption` | Merged. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. | +| B | `feature/terraform-dev-adoption` | This PR. `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. | +| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. | Creating these files, formatting them, initializing with `-backend=false`, and validating them does not authorize an AWS, HCP Terraform, GitHub, @@ -45,9 +45,8 @@ before the first release after any Terraform merge: `>= 1.9.0, < 2.0.0`; CI validates with `1.16.0`). - Dynamic AWS credentials only: environment variables `TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and - `TFC_AWS_APPLY_ROLE_ARN` pointing at the `seahaven-org-baseline` roles - `hcptf-shoc-frontend-new-dev-plan` and `hcptf-shoc-frontend-new-dev`. No - access keys. + `TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan` + and `hcptf-shoc-frontend-new-dev`. No access keys. - **No** `adoption_complete` workspace variable. The dev root pins it in code (`local.adoption_complete`) so the value under review is the value that applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable` @@ -86,7 +85,6 @@ The following remain outside state: - `CDKToolkit` resources and CDK metadata - the S3 auto-delete custom resource, its provider Lambda and role - the HCP plan/apply roles and the deploy-role permissions boundary - (`seahaven-org-baseline` owns them) ## Exact live inventory (dev) @@ -130,7 +128,7 @@ If read-back after that deploy differs from the root in any other way, update the root to the observed value and prove a zero-change import plan. Do not approve drift through the controlled-update checker. -## Phase 1: import-first adoption (this PR) +## Phase 1: import-first adoption (merged) Each step is gated. State the impact, get the go, act, read back, record. @@ -177,7 +175,7 @@ Each step is gated. State the impact, get the go, act, read back, record. After Phase 1 CloudFormation still owns every resource. Terraform holds state for them and nothing else. -## Phase 2: controlled ownership transfer (PR B) +## Phase 2: controlled ownership transfer (this PR) PR B pins `adoption_complete = true`. The controlled apply may update only: @@ -189,15 +187,19 @@ PR B pins `adoption_complete = true`. The controlled apply may update only: - `module.environment_owned.aws_iam_role.github_deploy` (tags) The OAC, both Route 53 records, and the deploy inline policy must be no-op. -PR B keeps the post-adoption inline policy byte-identical to live so the -policy address does not appear in the plan. Run the checker with one -`--allow-update-address` per updating address; it rejects unused allowlist -entries, unknown values, and replacements. +PR B keeps the GitHub deploy inline policy byte-identical to live so +`aws_iam_role_policy.github_deploy` does not appear in the plan. Run the +checker with one `--allow-update-address` per updating address; it rejects +unused allowlist entries, unknown values, and replacements: -Dependency: `hcptf-shoc-frontend-new-dev` currently lacks -`cloudfront:UpdateDistribution` and `cloudfront:UpdateFunction`. Codify the -expansion in `seahaven-org-baseline` (cross-family plus security review) and -deploy it before the controlled apply. +```bash +python3 scripts/check-terraform-import-plan.py plan.json --environment dev \ + --allow-update-address module.environment_owned.aws_s3_bucket.site \ + --allow-update-address module.environment_owned.aws_s3_bucket_policy.site \ + --allow-update-address module.environment_owned.aws_cloudfront_distribution.site \ + --allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \ + --allow-update-address module.environment_owned.aws_iam_role.github_deploy +``` After the apply and a no-op plan, deploy the same reviewed CDK SHA with `--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the @@ -205,6 +207,9 @@ After the apply and a no-op plan, deploy the same reviewed CDK SHA with `ManageSiteInfrastructure=true` again after that. See [`infra/cdk/README.md`](../infra/cdk/README.md). +Confirm `dev.seahaven.com` still serves and that a manual `workflow_dispatch` +of `deploy.yml` can still upload with the unchanged GitHub content policy. + ## Phase 3: content CD through Terraform (PR C) Summary only; PR C carries the full design. GitHub builds and uploads to an diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf index 37ed5472..f11f75d4 100644 --- a/terraform/live/dev/main.tf +++ b/terraform/live/dev/main.tf @@ -1,7 +1,6 @@ locals { - # Import-first phase. Pinned in code, never a workspace variable: the - # controlled ownership transfer flips this to true in its own reviewed PR. - adoption_complete = false + # Controlled ownership transfer. Pinned in code, never a workspace variable. + adoption_complete = true environment = "dev" workspace_name = "shoc-frontend-new-dev" diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index 3ddb5fdb..6a0e2a61 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -109,8 +109,11 @@ data "aws_iam_policy_document" "github_deploy_assume" { } data "aws_iam_policy_document" "github_deploy" { + # Byte-identical to the live GitHub content policy through Phase 2 so + # aws_iam_role_policy.github_deploy stays no-op. Phase 3 replaces this + # with the release-prefix policy. dynamic "statement" { - for_each = !var.adoption_complete && var.environment == "dev" ? [1] : [] + for_each = var.environment == "dev" ? [1] : [] content { sid = "AssumeCdkBootstrapRoles" @@ -120,72 +123,31 @@ data "aws_iam_policy_document" "github_deploy" { } } - dynamic "statement" { - for_each = var.adoption_complete ? [] : [1] - - content { - sid = "DescribeStack" - effect = "Allow" - actions = ["cloudformation:DescribeStacks"] - resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"] - } + statement { + sid = "DescribeStack" + effect = "Allow" + actions = ["cloudformation:DescribeStacks"] + resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"] } - dynamic "statement" { - for_each = var.adoption_complete ? [] : [1] - - content { - effect = "Allow" - actions = [ - "s3:Abort*", - "s3:DeleteObject*", - "s3:GetBucket*", - "s3:GetObject*", - "s3:List*", - "s3:PutObject", - "s3:PutObjectLegalHold", - "s3:PutObjectRetention", - "s3:PutObjectTagging", - "s3:PutObjectVersionTagging", - ] - resources = [ - local.bucket_arn, - "${local.bucket_arn}/*", - ] - } - } - - dynamic "statement" { - for_each = var.adoption_complete ? [1] : [] - - content { - sid = "ReadDeploymentBucket" - effect = "Allow" - actions = [ - "s3:GetBucketLocation", - "s3:GetBucketVersioning", - "s3:ListBucket", - "s3:ListBucketVersions", - ] - resources = [local.bucket_arn] - } - } - - dynamic "statement" { - for_each = var.adoption_complete ? [1] : [] - - content { - sid = "PublishAndRollbackSiteObjects" - effect = "Allow" - actions = [ - "s3:DeleteObject", - "s3:DeleteObjectVersion", - "s3:GetObject", - "s3:GetObjectVersion", - "s3:PutObject", - ] - resources = ["${local.bucket_arn}/*"] - } + statement { + effect = "Allow" + actions = [ + "s3:Abort*", + "s3:DeleteObject*", + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + ] + resources = [ + local.bucket_arn, + "${local.bucket_arn}/*", + ] } statement { diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index 6a135679..69434ebd 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -10,7 +10,7 @@ variable "environment" { variable "adoption_complete" { type = bool - description = "Switches only ownership tags and the deploy policy to their adopted values." + description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy. The GitHub deploy inline policy stays byte-identical to live until the content-CD PR." default = false } From b24e6f3b9ae4a5c71b1cd9272fb8afec04e64a52 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:21:00 -0400 Subject: [PATCH 11/16] fix(cdk): keep the auto-delete Lambda description off the site bucket (SH-300) (#179) * fix(cdk): keep the auto-delete Lambda description off the site bucket * style(cdk): format the auto-delete Lambda path check --- infra/cdk/lib/frontend-stack.ts | 18 +++++++++++++++++- infra/cdk/test/frontend-stack.test.mjs | 7 +++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index bce19e98..88ccb6d9 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -369,9 +369,25 @@ export class FrontendStack extends Stack { node.cfnResourceType === "AWS::IAM::Role" && node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"), ); - if (!rolePolicy || !autoDeleteProviderRole) { + const autoDeleteProviderHandler = this.node + .findAll() + .find( + (node): node is CfnResource => + node instanceof CfnResource && + node.cfnResourceType === "AWS::Lambda::Function" && + node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler"), + ); + if (!rolePolicy || !autoDeleteProviderRole || !autoDeleteProviderHandler) { throw new Error("Terraform adoption outputs require deploy and auto-delete roles."); } + // The provider Lambda stays unconditioned so it remains after + // ManageSiteInfrastructure=false. Its generated Description Refs the + // conditioned bucket and CloudFormation rejects that when the condition + // is false. Keep a static description. + autoDeleteProviderHandler.addPropertyOverride( + "Description", + "Lambda function for auto-deleting objects in the site S3 bucket.", + ); const recordName = domainNames[0]; gateOutput( diff --git a/infra/cdk/test/frontend-stack.test.mjs b/infra/cdk/test/frontend-stack.test.mjs index f285557d..8bcd607c 100644 --- a/infra/cdk/test/frontend-stack.test.mjs +++ b/infra/cdk/test/frontend-stack.test.mjs @@ -189,6 +189,13 @@ test("adoption mode requires ManageSiteInfrastructure and gates transferred reso for (const [outputName, output] of Object.entries(template.Outputs)) { assert.equal(output.Condition, CONDITION, outputName); } + + const [, autoDeleteHandler] = entriesByType(template, "AWS::Lambda::Function")[0]; + assert.equal( + autoDeleteHandler.Properties.Description, + "Lambda function for auto-deleting objects in the site S3 bucket.", + ); + assert.equal(typeof autoDeleteHandler.Properties.Description, "string"); }); test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => { From 69c24c1c2cfdddd4432071396f4b96455baf891b Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:40:14 -0400 Subject: [PATCH 12/16] feat(terraform): ship dev content CD through Terraform (SH-300) (#180) * feat(terraform): ship dev content CD through Terraform (SH-300) GitHub uploads immutable release prefixes; Terraform owns live publish. Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set. * fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300) --- .github/workflows/ci.yaml | 19 +- .github/workflows/deploy.yml | 369 ++++++++++--- .gitignore | 10 - QUALITY_GATES.md | 45 +- README.md | 109 ++-- e2e/vendors/vendors.visual.spec.ts | 2 + eslint.config.js | 13 +- infra/cdk/README.md | 270 --------- infra/cdk/bin/app.ts | 58 -- infra/cdk/cdk.json | 19 - infra/cdk/lib/frontend-stack.ts | 481 ---------------- .../cdk/lib/retain-for-terraform-adoption.ts | 63 --- infra/cdk/package-lock.json | 514 ----------------- infra/cdk/package.json | 31 -- infra/cdk/test/frontend-stack.test.mjs | 232 -------- infra/cdk/tsconfig.json | 25 - package.json | 5 +- scripts/check-github-workflows.sh | 50 ++ scripts/check-terraform-import-plan.py | 0 scripts/check-terraform-isolation.mjs | 8 + scripts/check-terraform-isolation.test.mjs | 12 +- scripts/check-terraform-release-plan.py | 521 ++++++++++++++++++ scripts/governance-check.mjs | 5 +- scripts/hcp-run-guard.py | 207 +++++++ scripts/read-release-pointer.py | 29 + scripts/summarize-cloudfront-live-state.sh | 23 + scripts/terraform_import_plan_resources.py | 0 scripts/test-hcp-run-guard.py | 243 ++++++++ scripts/test-terraform-import-plan-check.py | 14 +- scripts/test-terraform-release-plan-check.py | 331 +++++++++++ scripts/test-verify-cloudfront-release.sh | 251 +++++++++ .../terraform-release-plans/create.json | 94 ++++ .../terraform-release-plans/delete.json | 94 ++++ .../terraform-release-plans/dns-update.json | 116 ++++ .../terraform-release-plans/empty.json | 9 + .../terraform-release-plans/extra-action.json | 106 ++++ .../extra-origin-change.json | 102 ++++ .../terraform-release-plans/iam-update.json | 116 ++++ .../missing-action.json | 97 ++++ .../multiple-updates.json | 130 +++++ .../nested-unknown.json | 105 ++++ .../terraform-release-plans/replace.json | 58 ++ .../terraform-release-plans/unknown-only.json | 103 ++++ .../terraform-release-plans/version-only.json | 102 ++++ .../terraform-release-plans/wrong-before.json | 102 ++++ .../terraform-release-plans/wrong-label.json | 102 ++++ scripts/upload-sourcemaps.sh | 16 +- scripts/verify-cloudfront-release.sh | 177 ++++++ terraform/README.md | 105 ++-- terraform/live/dev/main.tf | 2 + terraform/live/dev/outputs.tf | 8 + terraform/live/dev/variables.tf | 33 ++ terraform/live/dev/versions.tf | 2 +- .../live/modules/environment-owned/main.tf | 144 +++-- .../live/modules/environment-owned/outputs.tf | 30 + .../modules/environment-owned/variables.tf | 36 +- 56 files changed, 3998 insertions(+), 1950 deletions(-) delete mode 100644 infra/cdk/README.md delete mode 100644 infra/cdk/bin/app.ts delete mode 100644 infra/cdk/cdk.json delete mode 100644 infra/cdk/lib/frontend-stack.ts delete mode 100644 infra/cdk/lib/retain-for-terraform-adoption.ts delete mode 100644 infra/cdk/package-lock.json delete mode 100644 infra/cdk/package.json delete mode 100644 infra/cdk/test/frontend-stack.test.mjs delete mode 100644 infra/cdk/tsconfig.json create mode 100755 scripts/check-github-workflows.sh mode change 100644 => 100755 scripts/check-terraform-import-plan.py create mode 100755 scripts/check-terraform-release-plan.py create mode 100755 scripts/hcp-run-guard.py create mode 100755 scripts/read-release-pointer.py create mode 100755 scripts/summarize-cloudfront-live-state.sh mode change 100644 => 100755 scripts/terraform_import_plan_resources.py create mode 100755 scripts/test-hcp-run-guard.py mode change 100644 => 100755 scripts/test-terraform-import-plan-check.py create mode 100755 scripts/test-terraform-release-plan-check.py create mode 100755 scripts/test-verify-cloudfront-release.sh create mode 100644 scripts/testdata/terraform-release-plans/create.json create mode 100644 scripts/testdata/terraform-release-plans/delete.json create mode 100644 scripts/testdata/terraform-release-plans/dns-update.json create mode 100644 scripts/testdata/terraform-release-plans/empty.json create mode 100644 scripts/testdata/terraform-release-plans/extra-action.json create mode 100644 scripts/testdata/terraform-release-plans/extra-origin-change.json create mode 100644 scripts/testdata/terraform-release-plans/iam-update.json create mode 100644 scripts/testdata/terraform-release-plans/missing-action.json create mode 100644 scripts/testdata/terraform-release-plans/multiple-updates.json create mode 100644 scripts/testdata/terraform-release-plans/nested-unknown.json create mode 100644 scripts/testdata/terraform-release-plans/replace.json create mode 100644 scripts/testdata/terraform-release-plans/unknown-only.json create mode 100644 scripts/testdata/terraform-release-plans/version-only.json create mode 100644 scripts/testdata/terraform-release-plans/wrong-before.json create mode 100644 scripts/testdata/terraform-release-plans/wrong-label.json create mode 100755 scripts/verify-cloudfront-release.sh create mode 100644 terraform/live/dev/variables.tf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9a39ad3b..8a49b667 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,10 +24,10 @@ jobs: # `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, 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`. + # maintainability gate, Terraform fmt/validate, Terraform import-plan and + # release-plan guards, isolation tests, HCP run guard, CloudFront verify, + # and GitHub workflow shell). 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/) @@ -66,6 +66,17 @@ jobs: with: node-version: "24" cache: npm + - name: Install actionlint + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + set -euo pipefail + curl -fsSL -o actionlint.tar.gz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - + tar -xzf actionlint.tar.gz actionlint + sudo mv actionlint /usr/local/bin/actionlint - run: npm ci - run: npm run verify env: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 099b55de..4423e6ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,40 +1,26 @@ -name: Deploy dev content +name: Validate and deploy -# Manual dev content deployment during the Terraform adoption (SH-300). -# -# 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. -# -# 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. +# Dev content CD through Terraform (SH-300). GitHub uploads an immutable +# releases/--/ prefix. Terraform owns the pointer, origin +# group, and invalidation. Push-to-dev stays off until +# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true. on: + pull_request: + branches: [dev] + push: + branches: [dev] + paths-ignore: + - "terraform/**" workflow_dispatch: {} permissions: - id-token: write contents: read -concurrency: - group: deploy-dev - cancel-in-progress: false - jobs: - 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' + validate: + name: Validate production build runs-on: ubuntu-latest - env: - AWS_REGION: us-east-1 - VITE_APP_COMMIT_SHA: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -44,65 +30,316 @@ jobs: node-version: "24" cache: npm - 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) + - name: Install actionlint + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + set -euo pipefail + curl -fsSL -o actionlint.tar.gz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - + tar -xzf actionlint.tar.gz actionlint + sudo mv actionlint /usr/local/bin/actionlint + - name: Quality gates run: npm ci && npm run verify env: - GOVERNANCE_BASE: origin/dev + GOVERNANCE_BASE: ${{ github.event.pull_request.base.sha || 'origin/dev' }} + - name: Build with pinned API URL + env: + VITE_API_URL: https://api.dev.seahaven.com/api + VITE_APP_COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + npm run build + if grep -Rq "api.staging.seahaven.com" dist/; then + echo "::error::Built assets contain the staging API URL." >&2 + exit 1 + fi + if grep -Rq "localhost:5141" dist/; then + echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 + exit 1 + fi + grep -Rq "api.dev.seahaven.com" dist/ - - name: Assume dev deploy role (OIDC) + deploy-dev: + name: Deploy shoc-frontend-new-dev through Terraform + if: > + (github.event_name == 'push' && github.ref == 'refs/heads/dev' && + vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + contents: read + id-token: write + concurrency: + group: deploy-dev + cancel-in-progress: false + env: + AWS_REGION: us-east-1 + TF_CLOUD_ORGANIZATION: seahaven + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + SITE_BUCKET: seahaven-shoc-frontend-dev + DISTRIBUTION_ID: E2CWLM1AFB964P + SITE_URL: https://dev.seahaven.com + VITE_API_URL: https://api.dev.seahaven.com/api + VITE_APP_COMMIT_SHA: ${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Build SPA + run: | + set -euo pipefail + npm ci + npm run build + if grep -Rq "api.staging.seahaven.com" dist/; then + echo "::error::Built assets contain the staging API URL." >&2 + exit 1 + fi + if grep -Rq "localhost:5141" dist/; then + echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 + exit 1 + fi + grep -Rq "api.dev.seahaven.com" dist/ + + - name: Configure AWS credentials (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 + audience: sts.amazonaws.com - # 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: Assign immutable release identity + id: release + run: | + set -euo pipefail + version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + prefix="releases/${version_label}" + { + echo "version_label=${version_label}" + echo "prefix=${prefix}" + } >> "${GITHUB_OUTPUT}" - name: Upload private source maps run: bash scripts/upload-sourcemaps.sh env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_RELEASE: ${{ steps.release.outputs.version_label }} - - name: Verify deployment + - name: Read previous release pointer + id: pointer 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." + body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)" + printf '%s' "${body}" | python3 scripts/read-release-pointer.py - # 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 + - name: Upload immutable release prefix + run: | + set -euo pipefail + prefix="${{ steps.release.outputs.prefix }}" + aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \ + --exclude "index.html" \ + --exclude "*.map" \ + --cache-control "public,max-age=31536000,immutable" + aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" + aws s3 ls "s3://${SITE_BUCKET}/${prefix}/" | grep -q index.html + index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')" + echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}" + echo "Uploaded ${prefix}; index.html sha256=${index_sha}" + + - name: Capture previous served hash + id: previous-hash + run: | + set -euo pipefail + hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)" + echo "sha256=${hash}" >> "${GITHUB_OUTPUT}" + + - name: Discard blocking VCS run before GitHub CD + id: discard-vcs + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev + + - name: Create Terraform release run + id: release-run + uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + env: + TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"' + TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"' + with: + workspace: shoc-frontend-new-dev + message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions" + + - name: Read Terraform release plan counts + id: release-plan + uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + plan: ${{ steps.release-run.outputs.plan_id }} + + - name: Reject non-release resource counts + env: + PLAN_ADD: ${{ steps.release-plan.outputs.add }} + PLAN_CHANGE: ${{ steps.release-plan.outputs.change }} + PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }} + run: | + set -euo pipefail + if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then + echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&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." + + - name: Guard pointer-and-origin-path Terraform plan + run: | + set -euo pipefail + # Flags must match check-terraform-release-plan.py. Pointer `before` + # and origin-ID-set stability are asserted from the plan JSON. + python3 scripts/check-terraform-release-plan.py \ + --plan-id "${{ steps.release-run.outputs.plan_id }}" \ + --expected-version-label "${{ steps.release.outputs.version_label }}" \ + --expected-previous-version-label "${{ steps.pointer.outputs.live_current }}" + + - name: Discard release run when the guard fails + if: failure() && steps.release-run.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.release-run.outputs.run_id }} + comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions + + - name: Apply Terraform release run + id: release-apply + continue-on-error: true + uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.release-run.outputs.run_id }} + comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }} + + - name: Treat already-applied release run as success + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: | + python3 scripts/hcp-run-guard.py reconcile-apply \ + --run-id "${{ steps.release-run.outputs.run_id }}" \ + --apply-outcome "${{ steps.release-apply.outcome }}" + + - name: Verify CloudFront release + env: + EXPECTED_LABEL: ${{ steps.release.outputs.version_label }} + EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }} + PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }} + run: bash scripts/verify-cloudfront-release.sh + + - name: Restore previous release on failure + if: failure() + id: rollback-prepare + run: | + set -euo pipefail + prev="${{ steps.pointer.outputs.live_current }}" + if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then + echo "No Terraform-managed previous label; cannot roll back through HCP." >&2 + exit 0 + fi + echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}" + echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}" + + - name: Discard blocking VCS run before GitHub rollback + id: rollback-discard-vcs + if: failure() && steps.rollback-prepare.outputs.rollback_label != '' + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev + + - name: Create Terraform rollback run + id: rollback-run + if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + env: + TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"' + TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"' + with: + workspace: shoc-frontend-new-dev + message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions" + + - name: Read Terraform rollback plan counts + id: rollback-plan + if: failure() && steps.rollback-run.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + plan: ${{ steps.rollback-run.outputs.plan_id }} + + - name: Reject non-release rollback counts + id: rollback-count-guard + if: failure() && steps.rollback-plan.outcome == 'success' + env: + PLAN_ADD: ${{ steps.rollback-plan.outputs.add }} + PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }} + PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }} + run: | + set -euo pipefail + if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then + echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2 + exit 1 + fi + + - name: Guard pointer-and-origin-path Terraform rollback plan + id: rollback-json-guard + if: failure() && steps.rollback-count-guard.outcome == 'success' + run: | + set -euo pipefail + python3 scripts/check-terraform-release-plan.py \ + --plan-id "${{ steps.rollback-run.outputs.plan_id }}" \ + --expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \ + --expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}" + + - name: Discard rollback run when the guard fails + if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success' + uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.rollback-run.outputs.run_id }} + comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions + + - name: Apply Terraform rollback run + id: rollback-apply + if: failure() && steps.rollback-json-guard.outcome == 'success' + continue-on-error: true + uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.rollback-run.outputs.run_id }} + comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }} + + - name: Treat already-applied rollback run as success + id: rollback-apply-result + if: failure() && steps.rollback-apply.outcome != 'skipped' + env: + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + run: | + python3 scripts/hcp-run-guard.py reconcile-apply \ + --run-id "${{ steps.rollback-run.outputs.run_id }}" \ + --apply-outcome "${{ steps.rollback-apply.outcome }}" + + - name: Verify CloudFront rollback + if: failure() && steps.rollback-apply-result.outcome == 'success' + env: + EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }} + run: | + set -euo pipefail + expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" + export EXPECTED_INDEX_SHA256="${expected_sha}" + bash scripts/verify-cloudfront-release.sh + + - name: Live-state summary + if: always() + continue-on-error: true + run: bash scripts/summarize-cloudfront-live-state.sh diff --git a/.gitignore b/.gitignore index 5e4ce209..9536adc7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,16 +38,6 @@ seed-data.sql # typescript *.tsbuildinfo -# cdk (infra/cdk) -infra/cdk/node_modules -infra/cdk/cdk.out -infra/cdk/cdk.context.json -infra/cdk/*.d.ts -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 diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 64d5292e..61fd26f6 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -8,29 +8,32 @@ npm run verify `verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) → `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. +gates: Terraform import-plan and release-plan checkers, isolation tests, +Terraform formatting and validation, the HCP run guard, CloudFront verify, and +workflow shell checks. A task is not done until this is green. ## Gate matrix -| Gate | Command / rule source | Enforced by | Scope | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ | -| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | -| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | -| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | -| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | -| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | -| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | -| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | -| 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 | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | +| Gate | Command / rule source | Enforced by | Scope | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- | +| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | +| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | +| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | +| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | +| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | +| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | +| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | +| 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 release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures | +| 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` | +| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | +| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | +| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | +| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR | ## No-false-pass guarantees @@ -76,5 +79,5 @@ and synthesis. A task is not done until this is green. 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`. Governance also needs -`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and +`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.14.0, < 2.0.0`) and `python3` (3.10+) on `PATH`. diff --git a/README.md b/README.md index 1abeac15..b96e8b1e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white) -![AWS CDK](https://img.shields.io/badge/AWS_CDK-FF9900?logo=amazonwebservices&logoColor=white) +![Terraform](https://img.shields.io/badge/Terraform-844FBA?logo=terraform&logoColor=white) Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor portal, uplifts, and related admin features. This is the selective rebuild of @@ -18,25 +18,24 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md). ## Architecture -Static SPA hosting on AWS, provisioned by a CDK app local to this repo -([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/` -from a private S3 bucket; the SPA calls the backend directly over HTTPS at -`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS). +Static SPA hosting on AWS, owned by HCP Terraform +([`terraform/README.md`](terraform/README.md)). CloudFront serves the built +`dist/` from a private S3 bucket using a current/previous origin group; +the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api` +proxy at the CDN — the backend allows CORS). ```mermaid graph LR U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront] - CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev] + CF -->|origin group 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: 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 + GH[GitHub Actions] -->|OIDC upload releases/*| S3 + TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF ``` -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. +Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still +uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287). Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack Query, React Router (via `@generouted/react-router`), React Hook Form + Zod, @@ -45,8 +44,8 @@ architecture plan for the keep/discard migration matrix). ## AWS Resources -Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region -`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts). +HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region +`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev). | Resource | Name | Purpose | | ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | @@ -83,9 +82,8 @@ repo secret** is: 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 an administrator runs -`cdk deploy` with no flags. +Pinned hosting constants (domain, certificate ARN, hosted zone) live in +[`terraform/live/dev/main.tf`](terraform/live/dev/main.tf). ## Local Development @@ -102,22 +100,20 @@ 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` | Governance checks (godfile, maintainability, Terraform, CDK) | -| `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, CD guards) | +| `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`. +Terraform and content-CD gates. Husky + lint-staged run ESLint and Prettier on staged files at commit; commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or @@ -137,8 +133,8 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or Merged branches are deleted automatically. - 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) +- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through + Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`) `→ main` (production promotion — no prod environment exists yet). ## Deployment @@ -151,7 +147,7 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately: 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), - the Terraform gates, and the CDK template tests) is guaranteed from this + the Terraform gates, and the content-CD guards) 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 @@ -161,28 +157,21 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately: — 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, then uploads source maps and checks the served - `index.html` matches the build. Push-to-`dev` releases return with the - Terraform content-CD change. + — `workflow_dispatch` on `dev`, and push-to-`dev` when + `vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`). + GitHub uploads `releases/--/` only. Terraform updates + `.release/current`, both origin paths, and the invalidation action. Verify + and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints + a live-state summary. - **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)). +- **Infrastructure** — administrator-run HCP Terraform workspace + `shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)). + Staging hosting stays on the existing CloudFormation stack until SH-287. -Manual content deploy (emergency/reference only — needs credentials for the -external-dev AWS account): - -```bash -SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \ - AWS_REGION=us-east-1 bash scripts/deploy-web.sh -``` +Do not run `scripts/deploy-web.sh` against dev. That script remains the staging +content publisher only. ## Operations @@ -193,8 +182,9 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P 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. + - _Stale content after deploy_ — CloudFront is still `InProgress` or an edge + still serves the previous `index.html` hash. Read the live-state summary + before assuming the site is down. - _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. @@ -202,14 +192,13 @@ SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P 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`. -- **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`). +- **Push-to-`dev` is gated.** Merging to `dev` publishes only when + `TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues + an HCP Terraform run that a human confirms or discards before the next + content release (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) +- Dev Terraform 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/) diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts index 705c4f4e..fea343d4 100644 --- a/e2e/vendors/vendors.visual.spec.ts +++ b/e2e/vendors/vendors.visual.spec.ts @@ -244,7 +244,9 @@ test.describe("Vendor deterministic pixel regression", () => { await openVendorPage(page, "error"); await expect(page.getByRole("main").getByRole("alert")).toContainText( /server error|vendor directory unavailable/i, + { timeout: 15_000 }, ); + await expect(page.getByRole("progressbar")).toHaveCount(0); await expectStableScreenshot(page, "vendor-error.png"); }); diff --git a/eslint.config.js b/eslint.config.js index 10c0aa46..ca716ef7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,18 +30,7 @@ const legacyIgnores = [ export default tseslint.config( { - ignores: [ - "dist/**", - "build/**", - "node_modules/**", - "coverage/**", - "infra/cdk/cdk.out/**", - "infra/cdk/bin/**/*.d.ts", - "infra/cdk/bin/**/*.js", - "infra/cdk/lib/**/*.d.ts", - "infra/cdk/lib/**/*.js", - ...legacyIgnores, - ], + ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/infra/cdk/README.md b/infra/cdk/README.md deleted file mode 100644 index cf7d466f..00000000 --- a/infra/cdk/README.md +++ /dev/null @@ -1,270 +0,0 @@ -# Infrastructure & CI/CD — Sea Haven SHOC frontend - -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 `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) -- **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`. - -``` -infra/cdk/ - 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 / governance / terraform isolation) - deploy.yml dev content publish (workflow_dispatch on dev) - deploy-staging.yml standalone staging deploy (push to staging) -``` - -## What the stack creates - -| Resource | Purpose | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) | -| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) | -| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) | -| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | - -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. - ---- - -## One-time setup (run by a human with admin AWS creds) - -### 1. Authenticate to the AWS account - -```bash -aws configure # or: aws sso login --profile -aws sts get-caller-identity # confirm the right account + region (us-east-1) -``` - -### 2. Ensure the GitHub OIDC provider exists (once per account) - -```bash -aws iam list-open-id-connect-providers -# If none ends in token.actions.githubusercontent.com, create it (thumbprint is -# no longer required — AWS validates GitHub against its own trust store): -aws iam create-open-id-connect-provider \ - --url https://token.actions.githubusercontent.com \ - --client-id-list sts.amazonaws.com -``` - -### 3. CDK bootstrap (once per account/region) - -```bash -cd infra/cdk -npm ci -npx cdk bootstrap aws:///us-east-1 -``` - -### 4. Domain, cert, and API URL (already wired for dev) - -Domain/cert/zone are set in `cdk.json` context (account `396287094661`): - -| Context key | Value | -| --------------------------------- | ------------------------------------------------------------ | -| `domainNames` | `dev.seahaven.com` | -| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) | -| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` | - -The stack creates the apex A/AAAA alias in the hosted zone (in this account, -delegated from the parent `seahaven.com` zone). The **API URL is not infra** — -it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`), -baked into the build. Per-environment; override for staging/prod. - -### 5. First deploy (locally, with admin creds) - -The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it -locally. This provisions infra + the role: - -```bash -cd infra/cdk -npx cdk deploy -``` - -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. Content deploys - -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) 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 - environment subject - `repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging` - (`StringEquals` on both `aud` and `sub`). The workflow declares - `environment: staging`, so only runs in that environment can assume the role. - Without `githubEnvironment`, the dev stack keeps its branch-ref trust - unchanged. -- **No secret:** the role ARN is static (the role name is deterministic), so - the workflow pins - `arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging` - directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set. -- **Gates first:** the workflow runs the full `npm run verify` before assuming - the staging role, then runs `scripts/deploy-web.sh` with - `STACK_NAME=shoc-frontend-staging`, - `VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the - CloudFront invalidation to complete. -- **Application-only role:** the recurring staging workflow can describe only - its exact stack, publish only to its exact bucket, and invalidate only its - exact distribution. It cannot assume the shared CDK bootstrap roles or - modify infrastructure. Staging infrastructure changes use the Administrator - command below. -- **Post-deploy checks:** bucket + distribution existence, HTTPS on - `https://staging.seahaven.com`, and the actual post-invalidation remote assets - contain the staging API URL and no dev API URL. (Not browser QA.) - -### One-time setup (run by a human with admin AWS creds + GitHub Admin) - -1. **GitHub Admin — create the `staging` environment** (Settings → - Environments → New environment → `staging`). Add protection rules as - appropriate (e.g. required reviewers, restrict to the `staging` branch). If - the environment does not exist, GitHub creates it unprotected on first use. -2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the - OIDC provider and bootstrap already exist in this account): - - ```bash - cd infra/cdk - npx cdk deploy shoc-frontend-staging \ - -c envName=staging \ - -c deployBranch=staging \ - -c githubEnvironment=staging \ - -c domainNames=staging.seahaven.com \ - -c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \ - -c hostedZoneId=Z02602739VQWBWCAGXP4 \ - -c hostedZoneName=staging.seahaven.com - ``` - - The `DeployRoleArn` output must match the ARN pinned in - `deploy-staging.yml` (it will — the role name is deterministic). - -3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must - allow the `https://staging.seahaven.com` origin. -4. Push to `staging` — `ci.yaml` runs the quality gates and - `deploy-staging.yml` deploys. - -### Adding prod later - -Same pattern: a prod account/stack with its own contexts and, ideally, its own -`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked -into each environment's build, and the bucket's `RemovalPolicy.DESTROY` + -`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited -for prod. - -## Notes - -- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + - `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. - 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 deleted file mode 100644 index afb51ff9..00000000 --- a/infra/cdk/bin/app.ts +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -import { App, Tags } from "aws-cdk-lib"; -import { FrontendStack } from "../lib/frontend-stack"; - -const app = new App(); - -// Defaults match the dev setup; override via `-c key=value` on the CLI. -const envName = app.node.tryGetContext("envName") ?? "dev"; -const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; -const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; -// When set (e.g. "staging"), the deploy role trusts the exact GitHub -// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style -// branch-ref trust. -const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? ""; - -// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com -// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. -const domainNames = (app.node.tryGetContext("domainNames") ?? "") - .split(",") - .map((d: string) => d.trim()) - .filter((d: string) => d.length > 0); -const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; - -// Route 53 hosted zone (this account) for the custom-domain alias record. -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. -const terminationProtection = envName !== "dev"; - -const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { - envName, - githubRepo, - deployBranch, - githubEnvironment, - terminationProtection, - domainNames, - certificateArn, - hostedZoneId, - hostedZoneName, - retainForTerraformAdoption, - env: { - account: process.env.CDK_DEFAULT_ACCOUNT, - region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", - }, -}); - -Tags.of(stack).add("Project", "shoc-frontend"); -Tags.of(stack).add("Environment", envName); -Tags.of(stack).add("ManagedBy", "cdk"); diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json deleted file mode 100644 index aaecf396..00000000 --- a/infra/cdk/cdk.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "app": "npx ts-node --prefer-ts-exts bin/app.ts", - "watch": { - "include": ["**"], - "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"] - }, - "context": { - "@aws-cdk/aws-iam:minimizePolicies": true, - "@aws-cdk/core:checkSecretUsage": true, - "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, - "@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true, - - "//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.", - "domainNames": "dev.seahaven.com", - "certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00", - "hostedZoneId": "Z07671212N75U4YLPWZR8", - "hostedZoneName": "dev.seahaven.com" - } -} diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts deleted file mode 100644 index 88ccb6d9..00000000 --- a/infra/cdk/lib/frontend-stack.ts +++ /dev/null @@ -1,481 +0,0 @@ -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"; -import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; -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. */ - readonly envName: string; - /** GitHub repo in owner/name form, for OIDC trust scoping. */ - readonly githubRepo: string; - /** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */ - readonly deployBranch: string; - /** - * GitHub Actions environment name (e.g. "staging"). When set, the OIDC - * trust uses the EXACT environment subject - * `repo::environment:` (StringEquals) instead of the - * deploy-branch ref match below. Unset = dev-style branch-ref trust. - */ - readonly githubEnvironment?: string; - /** - * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. - * Empty = serve on the default *.cloudfront.net domain. - */ - readonly domainNames: string[]; - /** - * ARN of an ACM certificate (us-east-1, SAME account as this stack) covering - * `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot - * use a certificate from another account, so for Option B the cert must live - * in whichever account this stack deploys to. - */ - readonly certificateArn: string; - /** - * Route 53 hosted zone (in THIS account) to create the custom-domain alias - * record in. Empty = don't manage DNS (add the record manually). When set, - * hostedZoneName must also be provided. - */ - 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; -} - -/** - * Static SPA hosting for the Sea Haven SHOC frontend: - * - private S3 bucket (no public access; CloudFront reads it via OAC) - * - CloudFront distribution (HTTPS, SPA deep-link fallback) - * - a GitHub Actions OIDC deploy role - * - * 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) { - super(scope, id, props); - - const { - envName, - githubRepo, - deployBranch, - githubEnvironment = "", - domainNames, - 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( - "certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).", - ); - } - - // --- Origin bucket: private, encrypted, no public access ---------------- - const bucket = new s3.Bucket(this, "SiteBucket", { - bucketName: `seahaven-shoc-frontend-${envName}`, - blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, - objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED, - encryption: s3.BucketEncryption.S3_MANAGED, - enforceSSL: true, - versioned: true, - // dev artifacts are reproducible from the build — safe to tear down. - removalPolicy: RemovalPolicy.DESTROY, - autoDeleteObjects: true, - }); - - // SPA client-side routing: rewrite extensionless paths (e.g. /work-orders) - // to /index.html so deep links resolve. Done with a CloudFront Function - // rather than customErrorResponses so real asset 404s stay 404s. - const spaRewrite = new cloudfront.Function(this, "SpaRewrite", { - comment: "SPA routing: rewrite extensionless paths to /index.html", - code: cloudfront.FunctionCode.fromInline( - [ - "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;", - "}", - ].join("\n"), - ), - }); - - // --- 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", - priceClass: cloudfront.PriceClass.PRICE_CLASS_100, - httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, - // Option B: serve on the custom domain(s) with the ACM cert. When unset, - // CloudFront uses its default *.cloudfront.net domain + certificate. - domainNames: hasCustomDomain ? domainNames : undefined, - certificate: hasCustomDomain - ? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn) - : undefined, - minimumProtocolVersion: hasCustomDomain - ? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021 - : undefined, - defaultBehavior: { - // withOriginAccessControl wires up OAC + the bucket policy automatically. - origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, { - originId, - }), - viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, - allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, - compress: true, - functionAssociations: [ - { - function: spaRewrite, - eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, - }, - ], - }, - }); - - // --- GitHub Actions OIDC deploy role ----------------------------------- - // The OIDC provider is a singleton account-global resource, created once - // out-of-band (see README step 2) — we only IMPORT it here so this stack's - // lifecycle (including `cdk destroy`) never deletes a resource shared by - // every role in the account. - const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn( - this, - "GitHubOidcProvider", - `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`, - ); - - // 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. 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: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, - }, - } - : 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}`); - } - - // 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({ - sid: "AssumeCdkBootstrapRoles", - actions: ["sts:AssumeRole"], - resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], - }), - ); - } - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "DescribeStack", - actions: ["cloudformation:DescribeStacks"], - resources: [ - `arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`, - ], - }), - ); - bucket.grantReadWrite(deployRole); - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "InvalidateDistribution", - actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"], - resources: [ - `arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`, - ], - }), - ); - - // --- 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, - zoneName: hostedZoneName, - }); - const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)); - // apex record when the domain equals the zone name. - const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0]; - - 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. - 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"), - ); - const autoDeleteProviderHandler = this.node - .findAll() - .find( - (node): node is CfnResource => - node instanceof CfnResource && - node.cfnResourceType === "AWS::Lambda::Function" && - node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler"), - ); - if (!rolePolicy || !autoDeleteProviderRole || !autoDeleteProviderHandler) { - throw new Error("Terraform adoption outputs require deploy and auto-delete roles."); - } - // The provider Lambda stays unconditioned so it remains after - // ManageSiteInfrastructure=false. Its generated Description Refs the - // conditioned bucket and CloudFormation rejects that when the condition - // is false. Keep a static description. - autoDeleteProviderHandler.addPropertyOverride( - "Description", - "Lambda function for auto-deleting objects in the site S3 bucket.", - ); - - 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 deleted file mode 100644 index c02aa3e9..00000000 --- a/infra/cdk/lib/retain-for-terraform-adoption.ts +++ /dev/null @@ -1,63 +0,0 @@ -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-lock.json b/infra/cdk/package-lock.json deleted file mode 100644 index c5730f17..00000000 --- a/infra/cdk/package-lock.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "name": "shoc-frontend-infra", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "shoc-frontend-infra", - "version": "0.1.0", - "dependencies": { - "aws-cdk-lib": "^2.261.0", - "constructs": "^10.4.2" - }, - "bin": { - "app": "bin/app.ts" - }, - "devDependencies": { - "@types/node": "^24.13.3", - "aws-cdk": "^2.1130.0", - "ts-node": "^10.9.2", - "typescript": "~6.0.3" - }, - "engines": { - "node": ">=22.22.1" - } - }, - "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.282", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", - "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", - "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "54.9.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz", - "integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==", - "bundleDependencies": [ - "jsonschema", - "semver" - ], - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.5" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.8.5", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/aws-cdk": { - "version": "2.1130.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz", - "integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "cdk": "bin/cdk" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/aws-cdk-lib": { - "version": "2.261.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz", - "integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==", - "bundleDependencies": [ - "@balena/dockerignore", - "@aws-cdk/cloud-assembly-api", - "case", - "fs-extra", - "ignore", - "jsonschema", - "minimatch", - "punycode", - "semver", - "yaml", - "mime-types" - ], - "license": "Apache-2.0", - "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.282", - "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", - "@aws-cdk/cloud-assembly-api": "^2.2.5", - "@aws-cdk/cloud-assembly-schema": "^54.0.0", - "@balena/dockerignore": "^1.0.2", - "case": "1.6.3", - "fs-extra": "^11.3.5", - "ignore": "^5.3.2", - "jsonschema": "^1.5.0", - "mime-types": "^2.1.35", - "minimatch": "^10.2.5", - "punycode": "^2.3.1", - "semver": "^7.8.1", - "yaml": "1.10.3" - }, - "engines": { - "node": ">= 20.0.0" - }, - "peerDependencies": { - "constructs": "^10.5.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.5", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.0" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.28.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { - "version": "1.0.2", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/aws-cdk-lib/node_modules/balanced-match": { - "version": "4.0.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/case": { - "version": "1.6.3", - "inBundle": true, - "license": "(MIT OR GPL-3.0-or-later)", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.3.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/aws-cdk-lib/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.3.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-db": { - "version": "1.52.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-types": { - "version": "2.1.35", - "inBundle": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "10.2.5", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.8.1", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.3", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/constructs": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", - "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", - "license": "Apache-2.0" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - } - } -} diff --git a/infra/cdk/package.json b/infra/cdk/package.json deleted file mode 100644 index 4529259f..00000000 --- a/infra/cdk/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "shoc-frontend-infra", - "version": "0.1.0", - "private": true, - "description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.", - "bin": { - "app": "bin/app.ts" - }, - "engines": { - "node": ">=22.22.1" - }, - "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" - }, - "devDependencies": { - "@types/node": "^24.13.3", - "aws-cdk": "^2.1130.0", - "ts-node": "^10.9.2", - "typescript": "~6.0.3" - }, - "dependencies": { - "aws-cdk-lib": "^2.261.0", - "constructs": "^10.4.2" - }, - "packageManager": "npm@11.16.0" -} diff --git a/infra/cdk/test/frontend-stack.test.mjs b/infra/cdk/test/frontend-stack.test.mjs deleted file mode 100644 index 8bcd607c..00000000 --- a/infra/cdk/test/frontend-stack.test.mjs +++ /dev/null @@ -1,232 +0,0 @@ -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); - } - - const [, autoDeleteHandler] = entriesByType(template, "AWS::Lambda::Function")[0]; - assert.equal( - autoDeleteHandler.Properties.Description, - "Lambda function for auto-deleting objects in the site S3 bucket.", - ); - assert.equal(typeof autoDeleteHandler.Properties.Description, "string"); -}); - -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/, - ); -}); diff --git a/infra/cdk/tsconfig.json b/infra/cdk/tsconfig.json deleted file mode 100644 index 37092ab0..00000000 --- a/infra/cdk/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "declaration": true, - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "types": ["node"] - }, - "include": ["bin/**/*.ts", "lib/**/*.ts"], - "exclude": ["node_modules", "cdk.out"] -} diff --git a/package.json b/package.json index 50346ac3..c1a28231 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "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-release-plan": "python3 scripts/test-terraform-release-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", + "test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py", + "test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh", + "test:github-workflows": "bash scripts/check-github-workflows.sh", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", diff --git a/scripts/check-github-workflows.sh b/scripts/check-github-workflows.sh new file mode 100755 index 00000000..7a27c1ac --- /dev/null +++ b/scripts/check-github-workflows.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# bash -n every shell script and every workflow `run:` block. actionlint when present. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${ROOT}" + +for script in scripts/*.sh; do + bash -n "${script}" +done + +python3 - "${ROOT}" << 'PY' +import pathlib, re, subprocess, sys, tempfile +root = pathlib.Path(sys.argv[1]) +failures = 0 +workflow_count = 0 +block_count = 0 +for workflow in sorted((root / ".github/workflows").glob("*.yml")) + sorted( + (root / ".github/workflows").glob("*.yaml") +): + workflow_count += 1 + text = workflow.read_text(encoding="utf-8") + blocks = [] + for match in re.finditer(r"^(\s+)run:\s*\|[^\n]*\n((?:\1 .*\n)+)", text, re.M): + indent = len(match.group(1)) + 2 + body = [] + for line in match.group(2).splitlines(): + body.append(line[indent:] if len(line) >= indent else line.lstrip()) + blocks.append("\n".join(body) + "\n") + block_count += len(blocks) + for index, block in enumerate(blocks, start=1): + with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle: + handle.write(block) + name = handle.name + result = subprocess.run(["bash", "-n", name], capture_output=True, text=True) + pathlib.Path(name).unlink() + if result.returncode != 0: + failures += 1 + sys.stderr.write(f"{workflow.relative_to(root)} run block {index}: {result.stderr}") +if failures: + raise SystemExit(1) +print( + f"bash -n passed for scripts and {block_count} run blocks in {workflow_count} workflows" +) +PY + +if command -v actionlint >/dev/null 2>&1; then + actionlint -color +else + echo "actionlint not installed; skipped (CI installs it)" +fi diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py old mode 100644 new mode 100755 diff --git a/scripts/check-terraform-isolation.mjs b/scripts/check-terraform-isolation.mjs index 484b6a50..6386456b 100644 --- a/scripts/check-terraform-isolation.mjs +++ b/scripts/check-terraform-isolation.mjs @@ -45,6 +45,14 @@ export function mayAccompanyTerraform(file) { if (file.endsWith(".md")) return true; if (file.startsWith("docs/")) return true; if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true; + if ( + /^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test( + file, + ) + ) { + return true; + } + if (file.startsWith("scripts/testdata/terraform-")) return true; return false; } diff --git a/scripts/check-terraform-isolation.test.mjs b/scripts/check-terraform-isolation.test.mjs index 45c18fc8..c2913a2f 100644 --- a/scripts/check-terraform-isolation.test.mjs +++ b/scripts/check-terraform-isolation.test.mjs @@ -37,12 +37,21 @@ test("terraform tree, docs, and terraform tooling may accompany a Terraform chan "scripts/test-terraform-import-plan-check.py", "scripts/terraform-validate.mjs", "scripts/check-terraform-isolation.mjs", + "scripts/check-terraform-release-plan.py", + "scripts/hcp-run-guard.py", + "scripts/test-hcp-run-guard.py", + "scripts/verify-cloudfront-release.sh", + "scripts/test-verify-cloudfront-release.sh", + "scripts/summarize-cloudfront-live-state.sh", + "scripts/check-github-workflows.sh", + "scripts/read-release-pointer.py", + "scripts/testdata/terraform-release-plans/version-only.json", ]) { assert.equal(mayAccompanyTerraform(file), true, file); } }); -test("application, workflow, CDK, and dependency files count as application changes", () => { +test("application, workflow, and dependency files count as application changes", () => { for (const file of [ "src/App.tsx", "public/favicon.ico", @@ -52,7 +61,6 @@ test("application, workflow, CDK, and dependency files count as application chan ".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", diff --git a/scripts/check-terraform-release-plan.py b/scripts/check-terraform-release-plan.py new file mode 100755 index 00000000..1d713891 --- /dev/null +++ b/scripts/check-terraform-release-plan.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +"""Reject HCP Terraform plans that are not a frontend content-release update. + +Accepts exactly: + - an update of the release pointer (content, plus computed etag/version_id) + - an update of the distribution with only origin[*].origin_path changed + - exactly one action invocation for the CloudFront invalidation + +after origin_path values must match the expected labels. before origin_path +values must match the pointer's prior current/previous. This script may read a +local plan JSON file or download plan JSON from the documented HashiCorp +endpoint: + + GET https://app.terraform.io/api/v2/plans/:id/json-output + +The download follows exactly one redirect, and only to archivist.terraform.io. +It does not create, apply, discard, or poll runs. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import ssl +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + + +POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer" +DISTRIBUTION_ADDRESS = "module.environment_owned.aws_cloudfront_distribution.site" +ACTION_ADDRESS = ( + "module.environment_owned.action.aws_cloudfront_create_invalidation.release" +) +API_HOST = "app.terraform.io" +ARCHIVE_HOST = "archivist.terraform.io" +PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$") +VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$") +IGNORED_ACTIONS = {"no-op", "read"} +UNSAFE_ACTIONS = {"create", "delete"} +POINTER_UNKNOWN_ATTRIBUTES = frozenset({"etag", "version_id"}) +DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset( + { + "etag", + "last_modified_time", + "status", + "in_progress_validation_batches", + } +) +REDIRECT_STATUSES = {301, 302, 303, 307, 308} + +UrlOpen = Callable[..., Any] + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Return the redirect response instead of following it.""" + + def http_error_301(self, req, fp, code, msg, headers): + return self._capture(req, fp, code, headers) + + http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301 + + @staticmethod + def _capture(req, fp, code, headers): + response = urllib.response.addinfourl(fp, headers, req.full_url, code=code) + response.msg = "Redirect" + return response + + +def _urlopen_without_redirects( + *handlers: urllib.request.BaseHandler, +) -> UrlOpen: + context = ssl.create_default_context() + opener = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=context), + _NoRedirectHandler, + *handlers, + ) + return opener.open + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument( + "plan_json", + type=Path, + nargs="?", + help="Local Terraform plan JSON. Mutually exclusive with --plan-id.", + ) + source.add_argument( + "--plan-id", + help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.", + ) + parser.add_argument( + "--expected-version-label", + required=True, + help="Immutable current release the plan must apply. Empty string is the legacy root.", + ) + parser.add_argument( + "--expected-previous-version-label", + default="", + help="Previous release label the origin group must fail over to.", + ) + parser.add_argument( + "--evidence-out", + type=Path, + help="Write machine-readable proof after every assertion passes.", + ) + return parser.parse_args() + + +def download_plan_json( + plan_id: str, + token: str, + *, + urlopen: UrlOpen | None = None, + handlers: tuple[urllib.request.BaseHandler, ...] = (), +) -> dict[str, Any]: + if not PLAN_ID_RE.fullmatch(plan_id): + raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id") + if not token: + raise ValueError("TF_API_TOKEN is required to download plan JSON") + + opener = urlopen or _urlopen_without_redirects(*handlers) + api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output" + request = urllib.request.Request( + api_url, + method="GET", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + "Accept": "application/json", + }, + ) + first = _open_pinned(opener, request, allowed_host=API_HOST) + try: + if first.status == 204: + raise ValueError( + "plan JSON is not ready; refusing to poll the plans endpoint" + ) + if first.status not in REDIRECT_STATUSES: + raise ValueError( + f"expected a redirect from {API_HOST}, got HTTP {first.status}" + ) + location = first.headers.get("Location") + if not location: + raise ValueError(f"{API_HOST} redirect is missing a Location header") + archive = urlparse(location) + if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST: + raise ValueError( + "refusing redirect that is not https://" + f"{ARCHIVE_HOST}/" + ) + archive_request = urllib.request.Request(location, method="GET") + second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST) + try: + if second.status in REDIRECT_STATUSES: + raise ValueError( + f"refusing a second redirect from {ARCHIVE_HOST}" + ) + if second.status != 200: + raise ValueError( + f"plan JSON download from {ARCHIVE_HOST} returned " + f"HTTP {second.status}" + ) + payload = second.read() + finally: + second.close() + finally: + first.close() + + plan = json.loads(payload.decode("utf-8")) + if not isinstance(plan, dict): + raise ValueError("plan JSON must be an object") + return plan + + +def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str): + parsed = urlparse(request.full_url) + if parsed.scheme != "https" or parsed.hostname != allowed_host: + raise ValueError( + f"refusing to contact {parsed.scheme}://{parsed.hostname} " + f"(pinned host is {allowed_host})" + ) + context = ssl.create_default_context() + try: + return urlopen(request, context=context, timeout=30) + except TypeError: + return urlopen(request, timeout=30) + + +def _is_nested_unknown(value: Any) -> bool: + if isinstance(value, dict): + return any(item is True or _is_nested_unknown(item) for item in value.values()) + if isinstance(value, list): + return any(item is True or _is_nested_unknown(item) for item in value) + return False + + +def changed_attributes( + change: dict[str, Any], + *, + computed_unknown: frozenset[str], +) -> set[str]: + before = change.get("before") or {} + after = change.get("after") or {} + unknown = change.get("after_unknown") or {} + keys = set(before) | set(after) | set(unknown) + changed: set[str] = set() + for key in keys: + unknown_value = unknown.get(key) + if unknown_value is True: + if key in computed_unknown: + continue + changed.add(key) + continue + if _is_nested_unknown(unknown_value): + changed.add(key) + continue + if before.get(key) != after.get(key): + changed.add(key) + return changed + + +def _label_ok(label: str) -> bool: + return label == "" or bool(VERSION_LABEL_RE.fullmatch(label)) + + +def origin_path_for_label(label: str) -> str: + return "" if label == "" else f"/releases/{label}" + + +def _origin_map(origins: Any) -> dict[str, dict[str, Any]]: + if not isinstance(origins, list): + return {} + mapped: dict[str, dict[str, Any]] = {} + for origin in origins: + if not isinstance(origin, dict): + continue + origin_id = origin.get("origin_id") + if not isinstance(origin_id, str) or not origin_id: + continue + mapped[origin_id] = origin + return mapped + + +def _origin_paths(origins: Any) -> dict[str, str]: + return { + origin_id: origin.get("origin_path") or "" + for origin_id, origin in _origin_map(origins).items() + } + + +def _decode_pointer(content: Any) -> dict[str, str]: + if not isinstance(content, str) or not content: + return {} + try: + payload = json.loads(content) + except json.JSONDecodeError: + return {} + if not isinstance(payload, dict): + return {} + return { + "current": payload.get("current") or "", + "previous": payload.get("previous") or "", + } + + +def _validate_pointer( + resource: dict[str, Any], + expected_current: str, + expected_previous: str, +) -> list[str]: + violations: list[str] = [] + change = resource.get("change") or {} + changed = changed_attributes(change, computed_unknown=POINTER_UNKNOWN_ATTRIBUTES) + if changed != {"content"}: + violations.append( + f"{POINTER_ADDRESS}: expected only content to change, found " + f"{sorted(changed) if changed else 'no attribute changes'}" + ) + after = _decode_pointer((change.get("after") or {}).get("content")) + if after.get("current") != expected_current: + violations.append( + f"{POINTER_ADDRESS}: after current {after.get('current')!r} does not match " + f"{expected_current!r}" + ) + if after.get("previous") != expected_previous: + violations.append( + f"{POINTER_ADDRESS}: after previous {after.get('previous')!r} does not match " + f"{expected_previous!r}" + ) + unknown = change.get("after_unknown") or {} + if unknown.get("content") is True: + violations.append(f"{POINTER_ADDRESS}: content after value is unknown") + return violations + + +def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool: + before_rest = {key: value for key, value in before.items() if key != "origin_path"} + after_rest = {key: value for key, value in after.items() if key != "origin_path"} + return before_rest != after_rest + + +def _validate_distribution( + resource: dict[str, Any], + pointer_before: dict[str, str], + expected_current: str, + expected_previous: str, +) -> list[str]: + violations: list[str] = [] + change = resource.get("change") or {} + changed = changed_attributes( + change, computed_unknown=DISTRIBUTION_UNKNOWN_ATTRIBUTES + ) + if changed != {"origin"}: + violations.append( + f"{DISTRIBUTION_ADDRESS}: expected only origin to change, found " + f"{sorted(changed) if changed else 'no attribute changes'}" + ) + return violations + + before_origins = _origin_map((change.get("before") or {}).get("origin")) + after_origins = _origin_map((change.get("after") or {}).get("origin")) + if set(before_origins) != set(after_origins): + violations.append( + f"{DISTRIBUTION_ADDRESS}: origin IDs changed " + f"from {sorted(before_origins)} to {sorted(after_origins)}" + ) + return violations + + for origin_id, before_origin in before_origins.items(): + if _origin_non_path_fields_changed(before_origin, after_origins[origin_id]): + violations.append( + f"{DISTRIBUTION_ADDRESS}: origin {origin_id!r} changed a field other than origin_path" + ) + + after_paths = sorted(_origin_paths((change.get("after") or {}).get("origin")).values()) + expected_after = sorted( + [ + origin_path_for_label(expected_current), + origin_path_for_label(expected_previous), + ] + ) + if after_paths != expected_after: + violations.append( + f"{DISTRIBUTION_ADDRESS}: after origin_path {after_paths} does not match " + f"{expected_after}" + ) + + before_paths = sorted(_origin_paths((change.get("before") or {}).get("origin")).values()) + expected_before = sorted( + [ + origin_path_for_label(pointer_before.get("current", "")), + origin_path_for_label(pointer_before.get("previous", "")), + ] + ) + if before_paths != expected_before: + violations.append( + f"{DISTRIBUTION_ADDRESS}: before origin_path {before_paths} does not match " + f"pointer prior values {expected_before}" + ) + return violations + + +def _validate_actions(plan: dict[str, Any]) -> list[str]: + invocations = plan.get("action_invocations") + if invocations is None: + return ["plan is missing action_invocations"] + if not isinstance(invocations, list): + return ["action_invocations must be a list"] + addresses = [ + item.get("address") + for item in invocations + if isinstance(item, dict) + ] + if addresses != [ACTION_ADDRESS]: + return [ + "expected exactly one action_invocations entry " + f"{ACTION_ADDRESS}, found {addresses}" + ] + return [] + + +def validate_plan( + plan: dict[str, Any], + expected_current: str, + expected_previous: str, +) -> list[str]: + violations: list[str] = [] + if not _label_ok(expected_current): + violations.append( + "expected version label must be empty or --" + ) + return violations + if not _label_ok(expected_previous): + violations.append( + "expected previous version label must be empty or --" + ) + return violations + + updates: dict[str, dict[str, Any]] = {} + for resource in plan.get("resource_changes", []): + if resource.get("mode", "managed") != "managed": + continue + address = resource.get("address", "") + change = resource.get("change") or {} + actions = list(change.get("actions") or []) + action_set = set(actions) + if action_set <= IGNORED_ACTIONS: + continue + + if change.get("importing"): + violations.append(f"{address}: import actions are not allowed") + + unsafe = sorted(action_set & UNSAFE_ACTIONS) + if unsafe: + violations.append(f"{address}: unsafe actions {unsafe}") + if "replace" in action_set or actions in ( + ["delete", "create"], + ["create", "delete"], + ): + violations.append(f"{address}: replacement is not allowed") + + if "update" in action_set: + updates[address] = resource + if action_set != {"update"}: + violations.append( + f"{address}: update must be the only action, got {actions}" + ) + + if address not in {POINTER_ADDRESS, DISTRIBUTION_ADDRESS} and ( + action_set - IGNORED_ACTIONS + ): + violations.append( + f"{address}: managed address is outside the content-release update" + ) + + if set(updates) != {POINTER_ADDRESS, DISTRIBUTION_ADDRESS}: + violations.append( + "expected exactly the pointer and distribution updates, found " + f"{sorted(updates)}" + ) + violations.extend(_validate_actions(plan)) + return violations + + pointer_change = updates[POINTER_ADDRESS].get("change") or {} + pointer_before = _decode_pointer((pointer_change.get("before") or {}).get("content")) + violations.extend( + _validate_pointer(updates[POINTER_ADDRESS], expected_current, expected_previous) + ) + violations.extend( + _validate_distribution( + updates[DISTRIBUTION_ADDRESS], + pointer_before, + expected_current, + expected_previous, + ) + ) + violations.extend(_validate_actions(plan)) + return violations + + +def main() -> int: + args = parse_args() + if args.plan_id: + try: + plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", "")) + except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc: + print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr) + return 1 + else: + if args.plan_json is None: + print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr) + return 1 + plan = json.loads(args.plan_json.read_text(encoding="utf-8")) + + violations = validate_plan( + plan, + args.expected_version_label, + args.expected_previous_version_label, + ) + if violations: + print("FAIL: Terraform plan is not a content-release update", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + + if args.evidence_out: + evidence = { + "pointer_address": POINTER_ADDRESS, + "distribution_address": DISTRIBUTION_ADDRESS, + "action_address": ACTION_ADDRESS, + "expected_version_label": args.expected_version_label, + "expected_previous_version_label": args.expected_previous_version_label, + "managed_updates": 2, + "action_invocations": 1, + "creates": 0, + "deletes": 0, + "replacements": 0, + } + args.evidence_out.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print( + "PASS: content-release plan updates " + f"{POINTER_ADDRESS} and {DISTRIBUTION_ADDRESS} to " + f"{args.expected_version_label} (previous {args.expected_previous_version_label!r})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index f777ce09..96209314 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -21,9 +21,12 @@ const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/; // script so it can also be run on its own. const REPOSITORY_GATES = [ ["Terraform import-plan contract", "test:terraform-import-plan"], + ["Terraform release-plan contract", "test:terraform-release-plan"], ["Terraform isolation gate", "test:terraform-isolation"], ["Terraform formatting and validation", "test:terraform"], - ["CDK build, tests, and synth", "test:infra"], + ["HCP run guard", "test:hcp-run-guard"], + ["CloudFront release verify", "test:cloudfront-release-verify"], + ["GitHub workflow shell", "test:github-workflows"], ]; function isGoverned(relativePath) { diff --git a/scripts/hcp-run-guard.py b/scripts/hcp-run-guard.py new file mode 100755 index 00000000..15c0f318 --- /dev/null +++ b/scripts/hcp-run-guard.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Guard HCP Terraform runs used by GitHub content CD. + +Subcommands: + check-and-discard Refuse unsafe workspace settings. Discard a blocking + non-speculative VCS run so GitHub CD can create-run. + reconcile-apply Treat an HCP run whose status is already ``applied`` as + success when the GitHub apply-run step reported failure. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Any, Callable + +API = "https://app.terraform.io/api/v2" +DEFAULT_WORKSPACE = "shoc-frontend-new-dev" +EXPECTED_TRIGGER_PATTERNS = [ + "terraform/live/dev/**", + "terraform/live/modules/**", +] +DISCARDABLE = { + "pending", + "planned", + "cost_estimated", + "policy_checked", + "policy_override", +} +APPLYING = {"applying", "apply_queued"} + +HttpGet = Callable[[str], dict[str, Any]] +HttpPost = Callable[[str, dict[str, Any]], int] + + +class GuardError(Exception): + """Refused to continue.""" + + +def _headers(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + } + + +def default_get(token: str) -> HttpGet: + def get(url: str) -> dict[str, Any]: + request = urllib.request.Request(url, headers=_headers(token)) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + return get + + +def default_post(token: str) -> HttpPost: + def post(url: str, payload: dict[str, Any]) -> int: + data = json.dumps(payload).encode() + request = urllib.request.Request( + url, data=data, method="POST", headers=_headers(token) + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return int(response.status) + except urllib.error.HTTPError as exc: + if exc.code in (409, 404): + body = exc.read().decode("utf-8", "replace") + print(f"discard returned HTTP {exc.code}: {body}") + return exc.code + raise + + return post + + +def require_token(token: str) -> str: + if not token: + raise GuardError("TF_API_TOKEN is required") + return token + + +def check_invariants(attrs: dict[str, Any], workspace: str) -> None: + if attrs.get("auto-apply") is True: + raise GuardError(f"{workspace} auto-apply is on; refuse to continue") + if not attrs.get("speculative-enabled"): + raise GuardError("speculative plans are off; refuse to continue") + if (attrs.get("vcs-repo") or {}).get("tags-regex"): + raise GuardError("tag-based VCS triggering is set; refuse to continue") + if attrs.get("trigger-patterns") != EXPECTED_TRIGGER_PATTERNS: + raise GuardError( + "trigger-patterns must be " + f"{EXPECTED_TRIGGER_PATTERNS}; got {attrs.get('trigger-patterns')}" + ) + + +def check_and_discard( + *, + workspace: str, + token: str, + get: HttpGet | None = None, + post: HttpPost | None = None, +) -> int: + token = require_token(token) + get = get or default_get(token) + post = post or default_post(token) + workspace_payload = get( + f"{API}/organizations/seahaven/workspaces/{workspace}" + )["data"] + attrs = workspace_payload["attributes"] + check_invariants(attrs, workspace) + if not attrs.get("locked"): + print("workspace is unlocked") + return 0 + + current = ( + workspace_payload.get("relationships", {}) + .get("current-run", {}) + .get("data") + ) + if not current: + raise GuardError("workspace is locked without a current run") + run_id = current["id"] + run = get(f"{API}/runs/{run_id}")["data"] + run_attrs = run["attributes"] + status = run_attrs.get("status") + plan_only = run_attrs.get("plan-only") + print(f"current run {run_id} status={status} plan-only={plan_only}") + if plan_only: + print("speculative run does not block GitHub CD") + return 0 + if status in APPLYING: + raise GuardError(f"{run_id} is {status}; wait, do not discard an apply") + if status not in DISCARDABLE: + raise GuardError(f"{run_id} status {status} is not discardable") + code = post( + f"{API}/runs/{run_id}/actions/discard", + { + "comment": ( + "Discarded so GitHub CD can create the content-release applyable run" + ) + }, + ) + print(f"discarded {run_id} http={code}") + return 0 + + +def reconcile_apply( + *, + run_id: str, + apply_outcome: str, + token: str, + get: HttpGet | None = None, +) -> int: + token = require_token(token) + if not run_id: + raise GuardError("run id is required") + if apply_outcome == "success": + print("Apply succeeded.") + return 0 + get = get or default_get(token) + status = get(f"{API}/runs/{run_id}")["data"]["attributes"]["status"] + print(f"HCP run {run_id} status={status}") + if status == "applied": + return 0 + raise GuardError( + f"Apply failed: GitHub outcome={apply_outcome} HCP status={status}" + ) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + + check = sub.add_parser("check-and-discard") + check.add_argument("--workspace", default=DEFAULT_WORKSPACE) + check.add_argument("--token", default=os.environ.get("TF_API_TOKEN", "")) + + reconcile = sub.add_parser("reconcile-apply") + reconcile.add_argument("--run-id", required=True) + reconcile.add_argument( + "--apply-outcome", + default=os.environ.get("APPLY_OUTCOME", ""), + ) + reconcile.add_argument("--token", default=os.environ.get("TF_API_TOKEN", "")) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + if args.command == "check-and-discard": + return check_and_discard(workspace=args.workspace, token=args.token) + return reconcile_apply( + run_id=args.run_id, + apply_outcome=args.apply_outcome, + token=args.token, + ) + except GuardError as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/read-release-pointer.py b/scripts/read-release-pointer.py new file mode 100755 index 00000000..d570310f --- /dev/null +++ b/scripts/read-release-pointer.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Read .release/current JSON from stdin and write GitHub Actions outputs.""" +from __future__ import annotations + +import json +import os +import sys + + +def main() -> int: + raw = sys.stdin.read().strip() + data = json.loads(raw) if raw else {} + current = data.get("current") or "" + previous = data.get("previous") or "" + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as handle: + handle.write(f"live_current={current}\n") + handle.write(f"live_previous={previous}\n") + print( + "Pointer live current=" + + (current or "") + + " previous=" + + (previous or "") + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/summarize-cloudfront-live-state.sh b/scripts/summarize-cloudfront-live-state.sh new file mode 100755 index 00000000..4389ec3e --- /dev/null +++ b/scripts/summarize-cloudfront-live-state.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Print pointer body, origin paths, distribution status, and served index hash. +# Used by deploy.yml's always() summary. Never fails the job on a missing pointer. +set -u +DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}" +SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}" +SITE_URL="${SITE_URL:-https://dev.seahaven.com}" +echo "=== CloudFront live state ===" +echo "pointer:" +aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)" +echo +aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c ' +import json, sys +payload = json.load(sys.stdin) +dist = payload.get("Distribution") or {} +config = dist.get("DistributionConfig") or {} +print("status:", dist.get("Status")) +for origin in ((config.get("Origins") or {}).get("Items") or []): + print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or "")) +' +echo +echo -n "served index sha256: " +curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable" diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py old mode 100644 new mode 100755 diff --git a/scripts/test-hcp-run-guard.py b/scripts/test-hcp-run-guard.py new file mode 100755 index 00000000..0e97e6d8 --- /dev/null +++ b/scripts/test-hcp-run-guard.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Tests for every hcp-run-guard refusal, exit-0, discard, and reconcile case.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + +SCRIPT = Path(__file__).with_name("hcp-run-guard.py") +WORKSPACE = "shoc-frontend-new-dev" +PATTERNS = [ + "terraform/live/dev/**", + "terraform/live/modules/**", +] + + +def load_module(): + spec = importlib.util.spec_from_file_location("hcp_run_guard", SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def workspace_payload( + *, + auto_apply: bool = False, + speculative: bool = True, + tags_regex: str | None = None, + trigger_patterns: list[str] | None = None, + locked: bool = False, + current_run: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "data": { + "attributes": { + "auto-apply": auto_apply, + "speculative-enabled": speculative, + "vcs-repo": {"tags-regex": tags_regex}, + "trigger-patterns": PATTERNS if trigger_patterns is None else trigger_patterns, + "locked": locked, + }, + "relationships": { + "current-run": {"data": current_run}, + }, + } + } + + +def run_payload(*, status: str, plan_only: bool = False) -> dict[str, Any]: + return {"data": {"attributes": {"status": status, "plan-only": plan_only}}} + + +def check(module, payloads: dict[str, Any], posts: list | None = None): + calls: list[str] = [] + + def get(url: str) -> dict[str, Any]: + calls.append(url) + if url not in payloads: + raise AssertionError(f"unexpected GET {url}") + return payloads[url] + + recorded: list[tuple[str, dict[str, Any]]] = [] + + def post(url: str, payload: dict[str, Any]) -> int: + recorded.append((url, payload)) + if posts: + return posts.pop(0) + return 202 + + try: + code = module.check_and_discard( + workspace=WORKSPACE, + token="test-token", + get=get, + post=post, + ) + return code, None, calls, recorded + except module.GuardError as exc: + return 1, str(exc), calls, recorded + + +def reconcile(module, outcome: str, payloads: dict[str, Any], run_id: str = "run-1"): + def get(url: str) -> dict[str, Any]: + if url not in payloads: + raise AssertionError(f"unexpected GET {url}") + return payloads[url] + + try: + code = module.reconcile_apply( + run_id=run_id, + apply_outcome=outcome, + token="test-token", + get=get, + ) + return code, None + except module.GuardError as exc: + return 1, str(exc) + + +def main() -> int: + module = load_module() + ws = f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{WORKSPACE}" + run_url = "https://app.terraform.io/api/v2/runs/run-1" + discard_url = f"{run_url}/actions/discard" + failures: list[str] = [] + + def expect_refuse(name: str, payloads: dict[str, Any], fragment: str) -> None: + code, error, _, recorded = check(module, payloads) + if code != 1 or not error or fragment not in error: + failures.append(f"{name}: expected refuse containing {fragment!r}, got {code} {error}") + if recorded: + failures.append(f"{name}: discard was posted on a refusal") + + expect_refuse( + "auto-apply", + {ws: workspace_payload(auto_apply=True)}, + "auto-apply is on", + ) + expect_refuse( + "speculative-off", + {ws: workspace_payload(speculative=False)}, + "speculative plans are off", + ) + expect_refuse( + "tags-regex", + {ws: workspace_payload(tags_regex="^v")}, + "tag-based VCS triggering", + ) + expect_refuse( + "wrong-patterns", + {ws: workspace_payload(trigger_patterns=["terraform/**"])}, + "trigger-patterns must be", + ) + expect_refuse( + "locked-without-run", + {ws: workspace_payload(locked=True, current_run=None)}, + "locked without a current run", + ) + expect_refuse( + "applying", + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="applying"), + }, + "wait, do not discard an apply", + ) + expect_refuse( + "not-discardable", + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="errored"), + }, + "is not discardable", + ) + + code, error, _, recorded = check(module, {ws: workspace_payload(locked=False)}) + if code != 0 or error is not None or recorded: + failures.append(f"unlocked: expected exit 0, got {code} {error} {recorded}") + + code, error, _, recorded = check( + module, + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="planned", plan_only=True), + }, + ) + if code != 0 or recorded: + failures.append(f"plan-only: expected exit 0 without discard, got {code} {recorded}") + + code, error, _, recorded = check( + module, + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="planned"), + }, + ) + if code != 0 or error is not None: + failures.append(f"discard: expected exit 0, got {code} {error}") + if not recorded or recorded[0][0] != discard_url: + failures.append(f"discard: posted {recorded}") + + code, error, _, recorded = check( + module, + { + ws: workspace_payload(locked=True, current_run={"id": "run-1"}), + run_url: run_payload(status="policy_checked"), + }, + posts=[409], + ) + if code != 0: + failures.append(f"discard-409: expected exit 0, got {code} {error}") + + try: + module.check_and_discard(workspace=WORKSPACE, token="", get=lambda _url: {}) + failures.append("missing-token: accepted empty token") + except module.GuardError: + pass + + code, error = reconcile(module, "success", {}) + if code != 0: + failures.append(f"reconcile-success: expected 0, got {code} {error}") + + code, error = reconcile( + module, + "failure", + {run_url: run_payload(status="applied")}, + ) + if code != 0: + failures.append(f"reconcile-applied: expected 0, got {code} {error}") + + code, error = reconcile( + module, + "failure", + {run_url: run_payload(status="errored")}, + ) + if code != 1 or not error or "errored" not in error: + failures.append(f"reconcile-errored: expected refuse, got {code} {error}") + + try: + module.reconcile_apply(run_id="", apply_outcome="failure", token="test-token") + failures.append("reconcile-missing-run: accepted empty run id") + except module.GuardError: + pass + + try: + module.reconcile_apply(run_id="run-1", apply_outcome="failure", token="") + failures.append("reconcile-missing-token: accepted empty token") + except module.GuardError: + pass + + if failures: + print("FAIL: hcp-run-guard cases failed", file=__import__("sys").stderr) + for item in failures: + print(f" - {item}", file=__import__("sys").stderr) + return 1 + print("PASS: HCP run guard checks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py old mode 100644 new mode 100755 index c122ae19..f0c8c2e4 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -452,15 +452,23 @@ class ImportPlanCheckerTests(unittest.TestCase): with self.subTest(mutation=mutation): self.assert_fails(plan, "dev", BUCKET_POLICY) - def test_github_deploy_policy_stays_byte_identical(self) -> None: + def test_github_deploy_policy_is_release_prefix_only(self) -> None: source = ( REPOSITORY / "terraform/live/modules/environment-owned/main.tf" ).read_text(encoding="utf-8") document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1] document = document.split("resource ", 1)[0] self.assertNotIn("var.adoption_complete", document) - self.assertIn("AssumeCdkBootstrapRoles", document) - self.assertIn("DescribeStack", document) + self.assertIn("ListReleasePrefixes", document) + self.assertIn("PublishReleasePrefix", document) + self.assertIn("ReadReleasePointer", document) + self.assertIn("ReadDistribution", document) + self.assertIn("cloudfront:GetDistribution", document) + self.assertIn("cloudfront:GetDistributionConfig", document) + self.assertIn("releases/*", document) + self.assertNotIn("AssumeCdkBootstrapRoles", document) + self.assertNotIn("DescribeStack", document) + self.assertNotIn("CreateInvalidation", document) self.assertNotIn("ReadDeploymentBucket", document) self.assertNotIn("PublishAndRollbackSiteObjects", document) self.assertNotIn( diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py new file mode 100755 index 00000000..70e436b7 --- /dev/null +++ b/scripts/test-terraform-release-plan-check.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Deterministic tests for check-terraform-release-plan.py.""" + +from __future__ import annotations + +import importlib.util +import io +import subprocess +import sys +import urllib.request +from email.message import EmailMessage +from pathlib import Path +from urllib.request import Request + +SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py") +FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans" +EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" +EXPECTED_PREVIOUS = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" +PLAN_ID = "plan-8F5JFydVYAmtTjET" +POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer" + + +def run_case( + fixture_name: str, + *, + expected_label: str = EXPECTED_LABEL, + expected_previous: str = EXPECTED_PREVIOUS, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(FIXTURES / fixture_name), + "--expected-version-label", + expected_label, + "--expected-previous-version-label", + expected_previous, + ], + check=False, + capture_output=True, + text=True, + ) + + +class FakeResponse: + def __init__( + self, + *, + url: str, + status: int, + headers: dict[str, str] | None = None, + body: bytes = b"", + ) -> None: + self.url = url + self.status = status + self.headers = headers or {} + self._body = body + + def read(self) -> bytes: + return self._body + + def close(self) -> None: + return None + + +def load_check_module(): + spec = importlib.util.spec_from_file_location( + "check_terraform_release_plan", SCRIPT + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_download_pinning() -> list[str]: + module = load_check_module() + fixture = (FIXTURES / "version-only.json").read_bytes() + archive_url = "https://archivist.terraform.io/v1/object/example" + calls: list[str] = [] + + def fake_urlopen(request: Request, **_kwargs): + url = request.full_url + calls.append(url) + host = request.host if hasattr(request, "host") else "" + if url.startswith("https://app.terraform.io/api/v2/plans/"): + if request.get_header("Authorization") != "Bearer test-token": + raise AssertionError("API request is missing the bearer token") + if "/runs" in url or "/apply" in url or "/discard" in url: + raise AssertionError(f"download contacted a run-control path: {url}") + return FakeResponse( + url=url, + status=307, + headers={"Location": archive_url}, + ) + if url == archive_url: + if request.get_header("Authorization"): + raise AssertionError("archivist request must not send TF_API_TOKEN") + return FakeResponse(url=url, status=200, body=fixture) + raise AssertionError(f"unexpected URL {url} host={host}") + + plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen) + failures: list[str] = [] + addresses = [item["address"] for item in plan["resource_changes"]] + if POINTER_ADDRESS not in addresses: + failures.append("download did not return the version-only fixture") + if calls != [ + f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output", + archive_url, + ]: + failures.append(f"download URLs were {calls}") + + try: + module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen) + failures.append("invalid plan id was accepted") + except ValueError: + pass + + def redirect_elsewhere(request: Request, **_kwargs): + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": "https://evil.example/plan.json"}, + ) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere) + failures.append("redirect to a non-archivist host was accepted") + except ValueError: + pass + + def double_redirect(request: Request, **_kwargs): + if request.full_url.startswith("https://app.terraform.io/"): + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": archive_url}, + ) + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": "https://archivist.terraform.io/v1/object/other"}, + ) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect) + failures.append("second archivist redirect was accepted") + except ValueError: + pass + + def not_ready(request: Request, **_kwargs): + return FakeResponse(url=request.full_url, status=204) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready) + failures.append("HTTP 204 was polled or accepted") + except ValueError as exc: + if "poll" not in str(exc): + failures.append(f"HTTP 204 error was {exc}") + + source = SCRIPT.read_text(encoding="utf-8") + for banned in ("/apply", "/discard", "/runs"): + if banned in source: + failures.append(f"download client contains run-control path {banned}") + + return failures + + +def _scripted_https_handler(fixture: bytes, archive_url: str): + calls: list[str] = [] + api_prefix = "https://app.terraform.io/api/v2/plans/" + + class ScriptedHTTPSHandler(urllib.request.BaseHandler): + handler_order = 100 + + def https_open(self, req: Request): + url = req.full_url + calls.append(url) + headers = EmailMessage() + if url.startswith(api_prefix): + headers["Location"] = archive_url + body = b"" + status = 307 + msg = "Temporary Redirect" + elif url == archive_url: + body = fixture + status = 200 + msg = "OK" + else: + raise AssertionError(f"unexpected URL {url}") + response = urllib.response.addinfourl( + io.BytesIO(body), + headers, + url, + code=status, + ) + response.msg = msg + return response + + return ScriptedHTTPSHandler(), calls + + +def test_download_standard_opener_redirect() -> list[str]: + """urllib follows the HCP 307; the guard must still inspect that first hop.""" + module = load_check_module() + fixture = (FIXTURES / "version-only.json").read_bytes() + archive_url = "https://archivist.terraform.io/v1/object/example" + api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output" + failures: list[str] = [] + + following_handler, following_calls = _scripted_https_handler(fixture, archive_url) + followed = urllib.request.build_opener(following_handler).open(api_url) + try: + if followed.status != 200: + failures.append( + f"standard opener first status was {followed.status}, not 200" + ) + if following_calls != [api_url, archive_url]: + failures.append(f"standard opener URLs were {following_calls}") + finally: + followed.close() + + guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url) + try: + plan = module.download_plan_json( + PLAN_ID, + "test-token", + handlers=(guard_handler,), + ) + except ValueError as exc: + failures.append(f"no-redirect download failed: {exc}") + return failures + + addresses = [item["address"] for item in plan["resource_changes"]] + if POINTER_ADDRESS not in addresses: + failures.append("no-redirect download did not return the version-only fixture") + if guard_calls != [api_url, archive_url]: + failures.append(f"no-redirect download URLs were {guard_calls}") + + following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url) + following_urlopen = urllib.request.build_opener(following_urlopen_handler).open + try: + module.download_plan_json( + PLAN_ID, + "test-token", + urlopen=following_urlopen, + ) + failures.append("redirect-following urlopen was accepted as the first hop") + except ValueError as exc: + if "expected a redirect" not in str(exc): + failures.append(f"following urlopen error was {exc}") + + return failures + + +def test_deploy_workflow_uses_script_flags() -> list[str]: + workflow = ( + Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml" + ).read_text(encoding="utf-8") + failures: list[str] = [] + if workflow.count("--expected-version-label") < 2: + failures.append( + "deploy.yml must pass --expected-version-label on release and rollback" + ) + if workflow.count("--expected-previous-version-label") < 2: + failures.append( + "deploy.yml must pass --expected-previous-version-label on release and rollback" + ) + for forbidden in ( + "--expected-current-label", + "--expected-previous-label", + "--before-current-label", + "--before-previous-label", + "--current-origin-id", + "--previous-origin-id", + "CURRENT_ORIGIN_ID", + ): + if forbidden in workflow: + failures.append(f"deploy.yml still passes unknown flag {forbidden}") + return failures + + +def main() -> int: + cases = [ + ("version-only", run_case("version-only.json"), 0), + ("wrong-label", run_case("wrong-label.json"), 1), + ("wrong-before", run_case("wrong-before.json"), 1), + ("extra-origin-change", run_case("extra-origin-change.json"), 1), + ("iam-update", run_case("iam-update.json"), 1), + ("dns-update", run_case("dns-update.json"), 1), + ("create", run_case("create.json"), 1), + ("delete", run_case("delete.json"), 1), + ("replace", run_case("replace.json"), 1), + ("multiple-updates", run_case("multiple-updates.json"), 1), + ("nested-unknown", run_case("nested-unknown.json"), 1), + ("unknown-only", run_case("unknown-only.json"), 1), + ("empty", run_case("empty.json"), 1), + ("missing-action", run_case("missing-action.json"), 1), + ("extra-action", run_case("extra-action.json"), 1), + ] + failures = [ + (name, result, expected) + for name, result, expected in cases + if result.returncode != expected + ] + download_failures = test_download_pinning() + redirect_failures = test_download_standard_opener_redirect() + download_failures.extend(redirect_failures) + download_failures.extend(test_deploy_workflow_uses_script_flags()) + if failures or download_failures: + if failures: + print( + "FAIL: release plan-check cases failed: " + + ", ".join(name for name, _, _ in failures), + file=sys.stderr, + ) + for name, result, expected in failures: + print( + f"{name}: expected {expected}, got {result.returncode}\n" + f"{result.stdout}{result.stderr}", + file=sys.stderr, + ) + for item in download_failures: + print(f"FAIL: {item}", file=sys.stderr) + return 1 + print("PASS: Terraform release plan safety checks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-verify-cloudfront-release.sh b/scripts/test-verify-cloudfront-release.sh new file mode 100755 index 00000000..8e192eae --- /dev/null +++ b/scripts/test-verify-cloudfront-release.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# Stubbed aws/curl tests for scripts/verify-cloudfront-release.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh" +CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" +PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" +NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111" +OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000" +INDEX_HTML='api.dev.seahaven.com' +INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" + +failures=0 +assert_exit() { + local name="$1" expected="$2" got="$3" log="$4" + if [[ "${got}" != "${expected}" ]]; then + echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + else + echo "PASS: ${name}" + fi +} + +make_stubs() { + local bin="$1" + mkdir -p "${bin}" + cat > "${bin}/aws" << 'AWS' +#!/usr/bin/env bash +set -euo pipefail +state_dir="${STUB_STATE}" +if [[ "${1:-}" == "s3" ]]; then + cat "${state_dir}/pointer.json" + exit 0 +fi +cat "${state_dir}/distribution.json" +AWS + cat > "${bin}/curl" << 'CURL' +#!/usr/bin/env bash +set -euo pipefail +state_dir="${STUB_STATE}" +method="GET" +url="" +dump="" +output="" +write_out="" +args=("$@") +i=0 +while [[ $i -lt ${#args[@]} ]]; do + arg="${args[$i]}" + case "${arg}" in + -X) i=$((i + 1)); method="${args[$i]}" ;; + -D) i=$((i + 1)); dump="${args[$i]}" ;; + -o) i=$((i + 1)); output="${args[$i]}" ;; + -w) i=$((i + 1)); write_out="${args[$i]}" ;; + -H|--max-time|-s|-S|-f|-fsS|-sS) ;; + http*) url="${arg}" ;; + esac + i=$((i + 1)) +done +if [[ "${method}" == "OPTIONS" ]]; then + [[ -n "${dump}" ]] && printf 'HTTP/1.1 204 No Content\nAccess-Control-Allow-Origin: https://dev.seahaven.com\n\n' > "${dump}" + [[ -n "${write_out}" ]] && printf '204' + exit 0 +fi +if [[ "${url}" == *"/assets/"* ]]; then + [[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: public,max-age=31536000,immutable\n\n' > "${dump}" + [[ -n "${output}" ]] && printf 'asset' > "${output}" + [[ -z "${output}" ]] && printf 'asset' + exit 0 +fi +body="$(cat "${state_dir}/index.html")" +[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}" +if [[ -n "${output}" ]]; then + printf '%s' "${body}" > "${output}" +else + printf '%s' "${body}" +fi +exit 0 +CURL + chmod +x "${bin}/aws" "${bin}/curl" +} + +dist_json() { + local status="$1" current_path="$2" + python3 -c 'import json,sys +status, path = sys.argv[1], sys.argv[2] +print(json.dumps({ + "Distribution": { + "Status": status, + "DistributionConfig": { + "Origins": {"Items": [ + {"Id": "current", "OriginPath": path}, + {"Id": "previous", "OriginPath": ""}, + ]} + } + } +}))' "${status}" "${current_path}" +} + +pointer_json() { + python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2" +} + +run_case() { + local name="$1" + local dir + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" + export PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" + export EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" + export PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" + export SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=3 + export INTERVAL=0 + local log="${dir}/log.txt" + set +e + bash "${VERIFY}" > "${log}" 2>&1 + local code=$? + set -e + assert_exit "${name}" "$2" "${code}" "${log}" + rm -rf "${dir}" +} + +# 1. Right config, then propagates (InProgress -> Deployed, hash already matches). +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + printf 'InProgress\n' > "${dir}/status" + cat > "${dir}/bin/aws" << AWS +#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "s3" ]]; then + cat "${dir}/pointer.json" + exit 0 +fi +status="\$(cat "${dir}/status")" +python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}" +echo Deployed > "${dir}/status" +AWS + chmod +x "${dir}/bin/aws" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=5 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt" + rm -rf "${dir}" +} + +# 2. Right config never propagates (Deployed, stale hash). +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf 'stale' > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt" + grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 3. Wrong origin path fails fast. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt" + grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 4. Wrong pointer fails fast. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt" + grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 5. Never Deployed. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "never-deployed" 1 "${code}" "${dir}/log.txt" + grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: never-deployed missing last observed state" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +if [[ "${failures}" -ne 0 ]]; then + echo "FAIL: ${failures} verify-cloudfront-release cases failed" >&2 + exit 1 +fi +echo "PASS: CloudFront release verify checks" diff --git a/scripts/testdata/terraform-release-plans/create.json b/scripts/testdata/terraform-release-plans/create.json new file mode 100644 index 00000000..7933f203 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/create.json @@ -0,0 +1,94 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["create"], + "before": null, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}" + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/delete.json b/scripts/testdata/terraform-release-plans/delete.json new file mode 100644 index 00000000..4c056b51 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/delete.json @@ -0,0 +1,94 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["delete"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}" + }, + "after": null + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/dns-update.json b/scripts/testdata/terraform-release-plans/dns-update.json new file mode 100644 index 00000000..940a2da3 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/dns-update.json @@ -0,0 +1,116 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + }, + { + "address": "module.environment_owned.aws_route53_record.site_a", + "mode": "managed", + "type": "aws_route53_record", + "change": { + "actions": ["update"], + "before": { + "ttl": 60 + }, + "after": { + "ttl": 300 + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/empty.json b/scripts/testdata/terraform-release-plans/empty.json new file mode 100644 index 00000000..49edaf62 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/empty.json @@ -0,0 +1,9 @@ +{ + "resource_changes": [], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/extra-action.json b/scripts/testdata/terraform-release-plans/extra-action.json new file mode 100644 index 00000000..758347e8 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/extra-action.json @@ -0,0 +1,106 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + }, + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release_extra", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/extra-origin-change.json b/scripts/testdata/terraform-release-plans/extra-origin-change.json new file mode 100644 index 00000000..734aed73 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/extra-origin-change.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 20, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/iam-update.json b/scripts/testdata/terraform-release-plans/iam-update.json new file mode 100644 index 00000000..e4a1019c --- /dev/null +++ b/scripts/testdata/terraform-release-plans/iam-update.json @@ -0,0 +1,116 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + }, + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["update"], + "before": { + "policy": "{}" + }, + "after": { + "policy": "{\"Version\":\"2012-10-17\"}" + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/missing-action.json b/scripts/testdata/terraform-release-plans/missing-action.json new file mode 100644 index 00000000..be48195f --- /dev/null +++ b/scripts/testdata/terraform-release-plans/missing-action.json @@ -0,0 +1,97 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [] +} diff --git a/scripts/testdata/terraform-release-plans/multiple-updates.json b/scripts/testdata/terraform-release-plans/multiple-updates.json new file mode 100644 index 00000000..a4b5e869 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/multiple-updates.json @@ -0,0 +1,130 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + }, + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["update"], + "before": { + "policy": "{}" + }, + "after": { + "policy": "{\"Version\":\"2012-10-17\"}" + } + } + }, + { + "address": "module.environment_owned.aws_route53_record.site_a", + "mode": "managed", + "type": "aws_route53_record", + "change": { + "actions": ["update"], + "before": { + "ttl": 60 + }, + "after": { + "ttl": 300 + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/nested-unknown.json b/scripts/testdata/terraform-release-plans/nested-unknown.json new file mode 100644 index 00000000..2c8a4400 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/nested-unknown.json @@ -0,0 +1,105 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true, + "tags": { + "Environment": true + } + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/replace.json b/scripts/testdata/terraform-release-plans/replace.json new file mode 100644 index 00000000..c37fb1aa --- /dev/null +++ b/scripts/testdata/terraform-release-plans/replace.json @@ -0,0 +1,58 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["delete", "create"], + "before": { + "origin": [] + }, + "after": { + "origin": [] + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/unknown-only.json b/scripts/testdata/terraform-release-plans/unknown-only.json new file mode 100644 index 00000000..8ef737df --- /dev/null +++ b/scripts/testdata/terraform-release-plans/unknown-only.json @@ -0,0 +1,103 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true, + "comment": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/version-only.json b/scripts/testdata/terraform-release-plans/version-only.json new file mode 100644 index 00000000..f9b5a86c --- /dev/null +++ b/scripts/testdata/terraform-release-plans/version-only.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/wrong-before.json b/scripts/testdata/terraform-release-plans/wrong-before.json new file mode 100644 index 00000000..478c7f6f --- /dev/null +++ b/scripts/testdata/terraform-release-plans/wrong-before.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/wrong-label.json b/scripts/testdata/terraform-release-plans/wrong-label.json new file mode 100644 index 00000000..c823a2e8 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/wrong-label.json @@ -0,0 +1,102 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"cccccccccccccccccccccccccccccccccccccccc-3-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/cccccccccccccccccccccccccccccccccccccccc-3-1" + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/upload-sourcemaps.sh b/scripts/upload-sourcemaps.sh index c557b6c5..a5a91781 100755 --- a/scripts/upload-sourcemaps.sh +++ b/scripts/upload-sourcemaps.sh @@ -6,15 +6,19 @@ set -euo pipefail SENTRY_ORG="${SENTRY_ORG:-seahaven}" SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}" COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" +RELEASE_LABEL="${SENTRY_RELEASE:-${RELEASE_LABEL:-}}" -if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2 - exit 1 +if [[ -n "${RELEASE_LABEL}" ]]; then + RELEASE="${RELEASE_LABEL}" +else + if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2 + exit 1 + fi + COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')" + RELEASE="shoc-frontend@${COMMIT_SHA}" fi -COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')" -RELEASE="shoc-frontend@${COMMIT_SHA}" - npm exec --no -- sentry-cli sourcemaps upload \ --org "${SENTRY_ORG}" \ --project "${SENTRY_PROJECT}" \ diff --git a/scripts/verify-cloudfront-release.sh b/scripts/verify-cloudfront-release.sh new file mode 100755 index 00000000..e46ee221 --- /dev/null +++ b/scripts/verify-cloudfront-release.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Verify a CloudFront content release or rollback. +# +# Fail fast when origin_path or .release/current is the wrong label. +# Poll while the distribution is InProgress or the served index.html hash +# still matches the previous release. On timeout, print last observed state. +set -euo pipefail + +DISTRIBUTION_ID="${DISTRIBUTION_ID:-}" +EXPECTED_LABEL="${EXPECTED_LABEL:-}" +EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}" +SITE_URL="${SITE_URL:-}" +SITE_BUCKET="${SITE_BUCKET:-}" +PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}" +API_URL="${API_URL:-https://api.dev.seahaven.com/api}" +BUDGET="${BUDGET:-40}" +INTERVAL="${INTERVAL:-15}" + +if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then + echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2 + exit 2 +fi + +SITE_URL="${SITE_URL%/}" +if [[ -n "${EXPECTED_LABEL}" ]]; then + EXPECTED_PATH="/releases/${EXPECTED_LABEL}" +else + EXPECTED_PATH="" +fi + +sha256_of() { + python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" +} + +read_pointer() { + aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true +} + +read_distribution_json() { + aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json +} + +parse_distribution() { + python3 -c ' +import json, os, sys +payload = json.load(sys.stdin) +dist = payload.get("Distribution") or payload +status = dist.get("Status") or "Unknown" +config = dist.get("DistributionConfig") or {} +origins = ((config.get("Origins") or {}).get("Items")) or [] +paths = [origin.get("OriginPath") or "" for origin in origins] +expected = os.environ["EXPECTED_PATH"] +print(status) +print("\x1f".join(paths)) +print("yes" if expected in paths else "no") +' +} + +pointer_current() { + POINTER_BODY="$1" python3 -c ' +import json, os +raw = os.environ.get("POINTER_BODY", "").strip() +if not raw: + print("") + raise SystemExit +print(json.loads(raw).get("current") or "") +' +} + +last_status="Unknown" +last_paths="Unknown" +last_pointer="Unknown" +last_hash="Unknown" +last_path_ok="no" + +observe() { + last_pointer="$(read_pointer)" + local parsed + parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)" + last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')" + last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')" + last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')" + local body + body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)" + if [[ -n "${body}" ]]; then + last_hash="$(printf '%s' "${body}" | sha256_of)" + else + last_hash="unreachable" + fi +} + +report_state() { + echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}" +} + +fail_fast_if_misconfigured() { + local current + current="$(pointer_current "${last_pointer}")" + if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then + echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2 + report_state >&2 + exit 1 + fi + if [[ "${last_path_ok}" != "yes" ]]; then + echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2 + report_state >&2 + exit 1 + fi +} + +observe +fail_fast_if_misconfigured + +attempt=0 +while [[ "${attempt}" -lt "${BUDGET}" ]]; do + attempt=$((attempt + 1)) + echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}" + fail_fast_if_misconfigured + if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then + break + fi + sleep "${INTERVAL}" + observe +done + +if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA256}" ]]; then + echo "FAIL: release did not converge within the budget." >&2 + report_state >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers" +curl -fsS --max-time 30 "${SITE_URL}/login" -o "${tmp}/login.html" +curl -fsS --max-time 30 "${SITE_URL}/work-orders" -o "${tmp}/route.html" +if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then + echo "FAIL: HTML Cache-Control is missing no-store." >&2 + exit 1 +fi +for forbidden in api.staging.seahaven.com localhost:5141; do + if grep -Fq "${forbidden}" "${tmp}/index.html"; then + echo "FAIL: served index contains forbidden URL ${forbidden}." >&2 + exit 1 + fi +done +if ! grep -Fq "api.dev.seahaven.com" "${tmp}/index.html"; then + echo "FAIL: served index is missing the dev API URL." >&2 + exit 1 +fi + +asset_path="$(python3 -c 'import re,sys; html=open(sys.argv[1],encoding="utf-8").read(); m=re.search(r"(/assets/[^\"'\'']+)", html); print(m.group(1) if m else "")' "${tmp}/index.html")" +if [[ -z "${asset_path}" ]]; then + echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2 + exit 1 +fi +curl -fsS --max-time 30 "${SITE_URL}${asset_path}" -o /dev/null -D "${tmp}/asset.headers" +if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then + echo "FAIL: hashed asset is missing Cache-Control immutable." >&2 + exit 1 +fi + +cors_code="$(curl -sS --max-time 30 -o /dev/null -D "${tmp}/cors.headers" -w '%{http_code}' -X OPTIONS "${API_URL}" \ + -H "Origin: ${SITE_URL}" \ + -H "Access-Control-Request-Method: GET")" +if [[ "${cors_code}" != "200" && "${cors_code}" != "204" ]]; then + echo "FAIL: CORS preflight returned HTTP ${cors_code}." >&2 + exit 1 +fi +if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then + echo "FAIL: CORS preflight is missing Access-Control-Allow-Origin." >&2 + exit 1 +fi + +echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean." +report_state diff --git a/terraform/README.md b/terraform/README.md index 190dd53b..f466e8d6 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -4,11 +4,11 @@ This tree adopts the existing Sea Haven SHOC frontend dev hosting resources into HCP Terraform without recreating them. It mirrors the backend adoption (`shoc-backend` #94, #98, #99, #102) and lands in three PRs: -| PR | Branch | Change | -| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| A | `feature/frontend-terraform-adoption` | Merged. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. | -| B | `feature/terraform-dev-adoption` | This PR. `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. | -| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. | +| PR | Branch | Change | +| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. | +| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. | +| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, rollback. | Creating these files, formatting them, initializing with `-backend=false`, and validating them does not authorize an AWS, HCP Terraform, GitHub, @@ -42,7 +42,7 @@ before the first release after any Terraform merge: `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`). + `>= 1.14.0, < 2.0.0`; CI validates with `1.16.0`). - Dynamic AWS credentials only: environment variables `TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and `TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan` @@ -54,7 +54,8 @@ before the first release after any Terraform merge: ## Ownership boundary -`live/modules/environment-owned` owns exactly these 13 addresses: +`live/modules/environment-owned` owns these 14 addresses (13 imported hosting +resources plus the release pointer created in Phase 3): 1. `module.environment_owned.aws_s3_bucket.site` 2. `module.environment_owned.aws_s3_bucket_public_access_block.site` @@ -69,7 +70,10 @@ before the first release after any Terraform merge: 11. `module.environment_owned.aws_route53_record.site_aaaa` 12. `module.environment_owned.aws_iam_role.github_deploy` 13. `module.environment_owned.aws_iam_role_policy.github_deploy` +14. `module.environment_owned.aws_s3_object.release_pointer` +The CloudFront invalidation is a Terraform action +(`action.aws_cloudfront_create_invalidation.release`), not a managed resource. Every managed resource has `prevent_destroy = true`. `live/modules/environment-inventory` is data-only. It resolves and checks the @@ -135,15 +139,8 @@ 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 - ``` +2. **CDK retain deploy.** Completed from the reviewed PR A head. The CDK app + is no longer in this repository. Expected: an update-only change set (no create, no delete, no replace) that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the @@ -175,7 +172,7 @@ Each step is gated. State the impact, get the go, act, read back, record. After Phase 1 CloudFormation still owns every resource. Terraform holds state for them and nothing else. -## Phase 2: controlled ownership transfer (this PR) +## Phase 2: controlled ownership transfer (merged #178) PR B pins `adoption_complete = true`. The controlled apply may update only: @@ -201,23 +198,58 @@ python3 scripts/check-terraform-import-plan.py plan.json --environment dev \ --allow-update-address module.environment_owned.aws_iam_role.github_deploy ``` -After the apply and a no-op plan, deploy the same reviewed CDK SHA with -`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the -13 transferred resources and the custom resource. Never deploy with -`ManageSiteInfrastructure=true` again after that. See -[`infra/cdk/README.md`](../infra/cdk/README.md). +After the apply and a no-op plan, the CDK stack was relinquished with +`ManageSiteInfrastructure=false`. Never deploy that stack with +`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C. -Confirm `dev.seahaven.com` still serves and that a manual `workflow_dispatch` -of `deploy.yml` can still upload with the unchanged GitHub content policy. +Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual +`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged +GitHub content policy. PR C replaces that policy with the release-prefix +document during bootstrap. -## Phase 3: content CD through Terraform (PR C) +## Phase 3: content CD through Terraform (this PR) -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`. +GitHub builds the SPA and uploads only `releases/--/`. +The GitHub role may `GetObject` on `.release/current` and read the exact +distribution (`GetDistribution` / `GetDistributionConfig`) so verify and +live-state summary can observe origin paths. It cannot invalidate or write +the pointer. Terraform owns `.release/current`, both origin paths of the +CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one +guarded Terraform run that swaps the labels. Push-to-`dev` stays off until +`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls +`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287). + +Release vars `release_version_label` and `previous_release_version_label` are +nullable, default null, and must not be set on the workspace or in tfvars. +Null VCS plans read the pointer back from S3. Empty string is the legacy root +layout. + +Per GitHub content release after bootstrap: exactly two managed updates plus +one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py` +accepts a plan that updates only the pointer `content` and +`origin[*].origin_path`, with `after` equal to the expected labels, `before` +equal to the pointer's prior values, and exactly one invalidation +`action_invocations` entry. + +The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates +`.release/current` (legacy empty labels), adds the previous origin and origin +group, switches the default behavior to the group, replaces the GitHub inline +policy with the release-prefix document, and invokes invalidation. A human +confirms that apply. GitHub CD starts only after bootstrap is applied. + +Activation (each step gated; do not run without an explicit go): + +1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard + the HCP VCS run. Apply bootstrap as a human-confirmed controlled update. +2. Re-read workspace invariants (auto-apply off, speculative on, trigger + patterns only, no prefixes, no tags-regex). +3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation, + smoke, and rollback readiness from the live-state summary. +4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner + approval. +5. Confirm the first push-to-`dev` run. Close SH-300 on that proof. + +A red job does not mean the site is down. Read the live-state summary first. ## Operational rules @@ -240,9 +272,9 @@ the labels. Push-to-`dev` releases return behind the repository variable 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. +- **A red job does not mean the site is down.** Read the live-state summary + first (served `index.html` hash, distribution status, pointer body, both + origin paths), then triage. - **Exact-head evidence.** Every live step records the run URL, the SHA, and a machine-readable read-back on the PR or SH-300. @@ -254,8 +286,11 @@ From the repository root (also run by `npm run verify` through ```bash npm run test:terraform # fmt -check, init -backend=false, validate npm run test:terraform-import-plan # checker unit tests against synthetic plans +npm run test:terraform-release-plan # content-release plan guard npm run test:terraform-isolation # isolation gate unit tests -npm run test:infra # CDK build, template tests, synth in both modes +npm run test:hcp-run-guard # workspace invariant and apply reconcile +npm run test:cloudfront-release-verify +npm run test:github-workflows # bash -n and actionlint ``` `terraform init -backend=false -lockfile=readonly` may download the provider diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf index f11f75d4..f926ffd1 100644 --- a/terraform/live/dev/main.tf +++ b/terraform/live/dev/main.tf @@ -90,4 +90,6 @@ module "environment_owned" { 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) + release_version_label = var.release_version_label + previous_release_version_label = var.previous_release_version_label } diff --git a/terraform/live/dev/outputs.tf b/terraform/live/dev/outputs.tf index 726ee1e8..8b9e94c5 100644 --- a/terraform/live/dev/outputs.tf +++ b/terraform/live/dev/outputs.tf @@ -9,3 +9,11 @@ output "distribution_id" { output "deploy_role_arn" { value = module.environment_owned.deploy_role_arn } + +output "current_origin_id" { + value = module.environment_owned.current_origin_id +} + +output "previous_origin_id" { + value = module.environment_owned.previous_origin_id +} diff --git a/terraform/live/dev/variables.tf b/terraform/live/dev/variables.tf new file mode 100644 index 00000000..b280e421 --- /dev/null +++ b/terraform/live/dev/variables.tf @@ -0,0 +1,33 @@ +variable "release_version_label" { + type = string + default = null + nullable = true + + description = "Immutable content release label. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.release_version_label == null || + var.release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label)) + ) + error_message = "release_version_label must be empty or --." + } +} + +variable "previous_release_version_label" { + type = string + default = null + nullable = true + + description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.previous_release_version_label == null || + var.previous_release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label)) + ) + error_message = "previous_release_version_label must be empty or --." + } +} diff --git a/terraform/live/dev/versions.tf b/terraform/live/dev/versions.tf index 9e341837..b8a94b0c 100644 --- a/terraform/live/dev/versions.tf +++ b/terraform/live/dev/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.9.0, < 2.0.0" + required_version = ">= 1.14.0, < 2.0.0" cloud { organization = "seahaven" diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index 6a0e2a61..1bf36612 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -5,6 +5,24 @@ locals { 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 + previous_origin_id = "${var.origin_id}-previous" + origin_group_id = "${var.origin_id}-group" + pointer_key = ".release/current" + pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {}) + # coalesce() skips empty strings, so a null var plus a missing pointer + # would error. Empty string is the legacy root layout and must be valid. + current_label = ( + var.release_version_label != null + ? var.release_version_label + : try(local.pointer_body.current, "") + ) + previous_label = ( + var.previous_release_version_label != null + ? var.previous_release_version_label + : try(local.pointer_body.previous, "") + ) + current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}" + previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}" spa_rewrite_code = join("\n", [ "function handler(event) {", @@ -19,6 +37,17 @@ locals { ]) } +data "aws_s3_objects" "release_prefix" { + bucket = aws_s3_bucket.site.bucket + prefix = ".release/" +} + +data "aws_s3_object" "release_pointer" { + count = contains(coalesce(data.aws_s3_objects.release_prefix.keys, []), local.pointer_key) ? 1 : 0 + bucket = aws_s3_bucket.site.bucket + key = local.pointer_key +} + data "aws_iam_policy_document" "site_bucket" { dynamic "statement" { for_each = var.adoption_complete ? [] : [1] @@ -109,53 +138,48 @@ data "aws_iam_policy_document" "github_deploy_assume" { } data "aws_iam_policy_document" "github_deploy" { - # Byte-identical to the live GitHub content policy through Phase 2 so - # aws_iam_role_policy.github_deploy stays no-op. Phase 3 replaces this - # with the release-prefix policy. - dynamic "statement" { - for_each = var.environment == "dev" ? [1] : [] + statement { + sid = "ListReleasePrefixes" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = [local.bucket_arn] - content { - sid = "AssumeCdkBootstrapRoles" - effect = "Allow" - actions = ["sts:AssumeRole"] - resources = ["arn:aws:iam::${var.aws_account_id}:role/cdk-hnb659fds-*"] + condition { + test = "StringLike" + variable = "s3:prefix" + values = [ + "releases/", + "releases/*", + ] } } statement { - sid = "DescribeStack" - effect = "Allow" - actions = ["cloudformation:DescribeStacks"] - resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"] - } - - statement { + sid = "PublishReleasePrefix" effect = "Allow" actions = [ - "s3:Abort*", - "s3:DeleteObject*", - "s3:GetBucket*", - "s3:GetObject*", - "s3:List*", + "s3:GetObject", "s3:PutObject", - "s3:PutObjectLegalHold", - "s3:PutObjectRetention", - "s3:PutObjectTagging", - "s3:PutObjectVersionTagging", - ] - resources = [ - local.bucket_arn, - "${local.bucket_arn}/*", ] + resources = ["${local.bucket_arn}/releases/*"] } statement { - sid = "InvalidateDistribution" + sid = "ReadReleasePointer" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["${local.bucket_arn}/${local.pointer_key}"] + } + + statement { + sid = "ReadDistribution" effect = "Allow" actions = [ - "cloudfront:CreateInvalidation", - "cloudfront:GetInvalidation", + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", ] resources = [local.distribution_arn] } @@ -233,6 +257,20 @@ resource "aws_s3_bucket_policy" "site" { } } +resource "aws_s3_object" "release_pointer" { + bucket = aws_s3_bucket.site.bucket + key = local.pointer_key + content_type = "application/json" + content = jsonencode({ + current = local.current_label + previous = local.previous_label + }) + + lifecycle { + prevent_destroy = true + } +} + resource "aws_cloudfront_origin_access_control" "site" { name = var.origin_access_control_name description = var.origin_access_control_description @@ -275,6 +313,32 @@ resource "aws_cloudfront_distribution" "site" { 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 + origin_path = local.current_origin_path + } + + 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 = local.previous_origin_id + origin_path = local.previous_origin_path + } + + origin_group { + origin_id = local.origin_group_id + + failover_criteria { + status_codes = [403, 404] + } + + member { + origin_id = var.origin_id + } + + member { + origin_id = local.previous_origin_id + } } default_cache_behavior { @@ -282,7 +346,7 @@ resource "aws_cloudfront_distribution" "site" { cache_policy_id = var.cache_policy_id cached_methods = ["GET", "HEAD"] compress = true - target_origin_id = var.origin_id + target_origin_id = local.origin_group_id viewer_protocol_policy = "redirect-to-https" function_association { @@ -305,6 +369,18 @@ resource "aws_cloudfront_distribution" "site" { lifecycle { prevent_destroy = true + + action_trigger { + events = [after_update] + actions = [action.aws_cloudfront_create_invalidation.release] + } + } +} + +action "aws_cloudfront_create_invalidation" "release" { + config { + distribution_id = aws_cloudfront_distribution.site.id + paths = ["/*"] } } diff --git a/terraform/live/modules/environment-owned/outputs.tf b/terraform/live/modules/environment-owned/outputs.tf index 44f519a7..ef7ebee9 100644 --- a/terraform/live/modules/environment-owned/outputs.tf +++ b/terraform/live/modules/environment-owned/outputs.tf @@ -12,3 +12,33 @@ output "deploy_role_arn" { value = aws_iam_role.github_deploy.arn description = "Imported GitHub deployment role ARN." } + +output "current_release_label" { + value = local.current_label + description = "Pointer current release label. Empty string is the legacy root layout." +} + +output "previous_release_label" { + value = local.previous_label + description = "Pointer previous release label. Empty string is the legacy root layout." +} + +output "current_origin_path" { + value = local.current_origin_path + description = "CloudFront origin_path for the current member of the origin group." +} + +output "previous_origin_path" { + value = local.previous_origin_path + description = "CloudFront origin_path for the previous member of the origin group." +} + +output "current_origin_id" { + value = var.origin_id + description = "CloudFront origin ID for the current release." +} + +output "previous_origin_id" { + value = local.previous_origin_id + description = "CloudFront origin ID for the previous release." +} diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index 69434ebd..05d6b7a9 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -10,10 +10,44 @@ variable "environment" { variable "adoption_complete" { type = bool - description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy. The GitHub deploy inline policy stays byte-identical to live until the content-CD PR." + description = "Switches ownership tags and drops the auto-delete helper grant from the bucket policy." default = false } +variable "release_version_label" { + type = string + default = null + nullable = true + + description = "Immutable content release label. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.release_version_label == null || + var.release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label)) + ) + error_message = "release_version_label must be empty or --." + } +} + +variable "previous_release_version_label" { + type = string + default = null + nullable = true + + description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3." + + validation { + condition = ( + var.previous_release_version_label == null || + var.previous_release_version_label == "" || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label)) + ) + error_message = "previous_release_version_label must be empty or --." + } +} + variable "aws_account_id" { type = string description = "AWS account containing the resources." From 7716b4afc86ea31cab284db63a654f770d2d864b Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:26:19 -0400 Subject: [PATCH 13/16] fix(ci): confirm release prefix with s3api head-object (SH-300) (#182) grep -q closed the aws s3 ls pipe after a successful upload and failed the deploy. --- .github/workflows/deploy.yml | 4 +++- scripts/test-terraform-release-plan-check.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4423e6ce..c276aa2c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -155,7 +155,9 @@ jobs: aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \ --cache-control "no-cache,no-store,must-revalidate" \ --content-type "text/html" - aws s3 ls "s3://${SITE_BUCKET}/${prefix}/" | grep -q index.html + aws s3api head-object \ + --bucket "${SITE_BUCKET}" \ + --key "${prefix}/index.html" index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')" echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}" echo "Uploaded ${prefix}; index.html sha256=${index_sha}" diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py index 70e436b7..4cfaac79 100755 --- a/scripts/test-terraform-release-plan-check.py +++ b/scripts/test-terraform-release-plan-check.py @@ -280,6 +280,20 @@ def test_deploy_workflow_uses_script_flags() -> list[str]: return failures +def test_deploy_workflow_confirms_prefix_with_head_object() -> list[str]: + workflow = ( + Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml" + ).read_text(encoding="utf-8") + failures: list[str] = [] + if "aws s3api head-object" not in workflow: + failures.append( + "deploy.yml must confirm the uploaded index.html with s3api head-object" + ) + if "aws s3 ls" in workflow: + failures.append("deploy.yml must not list the prefix with aws s3 ls") + return failures + + def main() -> int: cases = [ ("version-only", run_case("version-only.json"), 0), @@ -307,6 +321,7 @@ def main() -> int: redirect_failures = test_download_standard_opener_redirect() download_failures.extend(redirect_failures) download_failures.extend(test_deploy_workflow_uses_script_flags()) + download_failures.extend(test_deploy_workflow_confirms_prefix_with_head_object()) if failures or download_failures: if failures: print( From f23c60ccc20fd3dfde98a44a79a92bd53c1399ea Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:17:36 -0400 Subject: [PATCH 14/16] fix(ci): skip duplicate verify on content CD and ignore origin timeout drift (SH-300) (#183) * fix(terraform): ignore origin response_completion_timeout in the release plan guard (SH-300) AWS returns 0 when the timeout is unset. The provider writes null on origin_path updates, so the first real CD plan failed closed. * fix(ci): drop duplicate verify from the content CD workflow (SH-300) Frontend checks already runs verify on PRs and pushes. Removing the validate job also requires dropping needs: validate so dispatch can run. * fix(terraform): equate origin timeout 0 and null only (SH-300) Numeric timeout changes still fail closed. Rename the filter so it is not read as an after_unknown allowlist. --- .github/workflows/deploy.yml | 56 +--------- scripts/check-terraform-release-plan.py | 18 ++- scripts/test-terraform-release-plan-check.py | 17 +++ .../origin-timeout-change.json | 104 +++++++++++++++++ .../origin-timeout-normalization.json | 105 ++++++++++++++++++ 5 files changed, 245 insertions(+), 55 deletions(-) create mode 100644 scripts/testdata/terraform-release-plans/origin-timeout-change.json create mode 100644 scripts/testdata/terraform-release-plans/origin-timeout-normalization.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c276aa2c..a388a043 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,13 +1,14 @@ -name: Validate and deploy +name: Deploy dev content # Dev content CD through Terraform (SH-300). GitHub uploads an immutable # releases/--/ prefix. Terraform owns the pointer, origin # group, and invalidation. Push-to-dev stays off until # vars.TERRAFORM_CONTENT_CD_ENABLED is the string true. +# +# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not +# re-run those gates on pull requests, pushes, or workflow_dispatch. on: - pull_request: - branches: [dev] push: branches: [dev] paths-ignore: @@ -18,61 +19,12 @@ permissions: contents: read jobs: - validate: - name: Validate production build - 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" - cache: npm - - name: Set up Terraform - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.16.0" - terraform_wrapper: false - - name: Install actionlint - env: - ACTIONLINT_VERSION: "1.7.12" - ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 - run: | - set -euo pipefail - curl -fsSL -o actionlint.tar.gz \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - - tar -xzf actionlint.tar.gz actionlint - sudo mv actionlint /usr/local/bin/actionlint - - name: Quality gates - run: npm ci && npm run verify - env: - GOVERNANCE_BASE: ${{ github.event.pull_request.base.sha || 'origin/dev' }} - - name: Build with pinned API URL - env: - VITE_API_URL: https://api.dev.seahaven.com/api - VITE_APP_COMMIT_SHA: ${{ github.sha }} - run: | - set -euo pipefail - npm run build - if grep -Rq "api.staging.seahaven.com" dist/; then - echo "::error::Built assets contain the staging API URL." >&2 - exit 1 - fi - if grep -Rq "localhost:5141" dist/; then - echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 - exit 1 - fi - grep -Rq "api.dev.seahaven.com" dist/ - deploy-dev: name: Deploy shoc-frontend-new-dev through Terraform if: > (github.event_name == 'push' && github.ref == 'refs/heads/dev' && vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') - needs: validate runs-on: ubuntu-latest timeout-minutes: 180 permissions: diff --git a/scripts/check-terraform-release-plan.py b/scripts/check-terraform-release-plan.py index 1d713891..03ae590f 100755 --- a/scripts/check-terraform-release-plan.py +++ b/scripts/check-terraform-release-plan.py @@ -4,6 +4,7 @@ Accepts exactly: - an update of the release pointer (content, plus computed etag/version_id) - an update of the distribution with only origin[*].origin_path changed + (response_completion_timeout 0, null, and a missing key are equivalent) - exactly one action invocation for the CloudFront invalidation after origin_path values must match the expected labels. before origin_path @@ -52,6 +53,11 @@ DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset( "in_progress_validation_batches", } ) +# Not an after_unknown allowlist. AWS returns 0 when the timeout is unset; +# the provider writes null on origin_path updates. Treat 0, null, and a +# missing key as the same. Any other value still fails closed. +ORIGIN_RESPONSE_COMPLETION_TIMEOUT = "response_completion_timeout" +ORIGIN_TIMEOUT_UNSET = frozenset({0, None}) REDIRECT_STATUSES = {301, 302, 303, 307, 308} UrlOpen = Callable[..., Any] @@ -302,10 +308,16 @@ def _validate_pointer( return violations +def _origin_fields_for_compare(origin: dict[str, Any]) -> dict[str, Any]: + rest = {key: value for key, value in origin.items() if key != "origin_path"} + timeout = rest.get(ORIGIN_RESPONSE_COMPLETION_TIMEOUT) + if timeout in ORIGIN_TIMEOUT_UNSET: + rest.pop(ORIGIN_RESPONSE_COMPLETION_TIMEOUT, None) + return rest + + def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool: - before_rest = {key: value for key, value in before.items() if key != "origin_path"} - after_rest = {key: value for key, value in after.items() if key != "origin_path"} - return before_rest != after_rest + return _origin_fields_for_compare(before) != _origin_fields_for_compare(after) def _validate_distribution( diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py index 4cfaac79..d050701e 100755 --- a/scripts/test-terraform-release-plan-check.py +++ b/scripts/test-terraform-release-plan-check.py @@ -291,12 +291,29 @@ def test_deploy_workflow_confirms_prefix_with_head_object() -> list[str]: ) if "aws s3 ls" in workflow: failures.append("deploy.yml must not list the prefix with aws s3 ls") + if any( + line.lstrip().startswith("run:") and "npm run verify" in line + for line in workflow.splitlines() + ): + failures.append( + "deploy.yml must not re-run npm run verify; Frontend checks owns that gate" + ) + if any(line.lstrip().startswith("pull_request:") for line in workflow.splitlines()): + failures.append( + "deploy.yml must not run on pull_request; Frontend checks owns PR verify" + ) return failures def main() -> int: cases = [ ("version-only", run_case("version-only.json"), 0), + ( + "origin-timeout-normalization", + run_case("origin-timeout-normalization.json"), + 0, + ), + ("origin-timeout-change", run_case("origin-timeout-change.json"), 1), ("wrong-label", run_case("wrong-label.json"), 1), ("wrong-before", run_case("wrong-before.json"), 1), ("extra-origin-change", run_case("extra-origin-change.json"), 1), diff --git a/scripts/testdata/terraform-release-plans/origin-timeout-change.json b/scripts/testdata/terraform-release-plans/origin-timeout-change.json new file mode 100644 index 00000000..a5f42591 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/origin-timeout-change.json @@ -0,0 +1,104 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "response_completion_timeout": 10 + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "response_completion_timeout": 60 + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/origin-timeout-normalization.json b/scripts/testdata/terraform-release-plans/origin-timeout-normalization.json new file mode 100644 index 00000000..e70aa560 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/origin-timeout-normalization.json @@ -0,0 +1,105 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "response_completion_timeout": 0 + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1", + "response_completion_timeout": 0 + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "response_completion_timeout": null + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} From c30e8aa74b8b5bf21317fd14b68b1db9544529ba Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:38:07 -0400 Subject: [PATCH 15/16] fix(ci): assert the baked API URL in served JS assets (SH-300) (#184) Vite puts VITE_API_URL in hashed JS, not index.html. Scan every referenced /assets file and reject staging or localhost there too. --- scripts/test-verify-cloudfront-release.sh | 55 ++++++++++++++++- scripts/verify-cloudfront-release.sh | 74 ++++++++++++++++------- 2 files changed, 105 insertions(+), 24 deletions(-) diff --git a/scripts/test-verify-cloudfront-release.sh b/scripts/test-verify-cloudfront-release.sh index 8e192eae..fcbc6c2d 100755 --- a/scripts/test-verify-cloudfront-release.sh +++ b/scripts/test-verify-cloudfront-release.sh @@ -8,7 +8,7 @@ CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111" OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000" -INDEX_HTML='api.dev.seahaven.com' +INDEX_HTML='' INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" failures=0 @@ -66,8 +66,13 @@ if [[ "${method}" == "OPTIONS" ]]; then fi if [[ "${url}" == *"/assets/"* ]]; then [[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: public,max-age=31536000,immutable\n\n' > "${dump}" - [[ -n "${output}" ]] && printf 'asset' > "${output}" - [[ -z "${output}" ]] && printf 'asset' + if [[ -f "${state_dir}/asset.js" ]]; then + body="$(cat "${state_dir}/asset.js")" + else + body='const api="https://api.dev.seahaven.com/api";' + fi + [[ -n "${output}" ]] && printf '%s' "${body}" > "${output}" + [[ -z "${output}" ]] && printf '%s' "${body}" exit 0 fi body="$(cat "${state_dir}/index.html")" @@ -244,6 +249,50 @@ AWS rm -rf "${dir}" } +# 6. Hash-matched Deployed release whose JS assets omit the baked API URL. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + printf 'const x=1;' > "${dir}/asset.js" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt" + grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + +# 7. Hash-matched Deployed release whose JS assets contain the staging API URL. +{ + dir="$(mktemp -d)" + make_stubs "${dir}/bin" + pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json" + dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json" + printf '%s' "${INDEX_HTML}" > "${dir}/index.html" + printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js" + export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}" + export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}" + export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}" + export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev" + export BUDGET=2 INTERVAL=0 + set +e + bash "${VERIFY}" > "${dir}/log.txt" 2>&1 + code=$? + set -e + assert_exit "forbidden-staging-api-url" 1 "${code}" "${dir}/log.txt" + grep -q "forbidden URL api.staging.seahaven.com" "${dir}/log.txt" || { echo "FAIL: staging API URL did not name forbidden URL" >&2; failures=$((failures + 1)); } + rm -rf "${dir}" +} + if [[ "${failures}" -ne 0 ]]; then echo "FAIL: ${failures} verify-cloudfront-release cases failed" >&2 exit 1 diff --git a/scripts/verify-cloudfront-release.sh b/scripts/verify-cloudfront-release.sh index e46ee221..2382c37c 100755 --- a/scripts/verify-cloudfront-release.sh +++ b/scripts/verify-cloudfront-release.sh @@ -129,6 +129,57 @@ if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA2 exit 1 fi +write_asset_paths() { + python3 -c ' +import re, sys +html = open(sys.argv[1], encoding="utf-8").read() +seen = [] +for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html): + if path not in seen: + seen.append(path) + print(path) +' "$1" +} + +assert_baked_api_url() { + local tmp="$1" + if [[ ! -s "${tmp}/asset-paths.txt" ]]; then + echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2 + exit 1 + fi + : > "${tmp}/assets.txt" + local immutable_ok="no" + local asset_path + while IFS= read -r asset_path; do + curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \ + -o "${tmp}/asset-body" -D "${tmp}/asset.headers" + cat "${tmp}/asset-body" >> "${tmp}/assets.txt" + if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then + if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then + echo "FAIL: hashed asset is missing Cache-Control immutable." >&2 + exit 1 + fi + immutable_ok="yes" + fi + done < "${tmp}/asset-paths.txt" + if [[ "${immutable_ok}" != "yes" ]]; then + echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2 + exit 1 + fi + cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt" + local forbidden + for forbidden in api.staging.seahaven.com localhost:5141; do + if grep -Fq "${forbidden}" "${tmp}/served.txt"; then + echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2 + exit 1 + fi + done + if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then + echo "FAIL: served JS assets are missing the baked dev API URL." >&2 + exit 1 + fi +} + tmp="$(mktemp -d)" trap 'rm -rf "${tmp}"' EXIT @@ -139,27 +190,8 @@ if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then echo "FAIL: HTML Cache-Control is missing no-store." >&2 exit 1 fi -for forbidden in api.staging.seahaven.com localhost:5141; do - if grep -Fq "${forbidden}" "${tmp}/index.html"; then - echo "FAIL: served index contains forbidden URL ${forbidden}." >&2 - exit 1 - fi -done -if ! grep -Fq "api.dev.seahaven.com" "${tmp}/index.html"; then - echo "FAIL: served index is missing the dev API URL." >&2 - exit 1 -fi - -asset_path="$(python3 -c 'import re,sys; html=open(sys.argv[1],encoding="utf-8").read(); m=re.search(r"(/assets/[^\"'\'']+)", html); print(m.group(1) if m else "")' "${tmp}/index.html")" -if [[ -z "${asset_path}" ]]; then - echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2 - exit 1 -fi -curl -fsS --max-time 30 "${SITE_URL}${asset_path}" -o /dev/null -D "${tmp}/asset.headers" -if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then - echo "FAIL: hashed asset is missing Cache-Control immutable." >&2 - exit 1 -fi +write_asset_paths "${tmp}/index.html" > "${tmp}/asset-paths.txt" +assert_baked_api_url "${tmp}" cors_code="$(curl -sS --max-time 30 -o /dev/null -D "${tmp}/cors.headers" -w '%{http_code}' -X OPTIONS "${API_URL}" \ -H "Origin: ${SITE_URL}" \ From 29aecff2bbb0383fb14d27f42bdf00929c45acac Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:10:51 -0400 Subject: [PATCH 16/16] fix(ci): hash the served index.html byte stream in CloudFront verify (SH-300) (#186) Capturing the curl body in "$(...)" strips the trailing newline, so the served sha256 never matched dist/index.html and every release and rollback verify polled to the budget and failed. Hash the response stream directly and give the test fixture a trailing newline so the suite covers it. --- scripts/test-verify-cloudfront-release.sh | 10 ++++++---- scripts/verify-cloudfront-release.sh | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/test-verify-cloudfront-release.sh b/scripts/test-verify-cloudfront-release.sh index fcbc6c2d..7cc5607b 100755 --- a/scripts/test-verify-cloudfront-release.sh +++ b/scripts/test-verify-cloudfront-release.sh @@ -8,7 +8,9 @@ CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111" OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000" -INDEX_HTML='' +# Vite writes index.html with a trailing newline. Keep it in the fixture so +# the expected hash covers every served byte, exactly like dist/index.html. +INDEX_HTML=$'\n' INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')" failures=0 @@ -75,12 +77,12 @@ if [[ "${url}" == *"/assets/"* ]]; then [[ -z "${output}" ]] && printf '%s' "${body}" exit 0 fi -body="$(cat "${state_dir}/index.html")" +# Serve index.html byte-for-byte, trailing newline included, like real curl. [[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}" if [[ -n "${output}" ]]; then - printf '%s' "${body}" > "${output}" + cat "${state_dir}/index.html" > "${output}" else - printf '%s' "${body}" + cat "${state_dir}/index.html" fi exit 0 CURL diff --git a/scripts/verify-cloudfront-release.sh b/scripts/verify-cloudfront-release.sh index 2382c37c..f0ed3b7c 100755 --- a/scripts/verify-cloudfront-release.sh +++ b/scripts/verify-cloudfront-release.sh @@ -80,10 +80,11 @@ observe() { last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')" last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')" last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')" - local body - body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)" - if [[ -n "${body}" ]]; then - last_hash="$(printf '%s' "${body}" | sha256_of)" + # Hash the response stream directly. Capturing the body in "$(...)" strips + # trailing newlines, so the hash never matched dist/index.html. + local hash + if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then + last_hash="${hash}" else last_hash="unreachable" fi