d103a37419
---ci--- project: acdl phase: 14 milestone: v1.2 status: verify verdict: VERIFIED requirements: covered: [REQ-32] ---/ci--- Phase 14 plan-as-execute + verify. scripts/verify_phase14.sh green. l2-microservice composition (6 L1s, 2 wire kinds); contract schema extended (inputs allow objects + healthcheck); resolver extended (array-form wires, child->child refs, multi-resource L1 expansion); adapter extended (ref: interpolation translation). v1.2 IR: 11 resources. v1.1 S3 regression byte-identical. Ready to ship v1.2.4.
245 lines
9.7 KiB
Python
245 lines
9.7 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"})
|
|
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) |