"""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. Substrate-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 substrate-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", } # 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": {}, } # 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_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"}, } 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 == "aws:ecs:service" and in_name in ("subnets", "security_group"): # Collected into network_configuration block (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("}") body.append("desired_count = 1") body.append("launch_type = \"FARGATE\"") body.append("task_definition = aws_ecs_task_definition.service-taskdefinition.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: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: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": body.append("load_balancer_type = \"application\"") if rtype == "aws:elbv2:targetgroup": body.append("target_type = \"ip\"") body.append("vpc_id = aws_vpc.vpc-vpc.id") body.append("protocol = \"HTTP\"") 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 = {") body.append(' Name = "acdl-microservice-rt"') 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") parts = [] parts.append(_resource_block("vpc-igw", "aws_internet_gateway", [ f"vpc_id = aws_vpc.{vpc_id}.id", "tags = {", ' Name = "acdl-microservice-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) 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: 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)) 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)