From e1e547f7d0c91c320598c76f83b747ff00634dc2 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:05:46 -0400 Subject: [PATCH] fix(terraform): preserve live state during dev import (#98) --- scripts/check-terraform-import-plan.py | 97 +++++- scripts/terraform_import_plan_resources.py | 41 +++ scripts/test-terraform-import-plan-check.py | 98 +++++- terraform/live/dev/main.tf | 6 +- .../live/modules/environment-owned/main.tf | 279 +++++++++--------- .../modules/environment-owned/variables.tf | 15 + 6 files changed, 388 insertions(+), 148 deletions(-) diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py index c8d9be3..494d99c 100644 --- a/scripts/check-terraform-import-plan.py +++ b/scripts/check-terraform-import-plan.py @@ -8,7 +8,11 @@ import json import sys from pathlib import Path -from terraform_import_plan_resources import REQUIRED_RESOURCES +from terraform_import_plan_resources import ( + DEV_IMPORT_BASELINE, + DEV_IMPORT_IDS, + REQUIRED_RESOURCES, +) ALLOWED_MANAGED_TYPES = { @@ -38,9 +42,56 @@ def parse_args() -> argparse.Namespace: "no-op import is proven. Repeat for each reviewed update." ), ) + parser.add_argument( + "--evidence-out", + type=Path, + help="Write machine-readable proof after every import assertion passes.", + ) return parser.parse_args() +def validate_dev_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_alias[0]" + expected_tags = DEV_IMPORT_BASELINE["environment_tags"] + expected_alias = DEV_IMPORT_BASELINE["api_alias"] + + 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 dev 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 {} + ) + aliases = route.get("alias") or [] + if len(aliases) != 1 or aliases[0] != expected_alias: + violations.append( + f"{route_address}: {side} alias does not match the exact " + "live ALB target and zone" + ) + + def main() -> int: args = parse_args() plan = json.loads(args.plan_json.read_text(encoding="utf-8")) @@ -51,6 +102,8 @@ def main() -> int: seen_update_addresses: set[str] = set() seen_addresses: set[str] = set() required_resources = REQUIRED_RESOURCES[args.environment] + resources_by_address: dict[str, dict] = {} + initial_import = not allowed_update_addresses for resource in plan.get("resource_changes", []): if resource.get("mode", "managed") != "managed": @@ -61,6 +114,7 @@ def main() -> int: actions = set(resource.get("change", {}).get("actions", [])) managed += 1 seen_addresses.add(address) + resources_by_address[address] = resource if resource_type not in ALLOWED_MANAGED_TYPES: violations.append( @@ -89,12 +143,31 @@ def main() -> int: f"{address}: update is not explicitly allowlisted" ) + if initial_import and args.environment == "dev": + if actions != {"no-op"}: + violations.append( + f"{address}: initial dev import actions must be ['no-op'], " + f"got {sorted(actions)}" + ) + expected_import_id = DEV_IMPORT_IDS.get(address) + actual_import_id = ( + resource.get("change", {}).get("importing") or {} + ).get("id") + if actual_import_id != expected_import_id: + violations.append( + f"{address}: expected import id {expected_import_id!r}, " + f"got {actual_import_id!r}" + ) + for unused in sorted(allowed_update_addresses - seen_update_addresses): violations.append(f"{unused}: allowlisted update address is not updating") for missing in sorted(set(required_resources) - seen_addresses): violations.append(f"{missing}: required managed resource is absent") + if initial_import and args.environment == "dev": + validate_dev_import_baseline(resources_by_address, violations) + if violations: print("FAIL: live Terraform plan is not import-safe", file=sys.stderr) for violation in violations: @@ -102,6 +175,28 @@ def main() -> int: return 1 mode = "controlled update" if allowed_update_addresses else "no-op import" + if args.evidence_out: + evidence = { + "environment": args.environment, + "mode": mode, + "managed_resources": managed, + "updates": updates, + "creates": 0, + "deletes": 0, + "replacements": 0, + "imports": { + address: ( + resource.get("change", {}).get("importing") or {} + ).get("id") + for address, resource in sorted(resources_by_address.items()) + }, + } + if args.environment == "dev" and initial_import: + evidence["asserted_live_baseline"] = DEV_IMPORT_BASELINE + args.evidence_out.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) print( f"PASS: {mode} plan has {managed} managed resources, " f"{updates} updates, and no create/delete/replace actions" diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py index 75894af..f5ec5b8 100644 --- a/scripts/terraform_import_plan_resources.py +++ b/scripts/terraform_import_plan_resources.py @@ -30,3 +30,44 @@ REQUIRED_RESOURCES = { "module.environment.aws_route53_record.api_cname[0]": "aws_route53_record", }, } + +DEV_IMPORT_IDS = { + "module.environment.aws_elastic_beanstalk_environment.this": "e-hehnrqjjrt", + "module.environment.aws_iam_instance_profile.runtime": "shoc-backend-dev", + "module.environment.aws_iam_role.github_deploy": "githubdeploy-shoc-backend-dev", + "module.environment.aws_iam_role.runtime": "shoc-backend-dev", + "module.environment.aws_iam_role_policy.github_deploy": ( + "githubdeploy-shoc-backend-dev:GithubDeployRoleDefaultPolicyE8F540D1" + ), + "module.environment.aws_iam_role_policy.runtime_app_config": ( + "shoc-backend-dev:shoc-dev-secrets-read" + ), + "module.environment.aws_iam_role_policy.runtime_dynamo[0]": ( + "shoc-backend-dev:shoc-assume-dynamo-reader" + ), + "module.environment.aws_iam_role_policy.runtime_webhook[0]": ( + "shoc-backend-dev:shoc-procurement-webhook-hmac-read" + ), + "module.environment.aws_iam_role_policy_attachment.web_tier": ( + "shoc-backend-dev/arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" + ), + "module.environment.aws_secretsmanager_secret.app_config": ( + "arn:aws:secretsmanager:us-east-1:396287094661:secret:" + "shoc/dev/app-config-jLRBiw" + ), + "module.environment.aws_route53_record.api_alias[0]": ( + "Z07671212N75U4YLPWZR8_api.dev.seahaven.com_A" + ), +} + +DEV_IMPORT_BASELINE = { + "environment_tags": { + "env": "dev", + "project": "shoc", + }, + "api_alias": { + "name": "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com", + "zone_id": "Z35SXDOTRQ7X7K", + "evaluate_target_health": True, + }, +} diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py index f773ca0..78e0d60 100644 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -9,7 +9,11 @@ import sys import tempfile from pathlib import Path -from terraform_import_plan_resources import REQUIRED_RESOURCES +from terraform_import_plan_resources import ( + DEV_IMPORT_BASELINE, + DEV_IMPORT_IDS, + REQUIRED_RESOURCES, +) SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py") @@ -21,6 +25,8 @@ def run_case( omit_address: str | None = None, extra_resource: tuple[str, str, list[str]] | None = None, allowed_updates: tuple[str, ...] = (), + import_id_overrides: dict[str, str] | None = None, + state_overrides: dict[str, dict[str, object]] | None = None, empty: bool = False, ) -> subprocess.CompletedProcess[str]: changes = [] @@ -29,12 +35,39 @@ def run_case( if address == omit_address: continue actions = (actions_by_address or {}).get(address, ["no-op"]) + change: dict[str, object] = {"actions": actions} + if environment == "dev": + change["importing"] = { + "id": (import_id_overrides or {}).get( + address, DEV_IMPORT_IDS[address] + ) + } + if ( + address + == "module.environment.aws_elastic_beanstalk_environment.this" + ): + state: dict[str, object] = { + "tags": DEV_IMPORT_BASELINE["environment_tags"], + "setting": [], + } + change["before"] = state + change["after"] = state + elif ( + address + == "module.environment.aws_route53_record.api_alias[0]" + ): + state = {"alias": [DEV_IMPORT_BASELINE["api_alias"]]} + change["before"] = state + change["after"] = state + if address in (state_overrides or {}): + change["before"] = (state_overrides or {})[address] + change["after"] = (state_overrides or {})[address] changes.append( { "address": address, "mode": "managed", "type": resource_type, - "change": {"actions": actions}, + "change": change, } ) if extra_resource: @@ -83,6 +116,67 @@ def main() -> int: run_case("dev", actions_by_address={controlled_address: ["update"]}), 1, ), + ( + "unexpected initial action", + run_case("dev", actions_by_address={controlled_address: ["read"]}), + 1, + ), + ( + "wrong import id", + run_case( + "dev", + import_id_overrides={controlled_address: "wrong-role"}, + ), + 1, + ), + ( + "wrong environment tags", + run_case( + "dev", + state_overrides={ + "module.environment.aws_elastic_beanstalk_environment.this": { + "tags": { + "Name": "shoc-backend-dev", + "env": "dev", + "project": "shoc", + }, + "setting": [], + } + }, + ), + 1, + ), + ( + "managed settings during import", + run_case( + "dev", + state_overrides={ + "module.environment.aws_elastic_beanstalk_environment.this": { + "tags": DEV_IMPORT_BASELINE["environment_tags"], + "setting": [{"name": "ASPNETCORE_ENVIRONMENT"}], + } + }, + ), + 1, + ), + ( + "wrong api alias", + run_case( + "dev", + state_overrides={ + "module.environment.aws_route53_record.api_alias[0]": { + "alias": [ + { + "name": "shoc-backend-dev.us-east-1.elasticbeanstalk.com", + "zone_id": "Z117KPS5GTRQ2G", + "evaluate_target_health": True, + } + ] + } + }, + ), + 1, + ), ( "controlled update", run_case( diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf index 3214b48..6391581 100644 --- a/terraform/live/dev/main.tf +++ b/terraform/live/dev/main.tf @@ -27,6 +27,7 @@ module "environment" { aws_region = local.aws_region environment = "dev" 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 @@ -68,6 +69,10 @@ module "environment" { hosted_zone_id = "Z07671212N75U4YLPWZR8" api_domain = local.api_domain api_record_type = "A" + api_alias_target = { + name = "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com" + zone_id = "Z35SXDOTRQ7X7K" + } metadata_before_adoption = { runtime_role_description = "SHOC backend dev compute role (EB instance profile)" runtime_role_tags = { @@ -92,7 +97,6 @@ module "environment" { Project = "shoc-backend" } environment_tags = { - Name = "shoc-backend-dev" env = "dev" project = "shoc" } diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index 17c3b57..d4e4f27 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -327,6 +327,134 @@ resource "aws_iam_role_policy" "github_deploy" { } } +locals { + managed_eb_settings = concat( + [ + { + namespace = "aws:elasticbeanstalk:environment" + name = "EnvironmentType" + value = "LoadBalanced" + }, + { + namespace = "aws:elasticbeanstalk:environment" + name = "LoadBalancerType" + value = "application" + }, + { + namespace = "aws:elasticbeanstalk:environment" + name = "ServiceRole" + value = var.eb_service_role_name + }, + { + namespace = "aws:ec2:vpc" + name = "VPCId" + value = var.vpc_id + }, + { + namespace = "aws:ec2:vpc" + name = "Subnets" + value = join(",", sort(var.instance_subnet_ids)) + }, + { + namespace = "aws:ec2:vpc" + name = "ELBSubnets" + value = join(",", sort(var.load_balancer_subnet_ids)) + }, + { + namespace = "aws:ec2:vpc" + name = "ELBScheme" + value = "public" + }, + { + namespace = "aws:ec2:vpc" + name = "AssociatePublicIpAddress" + value = "true" + }, + { + namespace = "aws:autoscaling:launchconfiguration" + name = "IamInstanceProfile" + value = aws_iam_instance_profile.runtime.name + }, + { + namespace = "aws:autoscaling:launchconfiguration" + name = "InstanceType" + value = "t3.small" + }, + { + namespace = "aws:autoscaling:asg" + name = "MinSize" + value = "1" + }, + { + namespace = "aws:autoscaling:asg" + name = "MaxSize" + value = "1" + }, + { + namespace = "aws:elbv2:listener:443" + name = "Protocol" + value = "HTTPS" + }, + { + namespace = "aws:elbv2:listener:443" + name = "SSLCertificateArns" + value = var.shared_certificate_arn + }, + { + namespace = "aws:elasticbeanstalk:environment:process:default" + name = "HealthCheckPath" + value = "/" + }, + { + namespace = "aws:elasticbeanstalk:environment:process:default" + name = "MatcherHTTPCode" + value = "200-499" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "ASPNETCORE_ENVIRONMENT" + value = "Production" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "ASPNETCORE_URLS" + value = "http://0.0.0.0:5000" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__Enabled" + value = var.work_order_webhook_enabled ? "true" : "false" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__Region" + value = var.aws_region + }, + ], + var.instance_security_group_id == null ? [] : [ + { + namespace = "aws:autoscaling:launchconfiguration" + name = "SecurityGroups" + value = var.instance_security_group_id + }, + ], + [ + for key in sort(tolist(var.app_config_json_keys)) : { + namespace = "aws:elasticbeanstalk:application:environmentsecrets" + name = key + value = "${aws_secretsmanager_secret.app_config.arn}:${key}" + } + ], + var.webhook_secret_arn == null ? [] : [ + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__SecretId" + value = var.webhook_secret_arn + }, + ], + ) +} + resource "aws_elastic_beanstalk_environment" "this" { name = var.eb_environment_name application = var.eb_application_name @@ -334,150 +462,13 @@ resource "aws_elastic_beanstalk_environment" "this" { tier = "WebServer" cname_prefix = var.eb_environment_name - setting { - namespace = "aws:elasticbeanstalk:environment" - name = "EnvironmentType" - value = "LoadBalanced" - } - - setting { - namespace = "aws:elasticbeanstalk:environment" - name = "LoadBalancerType" - value = "application" - } - - setting { - namespace = "aws:elasticbeanstalk:environment" - name = "ServiceRole" - value = var.eb_service_role_name - } - - setting { - namespace = "aws:ec2:vpc" - name = "VPCId" - value = var.vpc_id - } - - setting { - namespace = "aws:ec2:vpc" - name = "Subnets" - value = join(",", sort(var.instance_subnet_ids)) - } - - setting { - namespace = "aws:ec2:vpc" - name = "ELBSubnets" - value = join(",", sort(var.load_balancer_subnet_ids)) - } - - setting { - namespace = "aws:ec2:vpc" - name = "ELBScheme" - value = "public" - } - - setting { - namespace = "aws:ec2:vpc" - name = "AssociatePublicIpAddress" - value = "true" - } - - setting { - namespace = "aws:autoscaling:launchconfiguration" - name = "IamInstanceProfile" - value = aws_iam_instance_profile.runtime.name - } - - setting { - namespace = "aws:autoscaling:launchconfiguration" - name = "InstanceType" - value = "t3.small" - } - dynamic "setting" { - for_each = var.instance_security_group_id == null ? [] : [var.instance_security_group_id] + for_each = var.manage_eb_settings ? local.managed_eb_settings : [] + content { - namespace = "aws:autoscaling:launchconfiguration" - name = "SecurityGroups" - value = setting.value - } - } - - setting { - namespace = "aws:autoscaling:asg" - name = "MinSize" - value = "1" - } - - setting { - namespace = "aws:autoscaling:asg" - name = "MaxSize" - value = "1" - } - - setting { - namespace = "aws:elbv2:listener:443" - name = "Protocol" - value = "HTTPS" - } - - setting { - namespace = "aws:elbv2:listener:443" - name = "SSLCertificateArns" - value = var.shared_certificate_arn - } - - setting { - namespace = "aws:elasticbeanstalk:environment:process:default" - name = "HealthCheckPath" - value = "/" - } - - setting { - namespace = "aws:elasticbeanstalk:environment:process:default" - name = "MatcherHTTPCode" - value = "200-499" - } - - dynamic "setting" { - for_each = var.app_config_json_keys - content { - namespace = "aws:elasticbeanstalk:application:environmentsecrets" - name = setting.value - value = "${aws_secretsmanager_secret.app_config.arn}:${setting.value}" - } - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "ASPNETCORE_ENVIRONMENT" - value = "Production" - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "ASPNETCORE_URLS" - value = "http://0.0.0.0:5000" - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "WorkOrderWebhook__Enabled" - value = var.work_order_webhook_enabled ? "true" : "false" - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "WorkOrderWebhook__Region" - value = var.aws_region - } - - dynamic "setting" { - for_each = var.webhook_secret_arn == null ? [] : [var.webhook_secret_arn] - content { - namespace = "aws:elasticbeanstalk:application:environment" - name = "WorkOrderWebhook__SecretId" - value = setting.value + namespace = setting.value.namespace + name = setting.value.name + value = setting.value.value } } @@ -499,8 +490,8 @@ resource "aws_route53_record" "api_alias" { type = "A" alias { - name = aws_elastic_beanstalk_environment.this.cname - zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id + name = var.api_alias_target == null ? aws_elastic_beanstalk_environment.this.cname : var.api_alias_target.name + zone_id = var.api_alias_target == null ? data.aws_elastic_beanstalk_hosted_zone.current.id : var.api_alias_target.zone_id evaluate_target_health = true } diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index 9980d9d..49a6066 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -21,6 +21,12 @@ variable "adoption_complete" { default = false } +variable "manage_eb_settings" { + type = bool + description = "False omits managed Elastic Beanstalk settings during the import-only phase." + default = true +} + variable "eb_application_name" { type = string } @@ -205,6 +211,15 @@ variable "api_record_type" { } } +variable "api_alias_target" { + type = object({ + name = string + zone_id = string + }) + description = "Exact existing Route 53 alias 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({