diff --git a/adapters/terraform/adapter.py b/adapters/terraform/adapter.py index 3a5be27..9f0c2f9 100644 --- a/adapters/terraform/adapter.py +++ b/adapters/terraform/adapter.py @@ -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": , "output": }. + # 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: diff --git a/core/contract_resolver.py b/core/contract_resolver.py index c29c43d..30fab79 100644 --- a/core/contract_resolver.py +++ b/core/contract_resolver.py @@ -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": ".outputs.", "to": "stack.outputs."} + # The child_outputs map (childId -> {outputName: resourceId}) resolves + # the source to a resource id, which the adapter uses to emit + # `output "" { value = aws_.. }`. + 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." + to_parts = to_expr.split(".") + if len(to_parts) != 3 or to_parts[1] != "outputs": + continue + out_name = to_parts[2] + # Parse "from": ".outputs." + 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 diff --git a/terraform/spike/main.tf b/terraform/spike/main.tf index a2717b4..56b62e9 100644 --- a/terraform/spike/main.tf +++ b/terraform/spike/main.tf @@ -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 +} diff --git a/tests/test_adapter.py b/tests/test_adapter.py index f49b54a..4fe3b76 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -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 \ No newline at end of file diff --git a/tests/test_contract_resolver.py b/tests/test_contract_resolver.py index 896a147..0fdabc0 100644 --- a/tests/test_contract_resolver.py +++ b/tests/test_contract_resolver.py @@ -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 \ No newline at end of file + 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"] \ No newline at end of file