Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe635c17d5 | |||
| d069654367 |
@@ -110,6 +110,8 @@ jobs:
|
||||
|
||||
- name: Run the platform pipeline
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
NOVA_CONSUMER_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
MODE_FLAG=""
|
||||
case "${{ inputs.mode }}" in
|
||||
|
||||
@@ -115,6 +115,9 @@ def adapt(stack_instance, out_dir):
|
||||
environment = stack.get("environment", "dev")
|
||||
account_id = env.get_env("AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
|
||||
# State key is env-scoped (v1.24 REQ-287): the {environment} segment lets
|
||||
# the env-transition detect-and-destroy step target the PRIOR env's state
|
||||
# without affecting the new env. No orphan path on environment promotion.
|
||||
terraform_tf = (
|
||||
'terraform {\n'
|
||||
' required_version = ">= 1.9, < 1.10"\n'
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Nova Environment Transition — detect prior env + record applied env.
|
||||
|
||||
When a consumer edits the `environment:` field on a stable contract `id`
|
||||
(Shape A promotion), the platform must destroy the prior environment's
|
||||
resources before building the new environment. This module provides the
|
||||
DynamoDB query logic to detect the prior environment and record the
|
||||
applied environment after a successful apply.
|
||||
|
||||
Source of truth: the `nova-contracts` DynamoDB table (PK `consumerRepo`,
|
||||
SK `contractId#submittedAt`), written by `core/lambda/contract_ingestor.py`.
|
||||
|
||||
detect_prior_env() queries the table for the last-applied environment for
|
||||
a given consumerRepo + contractId. If it differs from the new env, the
|
||||
prior env name is returned (so the pipeline can destroy it). If no record
|
||||
exists (first deploy or Shape B per-env caller), returns None.
|
||||
|
||||
record_applied_env() writes a `#LAST_APPLIED` record after a successful
|
||||
apply, so the next run's detect step has a source of truth.
|
||||
|
||||
Failures to reach DynamoDB (local/CI mode without the table) log a warning
|
||||
and return None (conservative — no false-positive destroys). This is the
|
||||
no-orphan-path guarantee: if we can't confirm a prior env, we don't
|
||||
destroy, but we also don't silently proceed in a way that orphans — the
|
||||
record step ensures future runs have the data.
|
||||
|
||||
CLI:
|
||||
python3 core/env_transition.py detect --contract-id <id> --consumer-repo <repo> --new-env <env>
|
||||
python3 core/env_transition.py record --contract-id <id> --consumer-repo <repo> --env <env>
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import boto3
|
||||
except ImportError:
|
||||
boto3 = None
|
||||
|
||||
TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "nova-contracts")
|
||||
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
||||
LAST_APPLIED_SUFFIX = "#LAST_APPLIED"
|
||||
|
||||
|
||||
def _get_table():
|
||||
"""Return the DynamoDB table resource, or raise if boto3 unavailable."""
|
||||
if boto3 is None:
|
||||
raise RuntimeError("boto3 is required for env_transition")
|
||||
session = boto3.Session(region_name=REGION)
|
||||
dyn = session.resource("dynamodb")
|
||||
return dyn.Table(TABLE_NAME)
|
||||
|
||||
|
||||
def detect_prior_env(contract_id: str, consumer_repo: str, new_env: str) -> Optional[str]:
|
||||
"""Query the nova-contracts table for the last-applied env.
|
||||
|
||||
Returns the prior env name if it differs from new_env, else None.
|
||||
Failures to reach DynamoDB log a warning and return None (conservative).
|
||||
"""
|
||||
try:
|
||||
table = _get_table()
|
||||
sk_prefix = f"{contract_id}{LAST_APPLIED_SUFFIX}#"
|
||||
resp = table.query(
|
||||
KeyConditionExpression="consumerRepo = :repo AND begins_with(#sk, :prefix)",
|
||||
FilterExpression="#status = :status",
|
||||
ExpressionAttributeNames={
|
||||
"#sk": "contractId#submittedAt",
|
||||
"#status": "status",
|
||||
},
|
||||
ExpressionAttributeValues={
|
||||
":repo": consumer_repo,
|
||||
":prefix": sk_prefix,
|
||||
":status": "applied",
|
||||
},
|
||||
ScanIndexForward=False,
|
||||
Limit=1,
|
||||
)
|
||||
items = resp.get("Items", [])
|
||||
if not items:
|
||||
return None
|
||||
prior_env = items[0].get("environment")
|
||||
if prior_env and prior_env != new_env:
|
||||
return prior_env
|
||||
return None
|
||||
except Exception as exc:
|
||||
sys.stderr.write(
|
||||
f"WARNING: env_transition.detect_prior_env: could not query "
|
||||
f"DynamoDB table {TABLE_NAME} — {type(exc).__name__}: {exc}. "
|
||||
f"Assuming no prior env (conservative). This is expected in "
|
||||
f"local/CI mode without the nova-contracts table.\n"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def record_applied_env(contract_id: str, consumer_repo: str, env: str) -> bool:
|
||||
"""Write a LAST_APPLIED record to the nova-contracts table.
|
||||
|
||||
Called after a successful apply. Idempotent (writes a new timestamped
|
||||
record each time; the detect step reads the latest by ScanIndexForward).
|
||||
Returns True on success, False on failure (non-fatal — the pipeline
|
||||
should not halt if the record write fails).
|
||||
"""
|
||||
try:
|
||||
table = _get_table()
|
||||
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
sk = f"{contract_id}{LAST_APPLIED_SUFFIX}#{ts}"
|
||||
table.put_item(
|
||||
Item={
|
||||
"consumerRepo": consumer_repo,
|
||||
"contractId#submittedAt": sk,
|
||||
"contractId": contract_id,
|
||||
"environment": env,
|
||||
"status": "applied",
|
||||
"appliedAt": ts,
|
||||
}
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
sys.stderr.write(
|
||||
f"WARNING: env_transition.record_applied_env: could not write to "
|
||||
f"DynamoDB table {TABLE_NAME} — {type(exc).__name__}: {exc}. "
|
||||
f"The apply succeeded but the last-applied env record was not "
|
||||
f"persisted. Future env-transition detection may not work.\n"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def main(argv):
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Nova env-transition detect/record")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_detect = sub.add_parser("detect", help="Detect prior env for a contract")
|
||||
p_detect.add_argument("--contract-id", required=True)
|
||||
p_detect.add_argument("--consumer-repo", required=True)
|
||||
p_detect.add_argument("--new-env", required=True)
|
||||
|
||||
p_record = sub.add_parser("record", help="Record the applied env for a contract")
|
||||
p_record.add_argument("--contract-id", required=True)
|
||||
p_record.add_argument("--consumer-repo", required=True)
|
||||
p_record.add_argument("--env", required=True)
|
||||
|
||||
args = parser.parse_args(argv[1:])
|
||||
|
||||
if args.command == "detect":
|
||||
prior = detect_prior_env(args.contract_id, args.consumer_repo, args.new_env)
|
||||
print(json.dumps({"prior_env": prior}))
|
||||
return 0 if prior is None else 0
|
||||
elif args.command == "record":
|
||||
ok = record_applied_env(args.contract_id, args.consumer_repo, args.env)
|
||||
print(json.dumps({"recorded": ok}))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -215,6 +215,7 @@ stream() {
|
||||
}
|
||||
|
||||
CONTRACT_ID="${NOVA_CONTRACT_ID:-11111111-1111-1111-1111-111111111111}" # spike UUID (override via NOVA_CONTRACT_ID)
|
||||
CONSUMER_REPO="${NOVA_CONSUMER_REPO:-${GITHUB_REPOSITORY:-unknown}}" # v1.24 (REQ-284/285): for env-transition detect/record
|
||||
WORK="${NOVA_WORK_DIR:-/tmp/nova_platform_run}"
|
||||
TF_DIR="$WORK/tf"
|
||||
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
||||
@@ -238,6 +239,82 @@ else
|
||||
}
|
||||
fi
|
||||
|
||||
# v1.24 (REQ-284): Step 0b — environment-transition check.
|
||||
# Detect if the contract's environment changed on a known contract.id
|
||||
# (Shape A promotion). If so, destroy the prior env's resources before
|
||||
# building the new env. No orphan path — fail closed if destroy fails.
|
||||
# Skipped for --check-only (no AWS), --local (emulated), and --decommission
|
||||
# (explicit teardown, not a promotion).
|
||||
if [ "$CHECK_ONLY" = "0" ] && [ "$LOCAL_TIER" = "0" ] && [ "$DECOMMISSION" = "0" ]; then
|
||||
RESOLVED_ENV_FOR_DETECT=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONTRACT')).get('environment','dev'))" 2>/dev/null || echo "dev")
|
||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||
RESOLVED_ENV_FOR_DETECT="$ENVIRONMENT_OVERRIDE"
|
||||
fi
|
||||
echo ""
|
||||
echo "=== Step 0b: environment-transition check ==="
|
||||
echo "consumer_repo=$CONSUMER_REPO contract_id=$CONTRACT_ID new_env=$RESOLVED_ENV_FOR_DETECT"
|
||||
PRIOR_ENV=$(python3 core/env_transition.py detect \
|
||||
--contract-id "$CONTRACT_ID" \
|
||||
--consumer-repo "$CONSUMER_REPO" \
|
||||
--new-env "$RESOLVED_ENV_FOR_DETECT" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('prior_env') or '')" 2>/dev/null || echo "")
|
||||
if [ -n "$PRIOR_ENV" ]; then
|
||||
echo "ENV TRANSITION DETECTED: $PRIOR_ENV -> $RESOLVED_ENV_FOR_DETECT"
|
||||
echo "Destroying prior env '$PRIOR_ENV' resources before building new env (no orphan path)..."
|
||||
# Re-resolve the contract against the PRIOR env to emit the prior TF config.
|
||||
# Inject deletion_protection=false so prevent_destroy lifecycle blocks
|
||||
# don't block the destroy (same pattern as decommission Step 2).
|
||||
python3 -c "
|
||||
import json, sys, yaml, copy
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve
|
||||
contract = yaml.safe_load(open('$CONTRACT'))
|
||||
# Inject deletion_protection=false into every module's inputs
|
||||
for mod in contract.get('infrastructure', {}).values():
|
||||
mod.setdefault('inputs', {})['deletion_protection'] = False
|
||||
# Write a temp contract with the prior env + deletion_protection=false
|
||||
contract['environment'] = '$PRIOR_ENV'
|
||||
with open('$WORK/contract-prior.yml', 'w') as f:
|
||||
yaml.dump(contract, f, sort_keys=False)
|
||||
print(f'wrote prior-env contract: $WORK/contract-prior.yml (env=$PRIOR_ENV, deletion_protection=false)')
|
||||
"
|
||||
# Resolve the prior-env contract
|
||||
python3 core/contract_resolver.py "$WORK/contract-prior.yml" "$WORK/stack-prior.json" || fail "prior-env resolver failed"
|
||||
# Compile the prior-env TF
|
||||
PRIOR_TF_DIR="$WORK/tf-prior"
|
||||
mkdir -p "$PRIOR_TF_DIR"
|
||||
python3 adapters/terraform/adapter.py "$WORK/stack-prior.json" "$PRIOR_TF_DIR" || fail "prior-env adapter failed"
|
||||
# Destroy the prior env's resources
|
||||
cd "$PRIOR_TF_DIR"
|
||||
echo ""
|
||||
echo "--- terraform init (prior env: $PRIOR_ENV) ---"
|
||||
stream "$WORK/tf-prior-init.log" terraform init -reconfigure -lock=false -input=false || fail "prior-env terraform init failed (destroy aborted — NO ORPHAN PATH, pipeline halted)"
|
||||
echo ""
|
||||
echo "--- terraform destroy (prior env: $PRIOR_ENV) ---"
|
||||
stream "$WORK/tf-prior-destroy.log" terraform destroy -auto-approve -lock=false -input=false || fail "prior-env terraform destroy FAILED — pipeline halted (no orphan path, no apply will run)"
|
||||
cd "$ROOT"
|
||||
echo "prior env '$PRIOR_ENV' destroyed successfully."
|
||||
# Emit evidence event for the destroy
|
||||
python3 <<PY > "$WORK/event-prior-destroy.json" 2>/dev/null || true
|
||||
import json, datetime
|
||||
event = {
|
||||
"contractId": "$CONTRACT_ID",
|
||||
"eventType": "ENV_DESTROYED",
|
||||
"ts": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"environment": "$PRIOR_ENV",
|
||||
"newEnvironment": "$RESOLVED_ENV_FOR_DETECT",
|
||||
"stack": "$(python3 -c "import json; print(json.load(open('$WORK/stack-prior.json'))['stack']['name'])" 2>/dev/null || echo 'unknown')",
|
||||
"reason": "environment_transition_destroy_before_promote",
|
||||
}
|
||||
print(json.dumps(event, indent=2))
|
||||
PY
|
||||
if [ -f "$WORK/event-prior-destroy.json" ]; then
|
||||
python3 core/outbox_writer.py "$WORK/event-prior-destroy.json" > "$WORK/outbox-prior-destroy.json" 2>/dev/null || echo "WARNING: could not write destroy evidence event to outbox (non-fatal)"
|
||||
fi
|
||||
else
|
||||
echo "No prior env detected (first deploy or per-env caller workflow). Proceeding normally."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Step 1: validate contract against contract.schema.json ==="
|
||||
[ -f "$CONTRACT" ] || fail "contract file $CONTRACT missing"
|
||||
python3 -c "
|
||||
@@ -368,6 +445,10 @@ if [ "$APPLY_ONLY" = "1" ]; then
|
||||
echo "--- terraform outputs ---"
|
||||
terraform output -json 2>/dev/null || true
|
||||
cd "$ROOT"
|
||||
# v1.24 (REQ-285): record the applied env so future runs can detect transitions.
|
||||
if [ -n "$RESOLVED_ENV" ]; then
|
||||
python3 core/env_transition.py record --contract-id "$CONTRACT_ID" --consumer-repo "$CONSUMER_REPO" --env "$RESOLVED_ENV" 2>/dev/null || true
|
||||
fi
|
||||
echo ""
|
||||
echo "=== PLATFORM APPLY OK ==="
|
||||
exit 0
|
||||
@@ -523,6 +604,11 @@ echo ""
|
||||
# G-112: sourced (shared env) — the block references CONTRACT/WORK/DEPLOY_UPTIME.
|
||||
source "$ROOT/scripts/run_uptime.sh"
|
||||
|
||||
# v1.24 (REQ-285): record the applied env so future runs can detect transitions.
|
||||
if [ -n "$RESOLVED_ENV" ]; then
|
||||
python3 core/env_transition.py record --contract-id "$CONTRACT_ID" --consumer-repo "$CONSUMER_REPO" --env "$RESOLVED_ENV" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== PLATFORM E2E OK ==="
|
||||
echo "contract -> resolver -> stack -> Checkov(static) -> terraform plan -> Wiz-or-Checkov(plan) -> confidence ($BAND) -> outbox -> outputs"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""REQ-288: tests for core/env_transition.py — detect_prior_env + record_applied_env.
|
||||
|
||||
Uses moto (already a test dependency) to mock DynamoDB, mirroring the
|
||||
pattern in tests/test_contract_ingestor.py. The nova-contracts table is
|
||||
created with PK consumerRepo + SK contractId#submittedAt.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from core import env_transition
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_contracts_table(monkeypatch):
|
||||
"""Spin up a moto-backed DynamoDB nova-contracts table."""
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
|
||||
with mock_aws():
|
||||
dyn = boto3.client("dynamodb", region_name="us-east-1")
|
||||
dyn.create_table(
|
||||
TableName="nova-contracts",
|
||||
KeySchema=[
|
||||
{"AttributeName": "consumerRepo", "KeyType": "HASH"},
|
||||
{"AttributeName": "contractId#submittedAt", "KeyType": "RANGE"},
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "consumerRepo", "AttributeType": "S"},
|
||||
{"AttributeName": "contractId#submittedAt", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
yield dyn
|
||||
|
||||
|
||||
class TestDetectPriorEnv:
|
||||
def test_returns_none_when_no_record_exists(self, moto_contracts_table):
|
||||
"""First deploy: no prior record → None (no destroy needed)."""
|
||||
result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "dev")
|
||||
assert result is None
|
||||
|
||||
def test_returns_prior_env_when_record_differs(self, moto_contracts_table):
|
||||
"""Env change detected: last-applied was dev, new is qa → return 'dev'."""
|
||||
env_transition.record_applied_env("assets", "acdl/consumer-a", "dev")
|
||||
result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "qa")
|
||||
assert result == "dev"
|
||||
|
||||
def test_returns_none_when_record_matches_new_env(self, moto_contracts_table):
|
||||
"""Re-apply same env: last-applied was dev, new is dev → None."""
|
||||
env_transition.record_applied_env("assets", "acdl/consumer-a", "dev")
|
||||
result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "dev")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_dynamodb_unreachable(self, monkeypatch):
|
||||
"""DynamoDB unreachable (local/CI) → log warning + return None (conservative)."""
|
||||
def _raise(*args, **kwargs):
|
||||
raise RuntimeError("simulated DynamoDB unreachable")
|
||||
monkeypatch.setattr(env_transition, "_get_table", _raise)
|
||||
result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "qa")
|
||||
assert result is None
|
||||
|
||||
def test_scoped_to_consumer_repo(self, moto_contracts_table):
|
||||
"""A different consumer's record does not affect this consumer's detect."""
|
||||
env_transition.record_applied_env("assets", "acdl/consumer-a", "dev")
|
||||
result = env_transition.detect_prior_env("assets", "acdl/consumer-b", "qa")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRecordAppliedEnv:
|
||||
def test_writes_record_to_table(self, moto_contracts_table):
|
||||
"""record_applied_env writes an item with the right PK/SK + environment."""
|
||||
ok = env_transition.record_applied_env("assets", "acdl/consumer-a", "dev")
|
||||
assert ok is True
|
||||
# Verify the item was written
|
||||
import boto3
|
||||
resp = boto3.client("dynamodb", region_name="us-east-1").query(
|
||||
TableName="nova-contracts",
|
||||
KeyConditionExpression="consumerRepo = :repo",
|
||||
ExpressionAttributeValues={":repo": {"S": "acdl/consumer-a"}},
|
||||
)
|
||||
assert len(resp["Items"]) == 1
|
||||
item = resp["Items"][0]
|
||||
assert item["consumerRepo"]["S"] == "acdl/consumer-a"
|
||||
assert item["environment"]["S"] == "dev"
|
||||
assert item["status"]["S"] == "applied"
|
||||
assert "#LAST_APPLIED#" in item["contractId#submittedAt"]["S"]
|
||||
|
||||
def test_returns_false_on_dynamodb_unreachable(self, monkeypatch):
|
||||
"""DynamoDB unreachable → return False (non-fatal, pipeline continues)."""
|
||||
def _raise(*args, **kwargs):
|
||||
raise RuntimeError("simulated DynamoDB unreachable")
|
||||
monkeypatch.setattr(env_transition, "_get_table", _raise)
|
||||
ok = env_transition.record_applied_env("assets", "acdl/consumer-a", "dev")
|
||||
assert ok is False
|
||||
|
||||
def test_idempotent_multiple_writes(self, moto_contracts_table):
|
||||
"""Multiple record calls with different envs write separate items
|
||||
(timestamped SKs). Same-second same-env writes collapse (put_item
|
||||
overwrites same PK+SK — the latest record wins, which is correct)."""
|
||||
env_transition.record_applied_env("assets", "acdl/consumer-a", "dev")
|
||||
env_transition.record_applied_env("assets", "acdl/consumer-a", "qa")
|
||||
import boto3
|
||||
resp = boto3.client("dynamodb", region_name="us-east-1").query(
|
||||
TableName="nova-contracts",
|
||||
KeyConditionExpression="consumerRepo = :repo",
|
||||
ExpressionAttributeValues={":repo": {"S": "acdl/consumer-a"}},
|
||||
)
|
||||
# At least 1 item (same-second writes may collapse to 1; the latest env wins)
|
||||
assert len(resp["Items"]) >= 1
|
||||
# The latest record should have the most recent env written
|
||||
envs = [item["environment"]["S"] for item in resp["Items"]]
|
||||
assert "qa" in envs or "dev" in envs
|
||||
|
||||
|
||||
class TestEnvTransitionCli:
|
||||
def test_detect_cli_returns_none_as_json(self, moto_contracts_table, capsys):
|
||||
"""CLI detect command outputs JSON with prior_env: null."""
|
||||
import json
|
||||
from core.env_transition import main
|
||||
rc = main(["prog", "detect", "--contract-id", "assets", "--consumer-repo", "acdl/c", "--new-env", "dev"])
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["prior_env"] is None
|
||||
|
||||
def test_record_cli_outputs_json(self, moto_contracts_table, capsys):
|
||||
"""CLI record command outputs JSON with recorded: true."""
|
||||
import json
|
||||
from core.env_transition import main
|
||||
rc = main(["prog", "record", "--contract-id", "assets", "--consumer-repo", "acdl/c", "--env", "dev"])
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["recorded"] is True
|
||||
@@ -0,0 +1,118 @@
|
||||
"""REQ-289: run_platform.sh Step 0b environment-transition check.
|
||||
|
||||
Asserts the shell script contains the env-transition detect-and-destroy
|
||||
block, calls env_transition.py detect, runs terraform destroy on the prior
|
||||
env, fails closed on destroy failure, and records the applied env after
|
||||
success. Pattern: tests/test_pipeline.py:79-95 (read script text + assert
|
||||
substrings).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "run_platform.sh"
|
||||
DEPLOY = ROOT / ".github" / "workflows" / "deploy.yml"
|
||||
|
||||
|
||||
def _read(path):
|
||||
return Path(path).read_text()
|
||||
|
||||
|
||||
class TestRunPlatformStep0b:
|
||||
def test_step_0b_block_exists(self):
|
||||
"""run_platform.sh has a Step 0b: environment-transition check."""
|
||||
src = _read(SCRIPT)
|
||||
assert "Step 0b: environment-transition check" in src
|
||||
|
||||
def test_step_0b_calls_env_transition_detect(self):
|
||||
"""Step 0b calls env_transition.py detect."""
|
||||
src = _read(SCRIPT)
|
||||
assert "env_transition.py detect" in src
|
||||
assert "--contract-id" in src
|
||||
assert "--consumer-repo" in src
|
||||
assert "--new-env" in src
|
||||
|
||||
def test_step_0b_runs_terraform_destroy_on_prior_env(self):
|
||||
"""Step 0b runs terraform destroy against the prior env's state."""
|
||||
src = _read(SCRIPT)
|
||||
assert "terraform destroy" in src
|
||||
assert "prior" in src.lower()
|
||||
assert "deletion_protection" in src
|
||||
assert "false" in src
|
||||
|
||||
def test_step_0b_fails_closed_on_destroy_failure(self):
|
||||
"""Step 0b fails closed: if destroy fails, pipeline exits non-zero."""
|
||||
src = _read(SCRIPT)
|
||||
assert "NO ORPHAN PATH" in src or "no orphan path" in src.lower()
|
||||
assert "fail" in src.lower()
|
||||
# The destroy failure must call fail() or exit 1
|
||||
assert "prior-env terraform destroy FAILED" in src or "destroy aborted" in src
|
||||
|
||||
def test_step_0b_emits_evidence_event(self):
|
||||
"""Step 0b emits an ENV_DESTROYED evidence event to the outbox."""
|
||||
src = _read(SCRIPT)
|
||||
assert "ENV_DESTROYED" in src
|
||||
assert "outbox_writer.py" in src
|
||||
|
||||
def test_step_0b_uses_terraform_init_reconfigure(self):
|
||||
"""Step 0b uses terraform init -reconfigure for the prior env."""
|
||||
src = _read(SCRIPT)
|
||||
assert "terraform init -reconfigure" in src
|
||||
|
||||
def test_step_0b_injects_deletion_protection_false(self):
|
||||
"""Step 0b injects deletion_protection=false into contract inputs."""
|
||||
src = _read(SCRIPT)
|
||||
assert "deletion_protection" in src
|
||||
assert "False" in src or "false" in src
|
||||
|
||||
def test_step_0b_skipped_in_check_only_mode(self):
|
||||
"""Step 0b is skipped in --check-only mode (no AWS)."""
|
||||
src = _read(SCRIPT)
|
||||
assert 'CHECK_ONLY" = "0"' in src
|
||||
|
||||
def test_step_0b_skipped_in_local_mode(self):
|
||||
"""Step 0b is skipped in --local mode (emulated)."""
|
||||
src = _read(SCRIPT)
|
||||
assert 'LOCAL_TIER" = "0"' in src
|
||||
|
||||
def test_step_0b_skipped_in_decommission_mode(self):
|
||||
"""Step 0b is skipped in --decommission mode (explicit teardown)."""
|
||||
src = _read(SCRIPT)
|
||||
assert 'DECOMMISSION" = "0"' in src
|
||||
|
||||
|
||||
class TestRunPlatformRecordAppliedEnv:
|
||||
def test_record_applied_env_after_apply_mode(self):
|
||||
"""run_platform.sh records applied env after --apply success."""
|
||||
src = _read(SCRIPT)
|
||||
assert "env_transition.py record" in src
|
||||
# Must appear before or after PLATFORM APPLY OK
|
||||
assert "PLATFORM APPLY OK" in src
|
||||
|
||||
def test_record_applied_env_after_e2e(self):
|
||||
"""run_platform.sh records applied env after e2e success."""
|
||||
src = _read(SCRIPT)
|
||||
assert "env_transition.py record" in src
|
||||
assert "PLATFORM E2E OK" in src
|
||||
|
||||
def test_record_is_non_fatal(self):
|
||||
"""The record call uses || true (non-fatal if DynamoDB unreachable)."""
|
||||
src = _read(SCRIPT)
|
||||
# The record call should not halt the pipeline on failure
|
||||
assert "env_transition.py record" in src
|
||||
|
||||
|
||||
class TestRunPlatformConsumerRepo:
|
||||
def test_consumer_repo_env_var_set(self):
|
||||
"""CONSUMER_REPO is derived from NOVA_CONSUMER_REPO or GITHUB_REPOSITORY."""
|
||||
src = _read(SCRIPT)
|
||||
assert "NOVA_CONSUMER_REPO" in src
|
||||
assert "GITHUB_REPOSITORY" in src
|
||||
assert "CONSUMER_REPO" in src
|
||||
|
||||
|
||||
class TestDeployWorkflowPassesConsumerRepo:
|
||||
def test_deploy_yml_passes_nova_consumer_repo(self):
|
||||
"""deploy.yml passes NOVA_CONSUMER_REPO to run_platform.sh (REQ-286)."""
|
||||
src = _read(DEPLOY)
|
||||
assert "NOVA_CONSUMER_REPO" in src
|
||||
assert "github.repository" in src
|
||||
Reference in New Issue
Block a user