feat(P67b): lifecycle tests default to plan-only; ACDL_LIFECYCLE_MODE flag overrides to full (REQ-134)

---
ci---
project: acdl
phase: 67b
milestone: v1.12
status: execute
---
/ci---

The modules-lifecycle pipeline now defaults to plan-only (fast, no AWS
mutation, no credentials, no cost) so it runs on every PR. A CI variable
ACDL_LIFECYCLE_MODE (workflow_dispatch input 'lifecycle_mode', default
'plan') overrides to 'full' for the real apply->modify->destroy against
live AWS.

Scripts: run_lifecycle_test.sh / run_lifecycle_destroy.sh /
run_l2_lifecycle_test.sh / run_l2_lifecycle_destroy.sh read the flag and
dispatch to --plan-only (plan mode) or --apply/--destroy (full mode).
Destroy is a no-op exit 0 in plan mode (nothing was applied). VPC-output
injection is gated on full mode.

Workflows: both .github + .gitea (byte-identical) expose lifecycle_mode
as a workflow_dispatch input (choice: plan/full), pass it via env:
ACDL_LIFECYCLE_MODE to every lifecycle step, skip ci-vpc-apply +
ci-vpc-destroy + Read-CI-VPC-outputs in plan mode, and run the lifecycle
+ l2-lifecycle jobs with if: always() so they execute (plan-only) even
when ci-vpc-apply is skipped.

Contract + schema: pipelines/modules-lifecycle.yml gains default_mode:
plan; the schema accepts default_mode (enum plan|full) and a richer
workflow_dispatch inputs shape.

Tests: 14 new tests in test_lifecycle_mode_flag.py (script dispatch) +
10 new tests in TestModulesLifecyclePipeline (workflow flag wiring,
byte-identity, plan-mode skips). Updated test_platform_vpc_destroy to
reflect the plan-mode skip. 516 tests pass; smoke-tested plan mode on
the s3 module (--plan-only green, no AWS apply).
This commit is contained in:
Jon Chery
2026-07-29 13:16:03 +00:00
parent 76364c33c2
commit 41c3377b96
11 changed files with 347 additions and 16 deletions
+104
View File
@@ -0,0 +1,104 @@
"""Tests for the lifecycle test scripts' plan-only/full mode flag (REQ-134).
The lifecycle scripts (run_lifecycle_test.sh, run_lifecycle_destroy.sh,
run_l2_lifecycle_test.sh, run_l2_lifecycle_destroy.sh) wrap run_platform.sh.
REQ-134 (v1.12) adds the ACDL_LIFECYCLE_MODE env var: default "plan" runs
`run_platform.sh --plan-only` (fast, no AWS mutation); "full" runs the real
`--apply`/`--destroy` against live AWS.
These tests verify the dispatch logic offline by inspecting script content
(running the scripts end-to-end requires AWS credentials in full mode).
"""
import re
import pytest
ROOT = __import__("pathlib").Path(__file__).resolve().parent.parent
SCRIPTS = [
"scripts/run_lifecycle_test.sh",
"scripts/run_lifecycle_destroy.sh",
"scripts/run_l2_lifecycle_test.sh",
"scripts/run_l2_lifecycle_destroy.sh",
]
def _read(path):
return (ROOT / path).read_text()
class TestLifecycleModeFlag:
"""REQ-134: the lifecycle scripts dispatch to plan-only by default."""
@pytest.mark.parametrize("script", SCRIPTS)
def test_script_reads_acdl_lifecycle_mode(self, script):
"""Every lifecycle script reads ACDL_LIFECYCLE_MODE with a 'plan' default."""
src = _read(script)
# The default must be 'plan' (the speed-up default). The scripts use
# an intermediate LIFECYCLE_MODE var sourced from ACDL_LIFECYCLE_MODE.
assert "${ACDL_LIFECYCLE_MODE:-plan}" in src, \
f"{script} must read ACDL_LIFECYCLE_MODE defaulting to 'plan'"
assert "LIFECYCLE_MODE=" in src, \
f"{script} must assign LIFECYCLE_MODE from the env var"
@pytest.mark.parametrize("script", [
"scripts/run_lifecycle_test.sh",
"scripts/run_l2_lifecycle_test.sh",
])
def test_apply_scripts_dispatch_to_plan_only_by_default(self, script):
"""Apply/modify scripts run --plan-only when mode != full."""
src = _read(script)
assert "--plan-only" in src, f"{script} must support --plan-only (plan mode)"
assert "--apply" in src, f"{script} must support --apply (full mode)"
# The dispatch must branch on LIFECYCLE_MODE
assert "LIFECYCLE_MODE" in src and "full" in src, \
f"{script} must branch on LIFECYCLE_MODE == 'full'"
@pytest.mark.parametrize("script", [
"scripts/run_lifecycle_destroy.sh",
"scripts/run_l2_lifecycle_destroy.sh",
])
def test_destroy_scripts_noop_in_plan_mode(self, script):
"""Destroy scripts are a no-op (exit 0) in plan mode — nothing was applied."""
src = _read(script)
# The destroy script must check the mode and exit 0 in plan mode.
assert "nothing to destroy" in src, \
f"{script} must no-op (with a message) in plan mode"
assert "--destroy" in src, f"{script} must still support --destroy in full mode"
assert "LIFECYCLE_MODE" in src, f"{script} must read LIFECYCLE_MODE"
def test_apply_script_plan_mode_uses_plan_only(self):
"""In plan mode, run_lifecycle_test.sh dispatches to --plan-only."""
src = _read("scripts/run_lifecycle_test.sh")
# The if/else: full -> --apply, else -> --plan-only
assert re.search(r'if.*LIFECYCLE_MODE.*=.*"full".*;.*then', src)
assert re.search(r'else\s+bash scripts/run_platform\.sh --plan-only', src, re.DOTALL)
def test_apply_script_full_mode_uses_apply(self):
"""In full mode, run_lifecycle_test.sh dispatches to --apply."""
src = _read("scripts/run_lifecycle_test.sh")
assert re.search(r'LIFECYCLE_MODE.*=.*"full".*bash scripts/run_platform\.sh --apply', src, re.DOTALL)
def test_destroy_script_plan_mode_exits_zero(self):
"""In plan mode, the L1 destroy script exits 0 without calling run_platform."""
src = _read("scripts/run_lifecycle_destroy.sh")
assert re.search(r'LIFECYCLE_MODE.*!=.*"full".*exiting 0', src, re.DOTALL)
def test_l2_apply_script_dispatches_correctly(self):
"""The L2 apply script dispatches to plan-only by default, apply in full."""
src = _read("scripts/run_l2_lifecycle_test.sh")
assert "--plan-only" in src and "--apply" in src
assert re.search(r'if.*LIFECYCLE_MODE.*=.*"full"', src)
def test_l2_destroy_script_noop_in_plan_mode(self):
"""The L2 destroy script is a no-op in plan mode."""
src = _read("scripts/run_l2_lifecycle_destroy.sh")
assert "nothing to destroy" in src
assert re.search(r'LIFECYCLE_MODE.*!=.*"full".*exiting 0', src, re.DOTALL)
def test_vpc_injection_gated_on_full_mode(self):
"""VPC output injection is gated on full mode (plan mode skips it)."""
src = _read("scripts/run_lifecycle_test.sh")
# The VPC injection block must be gated on LIFECYCLE_MODE == full.
assert re.search(r'LIFECYCLE_MODE.*=.*"full".*&&.*echo.*VPC_DEPENDENT', src, re.DOTALL) or \
re.search(r'\[ "\$LIFECYCLE_MODE" = "full" \] && echo.*VPC_DEPENDENT', src, re.DOTALL)
+70 -3
View File
@@ -581,10 +581,14 @@ class TestModulesLifecyclePipeline:
assert any("Modify" in n for n in step_names), "Missing modify step"
assert any("Destroy" in n for n in step_names), "Missing destroy step"
def test_platform_vpc_destroy_always_runs(self):
def test_platform_vpc_destroy_runs_in_full_mode(self):
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
destroy_job = wf["jobs"]["ci-vpc-destroy"]
assert destroy_job.get("if") == "always()", "ci-vpc-destroy must always run (cleanup)"
# ci-vpc-destroy must always run in full mode (cleanup), but is
# skipped in plan mode (REQ-134: nothing is applied).
cond = destroy_job.get("if", "")
assert "always()" in cond, "ci-vpc-destroy must run in full mode even if lifecycle fails"
assert "plan" in cond, "ci-vpc-destroy must be skipped in plan mode (REQ-134)"
def test_l2_lifecycle_job_exists(self):
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
@@ -613,4 +617,67 @@ class TestModulesLifecyclePipeline:
def test_contract_matrix_lists_l2_modules(self):
contract = _load_yaml("pipelines/modules-lifecycle.yml")
assert set(contract["matrix"]["l2_modules"]) == {"static-assets", "microservice"}
assert set(contract["matrix"]["l2_modules"]) == {"static-assets", "microservice"}
# --- REQ-134: lifecycle mode flag (plan-only default, full override) ---
def test_contract_declares_plan_as_default_mode(self):
"""The pipeline contract declares default_mode: plan (REQ-134)."""
contract = _load_yaml("pipelines/modules-lifecycle.yml")
assert contract.get("default_mode") == "plan", \
"default_mode must be 'plan' (fast, no AWS mutation, the default on every PR)"
def test_schema_accepts_default_mode_field(self):
"""The schema accepts the default_mode field with plan/full enum."""
schema = json.load(open(ROOT / "schemas/modules-lifecycle-pipeline.schema.json"))
props = schema["properties"]
assert "default_mode" in props
assert set(props["default_mode"]["enum"]) == {"plan", "full"}
def test_workflow_has_lifecycle_mode_dispatch_input(self):
"""workflow_dispatch exposes a lifecycle_mode input defaulting to plan."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
wd = wf["on"]["workflow_dispatch"]
assert isinstance(wd, dict), "workflow_dispatch must declare inputs"
inputs = wd.get("inputs", {})
assert "lifecycle_mode" in inputs
assert inputs["lifecycle_mode"].get("default") == "plan"
assert inputs["lifecycle_mode"].get("type") == "choice"
assert set(inputs["lifecycle_mode"].get("options", [])) == {"plan", "full"}
def test_lifecycle_job_passes_mode_env_to_steps(self):
"""The lifecycle job sets ACDL_LIFECYCLE_MODE env so scripts dispatch
to plan-only by default, full on override."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
env = wf["jobs"]["lifecycle"].get("env", {})
assert "ACDL_LIFECYCLE_MODE" in env
# The expression must resolve to 'plan' when no input/var is set.
assert "plan" in env["ACDL_LIFECYCLE_MODE"]
def test_l2_lifecycle_job_passes_mode_env_to_steps(self):
"""The L2 lifecycle job also sets ACDL_LIFECYCLE_MODE env."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
env = wf["jobs"]["l2-lifecycle"].get("env", {})
assert "ACDL_LIFECYCLE_MODE" in env
assert "plan" in env["ACDL_LIFECYCLE_MODE"]
def test_ci_vpc_apply_skipped_in_plan_mode(self):
"""The CI VPC apply job is skipped in plan mode (nothing is applied)."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
cond = wf["jobs"]["ci-vpc-apply"].get("if", "")
assert "plan" in cond, "ci-vpc-apply must be skipped in plan mode (REQ-134)"
def test_lifecycle_job_runs_even_if_vpc_apply_skipped(self):
"""The lifecycle job uses `if: always()` so it still runs (plan-only)
even when ci-vpc-apply is skipped in plan mode."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
assert wf["jobs"]["lifecycle"].get("if") == "always()"
assert wf["jobs"]["l2-lifecycle"].get("if") == "always()"
def test_read_ci_vpc_outputs_skipped_in_plan_mode(self):
"""The 'Read CI VPC outputs' step is skipped in plan mode (no VPC)."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
steps = wf["jobs"]["lifecycle"]["steps"]
read_step = next(s for s in steps if s.get("name") == "Read CI VPC outputs")
cond = read_step.get("if", "")
assert "full" in cond, "Read CI VPC outputs step must be skipped in plan mode (REQ-134)"