From 8145eee8fc05f418702651f88892a5974a955652 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 22 Jul 2026 22:12:42 +0000 Subject: [PATCH] feat(P32): deletion-protection-by-default + L2 feature flag (REQ-86, REQ-87) ---ci--- project: acdl phase: 32 milestone: v1.8 status: execute ---/ci--- - All 11 L1 primitives now have deletion_protection NFR (boolean, default true). - Adapter emits `lifecycle { prevent_destroy = true }` when NFR is true; omits it when false. Default is true when NFR is absent. - L2 composition resolver propagates inputs.deletion_protection to all children NFRs. When false, all resources get deletion_protection=false. - Stack schema updated with optional features object (deletion_protection, uptime_enabled). - Contract schema description updated to document deletion_protection and uptime_enabled inputs. Tests: +5 (307 -> 312). All pass. --- adapters/terraform/adapter.py | 6 ++ core/contract_resolver.py | 15 +++++ modules/l1/alb/interface.json | 5 ++ modules/l1/cloudfront/interface.json | 5 ++ modules/l1/ecr/interface.json | 5 ++ modules/l1/ecs-cluster/interface.json | 5 ++ modules/l1/ecs-service/interface.json | 5 ++ modules/l1/iam-role/interface.json | 5 ++ modules/l1/s3/interface.json | 5 ++ modules/l1/vpc/interface.json | 5 ++ modules/l1/waf/interface.json | 5 ++ schemas/contract.schema.json | 2 +- schemas/stack.schema.json | 16 +++++ tests/test_adapter.py | 92 ++++++++++++++++++++++++++- 14 files changed, 174 insertions(+), 2 deletions(-) diff --git a/adapters/terraform/adapter.py b/adapters/terraform/adapter.py index 088e987..7872692 100644 --- a/adapters/terraform/adapter.py +++ b/adapters/terraform/adapter.py @@ -464,6 +464,12 @@ def _emit_resource(resource, type_by_id=None): body.append(" }") body.append(" }") body.append("}") + nfrs = resource.get("nfrs", {}) + deletion_protection = nfrs.get("deletion_protection", True) + if deletion_protection: + body.append("lifecycle {") + body.append(" prevent_destroy = true") + body.append("}") return _resource_block(rid, tf_type, body) diff --git a/core/contract_resolver.py b/core/contract_resolver.py index 30fab79..5a41fa2 100644 --- a/core/contract_resolver.py +++ b/core/contract_resolver.py @@ -232,6 +232,21 @@ def resolve_l2(contract, registry, repo_root): "resources": resources, } + # REQ-87: Propagate deletion_protection feature flag from contract inputs + # to all children's NFRs. When inputs.deletion_protection is false, + # all resources get deletion_protection=false (used by decommission). + deletion_protection_input = inputs.get("deletion_protection", True) + if deletion_protection_input is not True: + for res in resources: + if "nfrs" not in res: + res["nfrs"] = {} + res["nfrs"]["deletion_protection"] = deletion_protection_input + # Also record the feature flag on the stack object for introspection. + if "deletion_protection" in inputs: + stack_instance["stack"]["features"] = { + "deletion_protection": deletion_protection_input + } + # 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 diff --git a/modules/l1/alb/interface.json b/modules/l1/alb/interface.json index 977de10..db2e1a2 100644 --- a/modules/l1/alb/interface.json +++ b/modules/l1/alb/interface.json @@ -62,6 +62,11 @@ "type": "boolean", "description": "Enable TLS listener.", "default": true + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } }, "resources": [ diff --git a/modules/l1/cloudfront/interface.json b/modules/l1/cloudfront/interface.json index 4de3c23..a780fc3 100644 --- a/modules/l1/cloudfront/interface.json +++ b/modules/l1/cloudfront/interface.json @@ -64,6 +64,11 @@ "type": "boolean", "description": "Enable encryption in transit (HTTPS only).", "default": true + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } }, "resources": [ diff --git a/modules/l1/ecr/interface.json b/modules/l1/ecr/interface.json index e4a0088..1e765cb 100644 --- a/modules/l1/ecr/interface.json +++ b/modules/l1/ecr/interface.json @@ -41,6 +41,11 @@ "type": "string", "description": "Encryption type.", "default": "KMS" + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } } } \ No newline at end of file diff --git a/modules/l1/ecs-cluster/interface.json b/modules/l1/ecs-cluster/interface.json index ec1150d..723ab39 100644 --- a/modules/l1/ecs-cluster/interface.json +++ b/modules/l1/ecs-cluster/interface.json @@ -36,6 +36,11 @@ "type": "boolean", "description": "Enable CloudWatch log group encryption.", "default": true + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } } } \ No newline at end of file diff --git a/modules/l1/ecs-service/interface.json b/modules/l1/ecs-service/interface.json index 7b5b58a..9f0d4d8 100644 --- a/modules/l1/ecs-service/interface.json +++ b/modules/l1/ecs-service/interface.json @@ -78,6 +78,11 @@ "type": "boolean", "description": "Enable CloudWatch log group encryption.", "default": true + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } }, "resources": [ diff --git a/modules/l1/iam-role/interface.json b/modules/l1/iam-role/interface.json index 955776c..f20c9d5 100644 --- a/modules/l1/iam-role/interface.json +++ b/modules/l1/iam-role/interface.json @@ -41,6 +41,11 @@ "type": "boolean", "description": "Encryption is not applicable to IAM roles but included for standards compliance.", "default": true + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } } } \ No newline at end of file diff --git a/modules/l1/s3/interface.json b/modules/l1/s3/interface.json index e19dc3e..80c5f02 100644 --- a/modules/l1/s3/interface.json +++ b/modules/l1/s3/interface.json @@ -50,6 +50,11 @@ "type": "string", "description": "SSE algorithm.", "default": "aws:kms" + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } } } \ No newline at end of file diff --git a/modules/l1/vpc/interface.json b/modules/l1/vpc/interface.json index d8d409d..cd8ae64 100644 --- a/modules/l1/vpc/interface.json +++ b/modules/l1/vpc/interface.json @@ -46,6 +46,11 @@ "type": "boolean", "description": "Encrypt VPC flow logs with KMS.", "default": true + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } }, "resources": [ diff --git a/modules/l1/waf/interface.json b/modules/l1/waf/interface.json index 3aa57ac..59df2a8 100644 --- a/modules/l1/waf/interface.json +++ b/modules/l1/waf/interface.json @@ -49,6 +49,11 @@ "type": "boolean", "description": "Enable WAF logging.", "default": true + }, + "deletion_protection": { + "type": "boolean", + "description": "Prevent resource destruction via Terraform lifecycle prevent_destroy", + "default": true } }, "resources": [ diff --git a/schemas/contract.schema.json b/schemas/contract.schema.json index 4e45f57..5de8499 100644 --- a/schemas/contract.schema.json +++ b/schemas/contract.schema.json @@ -22,7 +22,7 @@ }, "inputs": { "type": "object", - "description": "Module-specific inputs (bucket_name, region, image, port, env, etc.). Validated at resolution time against the module's interface.json or composition.json.", + "description": "Module-specific inputs (bucket_name, region, image, port, env, etc.). Validated at resolution time against the module's interface.json or composition.json. May include deletion_protection (boolean, default true — set to false only during decommission) and uptime_enabled (boolean, default true — set to false to disable uptime monitoring).", "additionalProperties": {"type": ["string", "number", "boolean", "object", "array"]} } } diff --git a/schemas/stack.schema.json b/schemas/stack.schema.json index b26a5ca..b1015d0 100644 --- a/schemas/stack.schema.json +++ b/schemas/stack.schema.json @@ -32,6 +32,22 @@ "minimum": 1, "maximum": 5, "description": "Composition depth (ARCHITECTURE.md §3: max depth 5). L2->L1 is depth 1." + }, + "features": { + "type": "object", + "description": "Optional feature flags for L2 modules (e.g. deletion_protection, uptime_enabled).", + "properties": { + "deletion_protection": { + "type": "boolean", + "description": "When true (default), all children get deletion_protection NFR. Set to false to disable (used by decommission).", + "default": true + }, + "uptime_enabled": { + "type": "boolean", + "description": "When true (default), the uptime monitoring stack is deployed after the L2 module.", + "default": true + } + } } } }, diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 96fe9c9..30a8e17 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -495,4 +495,94 @@ class TestEncryptionByDefault: assert "aws:kms:key" in types s3_res = next(r for r in stack["resources"] if r["type"] == "aws:s3:bucket") assert "kms_key_arn" in s3_res.get("inputs", {}), \ - "s3 must have kms_key_arn wired from the per-stack CMK" \ No newline at end of file + "s3 must have kms_key_arn wired from the per-stack CMK" + + +class TestDeletionProtectionByDefault: + """REQ-86: deletion_protection NFR on all primitives (default true). + REQ-87: L2 feature flag propagation.""" + + def test_all_l1_primitives_have_deletion_protection_nfr(self, registry, repo_root): + """REQ-86: every L1 primitive must have a deletion_protection NFR.""" + for name, entry in registry.items(): + iface_path = entry["1.0.0"]["interface"] + if not iface_path.startswith("modules/l1/"): + continue + iface = json.load(open(os.path.join(str(repo_root), iface_path))) + assert "deletion_protection" in iface.get("nfrs", {}), \ + f"L1 primitive '{name}' must have deletion_protection NFR" + + def test_adapter_emits_prevent_destroy_when_nfr_true(self, tmp_path): + """REQ-86: adapter emits lifecycle { prevent_destroy = true } when NFR is true.""" + s3_stack = { + "version": "1.0.0", + "stack": {"name": "s3-test", "kind": "l1", "depth": 1}, + "resources": [{ + "id": "s3", + "type": "aws:s3:bucket", + "module": "s3@1.0.0", + "inputs": {"bucket_name": "test-bucket", "region": "us-east-1"}, + "outputs": {}, + "nfrs": {"deletion_protection": True, "encryption_enabled": True, "versioning": True}, + }], + } + out_dir = str(tmp_path / "tf_out") + adapt(s3_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert "prevent_destroy = true" in main_tf + + def test_adapter_omits_prevent_destroy_when_nfr_false(self, tmp_path): + """REQ-86: adapter does not emit prevent_destroy when NFR is false.""" + s3_stack = { + "version": "1.0.0", + "stack": {"name": "s3-test", "kind": "l1", "depth": 1}, + "resources": [{ + "id": "s3", + "type": "aws:s3:bucket", + "module": "s3@1.0.0", + "inputs": {"bucket_name": "test-bucket", "region": "us-east-1"}, + "outputs": {}, + "nfrs": {"deletion_protection": False, "encryption_enabled": True, "versioning": True}, + }], + } + out_dir = str(tmp_path / "tf_out") + adapt(s3_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert "prevent_destroy = true" not in main_tf + + def test_adapter_emits_prevent_destroy_by_default(self, tmp_path): + """REQ-86: when deletion_protection NFR is absent, default is true.""" + s3_stack = { + "version": "1.0.0", + "stack": {"name": "s3-test", "kind": "l1", "depth": 1}, + "resources": [{ + "id": "s3", + "type": "aws:s3:bucket", + "module": "s3@1.0.0", + "inputs": {"bucket_name": "test-bucket", "region": "us-east-1"}, + "outputs": {}, + "nfrs": {}, + }], + } + out_dir = str(tmp_path / "tf_out") + adapt(s3_stack, out_dir) + main_tf = open(os.path.join(out_dir, "main.tf")).read() + assert "prevent_destroy = true" in main_tf + + def test_l2_feature_flag_propagates_deletion_protection_false(self, tmp_path): + """REQ-87: L2 feature flag deletion_protection=false propagates to all children.""" + import yaml + contract = { + "uses": "acdl/pipelines/deploy.yaml@v1.8", + "module": "static-assets", + "environment": "dev", + "inputs": {"bucket_name": "test-bucket", "region": "us-east-1", "deletion_protection": False}, + } + contract_path = tmp_path / "test-dp.yaml" + with open(contract_path, "w") as fh: + yaml.dump(contract, fh) + from core.contract_resolver import resolve + stack = resolve(str(contract_path), str(ROOT)) + for res in stack["resources"]: + assert res.get("nfrs", {}).get("deletion_protection") is False, \ + f"Resource {res['id']} should have deletion_protection=false" \ No newline at end of file