dca35c78ec
---ci---
phase: 22
title: rename-and-production-static-assets-stack
status: complete
verification:
- scripts/run_ci.sh: PASS (CI PIPELINE OK)
- python3 -m pytest tests/ -v: 175 passed
- scripts/run_platform.sh --check-only: PASS (PLATFORM CHECK OK)
- grep -R "static-asset[^s]" . (excl .git/): 0 hits
- grep -R "static-asset$" . (excl .git/): 0 hits
- floating git tags v1.6 + v1 point at v1.6.0 (a90a756)
changed_files:
- Task 1 (rename): contracts/static-asset.yaml→static-assets.yaml (git mv); modules/l2/static-asset→static-assets (git mv); sed replaceAll static-asset→static-assets in 22 files (README, docs, scripts/run_platform.sh, pipelines/deploy.yaml, modules/registry.json, tests/*, .ciagent/* historical narrative)
- Task 2 (cloudfront primitive): modules/l1/cloudfront/interface.json + README.md
- Task 3 (waf primitive): modules/l1/waf/interface.json + README.md
- Task 4 (registry): modules/registry.json (+cloudfront, +waf, static-assets renamed)
- Task 5 (augment static-assets): modules/l2/static-assets/composition.json (s3+cloudfront+waf, depth 1); modules/l1/s3/interface.json +instance.json (+bucket_regional_domain_name output); modules/l2/static-assets/README.md (production stack docs)
- Task 6 (adapter): adapters/terraform/adapter.py (+TYPE_MAP/INPUT_MAP/OUTPUT_MAP for cloudfront distribution+OAC+wafv2 webacl; special handling in _emit_resource for OAC defaults, distribution origin/cache_behavior/restrictions/viewer_certificate/web_acl_id, waf scope/default_action/visibility_config/managed rules)
- Task 7 (contract schema): no change needed (generic inputs object; new module names match ^[a-z][a-z0-9-]*$)
- Task 8 (@v1.6 bump): contracts/static-assets.yaml, .gitea/.github/workflows/deploy.yml (ref: v1.6 + header comments), docs/consumer-guide.md, docs/contracts/index.md, docs/pipeline/versioning.md, docs/pipeline/index.md, docs/architecture.md, README.md, modules/l2/microservice/README.md, tests/test_environment_check.py, tests/test_pipeline_contract.py
- Task 9 (floating tags): git tag -f v1.6 v1.6.0; git tag -f v1 v1.6.0
- Task 10 (tests): tests/test_adapter.py (registry 11 entries/9 L1/2 L2; cloudfront+waf type map tests; TestS3Output bucket_regional_domain_name; TestStaticAssetsStack 4 tests); tests/test_contract_resolver.py (+s3/cloudfront/waf resource assertions)
generated:
- terraform/spike/main.tf + terraform.tf (regenerated by run_platform.sh --check-only; reflect static-assets production stack + backend key spike/static-assets/)
notes:
- D-048 full rewrite of .ciagent/ historical narrative (verbatim phase descriptions, REQ-25/27/50, D-036) — produces intentional tautologies (e.g. "Rename static-assets → static-assets") per the decision to override the v1.6 preservation precedent.
- cloudfront interface.json resources array ordered distribution-first so the resolver (first-match wire resolution) routes bucket_regional_domain_name/waf_web_acl_arn/region to the distribution; the OAC gets adapter-provided defaults (name=acdl-oac, origin_type=s3, signing_behavior=always).
- .ciagent/ @v1.4 references left as historical record (D-048 scope was static-asset rename only; @v1.4 is historical narrative of Phase 20).
- s3 OUTPUT_MAP bucket_regional_domain_name not added (identity fallback in adapt() already handles it; OUTPUT_MAP documents non-identity mappings only).
---ci---
500 lines
22 KiB
Python
500 lines
22 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",
|
|
"aws:cloudfront:distribution": "aws_cloudfront_distribution",
|
|
"aws:cloudfront:originaccesscontrol": "aws_cloudfront_origin_access_control",
|
|
"aws:wafv2:webacl": "aws_wafv2_web_acl",
|
|
}
|
|
|
|
# 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": {},
|
|
"aws:cloudfront:distribution": {"bucket_regional_domain_name": "origin_domain_name", "price_class": "price_class", "viewer_protocol_policy": "viewer_protocol_policy", "default_ttl": "default_ttl", "max_ttl": "max_ttl", "waf_web_acl_arn": "web_acl_id"},
|
|
"aws:cloudfront:originaccesscontrol": {"name": "name", "origin_type": "origin_access_control_origin_type", "signing_behavior": "origin_access_control_signing_behavior"},
|
|
"aws:wafv2:webacl": {"name": "name", "scope": "scope", "default_action": "default_action", "rules": "rules"},
|
|
}
|
|
|
|
# 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"},
|
|
"aws:cloudfront:distribution": {"distribution_arn": "arn", "distribution_domain_name": "domain_name", "oac_id": "origin_access_control_id"},
|
|
"aws:cloudfront:originaccesscontrol": {"oac_id": "id"},
|
|
"aws:wafv2:webacl": {"web_acl_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
|
|
if rtype == "aws:cloudfront:distribution" and in_name in (
|
|
"bucket_regional_domain_name", "price_class", "viewer_protocol_policy",
|
|
"default_ttl", "max_ttl", "waf_web_acl_arn", "oac_id",
|
|
):
|
|
# Collected into the origin/default_cache_behavior/web_acl_id blocks
|
|
# emitted after all inputs.
|
|
continue
|
|
if rtype == "aws:cloudfront:originaccesscontrol" and in_name in (
|
|
"name", "origin_type", "signing_behavior",
|
|
):
|
|
# Defaults emitted after all inputs.
|
|
continue
|
|
if rtype == "aws:wafv2:webacl" and in_name in (
|
|
"name", "scope", "default_action", "rules",
|
|
):
|
|
# Structured blocks 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("}")
|
|
if rtype == "aws:cloudfront:originaccesscontrol":
|
|
name = inputs.get("name", "acdl-oac")
|
|
if isinstance(name, str) and name.startswith("ref:"):
|
|
name = _ref_expr(name, type_by_id)
|
|
else:
|
|
name = _tf_value(name)
|
|
body.append(f"name = {name}")
|
|
body.append("origin_access_control_origin_type = \"s3\"")
|
|
body.append("origin_access_control_signing_behavior = \"always\"")
|
|
if rtype == "aws:cloudfront:distribution":
|
|
origin_domain = inputs.get("bucket_regional_domain_name")
|
|
if isinstance(origin_domain, str) and origin_domain.startswith("ref:"):
|
|
origin_domain = _ref_expr(origin_domain, type_by_id)
|
|
else:
|
|
origin_domain = _tf_value(origin_domain)
|
|
# The OAC resource id follows the convention "<childId>-originaccesscontrol";
|
|
# derive it from this distribution's id.
|
|
if rid.endswith("-distribution"):
|
|
oac_rid = rid[: -len("distribution")] + "originaccesscontrol"
|
|
else:
|
|
oac_rid = "cloudfront-originaccesscontrol"
|
|
body.append("origin {")
|
|
body.append(f" domain_name = {origin_domain}")
|
|
body.append(f" origin_access_control = aws_cloudfront_origin_access_control.{oac_rid}.id")
|
|
body.append(" s3_origin_config {}")
|
|
body.append("}")
|
|
body.append("enabled = true")
|
|
price_class = inputs.get("price_class", "PriceClass_100")
|
|
vpp = inputs.get("viewer_protocol_policy", "redirect-to-https")
|
|
default_ttl = inputs.get("default_ttl", 3600)
|
|
max_ttl = inputs.get("max_ttl", 86400)
|
|
body.append("default_cache_behavior {")
|
|
body.append(f" viewer_protocol_policy = {_value_expr(vpp, type_by_id)}")
|
|
body.append(f" target_origin_id = {_tf_value(rid)}")
|
|
body.append(" min_ttl = 0")
|
|
body.append(f" default_ttl = {_value_expr(default_ttl, type_by_id)}")
|
|
body.append(f" max_ttl = {_value_expr(max_ttl, type_by_id)}")
|
|
body.append(" allowed_methods = [\"GET\", \"HEAD\"]")
|
|
body.append(" cached_methods = [\"GET\", \"HEAD\"]")
|
|
body.append("}")
|
|
body.append(f"price_class = {_value_expr(price_class, type_by_id)}")
|
|
body.append("restrictions {")
|
|
body.append(" geo_restriction {")
|
|
body.append(" restriction_type = \"none\"")
|
|
body.append(" }")
|
|
body.append("}")
|
|
body.append("viewer_certificate {")
|
|
body.append(" cloudfront_default_certificate = true")
|
|
body.append("}")
|
|
waf_arn = inputs.get("waf_web_acl_arn")
|
|
if waf_arn is not None:
|
|
if isinstance(waf_arn, str) and waf_arn.startswith("ref:"):
|
|
waf_expr = _ref_expr(waf_arn, type_by_id)
|
|
else:
|
|
waf_expr = _tf_value(waf_arn)
|
|
body.append(f"web_acl_id = {waf_expr}")
|
|
if rtype == "aws:wafv2:webacl":
|
|
name = inputs.get("name", "acdl-waf")
|
|
body.append(f"name = {_tf_value(name) if not isinstance(name, str) or not name.startswith('ref:') else _ref_expr(name, type_by_id)}")
|
|
body.append("scope = \"cloudfront\"")
|
|
body.append("default_action {")
|
|
body.append(" allow {}")
|
|
body.append("}")
|
|
body.append("visibility_config {")
|
|
body.append(" cloudwatch_metrics_enabled = true")
|
|
body.append(" metric_name = \"acdl-waf-metrics\"")
|
|
body.append(" sampled_requests_enabled = true")
|
|
body.append("}")
|
|
rules_input = inputs.get("rules")
|
|
if rules_input:
|
|
body.append(f"rules = {_value_expr(rules_input, type_by_id)}")
|
|
else:
|
|
body.append("rules {")
|
|
body.append(" name = \"aws-managed-rules\"")
|
|
body.append(" priority = 0")
|
|
body.append(" override_action {")
|
|
body.append(" none {}")
|
|
body.append(" }")
|
|
body.append(" statement {")
|
|
body.append(" managed_rule_group_statement {")
|
|
body.append(" name = \"AWSManagedRulesCommonRuleSet\"")
|
|
body.append(" vendor_name = \"AWS\"")
|
|
body.append(" }")
|
|
body.append(" }")
|
|
body.append(" visibility_config {")
|
|
body.append(" cloudwatch_metrics_enabled = true")
|
|
body.append(" metric_name = \"aws-managed-rules-metrics\"")
|
|
body.append(" sampled_requests_enabled = true")
|
|
body.append(" }")
|
|
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) |