From cd637808f5bbe314051fe77546f032220ecf119c Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Thu, 23 Jul 2026 04:34:10 +0000 Subject: [PATCH] feat(P41): per-environment CI jobs + environment workflow input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: acdl phase: 41 milestone: v1.9 status: execute ---/ci--- Phase 41 — per-environment-ci-jobs (REQ-105, REQ-106, D-082): Per-env contracts (REQ-105): - contracts/static-assets.{dev,qa,prod,dr}.yaml + microservice.{dev,qa,prod,dr}.yaml (8 files, each sets environment: to its own name, uses interpolation). - Default contracts/static-assets.yaml + microservice.yaml preserved (backwards compat). Deploy workflow environment input (REQ-106): - .github/workflows/deploy.yml + .gitea/workflows/deploy.yml (byte-identical): new 'environment' workflow_call input (default empty, override). - scripts/run_platform.sh: --environment flag; exports ACDL_ENVIRONMENT_OVERRIDE; re-runs env check against the override. - core/contract_resolver.py: resolve(environment_override=...) (D-088); CLI honors --environment flag + ACDL_ENVIRONMENT_OVERRIDE env var. Consumer guide (REQ-106): - docs/consumer-guide.md: 'Per-environment deployment' section with 4 caller-workflow examples (dev/qa/prod/dr), HITL gate structure (approve_qa/approve_prod/approve_dr, D-042), interpolation reference table. - Documents promotion-without-editing + hybrid model (per-env contracts OR single contract + env input). Tests: +40 (test_per_env_contracts.py, test_deploy_workflow_env_input.py, test_consumer_guide_per_env_section.py). 446 passed; run_ci.sh green; deploy workflows byte-identical. --- .ciagent/ROADMAP.md | 13 ++- .gitea/workflows/deploy.yml | 10 ++- .github/workflows/deploy.yml | 10 ++- contracts/microservice.dev.yaml | 11 +++ contracts/microservice.dr.yaml | 11 +++ contracts/microservice.prod.yaml | 11 +++ contracts/microservice.qa.yaml | 11 +++ contracts/static-assets.dev.yaml | 10 +++ contracts/static-assets.dr.yaml | 9 ++ contracts/static-assets.prod.yaml | 9 ++ contracts/static-assets.qa.yaml | 9 ++ core/contract_resolver.py | 20 +++-- docs/consumer-guide.md | 82 +++++++++++++++++- scripts/run_platform.sh | 19 ++++- tests/test_consumer_guide_per_env_section.py | 45 ++++++++++ tests/test_deploy_workflow_env_input.py | 81 ++++++++++++++++++ tests/test_per_env_contracts.py | 87 ++++++++++++++++++++ 17 files changed, 438 insertions(+), 10 deletions(-) create mode 100644 contracts/microservice.dev.yaml create mode 100644 contracts/microservice.dr.yaml create mode 100644 contracts/microservice.prod.yaml create mode 100644 contracts/microservice.qa.yaml create mode 100644 contracts/static-assets.dev.yaml create mode 100644 contracts/static-assets.dr.yaml create mode 100644 contracts/static-assets.prod.yaml create mode 100644 contracts/static-assets.qa.yaml create mode 100644 tests/test_consumer_guide_per_env_section.py create mode 100644 tests/test_deploy_workflow_env_input.py create mode 100644 tests/test_per_env_contracts.py diff --git a/.ciagent/ROADMAP.md b/.ciagent/ROADMAP.md index 307e87c..3561062 100644 --- a/.ciagent/ROADMAP.md +++ b/.ciagent/ROADMAP.md @@ -594,4 +594,15 @@ also closes P1-1 (adapter hardcoded defaults, deferred from v1.2). - `_expand_vars` in resolver; unknown tokens raise; recursive over dicts/lists/strings. - Sample contracts use `${env.*}` + `${contract.*}` naming patterns; resolve to concrete values. - `tests/test_environment_schema.py` + `tests/test_interpolation.py` + `tests/test_sample_contracts_interpolate.py` pass. - - `pytest` 406 (was 371, +35); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0. \ No newline at end of file + - `pytest` 406 (was 371, +35); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0. +### Phase 41 — per-environment-ci-jobs +- **Description:** Per-env contract files (static-assets + microservice × dev/qa/prod/dr, REQ-105) using interpolation. Deploy workflow (`.github` + `.gitea`, byte-identical) declares an `environment` `workflow_call` input (REQ-106); `run_platform.sh --environment ` overrides the contract's environment at load time (D-088, before schema validation + interpolation). `resolve()` accepts `environment_override`. Consumer guide documents the per-env caller-workflow pattern (4 jobs, one per environment) + HITL gate structure (approve_qa/approve_prod/approve_dr, D-042) + interpolation reference table. Promotion = running the matching job; no environment field editing. +- **Status:** complete (v1.8.3) +- **Depends on:** [40] +- **Requirements:** REQ-105, REQ-106 +- **Success Criteria:** + - 8 per-env contract files exist + validate + resolve to correct env. + - Deploy workflow has `environment` input (byte-identical Gitea + GitHub); `run_platform.sh --environment` overrides; resolver supports `environment_override`. + - Consumer guide documents per-env caller workflows + promotion-without-editing + HITL gates + interpolation reference. + - `tests/test_per_env_contracts.py` + `tests/test_deploy_workflow_env_input.py` + `tests/test_consumer_guide_per_env_section.py` pass. + - `pytest` 446 (was 406, +40); `run_ci.sh` exits 0; both deploy workflows byte-identical. diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4af60f7..1273565 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -60,6 +60,10 @@ on: description: Change request ID (required for decommission mode — validated against CMDB) type: string default: "" + environment: + description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used + type: string + default: "" permissions: id-token: write @@ -120,7 +124,11 @@ jobs: ;; *) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;; esac - bash platform/scripts/run_platform.sh $MODE_FLAG "${{ inputs.contract }}" + ENV_FLAG="" + if [ -n "${{ inputs.environment }}" ]; then + ENV_FLAG="--environment ${{ inputs.environment }}" + fi + bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}" - name: Post stage summary comment to PR if: success() && github.event_name == 'pull_request' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4af60f7..1273565 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -60,6 +60,10 @@ on: description: Change request ID (required for decommission mode — validated against CMDB) type: string default: "" + environment: + description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used + type: string + default: "" permissions: id-token: write @@ -120,7 +124,11 @@ jobs: ;; *) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;; esac - bash platform/scripts/run_platform.sh $MODE_FLAG "${{ inputs.contract }}" + ENV_FLAG="" + if [ -n "${{ inputs.environment }}" ]; then + ENV_FLAG="--environment ${{ inputs.environment }}" + fi + bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}" - name: Post stage summary comment to PR if: success() && github.event_name == 'pull_request' diff --git a/contracts/microservice.dev.yaml b/contracts/microservice.dev.yaml new file mode 100644 index 0000000..f38cac5 --- /dev/null +++ b/contracts/microservice.dev.yaml @@ -0,0 +1,11 @@ +# ACDL sample consumer contract — microservice module (dev) +# Per-environment contract (REQ-105). Promotion = running the dev job; +# no environment field editing. Interpolation resolves against dev.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: microservice +environment: dev +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} + image: public.ecr.aws/docker/library/nginx:latest + port: 80 diff --git a/contracts/microservice.dr.yaml b/contracts/microservice.dr.yaml new file mode 100644 index 0000000..73d8f74 --- /dev/null +++ b/contracts/microservice.dr.yaml @@ -0,0 +1,11 @@ +# ACDL sample consumer contract — microservice module (dr) +# Per-environment contract (REQ-105). Promotion = running the dr job; +# no environment field editing. Interpolation resolves against dr.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: microservice +environment: dr +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} + image: public.ecr.aws/docker/library/nginx:latest + port: 80 diff --git a/contracts/microservice.prod.yaml b/contracts/microservice.prod.yaml new file mode 100644 index 0000000..1a60129 --- /dev/null +++ b/contracts/microservice.prod.yaml @@ -0,0 +1,11 @@ +# ACDL sample consumer contract — microservice module (prod) +# Per-environment contract (REQ-105). Promotion = running the prod job; +# no environment field editing. Interpolation resolves against prod.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: microservice +environment: prod +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} + image: public.ecr.aws/docker/library/nginx:latest + port: 80 diff --git a/contracts/microservice.qa.yaml b/contracts/microservice.qa.yaml new file mode 100644 index 0000000..40a4225 --- /dev/null +++ b/contracts/microservice.qa.yaml @@ -0,0 +1,11 @@ +# ACDL sample consumer contract — microservice module (qa) +# Per-environment contract (REQ-105). Promotion = running the qa job; +# no environment field editing. Interpolation resolves against qa.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: microservice +environment: qa +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} + image: public.ecr.aws/docker/library/nginx:latest + port: 80 diff --git a/contracts/static-assets.dev.yaml b/contracts/static-assets.dev.yaml new file mode 100644 index 0000000..42e70e2 --- /dev/null +++ b/contracts/static-assets.dev.yaml @@ -0,0 +1,10 @@ +# ACDL sample consumer contract — static-assets module (dev) +# Per-environment contract (REQ-105). The dev default +# (contracts/static-assets.yaml) remains for backwards compat; this file +# is the explicit per-env dev contract. Interpolation resolves against dev.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: static-assets +environment: dev +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} \ No newline at end of file diff --git a/contracts/static-assets.dr.yaml b/contracts/static-assets.dr.yaml new file mode 100644 index 0000000..87e45f6 --- /dev/null +++ b/contracts/static-assets.dr.yaml @@ -0,0 +1,9 @@ +# ACDL sample consumer contract — static-assets module (dr) +# Per-environment contract (REQ-105). Promotion = running the dr job; +# no environment field editing. Interpolation resolves against dr.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: static-assets +environment: dr +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} \ No newline at end of file diff --git a/contracts/static-assets.prod.yaml b/contracts/static-assets.prod.yaml new file mode 100644 index 0000000..8160a55 --- /dev/null +++ b/contracts/static-assets.prod.yaml @@ -0,0 +1,9 @@ +# ACDL sample consumer contract — static-assets module (prod) +# Per-environment contract (REQ-105). Promotion = running the prod job; +# no environment field editing. Interpolation resolves against prod.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: static-assets +environment: prod +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} \ No newline at end of file diff --git a/contracts/static-assets.qa.yaml b/contracts/static-assets.qa.yaml new file mode 100644 index 0000000..9a44288 --- /dev/null +++ b/contracts/static-assets.qa.yaml @@ -0,0 +1,9 @@ +# ACDL sample consumer contract — static-assets module (qa) +# Per-environment contract (REQ-105). Promotion = running the qa job; +# no environment field editing. Interpolation resolves against qa.json. +uses: acdl/pipelines/deploy.yaml@v1.6 +module: static-assets +environment: qa +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} \ No newline at end of file diff --git a/core/contract_resolver.py b/core/contract_resolver.py index ddaa0ef..ffc4d26 100644 --- a/core/contract_resolver.py +++ b/core/contract_resolver.py @@ -459,10 +459,20 @@ def resolve(contract_path, repo_root=None, environment_override=None): if __name__ == "__main__": - if len(sys.argv) != 3: - print("usage: contract_resolver.py ", file=sys.stderr) + if len(sys.argv) < 3: + print("usage: contract_resolver.py [--environment ]", file=sys.stderr) sys.exit(2) - result = resolve(sys.argv[1]) - with open(sys.argv[2], "w") as fh: + contract_path = sys.argv[1] + out_path = sys.argv[2] + env_override = None + if "--environment" in sys.argv: + idx = sys.argv.index("--environment") + if idx + 1 < len(sys.argv): + env_override = sys.argv[idx + 1] + # Also honor the ACDL_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh). + if env_override is None and os.environ.get("ACDL_ENVIRONMENT_OVERRIDE"): + env_override = os.environ["ACDL_ENVIRONMENT_OVERRIDE"] + result = resolve(contract_path, environment_override=env_override) + with open(out_path, "w") as fh: json.dump(result, fh, indent=2) - print(f"resolver: resolved {sys.argv[1]} -> {sys.argv[2]}", file=sys.stderr) \ No newline at end of file + print(f"resolver: resolved {contract_path} -> {out_path}", file=sys.stderr) \ No newline at end of file diff --git a/docs/consumer-guide.md b/docs/consumer-guide.md index a5abb85..1eec3cd 100644 --- a/docs/consumer-guide.md +++ b/docs/consumer-guide.md @@ -376,4 +376,84 @@ window if needed. The CMK is permanently deleted after the window expires. The uptime monitoring stack (deployed with separate state) is not automatically destroyed by the decommission. It must be destroyed separately (or left running to monitor the decommissioned stack's -endpoints going dark). \ No newline at end of file +endpoints going dark). +## Per-environment deployment + +ACDL supports a **promotion-without-editing** model: you do not edit the +`environment:` field in a contract to promote dev → qa → prod → dr. +Instead, there is **one CI job per environment**, each pointing at its +respective contract (or the same contract + the `environment` workflow +input). Promotion = running the matching job. + +### Two shapes (both supported) + +**Shape 1 — per-environment contract files:** a consumer repo has one +contract per environment (e.g. `.acdl/static-assets.dev.yaml`, +`.acdl/static-assets.qa.yaml`, …). Each sets `environment:` to its own +name and uses interpolation so env-specific values differ automatically: + +```yaml +# .acdl/static-assets.qa.yaml +uses: acdl/pipelines/deploy.yaml@v1.9 +module: static-assets +environment: qa +inputs: + bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region} + region: ${env.region} +``` + +**Shape 2 — single contract + `environment` workflow input:** the +reusable deploy workflow (`acdl/.github/workflows/deploy.yml@v1.9`) +declares an `environment` input. When non-empty, it overrides the +contract's `environment` field at load time (before interpolation), so +the same contract can be promoted by passing a different environment: + +```yaml +# .github/workflows/deploy-qa.yml (caller workflow) +on: workflow_dispatch: + inputs: + approve_qa: + description: "Set to true to approve the QA promotion" + type: boolean + required: true +jobs: + deploy-qa: + uses: acdl/.github/workflows/deploy.yml@v1.9 + with: + environment: qa + contract: .acdl/contract.yaml +``` + +### One job per environment + +A consumer repo's `.github/workflows/` directory has one caller workflow +per environment: + +| File | Environment | Gate | +|------|-------------|------| +| `deploy-dev.yml` | dev | autonomous (no gate, confidence ≥ 0.50) | +| `deploy-qa.yml` | qa | QA HITL (`approve_qa` workflow_dispatch input; `github.actor` is the approver of record) | +| `deploy-prod.yml` | prod | SRE HITL (`approve_prod`; separation-of-duties enforced) | +| `deploy-dr.yml` | dr | SRE HITL (`approve_dr`) | + +**Promotion = running the matching job.** No `environment:` field editing. +The approver identity is recorded to the DynamoDB outbox +(`approver_qa` / `approver_prod` / `approver_dr`) and the separation-of- +duties check blocks a prod promotion when `approver_qa == approver_prod` +(see `core/hitl_matrix_design.md`). + +### Interpolation reference + +| Token | Resolves to | Example | +|-------|-------------|---------| +| `${env.environment}` | the environment name (dev/qa/prod/dr) | `qa` | +| `${env.region}` | the environment's AWS region | `us-east-1` | +| `${env.account_id}` | the environment's AWS account id | `123456789012` | +| `${env.state_backend.bucket}` | the environment's state bucket | `acdl-qa-state` | +| `${env.network.vpc_cidr}` | the environment's VPC CIDR | `10.1.0.0/16` | +| `${contract.module}` | the contract's module name | `static-assets` | +| `${contract.environment}` | the contract's environment field | `qa` | +| `${contract.inputs.}` | a contract input value | (as declared) | + +Unknown tokens raise `ValueError` (fail loud). Expansion is recursive +(nested map/list values expand too). diff --git a/scripts/run_platform.sh b/scripts/run_platform.sh index e021571..e437057 100755 --- a/scripts/run_platform.sh +++ b/scripts/run_platform.sh @@ -42,15 +42,26 @@ QUIET=0 DEPLOY_UPTIME=0 DECOMMISSION=0 CHANGE_REQUEST_ID="" +ENVIRONMENT_OVERRIDE="" CONTRACT="" +# Parse args; --environment takes a value (either --environment=VALUE or +# --environment VALUE). The contract / changeRequestId are the remaining +# positional args. +_prev="" for arg in "$@"; do + if [ "$_prev" = "--environment" ]; then + ENVIRONMENT_OVERRIDE="$arg"; _prev="" + continue + fi case "$arg" in --check-only) CHECK_ONLY=1 ;; --plan-only) PLAN_ONLY=1 ;; --quiet) QUIET=1 ;; --deploy-uptime) DEPLOY_UPTIME=1 ;; --decommission) DECOMMISSION=1 ;; + --environment=*) ENVIRONMENT_OVERRIDE="${arg#*=}" ;; + --environment) _prev="--environment" ;; --*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;; *) if [ "$DECOMMISSION" = "1" ] && [ -z "$CHANGE_REQUEST_ID" ]; then @@ -102,7 +113,13 @@ TF_DIR="$WORK/tf" rm -rf "$WORK"; mkdir -p "$TF_DIR" echo "=== Step 0: environment onboarding check ===" -if [ -f "$CONTRACT" ]; then +if [ -n "$ENVIRONMENT_OVERRIDE" ]; then + export ACDL_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE" + python3 core/environment_check.py --env="$ENVIRONMENT_OVERRIDE" || { + echo "FAIL: environment not bound — see the onboarding prompt above" >&2 + exit 1 + } +elif [ -f "$CONTRACT" ]; then python3 core/environment_check.py "$CONTRACT" || { echo "FAIL: environment not bound — see the onboarding prompt above" >&2 exit 1 diff --git a/tests/test_consumer_guide_per_env_section.py b/tests/test_consumer_guide_per_env_section.py new file mode 100644 index 0000000..76b0e81 --- /dev/null +++ b/tests/test_consumer_guide_per_env_section.py @@ -0,0 +1,45 @@ +"""REQ-106: consumer guide documents per-env caller workflows.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +GUIDE = ROOT / "docs" / "consumer-guide.md" + + +def test_consumer_guide_has_per_env_section(): + text = GUIDE.read_text() + assert "Per-environment deployment" in text + assert "promotion-without-editing" in text.lower() or "promotion = running the matching job" in text.lower() + + +def test_consumer_guide_has_four_caller_examples(): + text = GUIDE.read_text() + assert "deploy-dev" in text + assert "deploy-qa" in text + assert "deploy-prod" in text + assert "deploy-dr" in text + + +def test_consumer_guide_documents_environment_input(): + text = GUIDE.read_text() + assert "environment" in text + assert "workflow input" in text.lower() or "workflow_call" in text.lower() or "environment:" in text + + +def test_consumer_guide_documents_hitl_gates(): + text = GUIDE.read_text() + assert "approve_qa" in text + assert "approve_prod" in text + assert "approve_dr" in text + assert "separation-of-duties" in text.lower() or "separation of duties" in text.lower() + + +def test_consumer_guide_has_interpolation_reference(): + text = GUIDE.read_text() + assert "${env.environment}" in text + assert "${env.account_id}" in text + assert "${contract.module}" in text + + +def test_consumer_guide_states_no_field_editing(): + text = GUIDE.read_text() + assert "no" in text.lower() and "environment" in text.lower() and "editing" in text.lower() \ No newline at end of file diff --git a/tests/test_deploy_workflow_env_input.py b/tests/test_deploy_workflow_env_input.py new file mode 100644 index 0000000..6565b18 --- /dev/null +++ b/tests/test_deploy_workflow_env_input.py @@ -0,0 +1,81 @@ +"""REQ-106: deploy workflow environment input + run_platform.sh --environment. +Both deploy workflows (Gitea + GitHub) declare the environment input, +are byte-identical, and the resolver supports environment_override. +""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from core.contract_resolver import resolve + +GITHUB = ROOT / ".github" / "workflows" / "deploy.yml" +GITEA = ROOT / ".gitea" / "workflows" / "deploy.yml" + + +def test_both_deploy_workflows_exist(): + assert GITHUB.is_file() + assert GITEA.is_file() + + +def test_deploy_workflows_byte_identical(): + assert GITHUB.read_text() == GITEA.read_text(), "deploy workflows must be byte-identical" + + +def test_deploy_workflow_has_environment_input(): + text = GITHUB.read_text() + assert "environment:" in text + assert "Target environment override" in text + # The input is declared in the workflow_call inputs section. + assert 'default: ""' in text + + +def test_deploy_workflow_passes_env_flag_to_run_platform(): + text = GITHUB.read_text() + assert "--environment" in text + assert "ENV_FLAG" in text + assert "inputs.environment" in text + + +def test_resolver_environment_override_changes_env(): + """D-088: environment_override changes the resolved environment.""" + stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"), + environment_override="qa") + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert "qa" in s3["inputs"]["bucket_name"] + + +def test_resolver_environment_override_prod(): + stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"), + environment_override="prod") + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert "prod" in s3["inputs"]["bucket_name"] + + +def test_resolver_environment_override_dr(): + stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"), + environment_override="dr") + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert "dr" in s3["inputs"]["bucket_name"] + + +def test_resolver_no_override_uses_contract_env(): + stack = resolve(str(ROOT / "contracts" / "static-assets.yaml")) + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert "dev" in s3["inputs"]["bucket_name"] + + +def test_resolver_override_none_uses_contract_env(): + """Passing environment_override=None uses the contract's environment.""" + stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"), + environment_override=None) + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert "dev" in s3["inputs"]["bucket_name"] + + +def test_run_platform_sh_has_environment_flag(): + text = (ROOT / "scripts" / "run_platform.sh").read_text() + assert "--environment" in text + assert "ENVIRONMENT_OVERRIDE" in text + assert "ACDL_ENVIRONMENT_OVERRIDE" in text \ No newline at end of file diff --git a/tests/test_per_env_contracts.py b/tests/test_per_env_contracts.py new file mode 100644 index 0000000..00a4f28 --- /dev/null +++ b/tests/test_per_env_contracts.py @@ -0,0 +1,87 @@ +"""REQ-105: per-environment contract files exist + validate + resolve.""" +import json +import sys +from pathlib import Path + +import jsonschema +import pytest +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from core.contract_resolver import resolve + +SCHEMA = json.loads((ROOT / "schemas" / "contract.schema.json").read_text()) + +PER_ENV_CONTRACTS = [ + "contracts/static-assets.dev.yaml", + "contracts/static-assets.qa.yaml", + "contracts/static-assets.prod.yaml", + "contracts/static-assets.dr.yaml", + "contracts/microservice.dev.yaml", + "contracts/microservice.qa.yaml", + "contracts/microservice.prod.yaml", + "contracts/microservice.dr.yaml", +] + + +@pytest.mark.parametrize("rel", PER_ENV_CONTRACTS) +def test_per_env_contract_validates_against_schema(rel): + contract = yaml.safe_load((ROOT / rel).read_text()) + jsonschema.validate(contract, SCHEMA) + + +@pytest.mark.parametrize("rel", PER_ENV_CONTRACTS) +def test_per_env_contract_resolves(rel): + stack = resolve(str(ROOT / rel)) + assert stack["stack"]["name"] in ("static-assets", "microservice") + + +def test_static_assets_dev_has_dev_environment(): + c = yaml.safe_load((ROOT / "contracts/static-assets.dev.yaml").read_text()) + assert c["environment"] == "dev" + + +def test_static_assets_qa_has_qa_environment(): + c = yaml.safe_load((ROOT / "contracts/static-assets.qa.yaml").read_text()) + assert c["environment"] == "qa" + + +def test_static_assets_prod_has_prod_environment(): + c = yaml.safe_load((ROOT / "contracts/static-assets.prod.yaml").read_text()) + assert c["environment"] == "prod" + + +def test_static_assets_dr_has_dr_environment(): + c = yaml.safe_load((ROOT / "contracts/static-assets.dr.yaml").read_text()) + assert c["environment"] == "dr" + + +def test_per_env_contracts_use_interpolation(): + """Each per-env contract uses ${env.*} interpolation for the bucket name.""" + for rel in PER_ENV_CONTRACTS: + text = (ROOT / rel).read_text() + assert "${env.environment}" in text + assert "${env.account_id}" in text + + +def test_per_env_qa_resolves_to_qa_bucket_name(): + stack = resolve(str(ROOT / "contracts/static-assets.qa.yaml")) + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert s3["inputs"]["bucket_name"] == "acdl-qa-static-assets-000000000000-us-east-1" + + +def test_per_env_prod_resolves_to_prod_bucket_name(): + stack = resolve(str(ROOT / "contracts/static-assets.prod.yaml")) + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert s3["inputs"]["bucket_name"] == "acdl-prod-static-assets-000000000000-us-east-1" + + +def test_default_dev_contract_still_works(): + """The existing contracts/static-assets.yaml remains the dev default.""" + c = yaml.safe_load((ROOT / "contracts/static-assets.yaml").read_text()) + assert c["environment"] == "dev" + stack = resolve(str(ROOT / "contracts/static-assets.yaml")) + s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0] + assert s3["inputs"]["bucket_name"] == "acdl-dev-static-assets-000000000000-us-east-1" \ No newline at end of file