fix(P28): WAF nested rules + default_action + resolver outputs (P1-4, P1-5, P1-7)

---ci---
project: acdl
phase: 28
milestone: v1.8
status: execute
---/ci---

P1-4: WAF custom rules now emit nested `rules { ... }` blocks per rule
instead of `rules = [...]` attribute syntax (invalid HCL).
P1-5: WAF default_action input is honored (allow/block) instead of
hardcoding `allow {}`. Default is `allow` when absent (backward compat).
P1-7: L2 composition outputs[] array is now processed by resolve_l2().
The resolver builds stack.outputs from the composition outputs wires.
The adapter emits `output` blocks from stack.outputs.

Tests: +10 (275 -> 285). All pass. run_platform.sh --check-only green.
This commit is contained in:
Jon Chery
2026-07-22 22:03:54 +00:00
parent 045c7279aa
commit 0eb578c606
5 changed files with 247 additions and 4 deletions
+55 -3
View File
@@ -335,18 +335,57 @@ def _emit_resource(resource, type_by_id=None):
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\"")
# P1-5: Honor default_action input instead of hardcoding allow {}.
default_action_input = inputs.get("default_action", "allow")
if isinstance(default_action_input, str) and default_action_input.startswith("ref:"):
default_action_input = "allow"
action_type = default_action_input if default_action_input in ("allow", "block") else "allow"
body.append("default_action {")
body.append(" allow {}")
body.append(f" {action_type} {{}}")
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("}")
# P1-4: Emit custom rules as nested blocks, not an attribute assignment.
rules_input = inputs.get("rules")
if rules_input:
body.append(f"rules = {_value_expr(rules_input, type_by_id)}")
if rules_input and isinstance(rules_input, list):
for idx, rule in enumerate(rules_input):
if not isinstance(rule, dict):
continue
rule_name = rule.get("name", f"custom-rule-{idx}")
rule_priority = rule.get("priority", idx)
body.append("rules {")
body.append(f" name = {_tf_value(rule_name)}")
body.append(f" priority = {_tf_value(rule_priority)}")
override = rule.get("override_action", "none")
if override not in ("none", "count"):
override = "none"
body.append(" override_action {")
body.append(f" {override} {{}}")
body.append(" }")
statement = rule.get("statement", {})
if statement:
body.append(" statement {")
for sk, sv in statement.items():
body.append(f" {sk} {{")
if isinstance(sv, dict):
for sk2, sv2 in sv.items():
body.append(f" {sk2} = {_tf_value(sv2)}")
body.append(" }")
body.append(" }")
body.append(" visibility_config {")
body.append(" cloudwatch_metrics_enabled = true")
body.append(f" metric_name = {_tf_value(f'{rule_name}-metrics')}")
body.append(" sampled_requests_enabled = true")
body.append(" }")
body.append("}")
elif rules_input and isinstance(rules_input, str) and rules_input.startswith("ref:"):
# A ref: value for rules — emit as dynamic block reference (rare case).
body.append(f"rules = {_ref_expr(rules_input, type_by_id)}")
else:
# Default: emit the AWS-managed-rules block when no custom rules.
body.append("rules {")
body.append(" name = \"aws-managed-rules\"")
body.append(" priority = 0")
@@ -498,6 +537,19 @@ def adapt(stack_instance, out_dir):
main_tf_parts.append(_emit_output(out_name, f"{tf_type}.{rid}.{tf_attr}"))
if has_vpc:
main_tf_parts.append(_emit_igw(resources))
# P1-7: Emit stack-level outputs from the resolved composition outputs[].
# Each stack output has {"from": <resourceId>, "output": <outputName>}.
# We look up the resource type + OUTPUT_MAP to build the interpolation.
stack_outputs = stack_instance.get("outputs", {})
for out_name, out_spec in stack_outputs.items():
src_rid = out_spec.get("from", "")
src_output = out_spec.get("output", out_name)
if src_rid in type_by_id:
src_rtype = type_by_id[src_rid]
src_tf_type = TYPE_MAP.get(src_rtype, src_rtype.replace(":", "_"))
out_map = OUTPUT_MAP.get(src_rtype, {})
tf_attr = out_map.get(src_output, src_output)
main_tf_parts.append(_emit_output(out_name, f"{src_tf_type}.{src_rid}.{tf_attr}"))
main_tf = "\n".join(main_tf_parts)
with open(os.path.join(out_dir, "main.tf"), "w") as fh:
+31
View File
@@ -232,6 +232,37 @@ def resolve_l2(contract, registry, repo_root):
"resources": resources,
}
# P1-7: Process the composition's outputs[] array to build stack.outputs.
# Each output wire: {"from": "<childId>.outputs.<name>", "to": "stack.outputs.<outName>"}
# The child_outputs map (childId -> {outputName: resourceId}) resolves
# the source to a resource id, which the adapter uses to emit
# `output "<outName>" { value = aws_<type>.<resourceId>.<attr> }`.
stack_outputs = {}
for out_wire in composition.get("outputs", []):
from_expr = out_wire.get("from", "")
to_expr = out_wire.get("to", "")
# Parse "to": "stack.outputs.<outName>"
to_parts = to_expr.split(".")
if len(to_parts) != 3 or to_parts[1] != "outputs":
continue
out_name = to_parts[2]
# Parse "from": "<childId>.outputs.<name>"
from_parts = from_expr.split(".")
if len(from_parts) != 3 or from_parts[1] != "outputs":
continue
src_child = from_parts[0]
src_output = from_parts[2]
# Resolve the source resource id from child_outputs
child_out_map = child_outputs.get(src_child, {})
src_resource_id = child_out_map.get(src_output, src_child)
stack_outputs[out_name] = {
"type": "string",
"from": src_resource_id,
"output": src_output,
}
if stack_outputs:
stack_instance["outputs"] = stack_outputs
return stack_instance
+12
View File
@@ -97,3 +97,15 @@ resource "aws_wafv2_web_acl" "waf" {
output "web_acl_arn" {
value = aws_wafv2_web_acl.waf.arn
}
output "distribution_domain_name" {
value = aws_cloudfront_distribution.cloudfront-distribution.domain_name
}
output "bucket_arn" {
value = aws_s3_bucket.s3.arn
}
output "web_acl_arn" {
value = aws_wafv2_web_acl.waf.arn
}
+121
View File
@@ -280,4 +280,125 @@ class TestStaticAssetsStack:
adapt(static_assets_stack, out_dir)
main_tf = open(os.path.join(out_dir, "main.tf")).read()
assert 'output "distribution_domain_name"' in main_tf
assert 'output "web_acl_arn"' in main_tf
class TestWAFAdapterFixes:
"""P1-4: WAF custom rules emit nested blocks, not attribute syntax.
P1-5: WAF default_action input is honored instead of hardcoded allow."""
@pytest.fixture
def waf_stack_with_custom_rules(self):
return {
"version": "1.0.0",
"stack": {"name": "waf-test", "kind": "l1", "depth": 1},
"resources": [
{
"id": "waf",
"type": "aws:wafv2:webacl",
"module": "waf@1.0.0",
"inputs": {
"name": "custom-waf",
"region": "us-east-1",
"default_action": "block",
"rules": [
{
"name": "rate-limit",
"priority": 1,
"override_action": "count",
"statement": {"rate_based_statement": {"limit": 100}},
},
{
"name": "geo-block",
"priority": 2,
"override_action": "none",
},
],
},
"outputs": {},
}
],
}
@pytest.fixture
def waf_stack_default(self):
return {
"version": "1.0.0",
"stack": {"name": "waf-test", "kind": "l1", "depth": 1},
"resources": [
{
"id": "waf",
"type": "aws:wafv2:webacl",
"module": "waf@1.0.0",
"inputs": {"name": "default-waf", "region": "us-east-1"},
"outputs": {},
}
],
}
def test_waf_custom_rules_emit_nested_blocks(self, waf_stack_with_custom_rules, tmp_path):
"""P1-4: rules must be nested blocks, not `rules = [...]`."""
out_dir = str(tmp_path / "tf_out")
adapt(waf_stack_with_custom_rules, out_dir)
main_tf = open(os.path.join(out_dir, "main.tf")).read()
assert "rules {" in main_tf
assert 'name = "rate-limit"' in main_tf
assert 'name = "geo-block"' in main_tf
assert "rules = [" not in main_tf
def test_waf_default_action_block_honored(self, waf_stack_with_custom_rules, tmp_path):
"""P1-5: default_action: block must emit `block {}` not `allow {}`."""
out_dir = str(tmp_path / "tf_out")
adapt(waf_stack_with_custom_rules, out_dir)
main_tf = open(os.path.join(out_dir, "main.tf")).read()
assert "default_action {" in main_tf
assert "block {}" in main_tf
assert "allow {}" not in main_tf
def test_waf_default_action_allow_when_absent(self, waf_stack_default, tmp_path):
"""P1-5: when default_action is absent, default to allow {} (backward compat)."""
out_dir = str(tmp_path / "tf_out")
adapt(waf_stack_default, out_dir)
main_tf = open(os.path.join(out_dir, "main.tf")).read()
assert "default_action {" in main_tf
assert "allow {}" in main_tf
def test_waf_default_emits_managed_rules_block(self, waf_stack_default, tmp_path):
"""When no custom rules, the default AWS-managed-rules block is emitted."""
out_dir = str(tmp_path / "tf_out")
adapt(waf_stack_default, out_dir)
main_tf = open(os.path.join(out_dir, "main.tf")).read()
assert "aws-managed-rules" in main_tf
assert "rules = [" not in main_tf
class TestResolverOutputs:
"""P1-7: L2 composition outputs[] resolved into stack.outputs."""
def test_static_assets_has_stack_outputs(self):
from core.contract_resolver import resolve
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
assert "outputs" in stack
outputs = stack["outputs"]
assert "distribution_domain_name" in outputs
assert "bucket_arn" in outputs
assert "web_acl_arn" in outputs
def test_static_assets_output_has_from_and_output(self):
from core.contract_resolver import resolve
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
dist_out = stack["outputs"]["distribution_domain_name"]
assert "from" in dist_out
assert "output" in dist_out
assert dist_out["output"] == "distribution_domain_name"
def test_static_assets_adapter_emits_stack_output_blocks(self, tmp_path):
"""P1-7: adapter emits `output` blocks from stack.outputs."""
from core.contract_resolver import resolve
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
out_dir = str(tmp_path / "tf_out")
adapt(stack, out_dir)
main_tf = open(os.path.join(out_dir, "main.tf")).read()
assert 'output "distribution_domain_name"' in main_tf
assert 'output "bucket_arn"' in main_tf
assert 'output "web_acl_arn"' in main_tf
+28 -1
View File
@@ -160,4 +160,31 @@ class TestDeployPipelineContract:
assert "terraform-plan" in stage_names
assert "checkov" in stage_names
assert "confidence" in stage_names
assert "apply" in stage_names
assert "apply" in stage_names
class TestL2OutputsResolution:
"""P1-7: L2 composition outputs[] is resolved into stack.outputs."""
def test_static_assets_outputs_present(self):
from core.contract_resolver import resolve
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
assert "outputs" in stack, "stack.outputs must be present for L2 modules (P1-7)"
assert "distribution_domain_name" in stack["outputs"]
assert "bucket_arn" in stack["outputs"]
assert "web_acl_arn" in stack["outputs"]
def test_static_assets_output_from_field_resolves_to_resource_id(self):
from core.contract_resolver import resolve
stack = resolve(str(ROOT / "contracts/static-assets.yaml"), str(ROOT))
dist = stack["outputs"]["distribution_domain_name"]
assert "from" in dist
assert "output" in dist
assert dist["output"] == "distribution_domain_name"
def test_microservice_outputs_present(self):
from core.contract_resolver import resolve
stack = resolve(str(ROOT / "contracts/microservice.yaml"), str(ROOT))
assert "outputs" in stack, "stack.outputs must be present for L2 modules (P1-7)"
assert "lb_arn" in stack["outputs"]
assert "service_arn" in stack["outputs"]