"""ACDL Terraform adapter — compile a Target Stack instance to Terraform. ARCHITECTURE.md §12.2: the adapter translates the stack-typed L1 interface to a Terraform variable/output block, the L2 composition tree to a root module that calls the L1 modules, the stack-typed relationships to Terraform module references, and emits a Terraform plan from the stack. The adapter is a THIN LAYER; it does not own L1/L2 content — it only translates. Angine-agnostic in, Terraform out. Phase 09 spike: handled one L1 (s3, stack type aws:s3:bucket). Phase 13: generalized the resource/output emission via TYPE_MAP + INPUT_MAP + OUTPUT_MAP tables; added ECS Fargate stack types. S3 behavior is preserved (regression baseline: modules/l1/s3/instance.json). CLI: adapter.py """ import json import os import sys # Stack type -> Terraform resource type. The only engine-specific table. # As more L1s land, this grows; the L1 content + stack do not change. TYPE_MAP = { "aws:s3:bucket": "aws_s3_bucket", "aws:ec2:vpc": "aws_vpc", "aws:ec2:subnet": "aws_subnet", "aws:ec2:routetable": "aws_route_table", "aws:ecs:cluster": "aws_ecs_cluster", "aws:ecs:task_definition": "aws_ecs_task_definition", "aws:ecs:service": "aws_ecs_service", "aws:iam:role": "aws_iam_role", "aws:elbv2:loadbalancer": "aws_lb", "aws:elbv2:listener": "aws_lb_listener", "aws:elbv2:targetgroup": "aws_lb_target_group", "aws:ecr:repository": "aws_ecr_repository", "aws:cloudfront:distribution": "aws_cloudfront_distribution", "aws:cloudfront:originaccesscontrol": "aws_cloudfront_origin_access_control", "aws:wafv2:webacl": "aws_wafv2_web_acl", "aws:rds:instance": "aws_db_instance", "aws:kms:key": "aws_kms_key", "aws:kms:alias": "aws_kms_alias", "aws:ecs:uptime-service": "aws_ecs_service", } # Stack input name -> Terraform arg name, per stack type. Only non-identity # mappings are listed; any input not present here uses the stack name as # the Terraform arg name (identity). INPUT_MAP = { "aws:s3:bucket": {"bucket_name": "bucket"}, "aws:ec2:vpc": {"cidr": "cidr_block", "name": "_tag_name"}, "aws:ec2:subnet": {"cidr": "cidr_block", "az": "availability_zone", "name": "_tag_name", "vpc_id": "vpc_id"}, "aws:ec2:routetable": {"vpc_id": "vpc_id", "name": "_tag_name"}, "aws:ecs:cluster": {}, "aws:ecs:task_definition": {}, "aws:ecs:service": {"security_group": "security_groups", "subnets": "subnets", "cluster_arn": "cluster"}, "aws:iam:role": {"role_name": "name", "assume_role_policy": "assume_role_policy"}, "aws:elbv2:loadbalancer": {"subnets": "subnets", "security_group": "security_groups"}, "aws:elbv2:listener": {}, "aws:elbv2:targetgroup": {"port": "port", "protocol": "protocol"}, "aws:ecr:repository": {}, "aws:cloudfront:distribution": {"bucket_regional_domain_name": "origin_domain_name", "price_class": "price_class", "viewer_protocol_policy": "viewer_protocol_policy", "default_ttl": "default_ttl", "max_ttl": "max_ttl", "waf_web_acl_arn": "web_acl_id"}, "aws:cloudfront:originaccesscontrol": {"name": "name", "origin_type": "origin_access_control_origin_type", "signing_behavior": "origin_access_control_signing_behavior"}, "aws:wafv2:webacl": {"name": "name", "scope": "scope", "default_action": "default_action", "rules": "rules"}, "aws:rds:instance": {"db_name": "db_name", "instance_class": "instance_class", "allocated_storage": "allocated_storage", "engine": "engine", "engine_version": "engine_version", "username": "username", "multi_az": "multi_az", "storage_encrypted": "storage_encrypted"}, "aws:kms:key": {"description": "description", "deletion_window_days": "deletion_window_in_days"}, "aws:kms:alias": {}, } # Stack output name -> Terraform attribute name, per stack type. Only # non-identity mappings are listed; any output not present here uses the # stack name as the Terraform attribute name (identity). OUTPUT_MAP = { "aws:s3:bucket": {"bucket_arn": "arn", "bucket_name": "id"}, "aws:ec2:vpc": {"vpc_id": "id"}, "aws:ec2:subnet": {"subnet_ids": "id", "subnet_id": "id"}, "aws:ec2:routetable": {}, "aws:ecs:cluster": {"cluster_arn": "arn", "cluster_id": "id"}, "aws:ecs:task_definition": {"task_def_arn": "arn"}, "aws:ecs:service": {"service_arn": "id"}, "aws:iam:role": {"role_arn": "arn", "role_id": "id"}, "aws:elbv2:loadbalancer": {"lb_arn": "id"}, "aws:elbv2:listener": {"listener_arn": "id"}, "aws:elbv2:targetgroup": {"target_group_arn": "arn"}, "aws:ecr:repository": {"repository_arn": "arn"}, "aws:cloudfront:distribution": {"distribution_arn": "arn", "distribution_domain_name": "domain_name", "oac_id": "origin_access_control_id"}, "aws:cloudfront:originaccesscontrol": {"oac_id": "id"}, "aws:wafv2:webacl": {"web_acl_arn": "arn"}, "aws:rds:instance": {"db_endpoint": "endpoint", "db_arn": "arn"}, "aws:kms:key": {"kms_key_arn": "arn", "kms_key_id": "key_id"}, "aws:kms:alias": {}, } def _tf_value(value): """Render a Python value as a Terraform expression fragment.""" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)) and not isinstance(value, bool): return str(value) if isinstance(value, str): if value.startswith("ref:"): raise ValueError("ref: values must be resolved via _ref_expr, not _tf_value") # Detect a JSON string (object/array) and emit jsonencode() so inner # quotes don't break HCL. Plain strings stay double-quoted. stripped = value.lstrip() if stripped and stripped[0] in "{[" : try: parsed = json.loads(value) if isinstance(parsed, (dict, list)): return f"jsonencode({json.dumps(parsed, sort_keys=True)})" except json.JSONDecodeError: pass return f'"{value}"' if isinstance(value, (dict, list)): return f"jsonencode({json.dumps(value, sort_keys=True)})" raise ValueError(f"unsupported input value type {type(value).__name__}") def _ref_expr(ref_value, type_by_id): """Translate a "ref:." string to a Terraform interpolation "${..}". is the stack resource id of the producing resource; is the per-resource output name (e.g. `subnet_id`, `cluster_arn`); the attribute is mapped through OUTPUT_MAP for the referenced resource's stack type. The resolver emits the ref using the stack resource id directly (not the child id), so no child->resource lookup table is needed here. """ body = ref_value[len("ref:"):] rid, out_name = body.split(".", 1) rtype = type_by_id.get(rid) if not rtype: raise ValueError(f"ref to unknown stack resource id {rid!r}") tf_type = TYPE_MAP.get(rtype) if not tf_type: raise ValueError(f"ref target {rid!r} has unknown stack type {rtype!r}") out_map = OUTPUT_MAP.get(rtype, {}) tf_attr = out_map.get(out_name, out_name) return f"{tf_type}.{rid}.{tf_attr}" def _value_expr(value, type_by_id=None): """Render a value as a Terraform expression fragment. A "ref:." string becomes a Terraform interpolation; other values use _tf_value.""" if isinstance(value, str) and value.startswith("ref:"): if type_by_id is None: raise ValueError("ref: value encountered without a type_by_id table") return _ref_expr(value, type_by_id) return _tf_value(value) def _emit_resource(resource, type_by_id=None): rtype = resource["type"] rid = resource["id"] tf_type = TYPE_MAP.get(rtype) if not tf_type: raise ValueError(f"unknown stack type {rtype!r} (adapter TYPE_MAP has no entry)") in_map = INPUT_MAP.get(rtype, {}) body = [] inputs = resource.get("inputs", {}) for in_name, value in inputs.items(): if in_name == "region": continue arg = in_map.get(in_name, in_name) if arg == "_tag_name": if isinstance(value, str) and not value.startswith("ref:"): tag_name = value else: tag_name = "app" continue if rtype == "aws:ecs:task_definition" and in_name in ("image", "port", "env"): continue if rtype == "aws:iam:role" and in_name == "managed_policies": continue if rtype == "aws:elbv2:loadbalancer" and in_name == "subnets": if isinstance(value, str) and value.startswith("ref:"): body.append(f"subnets = [{_ref_expr(value, type_by_id)}]") else: body.append(f"subnets = [{value}]" if isinstance(value, str) else f"subnets = {_tf_value(value)}") continue if rtype == "aws:elbv2:loadbalancer" and in_name == "security_group": if isinstance(value, str) and value.startswith("ref:"): body.append(f"security_groups = [{_ref_expr(value, type_by_id)}]") else: body.append(f"security_groups = [{value}]" if isinstance(value, str) else f"security_groups = {_tf_value(value)}") continue if rtype == "aws:ec2:routetable" and in_name == "igw_id": continue if rtype == "aws:ecs:service" and in_name == "lb_target_group_arn": if isinstance(value, str) and value.startswith("ref:"): tg_arn = _ref_expr(value, type_by_id) else: tg_arn = _tf_value(value) body.append("load_balancer {") body.append(f" target_group_arn = {tg_arn}") body.append(" container_name = \"app\"") body.append(" container_port = 8080") body.append("}") continue if rtype in ("aws:ecs:service", "aws:ecs:uptime-service") and in_name in ("subnets", "security_group", "desired_count", "launch_type"): # Collected into network_configuration block (emitted after all # inputs); desired_count + launch_type emitted in the # ECS-specific block below (D-085 defaults). continue if rtype == "aws:elbv2:targetgroup" and in_name == "target_type": # Emitted in the targetgroup-specific block below (D-085 default). continue if rtype == "aws:ecs:task_definition" and in_name == "family": # Emitted in the task_definition-specific block below (D-085 default). continue if rtype == "aws:elbv2:loadbalancer" and in_name == "load_balancer_type": # Emitted in the loadbalancer-specific block below (D-085 default). continue if rtype == "aws:ecr:repository" and in_name == "kms_key_arn": # Emitted as encryption_configuration block below (not a bare arg). continue if rtype == "aws:ec2:subnet" and in_name == "cidr": # The L2 supplies a name string, not a real CIDR; the default # block below emits a valid cidr_block (10.0.1.0/24). continue if rtype == "aws:s3:bucket" and in_name == "kms_key_arn": # Emitted in the server_side_encryption_configuration block # below (not a bare arg on aws_s3_bucket). continue if rtype == "aws:cloudfront:distribution" and in_name in ( "bucket_regional_domain_name", "price_class", "viewer_protocol_policy", "default_ttl", "max_ttl", "waf_web_acl_arn", "oac_id", ): # Collected into the origin/default_cache_behavior/web_acl_id blocks # emitted after all inputs. continue if rtype == "aws:cloudfront:originaccesscontrol" and in_name in ( "name", "origin_type", "signing_behavior", ): # Defaults emitted after all inputs. continue if rtype == "aws:wafv2:webacl" and in_name in ( "name", "scope", "default_action", "rules", ): # Structured blocks emitted after all inputs. continue body.append(f"{arg} = {_value_expr(value, type_by_id)}") if rtype == "aws:ecs:service": subnets_val = inputs.get("subnets") sg_val = inputs.get("security_group") body.append("network_configuration {") body.append(" subnets = " + ( f"[{_ref_expr(subnets_val, type_by_id)}]" if isinstance(subnets_val, str) and subnets_val.startswith("ref:") else _tf_value([subnets_val] if isinstance(subnets_val, str) else subnets_val or []) )) body.append(" security_groups = " + ( f"[{_ref_expr(sg_val, type_by_id)}]" if isinstance(sg_val, str) and sg_val.startswith("ref:") else _tf_value([sg_val] if isinstance(sg_val, str) else sg_val or []) )) body.append("}") desired = inputs.get("desired_count", 1) launch = inputs.get("launch_type", "FARGATE") body.append(f"desired_count = {desired}") body.append(f'launch_type = "{launch}"') body.append("task_definition = aws_ecs_task_definition.service-task-definition.arn") body.append("name = \"acdl-microservice\"") nfrs = resource.get("nfrs", {}) if isinstance(nfrs, dict) and "versioning" in nfrs and rtype == "aws:s3:bucket": versioning = nfrs.get("versioning", True) body.append("versioning {") body.append(f' enabled = {"true" if versioning else "false"}') body.append("}") elif rtype == "aws:s3:bucket": body.append("versioning {") body.append(" enabled = true") body.append("}") if rtype == "aws:ecs:task_definition": body.append(_container_definitions(inputs)) family = inputs.get("family", "app") body.append(f'family = "{family}"') if rtype in ("aws:ec2:vpc", "aws:ec2:subnet") and "_tag_name" in in_map.values(): tag_name = inputs.get("name", "acdl") if isinstance(tag_name, str) and not tag_name.startswith("ref:"): body.append("tags = {") body.append(f' Name = "{tag_name}"') body.append("}") if rtype == "aws:ec2:vpc" and "cidr_block" not in inputs: # L2 compositions don't supply a CIDR; emit the default. body.append('cidr_block = "10.0.0.0/16"') if rtype == "aws:ec2:subnet": if "vpc_id" not in inputs: body.append("vpc_id = aws_vpc.vpc-vpc.id") if "cidr_block" not in inputs: # The L2 supplies a `cidr` name string (e.g. # "acdl-dev-microservice-...-us-east-1"), not a real CIDR. # Emit a default subnet CIDR within the VPC's /16. body.append('cidr_block = "10.0.1.0/24"') if rtype == "aws:ec2:routetable" and "vpc_id" not in inputs: body.append("vpc_id = aws_vpc.vpc-vpc.id") if rtype == "aws:ecs:cluster" and "name" not in inputs: body.append('name = "acdl-microservice"') if rtype == "aws:ecr:repository": if "name" not in inputs: body.append('name = "acdl-microservice"') if "kms_key_arn" in inputs: # `kms_key_arn` is not a valid aws_ecr_repository arg; emit # the encryption_configuration block instead. kms_val = inputs["kms_key_arn"] if isinstance(kms_val, str) and kms_val.startswith("ref:"): kms_expr = _ref_expr(kms_val, type_by_id) else: kms_expr = _tf_value(kms_val) body.append("encryption_configuration {") body.append(" encryption_type = \"KMS\"") body.append(f" kms_key = {kms_expr}") body.append("}") if rtype == "aws:iam:role" and "managed_policies" in inputs: arns = [a.strip() for a in str(inputs["managed_policies"]).split(",") if a.strip()] body.append("managed_policy_arns = [" + ", ".join(f'"{a}"' for a in arns) + "]") if rtype == "aws:iam:role" and "assume_role_policy" not in inputs: # The L2 microservice composition references iam-role@1.0.0 without # supplying an assume_role_policy (the L1 interface marks it # required, but the composition does not wire it). Emit a sensible # ECS task execution trust policy so terraform validate/plan can # proceed. This is the pragmatic in-sweep fix (Phase 54); the L2 # composition should ideally wire this explicitly. ecs_task_trust = ( '{"Version":"2012-10-17","Statement":[' '{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},' '"Action":"sts:AssumeRole"}]}' ) body.append(f"assume_role_policy = {json.dumps(ecs_task_trust)}") if rtype == "aws:iam:role" and "role_name" not in inputs: body.append('name = "acdl-microservice-role"') if rtype == "aws:elbv2:listener": body.append("default_action {") body.append(" type = \"forward\"") body.append(" target_group_arn = aws_lb_target_group.alb-targetgroup.arn") body.append("}") body.append("load_balancer_arn = aws_lb.alb-loadbalancer.id") if rtype == "aws:elbv2:loadbalancer": lb_type = inputs.get("load_balancer_type", "application") body.append(f'load_balancer_type = "{lb_type}"') if rtype == "aws:elbv2:targetgroup": tgt_type = inputs.get("target_type", "ip") body.append(f'target_type = "{tgt_type}"') body.append("vpc_id = aws_vpc.vpc-vpc.id") body.append("protocol = \"HTTP\"") body.append("port = 8080") if rtype == "aws:ec2:routetable": body.append("route {") body.append(" cidr_block = \"0.0.0.0/0\"") body.append(" gateway_id = aws_internet_gateway.vpc-igw.id") body.append("}") body.append("tags = {") rt_name = inputs.get("name", "app") body.append(f' Name = "{rt_name}-rt"') body.append("}") if rtype == "aws:cloudfront:originaccesscontrol": name = inputs.get("name", "acdl-oac") if isinstance(name, str) and name.startswith("ref:"): name = _ref_expr(name, type_by_id) else: name = _tf_value(name) body.append(f"name = {name}") body.append("origin_access_control_origin_type = \"s3\"") body.append("signing_behavior = \"always\"") body.append("signing_protocol = \"sigv4\"") if rtype == "aws:cloudfront:distribution": origin_domain = inputs.get("bucket_regional_domain_name") if isinstance(origin_domain, str) and origin_domain.startswith("ref:"): origin_domain = _ref_expr(origin_domain, type_by_id) else: origin_domain = _tf_value(origin_domain) # The OAC resource id follows the convention "-originaccesscontrol"; # derive it from this distribution's id. if rid.endswith("-distribution"): oac_rid = rid[: -len("distribution")] + "originaccesscontrol" else: oac_rid = "cloudfront-originaccesscontrol" body.append("origin {") body.append(f" origin_id = {_tf_value(rid)}") body.append(f" domain_name = {origin_domain}") body.append(f" origin_access_control_id = aws_cloudfront_origin_access_control.{oac_rid}.id") body.append(" s3_origin_config {") body.append(" origin_access_identity = \"\"") body.append(" }") body.append("}") body.append("enabled = true") price_class = inputs.get("price_class", "PriceClass_100") vpp = inputs.get("viewer_protocol_policy", "redirect-to-https") default_ttl = inputs.get("default_ttl", 3600) max_ttl = inputs.get("max_ttl", 86400) body.append("default_cache_behavior {") body.append(f" viewer_protocol_policy = {_value_expr(vpp, type_by_id)}") body.append(f" target_origin_id = {_tf_value(rid)}") body.append(" min_ttl = 0") body.append(f" default_ttl = {_value_expr(default_ttl, type_by_id)}") body.append(f" max_ttl = {_value_expr(max_ttl, type_by_id)}") body.append(" allowed_methods = [\"GET\", \"HEAD\"]") body.append(" cached_methods = [\"GET\", \"HEAD\"]") body.append("}") body.append(f"price_class = {_value_expr(price_class, type_by_id)}") body.append("restrictions {") body.append(" geo_restriction {") body.append(" restriction_type = \"none\"") body.append(" }") body.append("}") body.append("viewer_certificate {") body.append(" cloudfront_default_certificate = true") body.append("}") waf_arn = inputs.get("waf_web_acl_arn") if waf_arn is not None: if isinstance(waf_arn, str) and waf_arn.startswith("ref:"): waf_expr = _ref_expr(waf_arn, type_by_id) else: waf_expr = _tf_value(waf_arn) body.append(f"web_acl_id = {waf_expr}") if rtype == "aws:wafv2:webacl": name = inputs.get("name", "acdl-waf") body.append(f"name = {_tf_value(name) if not isinstance(name, str) or not name.startswith('ref:') else _ref_expr(name, type_by_id)}") body.append("scope = \"CLOUDFRONT\"") # P1-5: Honor default_action input instead of hardcoding allow {}. default_action_input = inputs.get("default_action", "allow") if isinstance(default_action_input, str) and default_action_input.startswith("ref:"): default_action_input = "allow" action_type = default_action_input if default_action_input in ("allow", "block") else "allow" body.append("default_action {") body.append(f" {action_type} {{}}") body.append("}") body.append("visibility_config {") body.append(" cloudwatch_metrics_enabled = true") body.append(" metric_name = \"acdl-waf-metrics\"") body.append(" sampled_requests_enabled = true") body.append("}") # P1-4: Emit custom rules as nested blocks, not an attribute assignment. rules_input = inputs.get("rules") if rules_input and isinstance(rules_input, list): for idx, rule in enumerate(rules_input): if not isinstance(rule, dict): continue rule_name = rule.get("name", f"custom-rule-{idx}") rule_priority = rule.get("priority", idx) body.append("rule {") body.append(f" name = {_tf_value(rule_name)}") body.append(f" priority = {_tf_value(rule_priority)}") override = rule.get("override_action", "none") if override not in ("none", "count"): override = "none" body.append(" override_action {") body.append(f" {override} {{}}") body.append(" }") statement = rule.get("statement", {}) if statement: body.append(" statement {") for sk, sv in statement.items(): body.append(f" {sk} {{") if isinstance(sv, dict): for sk2, sv2 in sv.items(): body.append(f" {sk2} = {_tf_value(sv2)}") body.append(" }") body.append(" }") body.append(" visibility_config {") body.append(" cloudwatch_metrics_enabled = true") body.append(f" metric_name = {_tf_value(f'{rule_name}-metrics')}") body.append(" sampled_requests_enabled = true") body.append(" }") body.append("}") elif rules_input and isinstance(rules_input, str) and rules_input.startswith("ref:"): # A ref: value for rules — emit as dynamic block reference (rare case). body.append(f"rules = {_ref_expr(rules_input, type_by_id)}") else: # Default: emit the AWS-managed-rules block when no custom rules. body.append("rule {") body.append(" name = \"aws-managed-rules\"") body.append(" priority = 0") body.append(" override_action {") body.append(" none {}") body.append(" }") body.append(" statement {") body.append(" managed_rule_group_statement {") body.append(" name = \"AWSManagedRulesCommonRuleSet\"") body.append(" vendor_name = \"AWS\"") body.append(" }") body.append(" }") body.append(" visibility_config {") body.append(" cloudwatch_metrics_enabled = true") body.append(" metric_name = \"aws-managed-rules-metrics\"") body.append(" sampled_requests_enabled = true") body.append(" }") body.append("}") if rtype == "aws:rds:instance": # Emit NFR-derived arguments: backup_retention_period + # deletion_protection from the nfrs block. Also emit # storage_encrypted = true (from inputs, already emitted above if # present) and skip_final_snapshot = true for dev safety. nfrs = resource.get("nfrs", {}) backup_retention = nfrs.get("backup_retention_period", 7) deletion_protection = nfrs.get("deletion_protection", True) body.append(f"backup_retention_period = {_tf_value(backup_retention)}") body.append(f"deletion_protection = {_tf_value(deletion_protection)}") # Ensure storage_encrypted is emitted (defaults to true if not in inputs). if "storage_encrypted" not in inputs: body.append("storage_encrypted = true") # Dev safety: skip the final snapshot so `terraform destroy` works # without a final DB snapshot (overridden by deletion_protection). body.append("skip_final_snapshot = true") if rtype == "aws:kms:key": nfrs = resource.get("nfrs", {}) enable_rotation = nfrs.get("enable_rotation", True) body.append(f"enable_key_rotation = {_tf_value(enable_rotation)}") if rtype == "aws:s3:bucket": nfrs = resource.get("nfrs", {}) encryption_enabled = nfrs.get("encryption_enabled", True) if encryption_enabled: kms_key_arn = inputs.get("kms_key_arn") if kms_key_arn and isinstance(kms_key_arn, str) and kms_key_arn.startswith("ref:"): kms_ref = _ref_expr(kms_key_arn, type_by_id) body.append("server_side_encryption_configuration {") body.append(" rule {") body.append(" apply_server_side_encryption_by_default {") body.append(f" sse_algorithm = \"aws:kms\"") body.append(f" kms_master_key_id = {kms_ref}") body.append(" }") body.append(" }") body.append("}") elif kms_key_arn: body.append("server_side_encryption_configuration {") body.append(" rule {") body.append(" apply_server_side_encryption_by_default {") body.append(" sse_algorithm = \"aws:kms\"") body.append(f" kms_master_key_id = {_tf_value(kms_key_arn)}") body.append(" }") body.append(" }") body.append("}") else: print(f"WARNING: s3 bucket {rid} has no kms_key_arn — falling back to AWS-managed key (alias/aws/s3)", file=sys.stderr) body.append("server_side_encryption_configuration {") body.append(" rule {") body.append(" apply_server_side_encryption_by_default {") body.append(" sse_algorithm = \"aws:kms\"") body.append(" }") body.append(" }") body.append("}") if rtype == "aws:ecs:uptime-service": feature_flag = inputs.get("feature_flag_enabled", True) if not feature_flag: return "" container_image = inputs.get("container_image", "louislam/uptime-kuma:1") monitored = inputs.get("monitored_endpoints", []) static_checks = inputs.get("static_checks", []) alert_channels = inputs.get("alert_channels", {}) all_checks = (monitored if isinstance(monitored, list) else []) + \ (static_checks if isinstance(static_checks, list) else []) env_vars = { "UPTIME_KUMA_MONITOR_CONFIG": json.dumps(all_checks), "UPTIME_KUMA_ALERT_CONFIG": json.dumps(alert_channels), } desired = inputs.get("desired_count", 1) launch = inputs.get("launch_type", "FARGATE") body.append(f"desired_count = {desired}") body.append(f'launch_type = "{launch}"') body.append("network_configuration {") body.append(" subnets = [\"subnet-uptime\"]") body.append(" security_groups = [\"sg-uptime\"]") body.append(" assign_public_ip = true") body.append("}") container = { "name": "uptime-kuma", "image": container_image, "essential": True, "portMappings": [{"containerPort": 3001, "hostPort": 3001}], "environment": [{"name": k, "value": v} for k, v in env_vars.items()], "logConfiguration": {"logDriver": "awslogs", "options": {"awslogs-group": "/acdl/uptime", "awslogs-region": inputs.get("region", "us-east-1")}}, } body.append("container_definitions = " + _tf_value([container])) nfrs = resource.get("nfrs", {}) deletion_protection = nfrs.get("deletion_protection", True) if deletion_protection: body.append("lifecycle {") body.append(" prevent_destroy = true") body.append("}") return _resource_block(rid, tf_type, body) def _emit_igw(resources): """Emit an internet gateway + route table associations for the VPC.""" vpc_id = next((r["id"] for r in resources if r["type"] == "aws:ec2:vpc"), "vpc-vpc") subnet_id = next((r["id"] for r in resources if r["type"] == "aws:ec2:subnet"), "vpc-subnet") rt_id = next((r["id"] for r in resources if r["type"] == "aws:ec2:routetable"), "vpc-routetable") vpc_res = next((r for r in resources if r["type"] == "aws:ec2:vpc"), None) igw_name = (vpc_res.get("inputs", {}).get("name", "app") if vpc_res else "app") parts = [] parts.append(_resource_block("vpc-igw", "aws_internet_gateway", [ f"vpc_id = aws_vpc.{vpc_id}.id", "tags = {", f' Name = "{igw_name}-igw"', "}", ])) parts.append(_resource_block("vpc-rta", "aws_route_table_association", [ f"subnet_id = aws_subnet.{subnet_id}.id", f"route_table_id = aws_route_table.{rt_id}.id", ])) return "\n".join(parts) def _container_definitions(inputs): image = inputs.get("image", "") port = inputs.get("port", 80) env_raw = inputs.get("env") environment = [] if isinstance(env_raw, dict): for k, v in env_raw.items(): environment.append({"name": k, "value": str(v)}) elif isinstance(env_raw, str) and env_raw: try: parsed = json.loads(env_raw) if isinstance(parsed, dict): for k, v in parsed.items(): environment.append({"name": k, "value": str(v)}) except json.JSONDecodeError: pass container = { "name": "app", "image": image, "essential": True, "portMappings": [{"containerPort": port}], } if environment: container["environment"] = environment return "container_definitions = " + _tf_value([container]) def _resource_block(rid, tf_type, body): """Emit a top-level resource block.""" head = f'resource "{tf_type}" "{rid}" {{' body_str = "\n".join(f" {l}" for l in body) return f"{head}\n{body_str}\n}}\n" def _emit_output(output_name, value_expr): return f'output "{output_name}" {{\n value = {value_expr}\n}}\n' def adapt(stack_instance, out_dir): """Emit main.tf + terraform.tf + providers.tf to out_dir for the stack instance.""" os.makedirs(out_dir, exist_ok=True) stack = stack_instance["stack"] resources = stack_instance["resources"] # --- providers.tf: aws provider, region from the first resource's inputs.region --- region = "us-east-1" for r in resources: if "region" in r.get("inputs", {}): region = r["inputs"]["region"] break providers_tf = ( f'provider "aws" {{\n' f' region = "{region}"\n' f'}}\n' ) # --- terraform.tf: required_version + required_providers + S3 backend (no DynamoDB lock per D-P09-1) --- # The backend key is derived from the stack name so l1 vs l2 spikes use separate state keys (D-P10-1). stack_name = stack.get("name", "spike") terraform_tf = ( 'terraform {\n' ' required_version = ">= 1.9, < 1.10"\n' ' required_providers {\n' ' aws = {\n' ' source = "hashicorp/aws"\n' ' version = "~> 5.0"\n' ' }\n' ' }\n' ' backend "s3" {\n' ' bucket = "acdl-tfstate-581513795199-us-east-1"\n' f' key = "spike/{stack_name}/terraform.tfstate"\n' ' region = "us-east-1"\n' ' }\n' '}\n' ) # --- main.tf: resources + outputs --- # Build a stack-resource-id -> stack-type table so `ref:` input values can # be resolved to Terraform interpolations without a child->resource # lookup (the resolver emits refs with the stack resource id directly). type_by_id = {r["id"]: r["type"] for r in resources} main_tf_parts = [] has_vpc = any(r["type"] == "aws:ec2:vpc" for r in resources) # Track emitted output names so per-resource outputs and stack-level # outputs never collide (duplicate output definitions break `terraform # init`). Stack-level outputs (below) are canonical; per-resource # outputs are only emitted when no stack output shares the name. emitted_outputs = set() # Pre-collect stack-level output names so per-resource emission can # skip them (the stack output is the authoritative one). stack_outputs = stack_instance.get("outputs", {}) stack_output_names = set(stack_outputs.keys()) for r in resources: main_tf_parts.append(_emit_resource(r, type_by_id)) rid = r["id"] rtype = r["type"] tf_type = TYPE_MAP.get(rtype) out_map = OUTPUT_MAP.get(rtype, {}) outputs = r.get("outputs", {}) for out_name in outputs: if out_name in stack_output_names: # The stack-level output (below) emits this name; skip # the per-resource emission to avoid a duplicate. continue if out_name in emitted_outputs: continue emitted_outputs.add(out_name) tf_attr = out_map.get(out_name, out_name) main_tf_parts.append(_emit_output(out_name, f"{tf_type}.{rid}.{tf_attr}")) if has_vpc: main_tf_parts.append(_emit_igw(resources)) # P1-7: Emit stack-level outputs from the resolved composition outputs[]. # Each stack output has {"from": , "output": }. # We look up the resource type + OUTPUT_MAP to build the interpolation. for out_name, out_spec in stack_outputs.items(): if out_name in emitted_outputs: continue src_rid = out_spec.get("from", "") src_output = out_spec.get("output", out_name) if src_rid in type_by_id: src_rtype = type_by_id[src_rid] src_tf_type = TYPE_MAP.get(src_rtype, src_rtype.replace(":", "_")) out_map = OUTPUT_MAP.get(src_rtype, {}) tf_attr = out_map.get(src_output, src_output) main_tf_parts.append(_emit_output(out_name, f"{src_tf_type}.{src_rid}.{tf_attr}")) emitted_outputs.add(out_name) main_tf = "\n".join(main_tf_parts) with open(os.path.join(out_dir, "main.tf"), "w") as fh: fh.write(main_tf) with open(os.path.join(out_dir, "terraform.tf"), "w") as fh: fh.write(terraform_tf) with open(os.path.join(out_dir, "providers.tf"), "w") as fh: fh.write(providers_tf) return out_dir if __name__ == "__main__": if len(sys.argv) != 3: print("usage: adapter.py ", file=sys.stderr) sys.exit(2) with open(sys.argv[1], "r") as fh: stack = json.load(fh) adapt(stack, sys.argv[2]) print(f"adapter: emitted terraform to {sys.argv[2]}", file=sys.stderr)