From 2c7c9b12e033f284fae3b1f9b610ab72862985dc Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 31 Aug 2026 15:11:04 -0400 Subject: [PATCH] fix(terraform): guard staging import baseline --- scripts/check-terraform-import-plan.py | 62 ++++++++++++-- scripts/terraform_import_plan_resources.py | 51 ++++++++++++ scripts/test-terraform-import-plan-check.py | 81 +++++++++++++++++-- .../live/modules/environment-owned/main.tf | 2 +- .../modules/environment-owned/variables.tf | 6 ++ terraform/live/staging/main.tf | 3 +- 6 files changed, 189 insertions(+), 16 deletions(-) diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py index 494d99c..1ffc18d 100644 --- a/scripts/check-terraform-import-plan.py +++ b/scripts/check-terraform-import-plan.py @@ -10,8 +10,10 @@ from pathlib import Path from terraform_import_plan_resources import ( DEV_IMPORT_BASELINE, - DEV_IMPORT_IDS, + IMPORT_BASELINES, + IMPORT_IDS, REQUIRED_RESOURCES, + STAGING_IMPORT_BASELINE, ) @@ -92,6 +94,51 @@ def validate_dev_import_baseline( ) +def validate_staging_import_baseline( + resources_by_address: dict[str, dict], violations: list[str] +) -> None: + environment_address = ( + "module.environment.aws_elastic_beanstalk_environment.this" + ) + route_address = "module.environment.aws_route53_record.api_cname[0]" + expected_tags = STAGING_IMPORT_BASELINE["environment_tags"] + expected_cname = STAGING_IMPORT_BASELINE["api_cname"] + + for side in ("before", "after"): + environment = ( + resources_by_address.get(environment_address, {}) + .get("change", {}) + .get(side) + or {} + ) + if environment.get("tags") != expected_tags: + violations.append( + f"{environment_address}: {side} environment tags do not match " + f"the exact staging import baseline" + ) + if environment.get("setting") != []: + violations.append( + f"{environment_address}: {side} contains managed EB settings " + "during the import-only phase" + ) + + route = ( + resources_by_address.get(route_address, {}) + .get("change", {}) + .get(side) + or {} + ) + actual_cname = { + "records": route.get("records"), + "ttl": route.get("ttl"), + } + if actual_cname != expected_cname: + violations.append( + f"{route_address}: {side} CNAME does not match the exact " + "live ALB target and TTL" + ) + + def main() -> int: args = parse_args() plan = json.loads(args.plan_json.read_text(encoding="utf-8")) @@ -143,13 +190,14 @@ def main() -> int: f"{address}: update is not explicitly allowlisted" ) - if initial_import and args.environment == "dev": + if initial_import and args.environment in IMPORT_IDS: if actions != {"no-op"}: violations.append( - f"{address}: initial dev import actions must be ['no-op'], " + f"{address}: initial {args.environment} import actions " + "must be ['no-op'], " f"got {sorted(actions)}" ) - expected_import_id = DEV_IMPORT_IDS.get(address) + expected_import_id = IMPORT_IDS[args.environment].get(address) actual_import_id = ( resource.get("change", {}).get("importing") or {} ).get("id") @@ -167,6 +215,8 @@ def main() -> int: if initial_import and args.environment == "dev": validate_dev_import_baseline(resources_by_address, violations) + elif initial_import and args.environment == "staging": + validate_staging_import_baseline(resources_by_address, violations) if violations: print("FAIL: live Terraform plan is not import-safe", file=sys.stderr) @@ -191,8 +241,8 @@ def main() -> int: for address, resource in sorted(resources_by_address.items()) }, } - if args.environment == "dev" and initial_import: - evidence["asserted_live_baseline"] = DEV_IMPORT_BASELINE + if args.environment in IMPORT_BASELINES and initial_import: + evidence["asserted_live_baseline"] = IMPORT_BASELINES[args.environment] args.evidence_out.write_text( json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8", diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py index f5ec5b8..e5f7d5f 100644 --- a/scripts/terraform_import_plan_resources.py +++ b/scripts/terraform_import_plan_resources.py @@ -71,3 +71,54 @@ DEV_IMPORT_BASELINE = { "evaluate_target_health": True, }, } + +STAGING_IMPORT_IDS = { + "module.environment.aws_elastic_beanstalk_environment.this": "e-6c9m4vb62z", + "module.environment.aws_iam_instance_profile.runtime": "shoc-backend-staging", + "module.environment.aws_iam_role.github_deploy": ( + "githubdeploy-shoc-backend-staging" + ), + "module.environment.aws_iam_role.runtime": "shoc-backend-staging", + "module.environment.aws_iam_role_policy.github_deploy": ( + "githubdeploy-shoc-backend-staging:GithubDeployRoleDefaultPolicyE8F540D1" + ), + "module.environment.aws_iam_role_policy.runtime_app_config": ( + "shoc-backend-staging:shoc-staging-secrets-read" + ), + "module.environment.aws_iam_role_policy.runtime_webhook[0]": ( + "shoc-backend-staging:shoc-backend-staging-webhook-secret-access" + ), + "module.environment.aws_iam_role_policy_attachment.web_tier": ( + "shoc-backend-staging/arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" + ), + "module.environment.aws_secretsmanager_secret.app_config": ( + "arn:aws:secretsmanager:us-east-1:396287094661:secret:" + "shoc/staging/app-config-CVV99L" + ), + "module.environment.aws_route53_record.api_cname[0]": ( + "Z02602739VQWBWCAGXP4_api.staging.seahaven.com_CNAME" + ), +} + +STAGING_IMPORT_BASELINE = { + "environment_tags": { + "env": "staging", + "project": "shoc", + }, + "api_cname": { + "records": [ + "awseb--AWSEB-pPXqiRgNnZe8-16996010.us-east-1.elb.amazonaws.com" + ], + "ttl": 60, + }, +} + +IMPORT_IDS = { + "dev": DEV_IMPORT_IDS, + "staging": STAGING_IMPORT_IDS, +} + +IMPORT_BASELINES = { + "dev": DEV_IMPORT_BASELINE, + "staging": STAGING_IMPORT_BASELINE, +} diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py index 78e0d60..d7369a4 100644 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -11,8 +11,9 @@ from pathlib import Path from terraform_import_plan_resources import ( DEV_IMPORT_BASELINE, - DEV_IMPORT_IDS, + IMPORT_IDS, REQUIRED_RESOURCES, + STAGING_IMPORT_BASELINE, ) SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py") @@ -36,29 +37,40 @@ def run_case( continue actions = (actions_by_address or {}).get(address, ["no-op"]) change: dict[str, object] = {"actions": actions} - if environment == "dev": + if environment in IMPORT_IDS: change["importing"] = { "id": (import_id_overrides or {}).get( - address, DEV_IMPORT_IDS[address] + address, IMPORT_IDS[environment][address] ) } - if ( - address - == "module.environment.aws_elastic_beanstalk_environment.this" + if address == ( + "module.environment.aws_elastic_beanstalk_environment.this" ): + baseline = ( + DEV_IMPORT_BASELINE + if environment == "dev" + else STAGING_IMPORT_BASELINE + ) state: dict[str, object] = { - "tags": DEV_IMPORT_BASELINE["environment_tags"], + "tags": baseline["environment_tags"], "setting": [], } change["before"] = state change["after"] = state - elif ( + elif environment == "dev" and ( address == "module.environment.aws_route53_record.api_alias[0]" ): state = {"alias": [DEV_IMPORT_BASELINE["api_alias"]]} change["before"] = state change["after"] = state + elif environment == "staging" and ( + address + == "module.environment.aws_route53_record.api_cname[0]" + ): + state = STAGING_IMPORT_BASELINE["api_cname"] + change["before"] = state + change["after"] = state if address in (state_overrides or {}): change["before"] = (state_overrides or {})[address] change["after"] = (state_overrides or {})[address] @@ -177,6 +189,59 @@ def main() -> int: ), 1, ), + ( + "wrong staging import id", + run_case( + "staging", + import_id_overrides={controlled_address: "wrong-role"}, + ), + 1, + ), + ( + "wrong staging environment tags", + run_case( + "staging", + state_overrides={ + "module.environment.aws_elastic_beanstalk_environment.this": { + "tags": { + "Name": "shoc-backend-staging", + "env": "staging", + "project": "shoc", + }, + "setting": [], + } + }, + ), + 1, + ), + ( + "staging managed settings during import", + run_case( + "staging", + state_overrides={ + "module.environment.aws_elastic_beanstalk_environment.this": { + "tags": STAGING_IMPORT_BASELINE["environment_tags"], + "setting": [{"name": "ASPNETCORE_ENVIRONMENT"}], + } + }, + ), + 1, + ), + ( + "wrong staging cname", + run_case( + "staging", + state_overrides={ + "module.environment.aws_route53_record.api_cname[0]": { + "records": [ + "shoc-backend-staging.us-east-1.elasticbeanstalk.com" + ], + "ttl": 60, + } + }, + ), + 1, + ), ( "controlled update", run_case( diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index 3a2522e..e94f07f 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -509,7 +509,7 @@ resource "aws_route53_record" "api_cname" { name = var.api_domain type = "CNAME" ttl = 60 - records = [aws_elastic_beanstalk_environment.this.endpoint_url] + records = [var.api_cname_target == null ? aws_elastic_beanstalk_environment.this.endpoint_url : var.api_cname_target] lifecycle { prevent_destroy = true diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index ecad6eb..db7d045 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -221,6 +221,12 @@ variable "api_alias_target" { default = null } +variable "api_cname_target" { + type = string + description = "Exact existing Route 53 CNAME target preserved during import. Null resolves the target from Elastic Beanstalk." + default = null +} + variable "metadata_before_adoption" { description = "Exact current metadata preserved while adoption_complete is false." type = object({ diff --git a/terraform/live/staging/main.tf b/terraform/live/staging/main.tf index baa5f4b..966351d 100644 --- a/terraform/live/staging/main.tf +++ b/terraform/live/staging/main.tf @@ -27,6 +27,7 @@ module "environment" { aws_region = local.aws_region environment = "staging" adoption_complete = false + manage_eb_settings = false eb_application_name = local.eb_application_name eb_environment_name = local.eb_environment_name eb_environment_id = local.eb_environment_id @@ -59,6 +60,7 @@ module "environment" { hosted_zone_id = "Z02602739VQWBWCAGXP4" api_domain = local.api_domain api_record_type = "CNAME" + api_cname_target = "awseb--AWSEB-pPXqiRgNnZe8-16996010.us-east-1.elb.amazonaws.com" metadata_before_adoption = { runtime_role_description = "SHOC backend staging compute role (EB instance profile)" runtime_role_tags = { @@ -80,7 +82,6 @@ module "environment" { Project = "shoc-backend" } environment_tags = { - Name = "shoc-backend-staging" env = "staging" project = "shoc" }