f68f85c9fd
---ci---
project: acdl
phase: 20
milestone: v1.5
status: review
verdict: READY TO SHIP
p0: 1 (fixed — contract path resolution in deploy workflow)
p1: 6 (flagged post-hoc)
---/ci---
Multi-persona review of v1.5 phase 20 (docs + reusable deploy workflow).
P0 (blocking) — AUTO-FIXED:
- C1: scripts/run_platform.sh contract path resolution broken in deploy
workflow. The reusable workflow invokes run_platform.sh from the consumer
workspace root with a relative contract path (.acdl/contract.yaml), but
run_platform.sh does `cd "$ROOT"` (platform repo) early, so the relative
path resolved against the platform repo and the pipeline could never run.
Fix (commit 75c2274): capture CALLER_CWD before cd "$ROOT"; resolve
caller-supplied relative paths against CALLER_CWD; default no-arg contract
stays relative to ROOT (preserves platform-local CI). Reproduced pre-fix;
verified post-fix.
P1 (important) — FLAGGED FOR POST-HOC REVIEW (do not block ship):
- C2: ref: v1.4 in the deploy workflow platform checkout — no v1.4 tag exists
(only v1.4.0 / v1.4.1). Operator must create a floating v1.4 tag or change
the ref to v1.4.1.
- C3: modules/l2/{static-asset,microservice}/README.md still use @v1 in their
Usage examples; missed by the v1.4 bump.
- S1: static-key override is not wired. ACDL_AWS_* env vars on the OIDC step
are not read by aws-actions/configure-aws-credentials@v4 (it reads AWS_*
or its own access-key/secret-key inputs). The README/CONSUMER_GUIDE claim
a working override that doesn't function as written. Needs a conditional
step or renamed env vars + input wiring.
- S2: README overstates ABAC repo:org/repo:ref:... scoping. The workflow
constructs a numeric role name (github.repository_id); the actual claim
enforcement lives in the IAM trust policy, not in this workflow.
- T1: no deploy-workflow triggers conformance test (CI workflow has one;
deploy doesn't). Minor — reusable workflows use workflow_call, not push
triggers, but the contract's triggers field is then unenforced.
- A1: terraform/spike/terraform.tf uploaded as artifact leaks the AWS account
ID via the state-backend bucket name. Recommend excluding terraform.tf or
gating artifact upload to non-public repos.
P2 (nits) — listed for awareness: floating-tag terminology imprecision (M1),
header comment "Gitea Actions" in the GitHub copy (M2, intentional byte-
identical), pip install split (P1-perf), comment drift in pipelines/deploy.yaml
header (C4), module README internal inconsistency (C5).
Verdict: READY TO SHIP. The one P0 is fixed. The 6 P1s are post-hoc items —
the deploy workflow is a scaffold whose first real consumer run requires
operator setup (tag, IAM role, secrets) that gates go-live. The P1s should
be addressed before any consumer invokes uses: acdl/.gitea/workflows/
deploy.yml@v1.4 in earnest.
Tests: 154 pass (19 new). run_ci.sh green.
384 lines
16 KiB
Python
384 lines
16 KiB
Python
"""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 <instance.json> <out_dir>
|
|
"""
|
|
|
|
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:<stack_resource_id>.<output>" string to a Terraform
|
|
interpolation "${<tf_type>.<id>.<attr>}".
|
|
|
|
<stack_resource_id> is the stack 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 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:<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 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 <instance.json> <out_dir>", 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) |