docs(P13): plan-as-execute + verify (v1.2.3)
---ci--- project: acdl phase: 13 milestone: v1.2 status: verify verdict: VERIFIED requirements: covered: [REQ-31] ---/ci--- Phase 13 plan-as-execute + verify. scripts/verify_phase13.sh green. 6 ECS L1s authored + registered (l1-vpc, l1-ecs-cluster, l1-ecs-service, l1-iam-role, l1-alb, l1-ecr). Adapter generalized to table-driven TYPE_MAP (12 IR types) + INPUT_MAP + OUTPUT_MAP. S3 regression: the v1.1 spike l1-s3 produces byte-identical main.tf. Ready to ship v1.2.3.
This commit is contained in:
+128
-20
@@ -8,8 +8,10 @@ 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.
|
||||
|
||||
Spike scope (Phase 09): handles one L1 (l1-s3, IR type aws:s3:bucket).
|
||||
L2 thin-composition + relationships land in Phase 10.
|
||||
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>
|
||||
"""
|
||||
@@ -23,13 +25,67 @@ import sys
|
||||
# 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_block(block_type, name, body_lines, indent=2):
|
||||
head = f'{block_type} "{name}" {{'
|
||||
body = "\n".join(f" {l}" for l in body_lines)
|
||||
return f"{head}\n{body}\n}}\n"
|
||||
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):
|
||||
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 _emit_resource(resource):
|
||||
@@ -37,19 +93,70 @@ def _emit_resource(resource):
|
||||
rid = resource["id"]
|
||||
tf_type = TYPE_MAP.get(rtype)
|
||||
if not tf_type:
|
||||
raise ValueError(f"unknown IR type {rtype!r} (adapter spike handles aws:s3:bucket only)")
|
||||
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", {})
|
||||
# S3 bucket: bucket_name -> bucket arg; region -> provider (handled separately)
|
||||
if "bucket_name" in inputs:
|
||||
body.append(f'bucket = "{inputs["bucket_name"]}"')
|
||||
# NFR: versioning (default true)
|
||||
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":
|
||||
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":
|
||||
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} = {_tf_value(value)}")
|
||||
nfrs = resource.get("nfrs", {})
|
||||
versioning = nfrs.get("versioning", True) if isinstance(nfrs, dict) else True
|
||||
body.append("versioning {")
|
||||
body.append(f' enabled = {"true" if versioning else "false"}')
|
||||
body.append("}")
|
||||
return _tf_block("resource", f'aws_s3_bucket.{rid}', body) if False else _resource_block(rid, tf_type, body)
|
||||
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):
|
||||
@@ -106,12 +213,13 @@ def adapt(ir_instance, out_dir):
|
||||
for r in resources:
|
||||
main_tf_parts.append(_emit_resource(r))
|
||||
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 == "bucket_arn":
|
||||
main_tf_parts.append(_emit_output("bucket_arn", f"aws_s3_bucket.{rid}.arn"))
|
||||
elif out_name == "bucket_name":
|
||||
main_tf_parts.append(_emit_output("bucket_name", f"aws_s3_bucket.{rid}.id"))
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user