mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
feat(terraform): add dev root and import guard for HCP adoption
Port the reviewed dev root and environment-owned/inventory modules from
111eb556 with the 13 pinned dev identifiers. adoption_complete is pinned
to false in code; the root has no variables so a workspace variable
cannot change what applies. The tf-poc root, staging root, and tf-poc
map entries are dropped; staging constants stay only for the checker's
cross-environment negative tests.
This commit is contained in:
parent
73346371e5
commit
78398482cf
17 changed files with 2629 additions and 0 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -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]
|
||||
|
|
|
|||
628
scripts/check-terraform-import-plan.py
Normal file
628
scripts/check-terraform-import-plan.py
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject plans that violate the frontend Terraform adoption boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
|
||||
DEPLOY_POLICY_ADDRESS = (
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
)
|
||||
DISTRIBUTION_ADDRESS = (
|
||||
"module.environment_owned.aws_cloudfront_distribution.site"
|
||||
)
|
||||
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
|
||||
BUCKET_POLICY_ADDRESS,
|
||||
DEPLOY_POLICY_ADDRESS,
|
||||
}
|
||||
OWNERSHIP_TAGS = {
|
||||
"Environment": None,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("plan_json", type=Path)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
choices=sorted(REQUIRED_RESOURCES),
|
||||
help="Exact environment ownership boundary expected in the plan.",
|
||||
)
|
||||
modes = parser.add_mutually_exclusive_group()
|
||||
modes.add_argument(
|
||||
"--post-import-no-op",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Require all managed resources to be no-op after import and forbid "
|
||||
"import metadata."
|
||||
),
|
||||
)
|
||||
modes.add_argument(
|
||||
"--allow-update-address",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="ADDRESS",
|
||||
help=(
|
||||
"Enter controlled-update mode and allow one exact reviewed address. "
|
||||
"Repeat for every expected update."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_plan(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("plan JSON root must be an object")
|
||||
if not isinstance(value.get("resource_changes"), list):
|
||||
raise ValueError("plan JSON must contain a resource_changes array")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_import_metadata(
|
||||
*,
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
importing = change.get("importing")
|
||||
if not isinstance(importing, dict) or set(importing) != {"id"}:
|
||||
return [f"{address}: import metadata must be exactly {{'id': <string>}}"]
|
||||
|
||||
import_id = importing.get("id")
|
||||
if not isinstance(import_id, str) or not import_id.strip():
|
||||
return [f"{address}: import ID must be a non-empty string"]
|
||||
if import_id.startswith("REPLACE_WITH_"):
|
||||
return [f"{address}: import ID is still a placeholder"]
|
||||
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
if expected is not None and import_id != expected:
|
||||
return [f"{address}: expected import ID {expected!r}, got {import_id!r}"]
|
||||
|
||||
other_environment_ids = {
|
||||
imports[address]
|
||||
for name, imports in REQUIRED_IMPORT_IDS.items()
|
||||
if name != environment and imports[address] is not None
|
||||
}
|
||||
if import_id in other_environment_ids:
|
||||
return [f"{address}: import ID belongs to another environment"]
|
||||
return []
|
||||
|
||||
|
||||
def _contains_unknown(value: Any) -> bool:
|
||||
if value is True:
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _changed_leaf_paths(
|
||||
before: Any,
|
||||
after: Any,
|
||||
path: tuple[str, ...] = (),
|
||||
) -> set[tuple[str, ...]]:
|
||||
if isinstance(before, dict) and isinstance(after, dict):
|
||||
result: set[tuple[str, ...]] = set()
|
||||
for key in set(before) | set(after):
|
||||
result.update(
|
||||
_changed_leaf_paths(
|
||||
before.get(key),
|
||||
after.get(key),
|
||||
(*path, str(key)),
|
||||
)
|
||||
)
|
||||
return result
|
||||
if before != after:
|
||||
return {path}
|
||||
return set()
|
||||
|
||||
|
||||
def _canonical(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _canonical(value[key]) for key in sorted(value)}
|
||||
if isinstance(value, list):
|
||||
items = [_canonical(item) for item in value]
|
||||
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
|
||||
return value
|
||||
|
||||
|
||||
def _parse_policy(value: Any, address: str, side: str) -> tuple[Any, list[str]]:
|
||||
if not isinstance(value, str):
|
||||
return None, [f"{address}: {side} policy must be a JSON string"]
|
||||
try:
|
||||
document = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None, [f"{address}: {side} policy is not valid JSON"]
|
||||
if not isinstance(document, dict):
|
||||
return None, [f"{address}: {side} policy must be a JSON object"]
|
||||
return _canonical(document), []
|
||||
|
||||
|
||||
def _distribution_id(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
) -> str | None:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
if isinstance(configured, str):
|
||||
return configured
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict) or resource.get("address") != DISTRIBUTION_ADDRESS:
|
||||
continue
|
||||
after = resource.get("change", {}).get("after")
|
||||
if isinstance(after, dict):
|
||||
identifier = after.get("id")
|
||||
if isinstance(identifier, str) and identifier.strip():
|
||||
return identifier
|
||||
return None
|
||||
|
||||
|
||||
def _expected_pre_adoption_bucket_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_pre_adoption_deploy_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
statements: list[dict[str, Any]] = []
|
||||
if environment == "dev":
|
||||
statements.append(
|
||||
{
|
||||
"Sid": "AssumeCdkBootstrapRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
|
||||
}
|
||||
)
|
||||
statements.extend(
|
||||
[
|
||||
{
|
||||
"Sid": "DescribeStack",
|
||||
"Effect": "Allow",
|
||||
"Action": "cloudformation:DescribeStacks",
|
||||
"Resource": (
|
||||
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
|
||||
f"{config['cloudformation_stack_name']}/*"
|
||||
),
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
]
|
||||
)
|
||||
return _canonical({"Version": "2012-10-17", "Statement": statements})
|
||||
|
||||
|
||||
def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadDeploymentBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions",
|
||||
],
|
||||
"Resource": bucket_arn,
|
||||
},
|
||||
{
|
||||
"Sid": "PublishAndRollbackSiteObjects",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjectVersion",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
],
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_tag_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
invalid = {
|
||||
path
|
||||
for path in changed
|
||||
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
|
||||
}
|
||||
violations = [
|
||||
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
|
||||
for path in sorted(invalid)
|
||||
]
|
||||
expected = {**OWNERSHIP_TAGS, "Environment": environment}
|
||||
if address == ROLE_ADDRESS:
|
||||
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
|
||||
"workspace_name"
|
||||
]
|
||||
if address == BUCKET_ADDRESS:
|
||||
expected["aws-cdk:auto-delete-objects"] = None
|
||||
expected_after = {
|
||||
key: value for key, value in expected.items() if value is not None
|
||||
}
|
||||
for tag_attribute in ("tags", "tags_all"):
|
||||
if after.get(tag_attribute) != expected_after:
|
||||
violations.append(
|
||||
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||
)
|
||||
for path in sorted(changed - invalid):
|
||||
key = path[1]
|
||||
if key not in expected:
|
||||
violations.append(f"{address}: tag {key!r} is not an ownership tag")
|
||||
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
|
||||
violations.append(
|
||||
f"{address}: legacy auto-delete ownership tag was not removed"
|
||||
)
|
||||
elif after.get(path[0], {}).get(key) != expected[key]:
|
||||
violations.append(
|
||||
f"{address}: tag {key!r} does not have its expected adopted value"
|
||||
)
|
||||
if not changed:
|
||||
violations.append(f"{address}: update has no changed leaf values")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_policy_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
if changed != {("policy",)}:
|
||||
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
|
||||
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
|
||||
after_policy, after_violations = _parse_policy(
|
||||
after.get("policy"), address, "after"
|
||||
)
|
||||
violations.extend(after_violations)
|
||||
if before_policy == after_policy:
|
||||
violations.append(f"{address}: policy semantics did not change")
|
||||
if distribution_id is None:
|
||||
violations.append(
|
||||
f"{address}: cannot verify policy without the pinned distribution ID"
|
||||
)
|
||||
return violations
|
||||
expected_before = (
|
||||
_expected_pre_adoption_bucket_policy(environment, distribution_id)
|
||||
if address == BUCKET_POLICY_ADDRESS
|
||||
else _expected_pre_adoption_deploy_policy(environment, distribution_id)
|
||||
)
|
||||
expected_after = (
|
||||
_expected_bucket_policy(environment, distribution_id)
|
||||
if address == BUCKET_POLICY_ADDRESS
|
||||
else _expected_deploy_policy(environment, distribution_id)
|
||||
)
|
||||
if before_policy is not None and before_policy != expected_before:
|
||||
violations.append(f"{address}: pre-adoption policy semantics are not exact")
|
||||
if after_policy is not None and after_policy != expected_after:
|
||||
violations.append(f"{address}: post-adoption policy semantics are not exact")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_controlled_update(
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
replace_paths = change.get("replace_paths", [])
|
||||
if replace_paths not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
if _contains_unknown(change.get("after_unknown", {})):
|
||||
violations.append(f"{address}: controlled update contains unknown values")
|
||||
before = change.get("before")
|
||||
after = change.get("after")
|
||||
if not isinstance(before, dict) or not isinstance(after, dict):
|
||||
return [*violations, f"{address}: controlled update requires before/after objects"]
|
||||
if address in TAG_UPDATE_ADDRESSES:
|
||||
violations.extend(_validate_tag_update(address, before, after, environment))
|
||||
elif address in {BUCKET_POLICY_ADDRESS, DEPLOY_POLICY_ADDRESS}:
|
||||
violations.extend(
|
||||
_validate_policy_update(
|
||||
address,
|
||||
before,
|
||||
after,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_plan(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
environment: str,
|
||||
mode: str,
|
||||
allowed_updates: set[str],
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
|
||||
for address in sorted(invalid_allowed):
|
||||
violations.append(
|
||||
f"{address}: address is not eligible for the controlled adoption update"
|
||||
)
|
||||
|
||||
distribution_id = _distribution_id(plan, environment)
|
||||
seen_addresses: set[str] = set()
|
||||
seen_updates: set[str] = set()
|
||||
required_resources = REQUIRED_RESOURCES[environment]
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict):
|
||||
violations.append("<unknown>: resource change must be an object")
|
||||
continue
|
||||
if resource.get("mode", "managed") != "managed":
|
||||
continue
|
||||
address = resource.get("address")
|
||||
if not isinstance(address, str):
|
||||
violations.append("<unknown>: managed resource has no valid address")
|
||||
continue
|
||||
if address in seen_addresses:
|
||||
violations.append(f"{address}: duplicate managed resource change")
|
||||
seen_addresses.add(address)
|
||||
|
||||
expected_type = required_resources.get(address)
|
||||
if expected_type is None:
|
||||
violations.append(f"{address}: managed address is outside the ownership boundary")
|
||||
elif resource.get("type") != expected_type:
|
||||
violations.append(
|
||||
f"{address}: expected managed type {expected_type!r}, "
|
||||
f"got {resource.get('type')!r}"
|
||||
)
|
||||
|
||||
change = resource.get("change")
|
||||
if not isinstance(change, dict):
|
||||
violations.append(f"{address}: missing change object")
|
||||
continue
|
||||
actions = change.get("actions")
|
||||
if not isinstance(actions, list) or not all(
|
||||
isinstance(action, str) for action in actions
|
||||
):
|
||||
violations.append(f"{address}: actions must be a string array")
|
||||
continue
|
||||
|
||||
if change.get("replace_paths") not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
|
||||
if mode == "import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if expected_type is not None:
|
||||
violations.extend(
|
||||
_validate_import_metadata(
|
||||
address=address,
|
||||
change=change,
|
||||
environment=environment,
|
||||
)
|
||||
)
|
||||
elif mode == "post-import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: post-import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in post-import mode"
|
||||
)
|
||||
else:
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in controlled-update mode"
|
||||
)
|
||||
if actions == ["update"]:
|
||||
seen_updates.add(address)
|
||||
if address not in allowed_updates:
|
||||
violations.append(f"{address}: update is not explicitly allowlisted")
|
||||
else:
|
||||
violations.extend(
|
||||
_validate_controlled_update(
|
||||
address,
|
||||
change,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
elif actions != ["no-op"]:
|
||||
violations.append(f"{address}: unsafe controlled actions {actions!r}")
|
||||
|
||||
for missing in sorted(set(required_resources) - seen_addresses):
|
||||
violations.append(f"{missing}: required managed resource is absent")
|
||||
for unused in sorted(allowed_updates - seen_updates):
|
||||
violations.append(f"{unused}: allowlisted update address is not updating")
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
plan = _load_plan(args.plan_json)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
allowed_updates = set(args.allow_update_address or [])
|
||||
if args.post_import_no_op:
|
||||
mode = "post-import"
|
||||
elif allowed_updates:
|
||||
mode = "controlled"
|
||||
else:
|
||||
mode = "import"
|
||||
violations = check_plan(
|
||||
plan,
|
||||
environment=args.environment,
|
||||
mode=mode,
|
||||
allowed_updates=allowed_updates,
|
||||
)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
label = {
|
||||
"import": "zero-change import",
|
||||
"post-import": "post-import no-op",
|
||||
"controlled": "controlled update",
|
||||
}[mode]
|
||||
print(
|
||||
f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} "
|
||||
f"managed resources and {len(allowed_updates)} exact updates"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
131
scripts/terraform_import_plan_resources.py
Normal file
131
scripts/terraform_import_plan_resources.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Canonical frontend Terraform ownership and import-ID maps.
|
||||
|
||||
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
|
||||
are kept so the checker can prove that a dev plan carrying a staging identifier
|
||||
is rejected; they do not authorize a staging import.
|
||||
"""
|
||||
|
||||
COMMON_RESOURCES = {
|
||||
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
|
||||
"module.environment_owned.aws_s3_bucket_public_access_block.site": (
|
||||
"aws_s3_bucket_public_access_block"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_ownership_controls.site": (
|
||||
"aws_s3_bucket_ownership_controls"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": (
|
||||
"aws_s3_bucket_server_side_encryption_configuration"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": (
|
||||
"aws_cloudfront_distribution"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"aws_cloudfront_origin_access_control"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"aws_cloudfront_function"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": "aws_route53_record",
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
|
||||
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
|
||||
}
|
||||
|
||||
REQUIRED_RESOURCES = {
|
||||
environment: dict(COMMON_RESOURCES)
|
||||
for environment in ("dev", "staging")
|
||||
}
|
||||
|
||||
CONTROLLED_UPDATE_ADDRESSES = frozenset(
|
||||
{
|
||||
"module.environment_owned.aws_s3_bucket.site",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
|
||||
"module.environment_owned.aws_iam_role.github_deploy",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
}
|
||||
)
|
||||
|
||||
ENVIRONMENT_CONFIG = {
|
||||
"dev": {
|
||||
"bucket_name": "seahaven-shoc-frontend-dev",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-dev",
|
||||
"distribution_id": "E2CWLM1AFB964P",
|
||||
"workspace_name": "shoc-frontend-new-dev",
|
||||
},
|
||||
"staging": {
|
||||
"bucket_name": "seahaven-shoc-frontend-staging",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-staging",
|
||||
"distribution_id": "E2JDVEZ6EGD49J",
|
||||
"workspace_name": "shoc-frontend-new-staging",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bucket_imports(bucket_name: str) -> dict[str, str]:
|
||||
return {
|
||||
address: bucket_name
|
||||
for address in COMMON_RESOURCES
|
||||
if address.startswith("module.environment_owned.aws_s3_bucket")
|
||||
}
|
||||
|
||||
|
||||
REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
||||
"dev": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-dev"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E30VSIK87N8H64"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
"staging": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-staging"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E1PF5R6QQNBZAI"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
}
|
||||
638
scripts/test-terraform-import-plan-check.py
Normal file
638
scripts/test-terraform-import-plan-check.py
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic unit tests for the frontend Terraform plan checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
|
||||
REPOSITORY = SCRIPT.parent.parent
|
||||
BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET = "module.environment_owned.aws_s3_bucket.site"
|
||||
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
|
||||
|
||||
|
||||
def import_id(environment: str, address: str) -> str:
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
assert expected is not None, f"{environment} must pin an import ID for {address}"
|
||||
return expected
|
||||
|
||||
|
||||
def distribution_id(environment: str) -> str:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
|
||||
return configured
|
||||
|
||||
|
||||
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def bucket_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def pre_adoption_deploy_policy(environment: str) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
statements: list[dict[str, Any]] = []
|
||||
if environment == "dev":
|
||||
statements.append(
|
||||
{
|
||||
"Sid": "AssumeCdkBootstrapRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
|
||||
}
|
||||
)
|
||||
statements.extend(
|
||||
[
|
||||
{
|
||||
"Sid": "DescribeStack",
|
||||
"Effect": "Allow",
|
||||
"Action": "cloudformation:DescribeStacks",
|
||||
"Resource": (
|
||||
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
|
||||
f"{config['cloudformation_stack_name']}/*"
|
||||
),
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
]
|
||||
)
|
||||
return {"Version": "2012-10-17", "Statement": statements}
|
||||
|
||||
|
||||
def deploy_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadDeploymentBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions",
|
||||
],
|
||||
"Resource": bucket_arn,
|
||||
},
|
||||
{
|
||||
"Sid": "PublishAndRollbackSiteObjects",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjectVersion",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
],
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tag_change(environment: str, address: str) -> dict[str, Any]:
|
||||
manager = {
|
||||
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"]
|
||||
}
|
||||
before_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "cdk",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
after_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
if address == ROLE:
|
||||
before_tags.update(manager)
|
||||
after_tags.update(manager)
|
||||
if address == BUCKET:
|
||||
before_tags["aws-cdk:auto-delete-objects"] = "true"
|
||||
before: dict[str, Any] = {
|
||||
"tags": before_tags,
|
||||
"tags_all": before_tags,
|
||||
}
|
||||
after: dict[str, Any] = {
|
||||
"tags": after_tags,
|
||||
"tags_all": after_tags,
|
||||
}
|
||||
if address == DISTRIBUTION:
|
||||
before["id"] = distribution_id(environment)
|
||||
after["id"] = distribution_id(environment)
|
||||
return {"actions": ["update"], "before": before, "after": after}
|
||||
|
||||
|
||||
def policy_change(environment: str, address: str) -> dict[str, Any]:
|
||||
before_policy = (
|
||||
pre_adoption_bucket_policy(environment)
|
||||
if address == BUCKET_POLICY
|
||||
else pre_adoption_deploy_policy(environment)
|
||||
)
|
||||
after_policy = (
|
||||
bucket_policy(environment)
|
||||
if address == BUCKET_POLICY
|
||||
else deploy_policy(environment)
|
||||
)
|
||||
return {
|
||||
"actions": ["update"],
|
||||
"before": {"policy": json.dumps(before_policy)},
|
||||
"after": {"policy": json.dumps(after_policy)},
|
||||
}
|
||||
|
||||
|
||||
def make_plan(
|
||||
environment: str,
|
||||
*,
|
||||
mode: str = "import",
|
||||
controlled_updates: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resources: list[dict[str, Any]] = []
|
||||
updates = controlled_updates or set()
|
||||
for address, resource_type in REQUIRED_RESOURCES[environment].items():
|
||||
if mode == "import":
|
||||
change: dict[str, Any] = {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": import_id(environment, address)},
|
||||
}
|
||||
elif mode == "post-import":
|
||||
change = {"actions": ["no-op"]}
|
||||
elif address in updates:
|
||||
change = (
|
||||
tag_change(environment, address)
|
||||
if address in TAG_ADDRESSES
|
||||
else policy_change(environment, address)
|
||||
)
|
||||
else:
|
||||
change = {"actions": ["no-op"]}
|
||||
if address == DISTRIBUTION:
|
||||
change["after"] = {"id": distribution_id(environment)}
|
||||
resources.append(
|
||||
{
|
||||
"address": address,
|
||||
"mode": "managed",
|
||||
"type": resource_type,
|
||||
"change": change,
|
||||
}
|
||||
)
|
||||
return {"resource_changes": resources}
|
||||
|
||||
|
||||
def resource(plan: dict[str, Any], address: str) -> dict[str, Any]:
|
||||
return next(
|
||||
item for item in plan["resource_changes"] if item["address"] == address
|
||||
)
|
||||
|
||||
|
||||
def run_checker(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "plan.json"
|
||||
path.write_text(json.dumps(plan), encoding="utf-8")
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(path),
|
||||
"--environment",
|
||||
environment,
|
||||
]
|
||||
if post_import:
|
||||
command.append("--post-import-no-op")
|
||||
for address in allowed_updates:
|
||||
command.extend(["--allow-update-address", address])
|
||||
return subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class ImportPlanCheckerTests(unittest.TestCase):
|
||||
def assert_passes(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def assert_fails(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
|
||||
source = (
|
||||
REPOSITORY
|
||||
/ "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
expected = """ spa_rewrite_code = join("\\n", [
|
||||
"function handler(event) {",
|
||||
" var request = event.request;",
|
||||
" var uri = request.uri;",
|
||||
" // No file extension after the last slash -> a client-side route.",
|
||||
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||
" request.uri = '/index.html';",
|
||||
" }",
|
||||
" return request;",
|
||||
"}",
|
||||
])"""
|
||||
self.assertIn(expected, source)
|
||||
|
||||
def test_only_dev_has_a_live_root(self) -> None:
|
||||
live_roots = sorted(
|
||||
path.name
|
||||
for path in (REPOSITORY / "terraform/live").iterdir()
|
||||
if path.is_dir() and path.name != "modules"
|
||||
)
|
||||
self.assertEqual(["dev"], live_roots)
|
||||
|
||||
def test_dev_root_pins_import_phase_in_code(self) -> None:
|
||||
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
self.assertRegex(source, r"\n\s+adoption_complete\s+= false\n")
|
||||
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
|
||||
self.assertNotIn('variable "adoption_complete"', source)
|
||||
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
|
||||
self.assertNotIn(
|
||||
"variable ",
|
||||
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
|
||||
root_file,
|
||||
)
|
||||
|
||||
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
|
||||
expected = {
|
||||
"dev": (
|
||||
"local.hosted_zone_id",
|
||||
"local.certificate_arn",
|
||||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
}
|
||||
for environment, values in expected.items():
|
||||
source = (
|
||||
REPOSITORY / f"terraform/live/{environment}/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
for name, value in zip(
|
||||
(
|
||||
"hosted_zone_id",
|
||||
"certificate_arn",
|
||||
"github_oidc_provider_arn",
|
||||
"cache_policy_id",
|
||||
),
|
||||
values,
|
||||
strict=True,
|
||||
):
|
||||
self.assertIn(f"{name}", source)
|
||||
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
|
||||
self.assertNotRegex(
|
||||
source,
|
||||
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
|
||||
)
|
||||
|
||||
def test_exact_import_plan_passes_for_every_environment(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
with self.subTest(environment=environment):
|
||||
self.assert_passes(make_plan(environment), environment)
|
||||
|
||||
def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None:
|
||||
for mutation in ("missing", "extra", "wrong-type", "cross-environment"):
|
||||
plan = make_plan("dev")
|
||||
if mutation == "missing":
|
||||
plan["resource_changes"].pop()
|
||||
elif mutation == "extra":
|
||||
plan["resource_changes"].append(
|
||||
{
|
||||
"address": "module.inventory.aws_route53_zone.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_zone",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": "Z00000000000000000000"},
|
||||
},
|
||||
}
|
||||
)
|
||||
elif mutation == "wrong-type":
|
||||
plan["resource_changes"][0]["type"] = "aws_s3_object"
|
||||
else:
|
||||
resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = (
|
||||
REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION]
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_import_rejects_mutation_and_invalid_metadata(self) -> None:
|
||||
for actions in (["create"], ["update"], ["delete"], ["delete", "create"]):
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["actions"] = actions
|
||||
with self.subTest(actions=actions):
|
||||
self.assert_fails(plan, "dev")
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": ""}
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_post_import_no_op_passes(self) -> None:
|
||||
self.assert_passes(
|
||||
make_plan("staging", mode="post-import"),
|
||||
"staging",
|
||||
post_import=True,
|
||||
)
|
||||
|
||||
def test_post_import_rejects_import_metadata_and_update(self) -> None:
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"}
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["actions"] = ["update"]
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
|
||||
def test_every_allowed_controlled_diff_passes(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
for address in CONTROLLED_UPDATE_ADDRESSES:
|
||||
with self.subTest(environment=environment, address=address):
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={address},
|
||||
),
|
||||
environment,
|
||||
address,
|
||||
)
|
||||
|
||||
def test_full_exact_controlled_allowlist_passes(self) -> None:
|
||||
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates=set(addresses),
|
||||
),
|
||||
"dev",
|
||||
*addresses,
|
||||
)
|
||||
|
||||
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_tag_update_requires_complete_adopted_tag_sets(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET})
|
||||
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
|
||||
self.assert_fails(plan, "dev", BUCKET)
|
||||
|
||||
def test_role_trust_change_is_rejected(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
role = resource(plan, ROLE)["change"]
|
||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
||||
for mutation in ("principal", "extra"):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={BUCKET_POLICY},
|
||||
)
|
||||
policy = copy.deepcopy(bucket_policy("dev"))
|
||||
if mutation == "principal":
|
||||
policy["Statement"][0]["Principal"] = {"AWS": "*"}
|
||||
else:
|
||||
policy["Statement"].append(
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||
policy
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
|
||||
def test_deploy_policy_rejects_resource_action_and_extra_statement(self) -> None:
|
||||
for mutation in ("resource", "action", "extra"):
|
||||
plan = make_plan(
|
||||
"staging",
|
||||
mode="controlled",
|
||||
controlled_updates={DEPLOY_POLICY},
|
||||
)
|
||||
policy = copy.deepcopy(deploy_policy("staging"))
|
||||
if mutation == "resource":
|
||||
policy["Statement"][0]["Resource"] = "*"
|
||||
elif mutation == "action":
|
||||
policy["Statement"][0]["Action"].append("iam:PassRole")
|
||||
else:
|
||||
policy["Statement"].append(
|
||||
{
|
||||
"Sid": "Extra",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
resource(plan, DEPLOY_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||
policy
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "staging", DEPLOY_POLICY)
|
||||
|
||||
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
for address in (BUCKET_POLICY, DEPLOY_POLICY):
|
||||
plan = make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={address},
|
||||
)
|
||||
change = resource(plan, address)["change"]
|
||||
before = json.loads(change["before"]["policy"])
|
||||
before["Statement"].append(
|
||||
{
|
||||
"Sid": "UnexpectedDrift",
|
||||
"Effect": "Deny",
|
||||
"Action": "*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
change["before"]["policy"] = json.dumps(before)
|
||||
with self.subTest(environment=environment, address=address):
|
||||
self.assert_fails(plan, environment, address)
|
||||
|
||||
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
|
||||
for field, value in (
|
||||
("after_unknown", {"tags": {"ManagedBy": True}}),
|
||||
("replace_paths", [["tags"]]),
|
||||
):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={ROLE},
|
||||
)
|
||||
resource(plan, ROLE)["change"][field] = value
|
||||
with self.subTest(field=field):
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
295
terraform/README.md
Normal file
295
terraform/README.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Frontend Terraform adoption runbook (dev)
|
||||
|
||||
This tree adopts the existing Sea Haven SHOC frontend dev hosting resources
|
||||
into HCP Terraform without recreating them. It mirrors the backend adoption
|
||||
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs:
|
||||
|
||||
| PR | Branch | Change |
|
||||
| --- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| A | `feature/frontend-terraform-adoption` | This PR. Dev root with `adoption_complete = false`, import guard, CDK retain mode, push-to-`dev` deploy off. |
|
||||
| B | `feature/terraform-dev-adoption` | `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant, CloudFormation detaches. |
|
||||
| C | `feature/terraform-dev-content-cd` | Content CD through Terraform: release prefixes, pointer object, origin group, invalidation action, rollback. |
|
||||
|
||||
Creating these files, formatting them, initializing with `-backend=false`, and
|
||||
validating them does not authorize an AWS, HCP Terraform, GitHub,
|
||||
CloudFormation, DNS, or deployment mutation. Every live step below is gated on
|
||||
an explicit go from the owner, with the production impact stated first.
|
||||
|
||||
Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked
|
||||
separately (SH-287) and adds its own root under `live/staging` when it starts.
|
||||
The `staging` constants in `scripts/terraform_import_plan_resources.py` exist
|
||||
only so the checker can prove a dev plan carrying a staging identifier fails.
|
||||
|
||||
## Fixed targets
|
||||
|
||||
- AWS account: `396287094661`
|
||||
- AWS region: `us-east-1`
|
||||
- HCP organization: `seahaven`
|
||||
- HCP project: `seahaven-external-dev`
|
||||
- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working
|
||||
directory `terraform/live/dev`
|
||||
- Site: `dev.seahaven.com`
|
||||
- API build value: `https://api.dev.seahaven.com/api`
|
||||
|
||||
## Workspace invariants
|
||||
|
||||
Set before any Terraform lands on `dev`, read back after setting, and re-read
|
||||
before the first release after any Terraform merge:
|
||||
|
||||
- Auto-apply **off**. GitHub or a human applies every run.
|
||||
- Automatic speculative plans **on** (PR plans are read-only evidence).
|
||||
- Automatic run triggering: **patterns**
|
||||
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
|
||||
prefixes, no tags regex. Do not switch to tag-based triggering.
|
||||
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
|
||||
`>= 1.9.0, < 2.0.0`; CI validates with `1.16.0`).
|
||||
- Dynamic AWS credentials only: environment variables
|
||||
`TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and
|
||||
`TFC_AWS_APPLY_ROLE_ARN` pointing at the `seahaven-org-baseline` roles
|
||||
`hcptf-shoc-frontend-new-dev-plan` and `hcptf-shoc-frontend-new-dev`. No
|
||||
access keys.
|
||||
- **No** `adoption_complete` workspace variable. The dev root pins it in code
|
||||
(`local.adoption_complete`) so the value under review is the value that
|
||||
applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable`
|
||||
block reappears in the root.
|
||||
|
||||
## Ownership boundary
|
||||
|
||||
`live/modules/environment-owned` owns exactly these 13 addresses:
|
||||
|
||||
1. `module.environment_owned.aws_s3_bucket.site`
|
||||
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
|
||||
3. `module.environment_owned.aws_s3_bucket_ownership_controls.site`
|
||||
4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site`
|
||||
5. `module.environment_owned.aws_s3_bucket_versioning.site`
|
||||
6. `module.environment_owned.aws_s3_bucket_policy.site`
|
||||
7. `module.environment_owned.aws_cloudfront_distribution.site`
|
||||
8. `module.environment_owned.aws_cloudfront_origin_access_control.site`
|
||||
9. `module.environment_owned.aws_cloudfront_function.spa_rewrite`
|
||||
10. `module.environment_owned.aws_route53_record.site_a`
|
||||
11. `module.environment_owned.aws_route53_record.site_aaaa`
|
||||
12. `module.environment_owned.aws_iam_role.github_deploy`
|
||||
13. `module.environment_owned.aws_iam_role_policy.github_deploy`
|
||||
|
||||
Every managed resource has `prevent_destroy = true`.
|
||||
|
||||
`live/modules/environment-inventory` is data-only. It resolves and checks the
|
||||
caller account, provider region, public hosted zone, ACM certificate, account
|
||||
GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache
|
||||
policy against pinned values, and fails the plan on any mismatch.
|
||||
|
||||
The following remain outside state:
|
||||
|
||||
- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate
|
||||
- the account-global GitHub OIDC provider
|
||||
- the AWS managed CloudFront cache policy
|
||||
- `CDKToolkit` resources and CDK metadata
|
||||
- the S3 auto-delete custom resource, its provider Lambda and role
|
||||
- the HCP plan/apply roles and the deploy-role permissions boundary
|
||||
(`seahaven-org-baseline` owns them)
|
||||
|
||||
## Exact live inventory (dev)
|
||||
|
||||
- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev`
|
||||
- Distribution: `E2CWLM1AFB964P`
|
||||
- OAC: `E30VSIK87N8H64`, name
|
||||
`shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`,
|
||||
description modeled as `""`
|
||||
- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1`
|
||||
- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8`
|
||||
- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A`
|
||||
- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA`
|
||||
- Deploy role: `githubdeploy-shoc-frontend-new-dev`
|
||||
- Inline policy import ID:
|
||||
`githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1`
|
||||
- Hosted zone: `Z07671212N75U4YLPWZR8`
|
||||
- Certificate:
|
||||
`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`
|
||||
- Legacy stack: `shoc-frontend-dev`
|
||||
- Auto-delete helper role:
|
||||
`arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV`
|
||||
- Permissions boundary:
|
||||
`arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary`
|
||||
|
||||
With `adoption_complete = false` the root declares the configuration observed
|
||||
after the CDK retain deploy (Phase 1, step 2), not the configuration live
|
||||
today:
|
||||
|
||||
- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the
|
||||
S3-only `aws-cdk:auto-delete-objects=true` tag
|
||||
- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag
|
||||
- the permissions boundary attached to the deploy role
|
||||
- `StringEquals` on the OIDC subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev`
|
||||
- the legacy bucket policy including the auto-delete helper grant
|
||||
- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`,
|
||||
bucket read/write, `InvalidateDistribution`)
|
||||
|
||||
The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing.
|
||||
If read-back after that deploy differs from the root in any other way, update
|
||||
the root to the observed value and prove a zero-change import plan. Do not
|
||||
approve drift through the controlled-update checker.
|
||||
|
||||
## Phase 1: import-first adoption (this PR)
|
||||
|
||||
Each step is gated. State the impact, get the go, act, read back, record.
|
||||
|
||||
1. **Workspace invariants.** Set the invariants above on
|
||||
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
|
||||
PR.
|
||||
2. **CDK retain deploy.** From the reviewed PR head, with administrator
|
||||
credentials:
|
||||
|
||||
```bash
|
||||
cd infra/cdk && npm ci
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=true
|
||||
```
|
||||
|
||||
Expected: an update-only change set (no create, no delete, no replace)
|
||||
that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the
|
||||
13 transferred resources and the `Custom::S3AutoDeleteObjects` resource,
|
||||
attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows
|
||||
the trust operator. Read back the role, bucket policy, and stack resources
|
||||
as JSON and attach it to the PR.
|
||||
|
||||
3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply
|
||||
off). Download the plan JSON and run the guard:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-terraform-import-plan.py plan.json --environment dev
|
||||
```
|
||||
|
||||
Confirm the apply only when the plan is exactly 13 imports, 0 create,
|
||||
0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the
|
||||
run and fix the root in a new PR.
|
||||
|
||||
4. **Post-import no-op.** Queue a plan and require it to be no-op:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-terraform-import-plan.py post-import.json \
|
||||
--environment dev --post-import-no-op
|
||||
```
|
||||
|
||||
Post the run URLs and the guard output on SH-300.
|
||||
|
||||
After Phase 1 CloudFormation still owns every resource. Terraform holds state
|
||||
for them and nothing else.
|
||||
|
||||
## Phase 2: controlled ownership transfer (PR B)
|
||||
|
||||
PR B pins `adoption_complete = true`. The controlled apply may update only:
|
||||
|
||||
- `module.environment_owned.aws_s3_bucket.site` (tags)
|
||||
- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the
|
||||
auto-delete helper grant)
|
||||
- `module.environment_owned.aws_cloudfront_distribution.site` (tags)
|
||||
- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags)
|
||||
- `module.environment_owned.aws_iam_role.github_deploy` (tags)
|
||||
|
||||
The OAC, both Route 53 records, and the deploy inline policy must be no-op.
|
||||
PR B keeps the post-adoption inline policy byte-identical to live so the
|
||||
policy address does not appear in the plan. Run the checker with one
|
||||
`--allow-update-address` per updating address; it rejects unused allowlist
|
||||
entries, unknown values, and replacements.
|
||||
|
||||
Dependency: `hcptf-shoc-frontend-new-dev` currently lacks
|
||||
`cloudfront:UpdateDistribution` and `cloudfront:UpdateFunction`. Codify the
|
||||
expansion in `seahaven-org-baseline` (cross-family plus security review) and
|
||||
deploy it before the controlled apply.
|
||||
|
||||
After the apply and a no-op plan, deploy the same reviewed CDK SHA with
|
||||
`--parameters ManageSiteInfrastructure=false`. Expect `DELETE_SKIPPED` on the
|
||||
13 transferred resources and the custom resource. Never deploy with
|
||||
`ManageSiteInfrastructure=true` again after that. See
|
||||
[`infra/cdk/README.md`](../infra/cdk/README.md).
|
||||
|
||||
## Phase 3: content CD through Terraform (PR C)
|
||||
|
||||
Summary only; PR C carries the full design. GitHub builds and uploads to an
|
||||
immutable `releases/<sha>-<run>-<attempt>/` prefix. Terraform owns the
|
||||
`.release/current` pointer, both origin paths of a CloudFront origin group,
|
||||
and the invalidation action. Rollback is one guarded Terraform run swapping
|
||||
the labels. Push-to-`dev` releases return behind the repository variable
|
||||
`TERRAFORM_CONTENT_CD_ENABLED`.
|
||||
|
||||
## Operational rules
|
||||
|
||||
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
|
||||
deployable application code. `.github/workflows/terraform-isolation.yaml`
|
||||
enforces this; documentation and the `scripts/*terraform*` tooling are
|
||||
allowed alongside. A reviewer may add the `terraform-isolation-override`
|
||||
label for the rare change that must introduce Terraform variables together
|
||||
with the workflow that consumes them (PR A and PR C). The label is the
|
||||
approval record.
|
||||
- **Every Terraform merge produces a VCS run.** A human confirms or discards
|
||||
it before the next content release. Do not leave a pending run on the
|
||||
workspace.
|
||||
- **Re-read the workspace invariants** before the first release after any
|
||||
Terraform merge or workspace settings change.
|
||||
- **A red job does not mean the site is down.** Read the live state first
|
||||
(served `index.html`, distribution status, pointer body once PR C lands),
|
||||
then triage.
|
||||
- **Exact-head evidence.** Every live step records the run URL, the SHA, and a
|
||||
machine-readable read-back on the PR or SH-300.
|
||||
|
||||
## Local validation
|
||||
|
||||
From the repository root (also run by `npm run verify` through
|
||||
`scripts/governance-check.mjs`):
|
||||
|
||||
```bash
|
||||
npm run test:terraform # fmt -check, init -backend=false, validate
|
||||
npm run test:terraform-import-plan # checker unit tests against synthetic plans
|
||||
npm run test:terraform-isolation # isolation gate unit tests
|
||||
npm run test:infra # CDK build, template tests, synth in both modes
|
||||
```
|
||||
|
||||
`terraform init -backend=false -lockfile=readonly` may download the provider
|
||||
but never contacts HCP state or plans against AWS. Only HCP runs plan against
|
||||
the account.
|
||||
|
||||
## Import plan safety
|
||||
|
||||
Import mode requires exactly the canonical 13 addresses and AWS types, valid
|
||||
import metadata for every resource, the exact dev import IDs (a staging ID in a
|
||||
dev plan fails), and zero create, update, delete, or replace actions.
|
||||
|
||||
Post-import mode requires all 13 resources to be no-op and rejects any
|
||||
remaining import metadata.
|
||||
|
||||
Controlled mode permits only in-place updates to the addresses explicitly
|
||||
listed with `--allow-update-address`, verifies `before` against the exact
|
||||
pre-adoption policies and tags and `after` against the exact adopted values,
|
||||
and rejects create, delete, replace, import metadata, unknown values,
|
||||
unapproved addresses, and unused allowlist entries.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Before import apply: discard the run and correct the root.
|
||||
- After import, before the controlled update (end of Phase 1): remove only the
|
||||
13 imported addresses from state under a separately reviewed state
|
||||
operation. CloudFormation remains authoritative; a
|
||||
`ManageSiteInfrastructure=true` stack is unchanged by this.
|
||||
- After the controlled update, before detachment: either complete the reviewed
|
||||
detachment or restore the exact pre-adoption policy and tags under a
|
||||
separate approval. Do not remove state or redeploy CloudFormation blindly.
|
||||
- After detachment: Terraform is authoritative. Restore content from the
|
||||
versioned bucket. Re-establishing CloudFormation ownership requires a
|
||||
reviewed `IMPORT` change set, never an ordinary update.
|
||||
|
||||
Any replacement, destroy, cross-environment ID, missing import, broad policy
|
||||
change, or failed smoke check is a hard stop.
|
||||
|
||||
## Evidence per phase
|
||||
|
||||
- HCP run URL and the workspace settings read-back
|
||||
- plan JSON and checker output
|
||||
- `terraform state list` showing exactly the 13 addresses
|
||||
- read-only inventory before and after each mutation
|
||||
- synthesized CloudFormation template, change set, and stack events
|
||||
- deploy, invalidation, and smoke output
|
||||
- the post-action no-op plan
|
||||
- phase close-out on SH-300: completed work, validation, risks, deviations,
|
||||
remaining work
|
||||
27
terraform/live/dev/.terraform.lock.hcl
generated
Normal file
27
terraform/live/dev/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "6.62.0"
|
||||
constraints = "~> 6.57"
|
||||
hashes = [
|
||||
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
|
||||
"h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=",
|
||||
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
|
||||
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
|
||||
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
|
||||
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
|
||||
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
|
||||
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
|
||||
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
|
||||
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
|
||||
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
|
||||
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
|
||||
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
|
||||
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
|
||||
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
|
||||
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
|
||||
]
|
||||
}
|
||||
64
terraform/live/dev/imports.tf
Normal file
64
terraform/live/dev/imports.tf
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
to = module.environment_owned.aws_s3_bucket.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_public_access_block.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_ownership_controls.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_versioning.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_s3_bucket_policy.site
|
||||
id = local.bucket_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_cloudfront_distribution.site
|
||||
id = local.distribution_id
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_cloudfront_origin_access_control.site
|
||||
id = local.oac_id
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_cloudfront_function.spa_rewrite
|
||||
id = local.function_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_route53_record.site_a
|
||||
id = "${local.hosted_zone_id}_${local.domain_name}_A"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_route53_record.site_aaaa
|
||||
id = "${local.hosted_zone_id}_${local.domain_name}_AAAA"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_iam_role.github_deploy
|
||||
id = local.deploy_role_name
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment_owned.aws_iam_role_policy.github_deploy
|
||||
id = "${local.deploy_role_name}:${local.inline_policy}"
|
||||
}
|
||||
94
terraform/live/dev/main.tf
Normal file
94
terraform/live/dev/main.tf
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
locals {
|
||||
# Import-first phase. Pinned in code, never a workspace variable: the
|
||||
# controlled ownership transfer flips this to true in its own reviewed PR.
|
||||
adoption_complete = false
|
||||
|
||||
environment = "dev"
|
||||
workspace_name = "shoc-frontend-new-dev"
|
||||
aws_account_id = "396287094661"
|
||||
aws_region = "us-east-1"
|
||||
bucket_name = "seahaven-shoc-frontend-dev"
|
||||
distribution_id = "E2CWLM1AFB964P"
|
||||
oac_id = "E30VSIK87N8H64"
|
||||
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
|
||||
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
|
||||
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
domain_name = "dev.seahaven.com"
|
||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||
certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||
deploy_role_name = "githubdeploy-shoc-frontend-new-dev"
|
||||
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
stack_name = "shoc-frontend-dev"
|
||||
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||
permissions_boundary_arn = (
|
||||
"arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary"
|
||||
)
|
||||
bucket_auto_delete_helper_role_arn = (
|
||||
"arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
|
||||
)
|
||||
legacy_tags = {
|
||||
Environment = "dev"
|
||||
ManagedBy = "cdk"
|
||||
Project = "shoc-frontend"
|
||||
}
|
||||
legacy_bucket_tags = merge(local.legacy_tags, {
|
||||
"aws-cdk:auto-delete-objects" = "true"
|
||||
})
|
||||
terraform_tags = {
|
||||
Environment = "dev"
|
||||
ManagedBy = "terraform"
|
||||
Ownership = "terraform"
|
||||
Project = "shoc-frontend"
|
||||
}
|
||||
manager_tag = {
|
||||
HcpTerraformWorkspace = local.workspace_name
|
||||
}
|
||||
}
|
||||
|
||||
module "inventory" {
|
||||
source = "../modules/environment-inventory"
|
||||
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
hosted_zone_name = local.domain_name
|
||||
expected_hosted_zone_id = local.hosted_zone_id
|
||||
certificate_domain = "*.seahaven.com"
|
||||
expected_certificate_arn = local.certificate_arn
|
||||
expected_github_oidc_provider_arn = local.github_oidc_arn
|
||||
expected_cache_policy_id = local.cache_policy_id
|
||||
}
|
||||
|
||||
module "environment_owned" {
|
||||
source = "../modules/environment-owned"
|
||||
|
||||
environment = local.environment
|
||||
adoption_complete = local.adoption_complete
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
bucket_name = local.bucket_name
|
||||
distribution_id = local.distribution_id
|
||||
origin_access_control_name = local.oac_name
|
||||
origin_access_control_description = ""
|
||||
origin_id = local.origin_id
|
||||
function_name = local.function_name
|
||||
domain_name = local.domain_name
|
||||
hosted_zone_id = local.hosted_zone_id
|
||||
certificate_arn = local.certificate_arn
|
||||
cache_policy_id = local.cache_policy_id
|
||||
github_oidc_provider_arn = local.github_oidc_arn
|
||||
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev"
|
||||
pre_adoption_github_subject_operator = "StringEquals"
|
||||
post_adoption_github_subject_operator = "StringEquals"
|
||||
deploy_branch = "dev"
|
||||
deploy_role_name = local.deploy_role_name
|
||||
deploy_inline_policy_name = local.inline_policy
|
||||
deploy_permissions_boundary_arn = local.permissions_boundary_arn
|
||||
cloudformation_stack_name = local.stack_name
|
||||
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
|
||||
pre_adoption_tags = local.legacy_tags
|
||||
pre_adoption_bucket_tags = local.legacy_bucket_tags
|
||||
ownership_tags = local.terraform_tags
|
||||
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
|
||||
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
|
||||
}
|
||||
11
terraform/live/dev/outputs.tf
Normal file
11
terraform/live/dev/outputs.tf
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
output "bucket_name" {
|
||||
value = module.environment_owned.bucket_name
|
||||
}
|
||||
|
||||
output "distribution_id" {
|
||||
value = module.environment_owned.distribution_id
|
||||
}
|
||||
|
||||
output "deploy_role_arn" {
|
||||
value = module.environment_owned.deploy_role_arn
|
||||
}
|
||||
3
terraform/live/dev/providers.tf
Normal file
3
terraform/live/dev/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
provider "aws" {
|
||||
region = local.aws_region
|
||||
}
|
||||
19
terraform/live/dev/versions.tf
Normal file
19
terraform/live/dev/versions.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
terraform {
|
||||
required_version = ">= 1.9.0, < 2.0.0"
|
||||
|
||||
cloud {
|
||||
organization = "seahaven"
|
||||
|
||||
workspaces {
|
||||
project = "seahaven-external-dev"
|
||||
name = "shoc-frontend-new-dev"
|
||||
}
|
||||
}
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 6.57"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
terraform/live/modules/environment-inventory/main.tf
Normal file
65
terraform/live/modules/environment-inventory/main.tf
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
data "aws_caller_identity" "current" {
|
||||
lifecycle {
|
||||
postcondition {
|
||||
condition = self.account_id == var.aws_account_id
|
||||
error_message = "Refusing to inspect resources outside the expected AWS account."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_region" "current" {
|
||||
lifecycle {
|
||||
postcondition {
|
||||
condition = self.region == var.aws_region
|
||||
error_message = "Refusing to inspect resources outside the expected AWS region."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_route53_zone" "site" {
|
||||
name = "${trimsuffix(var.hosted_zone_name, ".")}."
|
||||
private_zone = false
|
||||
|
||||
lifecycle {
|
||||
postcondition {
|
||||
condition = self.zone_id == var.expected_hosted_zone_id
|
||||
error_message = "The resolved Route 53 zone does not match the pinned hosted zone."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_acm_certificate" "shared" {
|
||||
domain = var.certificate_domain
|
||||
statuses = ["ISSUED"]
|
||||
types = ["AMAZON_ISSUED"]
|
||||
most_recent = true
|
||||
|
||||
lifecycle {
|
||||
postcondition {
|
||||
condition = self.arn == var.expected_certificate_arn
|
||||
error_message = "The resolved ACM certificate does not match the pinned certificate."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_openid_connect_provider" "github" {
|
||||
url = "https://token.actions.githubusercontent.com"
|
||||
|
||||
lifecycle {
|
||||
postcondition {
|
||||
condition = self.arn == var.expected_github_oidc_provider_arn
|
||||
error_message = "The GitHub OIDC provider does not match the pinned account provider."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_cloudfront_cache_policy" "managed" {
|
||||
name = var.cache_policy_name
|
||||
|
||||
lifecycle {
|
||||
postcondition {
|
||||
condition = self.id == var.expected_cache_policy_id
|
||||
error_message = "The AWS managed CloudFront cache policy does not match the pinned ID."
|
||||
}
|
||||
}
|
||||
}
|
||||
19
terraform/live/modules/environment-inventory/outputs.tf
Normal file
19
terraform/live/modules/environment-inventory/outputs.tf
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
output "hosted_zone_id" {
|
||||
value = data.aws_route53_zone.site.zone_id
|
||||
description = "Verified hosted zone ID."
|
||||
}
|
||||
|
||||
output "certificate_arn" {
|
||||
value = data.aws_acm_certificate.shared.arn
|
||||
description = "Verified ACM certificate ARN."
|
||||
}
|
||||
|
||||
output "github_oidc_provider_arn" {
|
||||
value = data.aws_iam_openid_connect_provider.github.arn
|
||||
description = "Verified GitHub OIDC provider ARN."
|
||||
}
|
||||
|
||||
output "cache_policy_id" {
|
||||
value = data.aws_cloudfront_cache_policy.managed.id
|
||||
description = "Verified AWS managed cache policy ID."
|
||||
}
|
||||
46
terraform/live/modules/environment-inventory/variables.tf
Normal file
46
terraform/live/modules/environment-inventory/variables.tf
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
variable "aws_account_id" {
|
||||
type = string
|
||||
description = "Expected AWS account ID."
|
||||
}
|
||||
|
||||
variable "aws_region" {
|
||||
type = string
|
||||
description = "Expected AWS provider region."
|
||||
}
|
||||
|
||||
variable "hosted_zone_name" {
|
||||
type = string
|
||||
description = "Public hosted zone DNS name."
|
||||
}
|
||||
|
||||
variable "expected_hosted_zone_id" {
|
||||
type = string
|
||||
description = "Pinned hosted zone ID."
|
||||
}
|
||||
|
||||
variable "certificate_domain" {
|
||||
type = string
|
||||
description = "Domain used to resolve the expected certificate."
|
||||
}
|
||||
|
||||
variable "expected_certificate_arn" {
|
||||
type = string
|
||||
description = "Pinned ACM certificate ARN."
|
||||
}
|
||||
|
||||
variable "expected_github_oidc_provider_arn" {
|
||||
type = string
|
||||
description = "Pinned account-global GitHub OIDC provider ARN."
|
||||
}
|
||||
|
||||
variable "cache_policy_name" {
|
||||
type = string
|
||||
description = "AWS managed CloudFront cache policy name."
|
||||
default = "Managed-CachingOptimized"
|
||||
}
|
||||
|
||||
variable "expected_cache_policy_id" {
|
||||
type = string
|
||||
description = "Pinned AWS managed CloudFront cache policy ID."
|
||||
default = "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
||||
}
|
||||
403
terraform/live/modules/environment-owned/main.tf
Normal file
403
terraform/live/modules/environment-owned/main.tf
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
locals {
|
||||
bucket_arn = "arn:aws:s3:::${var.bucket_name}"
|
||||
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
|
||||
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
|
||||
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
|
||||
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
|
||||
github_subject_operator = var.pre_adoption_github_subject_operator
|
||||
|
||||
spa_rewrite_code = join("\n", [
|
||||
"function handler(event) {",
|
||||
" var request = event.request;",
|
||||
" var uri = request.uri;",
|
||||
" // No file extension after the last slash -> a client-side route.",
|
||||
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||
" request.uri = '/index.html';",
|
||||
" }",
|
||||
" return request;",
|
||||
"}",
|
||||
])
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "site_bucket" {
|
||||
dynamic "statement" {
|
||||
for_each = var.adoption_complete ? [] : [1]
|
||||
|
||||
content {
|
||||
effect = "Allow"
|
||||
|
||||
principals {
|
||||
type = "AWS"
|
||||
identifiers = [var.bucket_auto_delete_helper_role_arn]
|
||||
}
|
||||
|
||||
actions = [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
]
|
||||
resources = [
|
||||
local.bucket_arn,
|
||||
"${local.bucket_arn}/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
effect = "Allow"
|
||||
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["cloudfront.amazonaws.com"]
|
||||
}
|
||||
|
||||
actions = ["s3:GetObject"]
|
||||
resources = ["${local.bucket_arn}/*"]
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "AWS:SourceArn"
|
||||
values = [local.distribution_arn]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
effect = "Deny"
|
||||
|
||||
principals {
|
||||
type = "AWS"
|
||||
identifiers = ["*"]
|
||||
}
|
||||
|
||||
actions = ["s3:*"]
|
||||
resources = [
|
||||
local.bucket_arn,
|
||||
"${local.bucket_arn}/*",
|
||||
]
|
||||
|
||||
condition {
|
||||
test = "Bool"
|
||||
variable = "aws:SecureTransport"
|
||||
values = ["false"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||
statement {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||
|
||||
principals {
|
||||
type = "Federated"
|
||||
identifiers = [var.github_oidc_provider_arn]
|
||||
}
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "token.actions.githubusercontent.com:aud"
|
||||
values = ["sts.amazonaws.com"]
|
||||
}
|
||||
|
||||
condition {
|
||||
test = local.github_subject_operator
|
||||
variable = "token.actions.githubusercontent.com:sub"
|
||||
values = [var.github_subject]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "github_deploy" {
|
||||
dynamic "statement" {
|
||||
for_each = !var.adoption_complete && var.environment == "dev" ? [1] : []
|
||||
|
||||
content {
|
||||
sid = "AssumeCdkBootstrapRoles"
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
resources = ["arn:aws:iam::${var.aws_account_id}:role/cdk-hnb659fds-*"]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = var.adoption_complete ? [] : [1]
|
||||
|
||||
content {
|
||||
sid = "DescribeStack"
|
||||
effect = "Allow"
|
||||
actions = ["cloudformation:DescribeStacks"]
|
||||
resources = ["arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${var.cloudformation_stack_name}/*"]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = var.adoption_complete ? [] : [1]
|
||||
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
]
|
||||
resources = [
|
||||
local.bucket_arn,
|
||||
"${local.bucket_arn}/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = var.adoption_complete ? [1] : []
|
||||
|
||||
content {
|
||||
sid = "ReadDeploymentBucket"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions",
|
||||
]
|
||||
resources = [local.bucket_arn]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = var.adoption_complete ? [1] : []
|
||||
|
||||
content {
|
||||
sid = "PublishAndRollbackSiteObjects"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjectVersion",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
]
|
||||
resources = ["${local.bucket_arn}/*"]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "InvalidateDistribution"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
]
|
||||
resources = [local.distribution_arn]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket" "site" {
|
||||
bucket = var.bucket_name
|
||||
force_destroy = false
|
||||
tags = local.bucket_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_public_access_block" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
block_public_acls = true
|
||||
block_public_policy = true
|
||||
ignore_public_acls = true
|
||||
restrict_public_buckets = true
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_ownership_controls" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
rule {
|
||||
object_ownership = "BucketOwnerEnforced"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
rule {
|
||||
apply_server_side_encryption_by_default {
|
||||
sse_algorithm = "AES256"
|
||||
}
|
||||
|
||||
bucket_key_enabled = false
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_policy" "site" {
|
||||
bucket = aws_s3_bucket.site.id
|
||||
policy = data.aws_iam_policy_document.site_bucket.json
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_cloudfront_origin_access_control" "site" {
|
||||
name = var.origin_access_control_name
|
||||
description = var.origin_access_control_description
|
||||
origin_access_control_origin_type = "s3"
|
||||
signing_behavior = "always"
|
||||
signing_protocol = "sigv4"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_cloudfront_function" "spa_rewrite" {
|
||||
name = var.function_name
|
||||
runtime = "cloudfront-js-1.0"
|
||||
comment = "SPA routing: rewrite extensionless paths to /index.html"
|
||||
publish = true
|
||||
code = local.spa_rewrite_code
|
||||
tags = local.resource_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
ignore_changes = [publish]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_cloudfront_distribution" "site" {
|
||||
aliases = [var.domain_name]
|
||||
comment = "SeaHaven SHOC frontend (${var.environment})"
|
||||
default_root_object = "index.html"
|
||||
enabled = true
|
||||
http_version = "http2and3"
|
||||
is_ipv6_enabled = true
|
||||
price_class = "PriceClass_100"
|
||||
tags = local.resource_tags
|
||||
|
||||
origin {
|
||||
connection_attempts = 3
|
||||
connection_timeout = 10
|
||||
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
|
||||
origin_id = var.origin_id
|
||||
}
|
||||
|
||||
default_cache_behavior {
|
||||
allowed_methods = ["GET", "HEAD", "OPTIONS"]
|
||||
cache_policy_id = var.cache_policy_id
|
||||
cached_methods = ["GET", "HEAD"]
|
||||
compress = true
|
||||
target_origin_id = var.origin_id
|
||||
viewer_protocol_policy = "redirect-to-https"
|
||||
|
||||
function_association {
|
||||
event_type = "viewer-request"
|
||||
function_arn = aws_cloudfront_function.spa_rewrite.arn
|
||||
}
|
||||
}
|
||||
|
||||
restrictions {
|
||||
geo_restriction {
|
||||
restriction_type = "none"
|
||||
}
|
||||
}
|
||||
|
||||
viewer_certificate {
|
||||
acm_certificate_arn = var.certificate_arn
|
||||
minimum_protocol_version = "TLSv1.2_2021"
|
||||
ssl_support_method = "sni-only"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "site_a" {
|
||||
zone_id = var.hosted_zone_id
|
||||
name = var.domain_name
|
||||
type = "A"
|
||||
|
||||
alias {
|
||||
name = aws_cloudfront_distribution.site.domain_name
|
||||
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
||||
evaluate_target_health = false
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "site_aaaa" {
|
||||
zone_id = var.hosted_zone_id
|
||||
name = var.domain_name
|
||||
type = "AAAA"
|
||||
|
||||
alias {
|
||||
name = aws_cloudfront_distribution.site.domain_name
|
||||
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
||||
evaluate_target_health = false
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "github_deploy" {
|
||||
name = var.deploy_role_name
|
||||
path = "/"
|
||||
description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}"
|
||||
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
||||
max_session_duration = 3600
|
||||
permissions_boundary = var.deploy_permissions_boundary_arn
|
||||
tags = local.deploy_role_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "github_deploy" {
|
||||
name = var.deploy_inline_policy_name
|
||||
role = aws_iam_role.github_deploy.id
|
||||
policy = data.aws_iam_policy_document.github_deploy.json
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
14
terraform/live/modules/environment-owned/outputs.tf
Normal file
14
terraform/live/modules/environment-owned/outputs.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
output "bucket_name" {
|
||||
value = aws_s3_bucket.site.id
|
||||
description = "Imported site bucket name."
|
||||
}
|
||||
|
||||
output "distribution_id" {
|
||||
value = aws_cloudfront_distribution.site.id
|
||||
description = "Imported CloudFront distribution ID."
|
||||
}
|
||||
|
||||
output "deploy_role_arn" {
|
||||
value = aws_iam_role.github_deploy.arn
|
||||
description = "Imported GitHub deployment role ARN."
|
||||
}
|
||||
160
terraform/live/modules/environment-owned/variables.tf
Normal file
160
terraform/live/modules/environment-owned/variables.tf
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
variable "environment" {
|
||||
type = string
|
||||
description = "Environment name."
|
||||
|
||||
validation {
|
||||
condition = contains(["dev", "staging"], var.environment)
|
||||
error_message = "environment must be dev or staging."
|
||||
}
|
||||
}
|
||||
|
||||
variable "adoption_complete" {
|
||||
type = bool
|
||||
description = "Switches only ownership tags and the deploy policy to their adopted values."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "aws_account_id" {
|
||||
type = string
|
||||
description = "AWS account containing the resources."
|
||||
}
|
||||
|
||||
variable "aws_region" {
|
||||
type = string
|
||||
description = "AWS region used by the environment."
|
||||
}
|
||||
|
||||
variable "bucket_name" {
|
||||
type = string
|
||||
description = "Existing private S3 origin bucket."
|
||||
}
|
||||
|
||||
variable "distribution_id" {
|
||||
type = string
|
||||
description = "Existing CloudFront distribution ID."
|
||||
}
|
||||
|
||||
variable "origin_access_control_name" {
|
||||
type = string
|
||||
description = "Exact existing CloudFront OAC name."
|
||||
}
|
||||
|
||||
variable "origin_access_control_description" {
|
||||
type = string
|
||||
description = "Exact existing CloudFront OAC description."
|
||||
}
|
||||
|
||||
variable "origin_id" {
|
||||
type = string
|
||||
description = "Exact origin ID in the existing distribution."
|
||||
}
|
||||
|
||||
variable "function_name" {
|
||||
type = string
|
||||
description = "Existing CloudFront Function name."
|
||||
}
|
||||
|
||||
variable "domain_name" {
|
||||
type = string
|
||||
description = "Site hostname."
|
||||
}
|
||||
|
||||
variable "hosted_zone_id" {
|
||||
type = string
|
||||
description = "Inventory-verified hosted zone ID."
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
type = string
|
||||
description = "Inventory-verified ACM certificate ARN."
|
||||
}
|
||||
|
||||
variable "cache_policy_id" {
|
||||
type = string
|
||||
description = "Inventory-verified AWS managed cache policy ID."
|
||||
}
|
||||
|
||||
variable "github_oidc_provider_arn" {
|
||||
type = string
|
||||
description = "Inventory-verified GitHub OIDC provider ARN."
|
||||
}
|
||||
|
||||
variable "github_subject" {
|
||||
type = string
|
||||
description = "Exact GitHub OIDC subject in the existing role."
|
||||
}
|
||||
|
||||
variable "pre_adoption_github_subject_operator" {
|
||||
type = string
|
||||
description = "Condition operator used by the role before adoption."
|
||||
|
||||
validation {
|
||||
condition = contains(["StringEquals", "StringLike"], var.pre_adoption_github_subject_operator)
|
||||
error_message = "pre_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||
}
|
||||
}
|
||||
|
||||
variable "post_adoption_github_subject_operator" {
|
||||
type = string
|
||||
description = "Condition operator used by the role after adoption."
|
||||
|
||||
validation {
|
||||
condition = contains(["StringEquals", "StringLike"], var.post_adoption_github_subject_operator)
|
||||
error_message = "post_adoption_github_subject_operator must be StringEquals or StringLike."
|
||||
}
|
||||
}
|
||||
|
||||
variable "deploy_branch" {
|
||||
type = string
|
||||
description = "Branch or environment named in the existing role description."
|
||||
}
|
||||
|
||||
variable "deploy_role_name" {
|
||||
type = string
|
||||
description = "Existing GitHub deployment role name."
|
||||
}
|
||||
|
||||
variable "deploy_inline_policy_name" {
|
||||
type = string
|
||||
description = "Existing generated inline policy name."
|
||||
}
|
||||
|
||||
variable "deploy_permissions_boundary_arn" {
|
||||
type = string
|
||||
description = "Exact permissions boundary attached before import."
|
||||
}
|
||||
|
||||
variable "cloudformation_stack_name" {
|
||||
type = string
|
||||
description = "Legacy CloudFormation stack used by the pre-adoption policy."
|
||||
}
|
||||
|
||||
variable "bucket_auto_delete_helper_role_arn" {
|
||||
type = string
|
||||
description = "Exact legacy S3 auto-delete helper role ARN."
|
||||
}
|
||||
|
||||
variable "pre_adoption_tags" {
|
||||
type = map(string)
|
||||
description = "Exact tags present while CloudFormation still owns the resources."
|
||||
}
|
||||
|
||||
variable "pre_adoption_bucket_tags" {
|
||||
type = map(string)
|
||||
description = "Exact pre-adoption S3 tags, including the CDK auto-delete marker."
|
||||
}
|
||||
|
||||
variable "ownership_tags" {
|
||||
type = map(string)
|
||||
description = "Tags applied by the controlled ownership transfer."
|
||||
}
|
||||
|
||||
variable "pre_adoption_deploy_role_tags" {
|
||||
type = map(string)
|
||||
description = "Exact pre-adoption deploy-role tags, including its HCP manager tag."
|
||||
}
|
||||
|
||||
variable "post_adoption_deploy_role_tags" {
|
||||
type = map(string)
|
||||
description = "Exact post-adoption deploy-role tags, preserving its HCP manager tag."
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue