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).
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""ACDL Contract Resolver — resolve a contract to a Target Stack IR instance.
|
|
|
|
ARCHITECTURE.md §12.8: the contract declares intent in IR-typed terms;
|
|
the resolver resolves the contract to a target stack (list of L1
|
|
instances + inputs + relationships); the adapter compiles the target
|
|
stack to a plan.
|
|
|
|
Steps:
|
|
1. Load the contract (YAML -> dict).
|
|
2. Validate the contract against schemas/contract.schema.json.
|
|
3. Look up the L2 in modules-ir/registry.json.
|
|
4. Load the L2's composition.json (the thin-composition tree).
|
|
5. Map the contract's inputs through the composition's wires to the
|
|
child L1s' inputs. Two wire kinds:
|
|
- passthrough: {target, input} (or an array of the same) -> the
|
|
concrete contract value.
|
|
- child->child: {target, input, source:"child:<id>.<output>"} ->
|
|
a "ref:<ir_resource_id>.<output>" string (value known at apply
|
|
time only).
|
|
A wire value may be a single object or an array of objects (for
|
|
contract inputs that fan out to multiple children); both forms are
|
|
iterated.
|
|
6. Emit an IR instance {version, stack:{name, kind:l2, depth},
|
|
resources:[<L1 instances with concrete inputs>], relationships:[...]}.
|
|
Multi-resource L1s (interface.json has a `resources` array) expand
|
|
into one IR resource per entry, id `<child_id>-<type_suffix>` where
|
|
type_suffix is the last IR-type segment with underscores stripped;
|
|
single-resource L1s keep the child id verbatim.
|
|
7. Validate the IR instance against schemas/ir.schema.json.
|
|
|
|
CLI: contract_resolver.py <contract.yaml> <out_ir.json>
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import yaml
|
|
import jsonschema
|
|
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _load_json(path):
|
|
with open(path, "r") as fh:
|
|
return json.load(fh)
|
|
|
|
|
|
def _iter_wire_targets(wire_value):
|
|
"""Yield each target-spec from a wire value (single object or array)."""
|
|
if isinstance(wire_value, list):
|
|
for spec in wire_value:
|
|
yield spec
|
|
elif isinstance(wire_value, dict):
|
|
yield wire_value
|
|
|
|
|
|
def _type_suffix(ir_type):
|
|
"""Last segment of an IR type, underscores stripped (e.g. aws:ec2:vpc -> vpc,
|
|
aws:elbv2:targetgroup -> targetgroup, aws:ecs:task_definition -> taskdefinition)."""
|
|
return ir_type.rsplit(":", 1)[-1].replace("_", "")
|
|
|
|
|
|
def _resolve_child_ref(source, child_id, l1_iface, child_ir_ids):
|
|
"""Resolve a "child:<id>.<output>" source to "ref:<ir_resource_id>.<output>".
|
|
|
|
The ir_resource_id is the producing child's sub-resource that
|
|
declares the output. For single-resource L1s that is the child id;
|
|
for multi-resource L1s the L1's `resources` array is scanned for
|
|
which sub-resource declares the output (exact match, then a
|
|
singular->plural fallback so e.g. `subnet_ids` matches a per-resource
|
|
`subnet_id`). The ref's output name is the per-resource output name
|
|
when matched that way, else the source output name verbatim.
|
|
"""
|
|
prefix = "child:"
|
|
if not source.startswith(prefix):
|
|
raise ValueError(f"unsupported wire source {source!r}")
|
|
body = source[len(prefix):]
|
|
src_child_id, src_output = body.split(".", 1)
|
|
if src_child_id != child_id:
|
|
# Cross-child reference: look up the producing child's first IR
|
|
# resource id (the child->child wiring table is keyed by child id
|
|
# by the caller; this branch is unused for v1.2's wires but kept
|
|
# for completeness).
|
|
ir_resource_id = child_ir_ids.get(src_child_id, src_child_id)
|
|
return f"ref:{ir_resource_id}.{src_output}"
|
|
# Same-child reference: find the producing sub-resource.
|
|
resources = l1_iface.get("resources")
|
|
if not resources:
|
|
return f"ref:{child_id}.{src_output}"
|
|
for idx, sub in enumerate(resources):
|
|
sub_outputs = sub.get("outputs", [])
|
|
if src_output in sub_outputs:
|
|
ir_id = child_ir_ids[child_id][idx]
|
|
return f"ref:{ir_id}.{src_output}"
|
|
# Singular->plural fallback (subnet_ids -> subnet_id).
|
|
singular = src_output[:-1] if src_output.endswith("s") else src_output
|
|
for idx, sub in enumerate(resources):
|
|
sub_outputs = sub.get("outputs", [])
|
|
if singular in sub_outputs:
|
|
ir_id = child_ir_ids[child_id][idx]
|
|
return f"ref:{ir_id}.{singular}"
|
|
# No per-resource match: point at the first sub-resource, keep the
|
|
# source output name verbatim.
|
|
ir_id = child_ir_ids[child_id][0]
|
|
return f"ref:{ir_id}.{src_output}"
|
|
|
|
|
|
def resolve(contract_path, repo_root=None):
|
|
"""Resolve a contract YAML to an IR instance dict."""
|
|
rr = repo_root or REPO_ROOT
|
|
|
|
# 1. Load the contract YAML.
|
|
with open(contract_path, "r") as fh:
|
|
contract = yaml.safe_load(fh)
|
|
|
|
# 2. Validate the contract against the contract schema.
|
|
contract_schema = _load_json(os.path.join(rr, "schemas/contract.schema.json"))
|
|
jsonschema.validate(contract, contract_schema)
|
|
|
|
# 3. Look up the L2 in the registry.
|
|
stack_name = contract["stack"]
|
|
registry = _load_json(os.path.join(rr, "modules-ir/registry.json"))
|
|
if stack_name not in registry:
|
|
raise ValueError(f"stack {stack_name!r} not in registry")
|
|
versions = registry[stack_name]
|
|
# Pick the highest 1.x.x (spike: just take the first non-deprecated).
|
|
entry = next(v for v in versions.values() if not v.get("deprecated", False))
|
|
|
|
# 4. Load the L2's composition.json.
|
|
composition_key = entry.get("composition") or entry.get("interface")
|
|
composition = _load_json(os.path.join(rr, composition_key))
|
|
|
|
# 5. Map the contract's inputs through the wires to the child L1s' inputs.
|
|
wires = composition.get("wires", {})
|
|
contract_inputs = contract.get("inputs", {})
|
|
children = composition.get("children", [])
|
|
|
|
# Pre-load every child's L1 interface + compute IR resource ids.
|
|
child_ifaces = {}
|
|
child_ir_ids = {}
|
|
for child in children:
|
|
child_id = child["id"]
|
|
child_module = child["module"]
|
|
l1_name, l1_version = child_module.split("@", 1)
|
|
l1_entry = registry.get(l1_name, {}).get(l1_version)
|
|
if not l1_entry:
|
|
raise ValueError(f"L1 {child_module!r} not in registry")
|
|
l1_iface = _load_json(os.path.join(rr, l1_entry["interface"]))
|
|
child_ifaces[child_id] = l1_iface
|
|
sub_resources = l1_iface.get("resources")
|
|
if sub_resources:
|
|
child_ir_ids[child_id] = [
|
|
f"{child_id}-{_type_suffix(sub['type'])}" for sub in sub_resources
|
|
]
|
|
else:
|
|
child_ir_ids[child_id] = [child_id]
|
|
|
|
# Build each child's mapped inputs (concrete values + ref strings).
|
|
child_inputs_map = {child["id"]: {} for child in children}
|
|
for wire_name, wire_value in wires.items():
|
|
for spec in _iter_wire_targets(wire_value):
|
|
target = spec.get("target")
|
|
if target not in child_inputs_map:
|
|
continue
|
|
input_name = spec["input"]
|
|
source = spec.get("source")
|
|
if source:
|
|
# Child->child reference: emit a ref string.
|
|
src_child_id = source[len("child:"):].split(".", 1)[0]
|
|
child_inputs_map[target][input_name] = _resolve_child_ref(
|
|
source, src_child_id, child_ifaces[src_child_id], child_ir_ids
|
|
)
|
|
else:
|
|
# Contract->child passthrough.
|
|
if wire_name in contract_inputs:
|
|
child_inputs_map[target][input_name] = contract_inputs[wire_name]
|
|
|
|
# 6. Emit the IR instance.
|
|
resources = []
|
|
relationships = []
|
|
for child in children:
|
|
child_id = child["id"]
|
|
child_module = child["module"]
|
|
l1_iface = child_ifaces[child_id]
|
|
l1_outputs = l1_iface.get("outputs", {})
|
|
child_inputs = child_inputs_map[child_id]
|
|
sub_resources = l1_iface.get("resources")
|
|
ir_ids = child_ir_ids[child_id]
|
|
if sub_resources:
|
|
for idx, sub in enumerate(sub_resources):
|
|
ir_id = ir_ids[idx]
|
|
sub_in_names = sub.get("inputs", [])
|
|
sub_out_names = sub.get("outputs", [])
|
|
sub_inputs = {
|
|
n: child_inputs[n] for n in sub_in_names if n in child_inputs
|
|
}
|
|
sub_outputs = {
|
|
n: l1_outputs[n] for n in sub_out_names if n in l1_outputs
|
|
}
|
|
resources.append({
|
|
"id": ir_id,
|
|
"type": sub["type"],
|
|
"module": child_module,
|
|
"inputs": sub_inputs,
|
|
"outputs": sub_outputs,
|
|
})
|
|
relationships.append({"from": "root", "to": ir_id, "kind": "parent"})
|
|
# Resolve intra-L1 refs (refs between sub-resources of the same L1).
|
|
intra_refs = l1_iface.get("intra_refs", [])
|
|
for iref in intra_refs:
|
|
from_type, from_input = iref["from"].split(".", 1)
|
|
to_type, to_output = iref["to"].split(".", 1)
|
|
from_ir_id = next((ir_ids[i] for i, s in enumerate(sub_resources) if s["type"] == from_type), None)
|
|
to_ir_id = next((ir_ids[i] for i, s in enumerate(sub_resources) if s["type"] == to_type), None)
|
|
if from_ir_id and to_ir_id:
|
|
for r in resources:
|
|
if r["id"] == from_ir_id:
|
|
r["inputs"][from_input] = f"ref:{to_ir_id}.{to_output}"
|
|
else:
|
|
resources.append({
|
|
"id": child_id,
|
|
"type": l1_iface["type"],
|
|
"module": child_module,
|
|
"inputs": child_inputs,
|
|
"outputs": l1_outputs,
|
|
})
|
|
relationships.append({"from": "root", "to": child_id, "kind": "parent"})
|
|
|
|
ir_instance = {
|
|
"version": "1.0.0",
|
|
"stack": {
|
|
"name": composition["name"],
|
|
"kind": composition["kind"],
|
|
"depth": composition["depth"],
|
|
},
|
|
"resources": resources,
|
|
"relationships": relationships,
|
|
}
|
|
|
|
# 7. Validate the IR instance against the IR schema.
|
|
ir_schema = _load_json(os.path.join(rr, "schemas/ir.schema.json"))
|
|
jsonschema.validate(ir_instance, ir_schema)
|
|
|
|
return ir_instance
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 3:
|
|
print("usage: contract_resolver.py <contract.yaml> <out_ir.json>", file=sys.stderr)
|
|
sys.exit(2)
|
|
ir = resolve(sys.argv[1])
|
|
with open(sys.argv[2], "w") as fh:
|
|
json.dump(ir, fh, indent=2)
|
|
print(f"resolver: emitted IR to {sys.argv[2]}", file=sys.stderr) |