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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -281,3 +281,124 @@ class TestStaticAssetsStack:
|
||||
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
|
||||
@@ -161,3 +161,30 @@ class TestDeployPipelineContract:
|
||||
assert "checkov" in stage_names
|
||||
assert "confidence" 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"]
|
||||
Reference in New Issue
Block a user