d103a37419
---ci--- project: acdl phase: 14 milestone: v1.2 status: verify verdict: VERIFIED requirements: covered: [REQ-32] ---/ci--- Phase 14 plan-as-execute + verify. scripts/verify_phase14.sh green. l2-microservice composition (6 L1s, 2 wire kinds); contract schema extended (inputs allow objects + healthcheck); resolver extended (array-form wires, child->child refs, multi-resource L1 expansion); adapter extended (ref: interpolation translation). v1.2 IR: 11 resources. v1.1 S3 regression byte-identical. Ready to ship v1.2.4.
287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""ACDL Terraform adapter — compile a Target Stack IR instance to Terraform.
|
|
|
|
ARCHITECTURE.md §12.2: the adapter translates the IR-typed L1 interface
|
|
to a Terraform variable/output block, the L2 thin-composition tree to a
|
|
root module that calls the L1 modules, the IR-typed relationships to
|
|
Terraform module references, and emits a Terraform plan from the IR.
|
|
|
|
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 (l1-s3, IR type aws:s3:bucket).
|
|
Phase 13: generalized the resource/output emission via TYPE_MAP +
|
|
INPUT_MAP + OUTPUT_MAP tables; added ECS Fargate IR types. S3 behavior
|
|
is preserved (regression baseline: modules-ir/l1/l1-s3/spike_instance.json).
|
|
|
|
CLI: adapter.py <ir_instance.json> <out_dir>
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
|
|
# IR type -> Terraform resource type. The only substrate-specific table.
|
|
# As more L1s land, this grows; the L1 content + IR 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",
|
|
}
|
|
|
|
# IR input name -> Terraform arg name, per IR type. Only non-identity
|
|
# mappings are listed; any input not present here uses the IR name as
|
|
# the Terraform arg name (identity).
|
|
INPUT_MAP = {
|
|
"aws:s3:bucket": {"bucket_name": "bucket"},
|
|
"aws:ec2:vpc": {"cidr": "cidr_block"},
|
|
"aws:ec2:subnet": {"cidr": "cidr_block", "az": "availability_zone"},
|
|
"aws:ec2:routetable": {"vpc_id": "vpc_id"},
|
|
"aws:ecs:cluster": {},
|
|
"aws:ecs:task_definition": {},
|
|
"aws:ecs:service": {},
|
|
"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": {},
|
|
}
|
|
|
|
# IR output name -> Terraform attribute name, per IR type. Only
|
|
# non-identity mappings are listed; any output not present here uses the
|
|
# IR 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")
|
|
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:<ir_resource_id>.<output>" string to a Terraform
|
|
interpolation "${<tf_type>.<id>.<attr>}".
|
|
|
|
<ir_resource_id> is the IR resource id of the producing resource;
|
|
<output> is the per-resource output name (e.g. `subnet_id`,
|
|
`cluster_arn`); the attribute is mapped through OUTPUT_MAP for the
|
|
referenced resource's IR type. The resolver emits the ref using the
|
|
IR 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 IR resource id {rid!r}")
|
|
tf_type = TYPE_MAP.get(rtype)
|
|
if not tf_type:
|
|
raise ValueError(f"ref target {rid!r} has unknown IR 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:<id>.<output>"
|
|
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 IR 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 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
|
|
body.append(f"{arg} = {_value_expr(value, type_by_id)}")
|
|
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))
|
|
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 = " + _tf_value(arns))
|
|
return _resource_block(rid, tf_type, body)
|
|
|
|
|
|
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(ir_instance, out_dir):
|
|
"""Emit main.tf + terraform.tf + providers.tf to out_dir for the IR instance."""
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
stack = ir_instance["stack"]
|
|
resources = ir_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 an IR-resource-id -> IR-type table so `ref:` input values can
|
|
# be resolved to Terraform interpolations without a child->resource
|
|
# lookup (the resolver emits refs with the IR resource id directly).
|
|
type_by_id = {r["id"]: r["type"] for r in resources}
|
|
main_tf_parts = []
|
|
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}"))
|
|
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 <ir_instance.json> <out_dir>", file=sys.stderr)
|
|
sys.exit(2)
|
|
with open(sys.argv[1], "r") as fh:
|
|
ir = json.load(fh)
|
|
adapt(ir, sys.argv[2])
|
|
print(f"adapter: emitted terraform to {sys.argv[2]}", file=sys.stderr) |