From 41c3377b962cb64bde2a7e74efdd8172084ae996 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 29 Jul 2026 13:16:03 +0000 Subject: [PATCH] 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). --- .ciagent/REQUIREMENTS.md | 16 +++ .gitea/workflows/modules-lifecycle.yml | 31 +++++- .github/workflows/modules-lifecycle.yml | 31 +++++- pipelines/modules-lifecycle.yml | 18 ++- .../modules-lifecycle-pipeline.schema.json | 28 ++++- scripts/run_l2_lifecycle_destroy.sh | 12 ++ scripts/run_l2_lifecycle_test.sh | 15 ++- scripts/run_lifecycle_destroy.sh | 14 +++ scripts/run_lifecycle_test.sh | 21 +++- tests/test_lifecycle_mode_flag.py | 104 ++++++++++++++++++ tests/test_pipeline_contract.py | 73 +++++++++++- 11 files changed, 347 insertions(+), 16 deletions(-) create mode 100644 tests/test_lifecycle_mode_flag.py diff --git a/.ciagent/REQUIREMENTS.md b/.ciagent/REQUIREMENTS.md index 58c9946..f4e5d9a 100644 --- a/.ciagent/REQUIREMENTS.md +++ b/.ciagent/REQUIREMENTS.md @@ -576,6 +576,21 @@ two probe fixes required to make the deck claims true. Marp structure (including the A6 Operating Model & Cost section that was missing from the talking points). PPTX exported to the v1.12.0 release. (Phase 69, Phase 70) +- **REQ-134** — The `modules-lifecycle` pipeline defaults to **plan-only** + (fast, no AWS mutation) so it runs on every PR without cost or AWS + credentials. A CI variable `ACDL_LIFECYCLE_MODE` (workflow input + `lifecycle_mode`, default `plan`) overrides to `full` for the real + apply→modify→destroy against live AWS. The four lifecycle 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). Both forge workflows (`.github` + `.gitea`, byte-identical) + expose `lifecycle_mode` as a `workflow_dispatch` input and pass it via + `env:` to every lifecycle step; the CI VPC apply/destroy jobs are + skipped in plan mode. `pipelines/modules-lifecycle.yml` + the schema + document the `default_mode: plan` field. Tests assert the plan-only + default, the override path, the byte-identity of both workflows, and + the CI VPC skip in plan mode. (Phase 67b) ### v1.12 Traceability @@ -583,6 +598,7 @@ two probe fixes required to make the deck claims true. |-------------|-------|--------| | REQ-129 | P67 | planned | | REQ-130 | P67 | planned | +| REQ-134 | P67b | planned | | REQ-131 | P68, P70 | planned | | REQ-132 | P68 | planned | | REQ-133 | P69, P70 | planned | diff --git a/.gitea/workflows/modules-lifecycle.yml b/.gitea/workflows/modules-lifecycle.yml index 8940ab5..54520ac 100644 --- a/.gitea/workflows/modules-lifecycle.yml +++ b/.gitea/workflows/modules-lifecycle.yml @@ -11,6 +11,14 @@ # This workflow implements pipelines/modules-lifecycle.yml (byte-identical # in .gitea/workflows/ and .github/workflows/). # +# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to +# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast, +# no AWS mutation, validates the contract->resolver->adapter->plan chain +# for every module on every PR, with no AWS credentials or cost). Set to +# "full" via workflow_dispatch (or the ACDL_LIFECYCLE_MODE repo variable) +# to run the real apply→modify→destroy against live AWS. In plan mode the +# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied). +# # A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent # modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed # after all tests complete. The CI VPC is separate from the long-lived platform @@ -22,15 +30,26 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + lifecycle_mode: + description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)" + required: false + default: "plan" + type: choice + options: + - plan + - full permissions: contents: read jobs: # Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice) + # Skipped in plan mode (no resources are applied, so no VPC is needed). ci-vpc-apply: name: CI VPC apply runs-on: ubuntu-latest + if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} steps: - uses: actions/checkout@v4 - name: Install Terraform 1.9.* @@ -52,11 +71,14 @@ jobs: lifecycle: name: L1 lifecycle (${{ matrix.module }}) needs: ci-vpc-apply + if: always() runs-on: ubuntu-latest strategy: fail-fast: false matrix: module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime] + env: + ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} steps: - uses: actions/checkout@v4 - name: Free disk space @@ -75,6 +97,7 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt-get update && sudo apt-get install -y terraform=1.9.* - name: Read CI VPC outputs + if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} working-directory: terraform/ci-vpc env: AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} @@ -106,11 +129,14 @@ jobs: l2-lifecycle: name: L2 lifecycle (${{ matrix.module }}) needs: ci-vpc-apply + if: always() runs-on: ubuntu-latest strategy: fail-fast: false matrix: module: [static-assets, microservice] + env: + ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} steps: - uses: actions/checkout@v4 - name: Free disk space @@ -129,6 +155,7 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt-get update && sudo apt-get install -y terraform=1.9.* - name: Read CI VPC outputs + if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} working-directory: terraform/ci-vpc env: AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} @@ -156,12 +183,12 @@ jobs: AWS_DEFAULT_REGION: us-east-1 run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json - # Cleanup: destroy the CI VPC (always runs, even if lifecycle fails) + # Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails) ci-vpc-destroy: name: CI VPC destroy needs: [lifecycle, l2-lifecycle] runs-on: ubuntu-latest - if: always() + if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} steps: - uses: actions/checkout@v4 - name: Install Terraform 1.9.* diff --git a/.github/workflows/modules-lifecycle.yml b/.github/workflows/modules-lifecycle.yml index 8940ab5..54520ac 100644 --- a/.github/workflows/modules-lifecycle.yml +++ b/.github/workflows/modules-lifecycle.yml @@ -11,6 +11,14 @@ # This workflow implements pipelines/modules-lifecycle.yml (byte-identical # in .gitea/workflows/ and .github/workflows/). # +# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to +# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast, +# no AWS mutation, validates the contract->resolver->adapter->plan chain +# for every module on every PR, with no AWS credentials or cost). Set to +# "full" via workflow_dispatch (or the ACDL_LIFECYCLE_MODE repo variable) +# to run the real apply→modify→destroy against live AWS. In plan mode the +# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied). +# # A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent # modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed # after all tests complete. The CI VPC is separate from the long-lived platform @@ -22,15 +30,26 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + lifecycle_mode: + description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)" + required: false + default: "plan" + type: choice + options: + - plan + - full permissions: contents: read jobs: # Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice) + # Skipped in plan mode (no resources are applied, so no VPC is needed). ci-vpc-apply: name: CI VPC apply runs-on: ubuntu-latest + if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} steps: - uses: actions/checkout@v4 - name: Install Terraform 1.9.* @@ -52,11 +71,14 @@ jobs: lifecycle: name: L1 lifecycle (${{ matrix.module }}) needs: ci-vpc-apply + if: always() runs-on: ubuntu-latest strategy: fail-fast: false matrix: module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime] + env: + ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} steps: - uses: actions/checkout@v4 - name: Free disk space @@ -75,6 +97,7 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt-get update && sudo apt-get install -y terraform=1.9.* - name: Read CI VPC outputs + if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} working-directory: terraform/ci-vpc env: AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} @@ -106,11 +129,14 @@ jobs: l2-lifecycle: name: L2 lifecycle (${{ matrix.module }}) needs: ci-vpc-apply + if: always() runs-on: ubuntu-latest strategy: fail-fast: false matrix: module: [static-assets, microservice] + env: + ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} steps: - uses: actions/checkout@v4 - name: Free disk space @@ -129,6 +155,7 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt-get update && sudo apt-get install -y terraform=1.9.* - name: Read CI VPC outputs + if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} working-directory: terraform/ci-vpc env: AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} @@ -156,12 +183,12 @@ jobs: AWS_DEFAULT_REGION: us-east-1 run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json - # Cleanup: destroy the CI VPC (always runs, even if lifecycle fails) + # Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails) ci-vpc-destroy: name: CI VPC destroy needs: [lifecycle, l2-lifecycle] runs-on: ubuntu-latest - if: always() + if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} steps: - uses: actions/checkout@v4 - name: Install Terraform 1.9.* diff --git a/pipelines/modules-lifecycle.yml b/pipelines/modules-lifecycle.yml index 5930626..d6b5591 100644 --- a/pipelines/modules-lifecycle.yml +++ b/pipelines/modules-lifecycle.yml @@ -11,18 +11,34 @@ name: acdl-modules-lifecycle # VPC-dependent L1s (alb, ecs-service, rds, uptime) reference the platform # VPC applied by a prerequisite job. Non-VPC L1s run independent. # +# Lifecycle mode (REQ-134, v1.12): `default_mode: plan` — the lifecycle +# scripts run `run_platform.sh --plan-only` (fast, no AWS mutation, no +# AWS credentials, no cost) on every PR. Set the ACDL_LIFECYCLE_MODE CI +# variable (workflow_dispatch input `lifecycle_mode`) to `full` for the +# real apply→modify→destroy against live AWS. The CI VPC apply/destroy +# jobs are skipped in plan mode (nothing is applied). +# # Both Gitea (.gitea/workflows/modules-lifecycle.yml) and GitHub # (.github/workflows/modules-lifecycle.yml) implement this contract # byte-identically. triggers: pull_request: [main] - workflow_dispatch: [] + workflow_dispatch: + inputs: + - name: lifecycle_mode + description: "Lifecycle mode: 'plan' (default, fast) or 'full' (real apply→destroy)" + default: plan runner: ubuntu-latest python_version: "3.12" terraform_version: "1.9.*" +# Default lifecycle mode: "plan" (plan-only, fast, no AWS mutation) or "full" +# (real apply→modify→destroy against live AWS). Overridable via the +# ACDL_LIFECYCLE_MODE CI variable / workflow_dispatch input. +default_mode: plan + stages: - name: platform-vpc-apply command: cd terraform/platform && terraform init -input=false && terraform apply -auto-approve -lock=false diff --git a/schemas/modules-lifecycle-pipeline.schema.json b/schemas/modules-lifecycle-pipeline.schema.json index 49922f6..2f53e56 100644 --- a/schemas/modules-lifecycle-pipeline.schema.json +++ b/schemas/modules-lifecycle-pipeline.schema.json @@ -20,9 +20,26 @@ "description": "Branches that trigger the pipeline on PR." }, "workflow_dispatch": { - "type": "array", - "items": {"type": "string"}, - "description": "Allows manual dispatch (empty array = no params)." + "description": "Manual dispatch — either an empty array (no params) or an object with an 'inputs' array of {name, description, default} objects (REQ-134).", + "oneOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "object", + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "default": {"type": "string"} + } + } + } + } + } + ] } } }, @@ -38,6 +55,11 @@ "type": "string", "description": "Terraform version constraint (e.g. '1.9.*')." }, + "default_mode": { + "type": "string", + "enum": ["plan", "full"], + "description": "Default lifecycle mode (REQ-134, v1.12): 'plan' = plan-only (fast, no AWS mutation, no credentials, no cost); 'full' = real apply→modify→destroy against live AWS. Overridable via the ACDL_LIFECYCLE_MODE CI variable / workflow_dispatch input." + }, "stages": { "type": "array", "minItems": 1, diff --git a/scripts/run_l2_lifecycle_destroy.sh b/scripts/run_l2_lifecycle_destroy.sh index d66069d..fee8604 100755 --- a/scripts/run_l2_lifecycle_destroy.sh +++ b/scripts/run_l2_lifecycle_destroy.sh @@ -5,12 +5,24 @@ # # Wraps run_platform.sh for L2 composition modules in the modules-lifecycle # pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state. +# +# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" = no-op +# (plan mode never applies resources, so there is nothing to destroy). +# Set to "full" for the real `--destroy` against live AWS. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" MODULE="$1" +# Lifecycle mode: "plan" (default) skips destroy; "full" runs the real destroy. +LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" + +if [ "$LIFECYCLE_MODE" != "full" ]; then + echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0" + exit 0 +fi + CONTRACT="modules/l2/${MODULE}/examples/complex.yml" # Point terraform_remote_state to the CI VPC state (not the platform VPC) diff --git a/scripts/run_l2_lifecycle_test.sh b/scripts/run_l2_lifecycle_test.sh index ae4be8a..a46214a 100755 --- a/scripts/run_l2_lifecycle_test.sh +++ b/scripts/run_l2_lifecycle_test.sh @@ -7,6 +7,10 @@ # pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state so the # microservice composition's terraform_remote_state data source reads from # the short-lived CI VPC (not the long-lived platform VPC). +# +# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" runs +# `run_platform.sh --plan-only` (fast, no AWS mutation). Set to "full" for +# the real `--apply` against live AWS. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" @@ -14,10 +18,17 @@ cd "$ROOT" MODULE="$1" EXAMPLE="$2" # simple or complex +# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS). +LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" + CONTRACT="modules/l2/${MODULE}/examples/${EXAMPLE}.yml" # Point terraform_remote_state to the CI VPC state (not the platform VPC) export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate" -# Run the platform lifecycle command -bash scripts/run_platform.sh --apply "$CONTRACT" \ No newline at end of file +# Run the platform lifecycle command (plan-only by default; full = apply). +if [ "$LIFECYCLE_MODE" = "full" ]; then + bash scripts/run_platform.sh --apply "$CONTRACT" +else + bash scripts/run_platform.sh --plan-only "$CONTRACT" +fi \ No newline at end of file diff --git a/scripts/run_lifecycle_destroy.sh b/scripts/run_lifecycle_destroy.sh index f64ddc7..d64bc5b 100755 --- a/scripts/run_lifecycle_destroy.sh +++ b/scripts/run_lifecycle_destroy.sh @@ -5,6 +5,11 @@ # # For VPC-dependent modules, injects CI VPC outputs into the complex contract # before destroy (so terraform can find the resources in the right VPC). +# +# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" = no-op +# (plan mode never applies resources, so there is nothing to destroy; the +# script exits 0 so the pipeline matrix cell stays green). Set to "full" +# for the real `--destroy` against live AWS. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" @@ -12,6 +17,15 @@ cd "$ROOT" MODULE="$1" CI_VPC_OUTPUTS="${2:-}" +# Lifecycle mode: "plan" (default) skips destroy (nothing was applied); +# "full" runs the real terraform destroy. +LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" + +if [ "$LIFECYCLE_MODE" != "full" ]; then + echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0" + exit 0 +fi + CONTRACT="modules/l1/${MODULE}/examples/complex.yml" VPC_DEPENDENT="alb ecs-service rds uptime" diff --git a/scripts/run_lifecycle_test.sh b/scripts/run_lifecycle_test.sh index ed3ebb6..715f9f8 100755 --- a/scripts/run_lifecycle_test.sh +++ b/scripts/run_lifecycle_test.sh @@ -9,6 +9,13 @@ # # The CI VPC is short-lived (created/destroyed by the pipeline), separate # from the long-lived platform VPC. +# +# Lifecycle mode (REQ-134): the ACDL_LIFECYCLE_MODE env var selects the +# tier. Default "plan" runs `run_platform.sh --plan-only` (fast, no AWS +# mutation, validates the contract->resolver->adapter->plan chain for +# every module). Set to "full" to run the real `--apply` (terraform apply +# against live AWS). The CI variable is passed via the workflow input +# `lifecycle_mode`. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" @@ -17,13 +24,17 @@ MODULE="$1" EXAMPLE="$2" # simple or complex CI_VPC_OUTPUTS="${3:-}" +# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS). +LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" + CONTRACT="modules/l1/${MODULE}/examples/${EXAMPLE}.yml" # VPC-dependent modules that need CI VPC outputs injected VPC_DEPENDENT="alb ecs-service rds uptime" # If the module is VPC-dependent and we have CI VPC outputs, inject them -if echo "$VPC_DEPENDENT" | grep -qw "$MODULE" && [ -n "$CI_VPC_OUTPUTS" ] && [ -f "$CI_VPC_OUTPUTS" ]; then +# (only meaningful in full mode; plan mode ignores VPC outputs) +if [ "$LIFECYCLE_MODE" = "full" ] && echo "$VPC_DEPENDENT" | grep -qw "$MODULE" && [ -n "$CI_VPC_OUTPUTS" ] && [ -f "$CI_VPC_OUTPUTS" ]; then # Generate a temporary contract with CI VPC outputs injected TMP_CONTRACT="/tmp/acdl-lifecycle-${MODULE}-${EXAMPLE}.yml" python3 -c " @@ -70,5 +81,9 @@ print('$TMP_CONTRACT') CONTRACT="$TMP_CONTRACT" fi -# Run the platform lifecycle command -bash scripts/run_platform.sh --apply "$CONTRACT" \ No newline at end of file +# Run the platform lifecycle command (plan-only by default; full = apply). +if [ "$LIFECYCLE_MODE" = "full" ]; then + bash scripts/run_platform.sh --apply "$CONTRACT" +else + bash scripts/run_platform.sh --plan-only "$CONTRACT" +fi \ No newline at end of file diff --git a/tests/test_lifecycle_mode_flag.py b/tests/test_lifecycle_mode_flag.py new file mode 100644 index 0000000..4f8cb55 --- /dev/null +++ b/tests/test_lifecycle_mode_flag.py @@ -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) \ No newline at end of file diff --git a/tests/test_pipeline_contract.py b/tests/test_pipeline_contract.py index cdd6091..e16cb9b 100644 --- a/tests/test_pipeline_contract.py +++ b/tests/test_pipeline_contract.py @@ -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"} \ No newline at end of file + 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)" \ No newline at end of file