699aa542df
---ci---
project: acdl
phase: 15
milestone: v1.2
status: verify
verdict: PARTIAL
requirements:
covered: [REQ-34]
partial: [REQ-33]
blocker:
- id: P0-IAM
description: terraform apply fails with AccessDenied on ECS/ECR/IAM/EC2 — live spike_runner_policy.json not pushed (root key deactivated per D-034)
unblock: operator runs create_iam_user.py with root/admin creds to push the expanded policy, then terraform apply succeeds (plan valid, 13 to add)
---/ci---
Phase 15 plan-as-execute + verify. PARTIAL: terraform apply blocked by IAM.
- Consumer microservice content authored (app.py + Dockerfile + README.md).
- Docker image acdl-microservice:latest built.
- Adapter fixed: ref emission (bare), JSON-string jsonencode, ECS service
network_configuration/load_balancer/desired_count/launch_type/task_definition,
listener default_action/load_balancer_arn, target group target_type/vpc_id/protocol,
VPC tags (not name), IGW + route table association, managed_policy_arns list.
- L1 fixes: l1-ecs-service (removed port from service sub-resource),
l1-vpc (added intra_refs, removed igw_id output).
- Resolver: intra_refs resolution (refs between sub-resources of same L1).
- terraform validate + plan succeed (13 to add).
- terraform apply BLOCKED (AccessDenied — live IAM policy not updated).
- Evidence event TERRAFORM_APPLY_BLOCKED written to DynamoDB outbox.
- v1.1 S3 regression: byte-identical.
Ready to ship v1.2.5 (partial).
384 lines
16 KiB
Python
384 lines
16 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", "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": {},
|
|
}
|
|
|
|
# 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")
|
|
# 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:<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 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(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 = []
|
|
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 <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) |