Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab7171236a | |||
| fe635c17d5 |
@@ -1,19 +1,30 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"phase": 4,
|
||||
"stage": "complete",
|
||||
"milestone": "v1.24",
|
||||
"phase_role": "pre_execution",
|
||||
"phase_role": "final",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-12T02:20:00Z",
|
||||
"updated_at": "2026-08-12T02:45:00Z",
|
||||
"project": "acdl",
|
||||
"milestone_complete": false,
|
||||
"tag": "v1.23.0",
|
||||
"milestone_complete": true,
|
||||
"tag": "v1.23.4",
|
||||
"tag_line": "v1.23.x",
|
||||
"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"],
|
||||
"release": {
|
||||
"forge": "gitea",
|
||||
"release_id": 635,
|
||||
"tag": "v1.23.0"
|
||||
"releases_created": true,
|
||||
"release_ids": {
|
||||
"v1.23.0": 635,
|
||||
"v1.23.1": 636,
|
||||
"v1.23.2": 637,
|
||||
"v1.23.3": 638
|
||||
},
|
||||
"milestone_release_tag": "v1.23.4"
|
||||
},
|
||||
"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"]
|
||||
"tests": {
|
||||
"total": 287,
|
||||
"passed": 287,
|
||||
"failed": 0
|
||||
},
|
||||
"notes": "v1.24 milestone complete. Tag v1.23.4 (milestone release). 15 requirements complete (REQ-276..290). 4 phases (P0 pre-execution + P1 docs + P2 feat + P3 tests + P4 final). 287 tests pass. Consumer guide accuracy fixes + env-transition detect-and-destroy (no orphan path)."
|
||||
}
|
||||
+15
-15
@@ -2181,18 +2181,18 @@ assert 20 main + 1 appendix.
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| REQ-276 | P1 | pending |
|
||||
| REQ-277 | P1 | pending |
|
||||
| REQ-278 | P1 | pending |
|
||||
| REQ-279 | P1 | pending |
|
||||
| REQ-280 | P1 | pending |
|
||||
| REQ-281 | P1 | pending |
|
||||
| REQ-282 | P2 | pending |
|
||||
| REQ-283 | P2 | pending |
|
||||
| REQ-284 | P2 | pending |
|
||||
| REQ-285 | P2 | pending |
|
||||
| REQ-286 | P2 | pending |
|
||||
| REQ-287 | P2 | pending |
|
||||
| REQ-288 | P3 | pending |
|
||||
| REQ-289 | P3 | pending |
|
||||
| REQ-290 | P3 | pending |
|
||||
| REQ-276 | P1 | complete |
|
||||
| REQ-277 | P1 | complete |
|
||||
| REQ-278 | P1 | complete |
|
||||
| REQ-279 | P1 | complete |
|
||||
| REQ-280 | P1 | complete |
|
||||
| REQ-281 | P1 | complete |
|
||||
| REQ-282 | P2 | complete |
|
||||
| REQ-283 | P2 | complete |
|
||||
| REQ-284 | P2 | complete |
|
||||
| REQ-285 | P2 | complete |
|
||||
| REQ-286 | P2 | complete |
|
||||
| REQ-287 | P2 | complete |
|
||||
| REQ-288 | P3 | complete |
|
||||
| REQ-289 | P3 | complete |
|
||||
| REQ-290 | P1 | complete |
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
- **v1.13.2 (complete, tag `v1.13.2`):** presentation badge cleanup + platform architecture diagram — removed all `testing`/`agentic` maturity badges from both decks (only `planned` retained); added a new Slide 3 "The platform at a glance" with a shared high-level logical architecture diagram (consumer surfaces → contract → central pipeline → cross-cutting components → AWS) to both decks; renumbered subsequent slides 4–11; synced talking points + README. Docs-only NFR patch (no code changes).
|
||||
- **v1.0 demo URL:** https://git.cloudinit.dev/continuous-intelligence/acdl-evidence/raw/branch/main/index.html
|
||||
- **v1.23 (complete, tag `v1.22.6`):** Nova Deck Cleanup & Python PPTX — consolidated the deck to a single source-of-truth `*-marp.md` (deleted the plain `.md`; speaker notes + talking points embedded as Marp HTML comments); restored the clean S&P visual style (Marp `default` theme + inline `style:` block, matching the old `the-developer-experience.html`); retired `nova-sp-theme.css` from the render path (kept as reference); base64-inlined all images in the HTML for redistribution (`scripts/inline_images.py`); built a parallel structured editable S&P-themed PPTX generator (`scripts/render_pptx.py` via `python-pptx`); restyled benefit callouts (`<div class="benefit">`); targeted ~20-30% word-count trim on 8 verbose slides; removed the term "penetrate" repo-wide. 13 requirements (REQ-263..275), 6 phases. 43 tests pass.
|
||||
- **v1.24 (active):** Consumer Guide Accuracy & Env-Promotion Lifecycle Enforcement — fixes 5 consumer-guide accuracy issues (stale contract-fields table, inconsistent caller examples, misleading "dev only" apply phrasing, Step 8 promotion contradicts the per-env section, stale `@v1.19` reference wording) and adds platform-enforced destroy-on-environment-change: when a consumer edits `environment:` on a stable `contract.id` (Shape A promotion), the platform detects the change via the `nova-contracts` DynamoDB table, destroys the prior env's Terraform state (`spike/{id}/{prior_env}/`) before building the new env, and fails closed if the destroy fails (no orphan path). The per-environment caller-workflow path (Shape B) remains supported. New `core/env_transition.py` module. 15 requirements (REQ-276..290), 4 phases. Feature milestone; tags on v1.23.x line.
|
||||
- **v1.24 (complete, tag `v1.23.4`):** Consumer Guide Accuracy & Env-Promotion Lifecycle Enforcement — fixes 5 consumer-guide accuracy issues (stale contract-fields table, inconsistent caller examples, misleading "dev only" apply phrasing, Step 8 promotion contradicts the per-env section, stale `@v1.19` reference wording) and adds platform-enforced destroy-on-environment-change: when a consumer edits `environment:` on a stable `contract.id` (Shape A promotion), the platform detects the change via the `nova-contracts` DynamoDB table, destroys the prior env's Terraform state (`spike/{id}/{prior_env}/`) before building the new env, and fails closed if the destroy fails (no orphan path). The per-environment caller-workflow path (Shape B) remains supported. New `core/env_transition.py` module. 15 requirements (REQ-276..290), 4 phases. 287 tests pass. Feature milestone; tags on v1.23.x line.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user