Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d069654367 | |||
| 25427250ad | |||
| eca1181716 |
@@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"phase": 0,
|
"phase": 0,
|
||||||
"stage": "plan",
|
"stage": "complete",
|
||||||
"milestone": "v1.24",
|
"milestone": "v1.24",
|
||||||
"phase_role": "pre_execution",
|
"phase_role": "pre_execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-12T02:15:00Z",
|
"updated_at": "2026-08-12T02:20:00Z",
|
||||||
"project": "acdl",
|
"project": "acdl",
|
||||||
"milestone_complete": false,
|
"milestone_complete": false,
|
||||||
|
"tag": "v1.23.0",
|
||||||
"tag_line": "v1.23.x",
|
"tag_line": "v1.23.x",
|
||||||
|
"release": {
|
||||||
|
"forge": "gitea",
|
||||||
|
"release_id": 635,
|
||||||
|
"tag": "v1.23.0"
|
||||||
|
},
|
||||||
"requirements": ["REQ-276","REQ-277","REQ-278","REQ-279","REQ-280","REQ-281","REQ-282","REQ-283","REQ-284","REQ-285","REQ-286","REQ-287","REQ-288","REQ-289","REQ-290"],
|
"requirements": ["REQ-276","REQ-277","REQ-278","REQ-279","REQ-280","REQ-281","REQ-282","REQ-283","REQ-284","REQ-285","REQ-286","REQ-287","REQ-288","REQ-289","REQ-290"],
|
||||||
"phases": ["P1:consumer-guide-fixes","P2:env-transition-detect-and-destroy","P3:env-transition-tests","P4:final-review-ship"],
|
"phases": ["P1:consumer-guide-fixes","P2:env-transition-detect-and-destroy","P3:env-transition-tests","P4:final-review-ship"]
|
||||||
"waves": 4
|
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run the platform pipeline
|
- name: Run the platform pipeline
|
||||||
working-directory: ${{ github.workspace }}
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
NOVA_CONSUMER_REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
MODE_FLAG=""
|
MODE_FLAG=""
|
||||||
case "${{ inputs.mode }}" in
|
case "${{ inputs.mode }}" in
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ def adapt(stack_instance, out_dir):
|
|||||||
environment = stack.get("environment", "dev")
|
environment = stack.get("environment", "dev")
|
||||||
account_id = env.get_env("AWS_ACCOUNT_ID", "581513795199")
|
account_id = env.get_env("AWS_ACCOUNT_ID", "581513795199")
|
||||||
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
|
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_tf = (
|
||||||
'terraform {\n'
|
'terraform {\n'
|
||||||
' required_version = ">= 1.9, < 1.10"\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))
|
||||||
+47
-11
@@ -140,10 +140,10 @@ name: microservice
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `uses` | string | yes | Reference to the central deployment pipeline, **versioned** with a floating MAJOR+MINOR tag (e.g. `nova/pipelines/contract.yml@v1.19`). Bare or `@main` references are discouraged. See [Versioning](pipeline/versioning). |
|
| `id` | string | yes | Short operational acronym (3-6 chars, lowercase + digits + hyphens). Becomes `stack.name`: the Terraform state key (`spike/<id>/<env>/terraform.tfstate`), the outbox event identity, and the resource naming prefix. Stable across deploys and environment promotions. |
|
||||||
| `module` | string | yes | Module name from the registry — any primitive or module (e.g. `static-assets`, `microservice`, `s3`). See the [module catalog](modules/). |
|
| `name` | string | yes | Full human-readable stack name. Becomes `stack.title`: the display name in PR comments, evidence records, and dashboards. |
|
||||||
| `environment` | string | yes | The platform-managed environment to deploy to (e.g. `dev`). See [Environments](environments/). |
|
| `environment` | string | yes | The platform-managed environment to deploy to (`dev`, `qa`, `prod`, or `dr`). See [Environments](environments/). |
|
||||||
| `inputs` | object | yes | Module-specific inputs (see the module's README). |
|
| `infrastructure` | object | yes | Map of modules to deploy, keyed by module name (matching a registry key in `modules/registry.json`). Each entry carries an optional `version` (defaults to latest published) and per-module `inputs`. One entry = single-module deploy; N entries = multi-module manifest. |
|
||||||
|
|
||||||
### Module inputs
|
### Module inputs
|
||||||
|
|
||||||
@@ -180,6 +180,7 @@ jobs:
|
|||||||
uses: nova/.github/workflows/deploy.yml@v1.19
|
uses: nova/.github/workflows/deploy.yml@v1.19
|
||||||
with:
|
with:
|
||||||
contract: .nova/contract.yml
|
contract: .nova/contract.yml
|
||||||
|
environment: dev
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the entire consumer-side workflow. When you push to `main`:
|
That is the entire consumer-side workflow. When you push to `main`:
|
||||||
@@ -229,7 +230,7 @@ flowchart TD
|
|||||||
S5["policy checks<br/>(adapter -> PolicyCheckResult)"] --> S6
|
S5["policy checks<br/>(adapter -> PolicyCheckResult)"] --> S6
|
||||||
S6["confidence<br/>score + band (dev >= 0.50)"] --> S7
|
S6["confidence<br/>score + band (dev >= 0.50)"] --> S7
|
||||||
S7["evidence event<br/>to the audit outbox"] --> S8
|
S7["evidence event<br/>to the audit outbox"] --> S8
|
||||||
S8["infrastructure apply<br/>(dev only)"]
|
S8["infrastructure apply<br/>(autonomous in dev;<br/>higher envs apply after HITL)"]
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **validate-contract** — validates your contract YAML against the contract
|
1. **validate-contract** — validates your contract YAML against the contract
|
||||||
@@ -250,9 +251,10 @@ flowchart TD
|
|||||||
threshold is ≥ 0.50. If the band is `pass`, the pipeline proceeds.
|
threshold is ≥ 0.50. If the band is `pass`, the pipeline proceeds.
|
||||||
7. **evidence event** — a hash-chained evidence event is written to the
|
7. **evidence event** — a hash-chained evidence event is written to the
|
||||||
audit outbox.
|
audit outbox.
|
||||||
8. **infrastructure apply** (dev only) — the infrastructure plan is applied,
|
8. **infrastructure apply** (autonomous in dev; higher environments apply
|
||||||
creating the resources in your AWS account. An evidence event for the
|
after HITL attestation) — the infrastructure plan is applied, creating
|
||||||
apply is recorded.
|
the resources in your AWS account. An evidence event for the apply is
|
||||||
|
recorded.
|
||||||
|
|
||||||
## Step 6 — What gets created
|
## Step 6 — What gets created
|
||||||
|
|
||||||
@@ -289,7 +291,14 @@ push your container image to the ECR repo the platform created.
|
|||||||
|
|
||||||
## Step 8 — Promote to qa / prod
|
## Step 8 — Promote to qa / prod
|
||||||
|
|
||||||
Change `environment` in your contract (the infrastructure stays the same):
|
There are **two supported promotion shapes**. Both are valid; pick the one
|
||||||
|
that fits your repo's workflow.
|
||||||
|
|
||||||
|
### Shape A — edit the environment field (destroy-then-rebuild)
|
||||||
|
|
||||||
|
Change `environment` in your contract (the infrastructure stays the same).
|
||||||
|
The contract `id` stays stable, so the platform knows this is the same
|
||||||
|
stack moving to a new environment:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
id: assets
|
id: assets
|
||||||
@@ -301,10 +310,32 @@ infrastructure:
|
|||||||
inputs: { ... }
|
inputs: { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**What happens when you change `environment: dev` → `environment: qa`:**
|
||||||
|
the platform detects that the environment changed on a known contract `id`.
|
||||||
|
Before building the new environment, it **destroys the prior environment's
|
||||||
|
resources** (Terraform state key `spike/{id}/dev/`) and records an evidence
|
||||||
|
event for the destroy. Only then does it apply the new environment (state
|
||||||
|
key `spike/{id}/qa/`). **There is no orphan path** — if the destroy fails,
|
||||||
|
the pipeline fails closed (no apply runs, no resources are left behind).
|
||||||
|
This is full lifecycle management: the platform never creates a state
|
||||||
|
where prior-environment resources are abandoned.
|
||||||
|
|
||||||
Higher environments require human attestation (a platform-runner deployment
|
Higher environments require human attestation (a platform-runner deployment
|
||||||
approval) and higher confidence thresholds. See [Environments](environments/)
|
approval) and higher confidence thresholds. See [Environments](environments/)
|
||||||
for the full table.
|
for the full table.
|
||||||
|
|
||||||
|
> **Note:** the destroy-then-rebuild runs within the same AWS account (the
|
||||||
|
> current platform scaffold uses one account). Cross-account promotion
|
||||||
|
> (separate accounts per env) is a future milestone.
|
||||||
|
|
||||||
|
### Shape B — per-environment caller workflows (no editing)
|
||||||
|
|
||||||
|
Alternatively, keep one contract per environment (or one contract + the
|
||||||
|
`environment` workflow input) and run the matching CI job to promote. This
|
||||||
|
avoids the destroy step because each environment has its own state from the
|
||||||
|
first deploy. See [Per-environment deployment](#per-environment-deployment)
|
||||||
|
below for the full pattern.
|
||||||
|
|
||||||
## Step 9 — Compliance extensions
|
## Step 9 — Compliance extensions
|
||||||
|
|
||||||
Each module lists compliance extension points for the future compliance
|
Each module lists compliance extension points for the future compliance
|
||||||
@@ -326,8 +357,8 @@ per-module extension points. Common examples:
|
|||||||
| Contract schema | `schemas/contract.schema.json` | JSON Schema for consumer contracts. |
|
| Contract schema | `schemas/contract.schema.json` | JSON Schema for consumer contracts. |
|
||||||
| Stack schema | `schemas/stack.schema.json` | JSON Schema for the resolved stack instance. |
|
| Stack schema | `schemas/stack.schema.json` | JSON Schema for the resolved stack instance. |
|
||||||
| Module catalog | [modules/](modules/) | All primitives and modules. |
|
| Module catalog | [modules/](modules/) | All primitives and modules. |
|
||||||
| Sample contract | `contracts/static-assets.yaml` | The reference example contract (uses `@v1.19`). |
|
| Sample contract | `contracts/static-assets.yml` | The reference example contract (used with caller workflow `@v1.19`). |
|
||||||
| Sample contract | `contracts/microservice.yaml` | The microservice example contract (uses `@v1.19`). |
|
| Sample contract | `contracts/microservice.yml` | The microservice example contract (used with caller workflow `@v1.19`). |
|
||||||
| Module examples | `modules/<name>/examples/` | Validated per-module example contracts (`simple.yaml` + `complex.yaml`). |
|
| Module examples | `modules/<name>/examples/` | Validated per-module example contracts (`simple.yaml` + `complex.yaml`). |
|
||||||
| Contract resolver | `core/contract_resolver.py` | Resolves contracts to stack instances. |
|
| Contract resolver | `core/contract_resolver.py` | Resolves contracts to stack instances. |
|
||||||
| Angine adapter | `adapters/terraform/adapter.py` | Compiles stack instances to infrastructure. |
|
| Angine adapter | `adapters/terraform/adapter.py` | Compiles stack instances to infrastructure. |
|
||||||
@@ -395,6 +426,11 @@ separately (or left running to monitor the decommissioned stack's
|
|||||||
endpoints going dark).
|
endpoints going dark).
|
||||||
## Per-environment deployment
|
## Per-environment deployment
|
||||||
|
|
||||||
|
> **This is Shape B** (the alternative to [Shape A's edit-and-destroy
|
||||||
|
> path](#step-8--promote-to-qa--prod) in Step 8). Shape B avoids the
|
||||||
|
> destroy step because each environment has its own state from the first
|
||||||
|
> deploy — no prior environment to tear down.
|
||||||
|
|
||||||
Nova supports a **promotion-without-editing** model: you do not edit the
|
Nova supports a **promotion-without-editing** model: you do not edit the
|
||||||
`environment:` field in a contract to promote dev → qa → prod → dr.
|
`environment:` field in a contract to promote dev → qa → prod → dr.
|
||||||
Instead, there is **one CI job per environment**, each pointing at its
|
Instead, there is **one CI job per environment**, each pointing at its
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ stream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CONTRACT_ID="${NOVA_CONTRACT_ID:-11111111-1111-1111-1111-111111111111}" # spike UUID (override via NOVA_CONTRACT_ID)
|
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}"
|
WORK="${NOVA_WORK_DIR:-/tmp/nova_platform_run}"
|
||||||
TF_DIR="$WORK/tf"
|
TF_DIR="$WORK/tf"
|
||||||
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
||||||
@@ -238,6 +239,82 @@ else
|
|||||||
}
|
}
|
||||||
fi
|
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 ==="
|
echo "=== Step 1: validate contract against contract.schema.json ==="
|
||||||
[ -f "$CONTRACT" ] || fail "contract file $CONTRACT missing"
|
[ -f "$CONTRACT" ] || fail "contract file $CONTRACT missing"
|
||||||
python3 -c "
|
python3 -c "
|
||||||
@@ -368,6 +445,10 @@ if [ "$APPLY_ONLY" = "1" ]; then
|
|||||||
echo "--- terraform outputs ---"
|
echo "--- terraform outputs ---"
|
||||||
terraform output -json 2>/dev/null || true
|
terraform output -json 2>/dev/null || true
|
||||||
cd "$ROOT"
|
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 ""
|
||||||
echo "=== PLATFORM APPLY OK ==="
|
echo "=== PLATFORM APPLY OK ==="
|
||||||
exit 0
|
exit 0
|
||||||
@@ -523,6 +604,11 @@ echo ""
|
|||||||
# G-112: sourced (shared env) — the block references CONTRACT/WORK/DEPLOY_UPTIME.
|
# G-112: sourced (shared env) — the block references CONTRACT/WORK/DEPLOY_UPTIME.
|
||||||
source "$ROOT/scripts/run_uptime.sh"
|
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 ""
|
||||||
echo "=== PLATFORM E2E OK ==="
|
echo "=== PLATFORM E2E OK ==="
|
||||||
echo "contract -> resolver -> stack -> Checkov(static) -> terraform plan -> Wiz-or-Checkov(plan) -> confidence ($BAND) -> outbox -> outputs"
|
echo "contract -> resolver -> stack -> Checkov(static) -> terraform plan -> Wiz-or-Checkov(plan) -> confidence ($BAND) -> outbox -> outputs"
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
"""REQ-106: consumer guide documents per-env caller workflows."""
|
"""REQ-106 + REQ-290: consumer guide documents both promotion shapes.
|
||||||
|
|
||||||
|
Shape A (Step 8): edit the environment field → platform destroys the prior
|
||||||
|
env before building the new env (no orphan path).
|
||||||
|
Shape B (Per-environment deployment): per-env caller workflows, no field
|
||||||
|
editing, promotion = running the matching job.
|
||||||
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -43,6 +49,28 @@ def test_consumer_guide_has_interpolation_reference():
|
|||||||
assert "${contract.module}" not in text
|
assert "${contract.module}" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_consumer_guide_states_no_field_editing():
|
def test_consumer_guide_documents_both_promotion_shapes():
|
||||||
|
"""REQ-290: the guide documents both Shape A (edit + destroy) and
|
||||||
|
Shape B (per-env caller workflows). Replaces the old
|
||||||
|
test_consumer_guide_states_no_field_editing which asserted only
|
||||||
|
Shape B."""
|
||||||
text = GUIDE.read_text()
|
text = GUIDE.read_text()
|
||||||
assert "no" in text.lower() and "environment" in text.lower() and "editing" in text.lower()
|
# Shape B: per-env caller workflows, no field editing
|
||||||
|
assert "Per-environment deployment" in text
|
||||||
|
assert "promotion-without-editing" in text.lower() or "promotion = running the matching job" in text.lower()
|
||||||
|
# Shape A: edit environment field (Step 8 documents this as a valid path)
|
||||||
|
assert "Shape A" in text or "Shape B" in text
|
||||||
|
assert "edit the environment field" in text.lower() or "change `environment`" in text.lower() or "change \"environment\"" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_consumer_guide_documents_destroy_on_env_change():
|
||||||
|
"""REQ-290: the guide states the platform destroys the prior env's
|
||||||
|
resources when the environment field is changed, and that there is no
|
||||||
|
orphan path."""
|
||||||
|
text = GUIDE.read_text()
|
||||||
|
text_lower = text.lower()
|
||||||
|
# The guide must state the platform destroys the prior environment
|
||||||
|
assert "destroy" in text_lower and ("prior environment" in text_lower or "prior env" in text_lower)
|
||||||
|
# The guide must state there is no orphan path / fail closed
|
||||||
|
assert "no orphan path" in text_lower or "orphan" in text_lower
|
||||||
|
assert "fail closed" in text_lower or "fails closed" in text_lower
|
||||||
Reference in New Issue
Block a user