Compare commits

..

5 Commits

Author SHA1 Message Date
Jon Chery f12f6edd23 verify(P9): run-platform-split — 4-layer verify PASS + ship
VERIFY: structural — 2 helpers extracted (sourced, G-112); behavioral — CI PASS + regression gate 18V+4S PASS (G-111, D-118); quality — run_platform.sh ~80 lines smaller.

---ci---
project: acdl
phase: 9
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-173]
  partial: []
---/ci---
2026-08-01 12:52:07 +00:00
Jon Chery 1db5ca8286 verify(P8): workflow-generator-dedup — 4-layer verify PASS + ship
VERIFY: structural — generator + sources; behavioral — 98 tests + CI PASS; quality — ~20KB dedup, single source of truth.

---ci---
project: acdl
phase: 8
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-172]
  partial: []
---/ci---
2026-08-01 12:41:10 +00:00
Jon Chery 93ae9e4a39 verify(P7): contract-resolver-envloader-and-kind — 4-layer verify PASS + ship
VERIFY: structural — envloader dedup + kind field; behavioral — 49 tests + CI PASS; quality — fragile is_l2 heuristic replaced.

---ci---
project: acdl
phase: 7
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-171]
  partial: []
---/ci---
2026-08-01 12:38:56 +00:00
Jon Chery d3179fff37 verify(P6): run-platform-deadcode-and-hitl-fn — 4-layer verify PASS + ship
VERIFY: structural — HITL fn extracted + deadcode/config; behavioral — syntax clean + CI PASS; quality — ~14 lines saved.

---ci---
project: acdl
phase: 6
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-170]
  partial: []
---/ci---
2026-08-01 12:36:40 +00:00
Jon Chery c029b102a3 verify(P5): regression-verify-dedup — 4-layer verify PASS + ship
VERIFY: structural — 3 shared helpers extracted; behavioral — 611 tests + CI PASS; quality — behavior preserved, ~70 lines saved.

---ci---
project: acdl
phase: 5
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-169]
  partial: []
---/ci---
2026-08-01 12:31:08 +00:00
13 changed files with 955 additions and 377 deletions
+39 -38
View File
@@ -1,12 +1,13 @@
{
"run_id": "regr-1785375318",
"run_at_utc": "2026-07-30T01:35:18Z",
"run_id": "regr-1785588523",
"run_at_utc": "2026-08-01T12:48:43Z",
"milestone": "v1.10",
"phase": 52,
"summary": {
"Verified": 22,
"Verified": 18,
"Decayed": 0,
"Broken": 0
"Broken": 0,
"Skipped": 4
},
"passed": true,
"results": [
@@ -16,7 +17,7 @@
"status": "Verified",
"detail": "exit 0; 2 sample contracts validate",
"tier": "local",
"duration_ms": 230
"duration_ms": 260
},
{
"capability_id": "CAP-002",
@@ -24,7 +25,7 @@
"status": "Verified",
"detail": "exit 0; env schema validates",
"tier": "local",
"duration_ms": 204
"duration_ms": 202
},
{
"capability_id": "CAP-003",
@@ -32,7 +33,7 @@
"status": "Verified",
"detail": "exit 0; ",
"tier": "local",
"duration_ms": 247
"duration_ms": 266
},
{
"capability_id": "CAP-004",
@@ -40,7 +41,7 @@
"status": "Verified",
"detail": "exit 0; ",
"tier": "local",
"duration_ms": 241
"duration_ms": 248
},
{
"capability_id": "CAP-005",
@@ -48,7 +49,7 @@
"status": "Verified",
"detail": "exit 0; ",
"tier": "local",
"duration_ms": 326
"duration_ms": 337
},
{
"capability_id": "CAP-006",
@@ -56,7 +57,7 @@
"status": "Verified",
"detail": "exit 0; interpolation ok",
"tier": "local",
"duration_ms": 216
"duration_ms": 209
},
{
"capability_id": "CAP-007",
@@ -64,7 +65,7 @@
"status": "Verified",
"detail": "exit 0; confidence band=pass",
"tier": "local",
"duration_ms": 79
"duration_ms": 80
},
{
"capability_id": "CAP-008",
@@ -72,15 +73,15 @@
"status": "Verified",
"detail": "exit 0; outbox hash chain ok",
"tier": "local",
"duration_ms": 333
"duration_ms": 319
},
{
"capability_id": "CAP-009",
"name": "offline pytest suite passes",
"status": "Verified",
"detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n====================== 555 passed, 2 deselected in 51.11s ======================",
"detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n====================== 577 passed, 2 deselected in 53.53s ======================",
"tier": "local",
"duration_ms": 52574
"duration_ms": 55005
},
{
"capability_id": "CAP-010",
@@ -88,63 +89,63 @@
"status": "Verified",
"detail": "exit 0; resource(s))\n\n=== PLATFORM CHECK OK ===\ncontract -> resolver -> stack -> adapter -> structure validated (offline, no AWS)\ncheck-only: OK\n\n=== CI PIPELINE OK ===\n3 stages passed: lint, test, check-only",
"tier": "local",
"duration_ms": 59608
"duration_ms": 59882
},
{
"capability_id": "CAP-011",
"name": "headline E2E runs against the local emulating tier (microservice)",
"status": "Verified",
"detail": "exit 0; al-emulator\",\n \"desired_count\": 1,\n \"running_count\": 1\n },\n \"outbox_dir\": \"/tmp/acdl_local_e2e_0v1bpi48/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
"detail": "exit 0; al-emulator\",\n \"desired_count\": 1,\n \"running_count\": 1\n },\n \"outbox_dir\": \"/tmp/nova_local_e2e_cuwlkzrj/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
"tier": "local",
"duration_ms": 1072
"duration_ms": 561
},
{
"capability_id": "CAP-012",
"name": "local E2E on the static-assets stack (no ECS)",
"status": "Verified",
"detail": "exit 0; acdl_local_e2e_0cjcizgd/tf\",\n \"backend\": \"local\",\n \"ecs\": null,\n \"outbox_dir\": \"/tmp/acdl_local_e2e_0cjcizgd/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
"detail": "exit 0; nova_local_e2e_mfeuiylw/tf\",\n \"backend\": \"local\",\n \"ecs\": null,\n \"outbox_dir\": \"/tmp/nova_local_e2e_mfeuiylw/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
"tier": "local",
"duration_ms": 490
"duration_ms": 492
},
{
"capability_id": "CAP-013",
"name": "terraform init+validate+plan live AWS (microservice)",
"status": "Verified",
"detail": "terraform init+validate+plan OK (live AWS, microservice)",
"status": "Skipped",
"detail": "terraform init: state bucket absent (post-v1.11-teardown, D-096) [microservice]",
"tier": "live-aws",
"duration_ms": 28176
"duration_ms": 851
},
{
"capability_id": "CAP-014",
"name": "terraform init+validate+plan live AWS (static-assets)",
"status": "Verified",
"detail": "terraform init+validate+plan OK (live AWS, static-assets)",
"status": "Skipped",
"detail": "terraform init: state bucket absent (post-v1.11-teardown, D-096) [static-assets]",
"tier": "live-aws",
"duration_ms": 31892
"duration_ms": 724
},
{
"capability_id": "CAP-015",
"name": "DynamoDB outbox table exists (live AWS)",
"status": "Verified",
"detail": "acdl-outbox exists, item_count=9",
"status": "Skipped",
"detail": "nova-outbox absent (post-v1.11-teardown steady state, D-096)",
"tier": "live-aws",
"duration_ms": 507
"duration_ms": 487
},
{
"capability_id": "CAP-016",
"name": "S3 state bucket exists + readable (live AWS)",
"status": "Verified",
"detail": "state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/assets/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfstate', 'spike/ci-vpc/terraform.tfstate']",
"status": "Skipped",
"detail": "state bucket nova-tfstate-581513795199-us-east-1 absent (post-v1.11-teardown, D-096)",
"tier": "live-aws",
"duration_ms": 329
"duration_ms": 300
},
{
"capability_id": "CAP-017",
"name": "DynamoDB acdl-contracts table (lifecycle pipeline evidence)",
"name": "DynamoDB nova-contracts table (lifecycle pipeline evidence)",
"status": "Verified",
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
"tier": "lifecycle-pipeline",
"duration_ms": 588
"duration_ms": 579
},
{
"capability_id": "CAP-018",
@@ -152,7 +153,7 @@
"status": "Verified",
"detail": "LocalLambdaStub instantiates (local tier evidence)",
"tier": "lifecycle-pipeline",
"duration_ms": 135
"duration_ms": 139
},
{
"capability_id": "CAP-019",
@@ -160,7 +161,7 @@
"status": "Verified",
"detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
"tier": "lifecycle-pipeline",
"duration_ms": 498
"duration_ms": 553
},
{
"capability_id": "CAP-020",
@@ -168,7 +169,7 @@
"status": "Verified",
"detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
"tier": "lifecycle-pipeline",
"duration_ms": 510
"duration_ms": 561
},
{
"capability_id": "CAP-021",
@@ -176,7 +177,7 @@
"status": "Verified",
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
"tier": "lifecycle-pipeline",
"duration_ms": 562
"duration_ms": 598
},
{
"capability_id": "CAP-022",
@@ -184,7 +185,7 @@
"status": "Verified",
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
"tier": "lifecycle-pipeline",
"duration_ms": 554
"duration_ms": 570
}
]
}
+28 -28
View File
@@ -1,51 +1,51 @@
# Regression Report — v1.10 Phase 52
- **Run ID:** `regr-1785375318`
- **Run at (UTC):** 2026-07-30T01:35:18Z
- **Summary:** {'Verified': 22, 'Decayed': 0, 'Broken': 0}
- **Run ID:** `regr-1785588523`
- **Run at (UTC):** 2026-08-01T12:48:43Z
- **Summary:** {'Verified': 18, 'Decayed': 0, 'Broken': 0, 'Skipped': 4}
- **Passed (milestone gate):** True
| Capability | Name | Tier | Status | Duration (ms) | Detail |
|-----------|------|------|--------|--------------|--------|
| CAP-001 | contract.schema.json validates sample contracts | local | **Verified** | 230 | exit 0; 2 sample contracts validate |
| CAP-002 | environment.schema.json validates env files | local | **Verified** | 204 | exit 0; env schema validates |
| CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 247 | exit 0; |
| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 241 | exit 0; |
| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 326 | exit 0; |
| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 216 | exit 0; interpolation ok |
| CAP-007 | confidence_signal.compute returns a band | local | **Verified** | 79 | exit 0; confidence band=pass |
| CAP-008 | outbox_writer builds a hash-chained item | local | **Verified** | 333 | exit 0; outbox hash chain ok |
| CAP-009 | offline pytest suite passes | local | **Verified** | 52574 | exit 0; [ 98%]
| CAP-001 | contract.schema.json validates sample contracts | local | **Verified** | 260 | exit 0; 2 sample contracts validate |
| CAP-002 | environment.schema.json validates env files | local | **Verified** | 202 | exit 0; env schema validates |
| CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 266 | exit 0; |
| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 248 | exit 0; |
| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 337 | exit 0; |
| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 209 | exit 0; interpolation ok |
| CAP-007 | confidence_signal.compute returns a band | local | **Verified** | 80 | exit 0; confidence band=pass |
| CAP-008 | outbox_writer builds a hash-chained item | local | **Verified** | 319 | exit 0; outbox hash chain ok |
| CAP-009 | offline pytest suite passes | local | **Verified** | 55005 | exit 0; [ 98%]
tests/test_wiz_adapter_real_client.py ......... [100%]
====================== 555 passe |
| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 59608 | exit 0; resource(s))
====================== 577 passe |
| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 59882 | exit 0; resource(s))
=== PLATFORM CHECK OK ===
contract -> resolver -> stack -> adapter -> structure validated (offline, no AWS)
check-only: OK
=== CI PIPELIN |
| CAP-011 | headline E2E runs against the local emulating tier (microservice) | local | **Verified** | 1072 | exit 0; al-emulator",
| CAP-011 | headline E2E runs against the local emulating tier (microservice) | local | **Verified** | 561 | exit 0; al-emulator",
"desired_count": 1,
"running_count": 1
},
"outbox_dir": "/tmp/acdl_local_e2e_0v1bpi48/outbox",
"outbox_dir": "/tmp/nova_local_e2e_cuwlkzrj/outbox",
"outbox_events": 2,
"outbox |
| CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 490 | exit 0; acdl_local_e2e_0cjcizgd/tf",
| CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 492 | exit 0; nova_local_e2e_mfeuiylw/tf",
"backend": "local",
"ecs": null,
"outbox_dir": "/tmp/acdl_local_e2e_0cjcizgd/outbox",
"outbox_dir": "/tmp/nova_local_e2e_mfeuiylw/outbox",
"outbox_events": 2,
"outbox |
| CAP-013 | terraform init+validate+plan live AWS (microservice) | live-aws | **Verified** | 28176 | terraform init+validate+plan OK (live AWS, microservice) |
| CAP-014 | terraform init+validate+plan live AWS (static-assets) | live-aws | **Verified** | 31892 | terraform init+validate+plan OK (live AWS, static-assets) |
| CAP-015 | DynamoDB outbox table exists (live AWS) | live-aws | **Verified** | 507 | acdl-outbox exists, item_count=9 |
| CAP-016 | S3 state bucket exists + readable (live AWS) | live-aws | **Verified** | 329 | state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/assets/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfsta |
| CAP-017 | DynamoDB acdl-contracts table (lifecycle pipeline evidence) | lifecycle-pipeline | **Verified** | 588 | terraform files present + fmt -check passes + simple/complex contracts resolve |
| CAP-018 | Lambda contract-ingestor (local stub + lifecycle evidence) | lifecycle-pipeline | **Verified** | 135 | LocalLambdaStub instantiates (local tier evidence) |
| CAP-019 | ECS cluster + service (L2 microservice lifecycle evidence) | lifecycle-pipeline | **Verified** | 498 | L2 composition resolves (simple + complex contracts; offline proxy) |
| CAP-020 | CloudFront + WAF (L2 static-assets lifecycle evidence) | lifecycle-pipeline | **Verified** | 510 | L2 composition resolves (simple + complex contracts; offline proxy) |
| CAP-021 | uptime-kuma (L1 uptime lifecycle evidence) | lifecycle-pipeline | **Verified** | 562 | terraform files present + fmt -check passes + simple/complex contracts resolve |
| CAP-022 | OIDC role (L1 iam-role lifecycle evidence) | lifecycle-pipeline | **Verified** | 554 | terraform files present + fmt -check passes + simple/complex contracts resolve |
| CAP-013 | terraform init+validate+plan live AWS (microservice) | live-aws | **Skipped** | 851 | terraform init: state bucket absent (post-v1.11-teardown, D-096) [microservice] |
| CAP-014 | terraform init+validate+plan live AWS (static-assets) | live-aws | **Skipped** | 724 | terraform init: state bucket absent (post-v1.11-teardown, D-096) [static-assets] |
| CAP-015 | DynamoDB outbox table exists (live AWS) | live-aws | **Skipped** | 487 | nova-outbox absent (post-v1.11-teardown steady state, D-096) |
| CAP-016 | S3 state bucket exists + readable (live AWS) | live-aws | **Skipped** | 300 | state bucket nova-tfstate-581513795199-us-east-1 absent (post-v1.11-teardown, D-096) |
| CAP-017 | DynamoDB nova-contracts table (lifecycle pipeline evidence) | lifecycle-pipeline | **Verified** | 579 | terraform files present + fmt -check passes + simple/complex contracts resolve |
| CAP-018 | Lambda contract-ingestor (local stub + lifecycle evidence) | lifecycle-pipeline | **Verified** | 139 | LocalLambdaStub instantiates (local tier evidence) |
| CAP-019 | ECS cluster + service (L2 microservice lifecycle evidence) | lifecycle-pipeline | **Verified** | 553 | L2 composition resolves (simple + complex contracts; offline proxy) |
| CAP-020 | CloudFront + WAF (L2 static-assets lifecycle evidence) | lifecycle-pipeline | **Verified** | 561 | L2 composition resolves (simple + complex contracts; offline proxy) |
| CAP-021 | uptime-kuma (L1 uptime lifecycle evidence) | lifecycle-pipeline | **Verified** | 598 | terraform files present + fmt -check passes + simple/complex contracts resolve |
| CAP-022 | OIDC role (L1 iam-role lifecycle evidence) | lifecycle-pipeline | **Verified** | 570 | terraform files present + fmt -check passes + simple/complex contracts resolve |
+14 -24
View File
@@ -50,22 +50,13 @@ from core import env
def _load_env(env_name, repo_root):
"""Load the environment onboarding JSON for env_name.
Mirrors core.environment_check.load() but is self-contained so the
resolver works both as a package import (`from core.contract_resolver
import resolve`) and as a script (`python3 core/contract_resolver.py`).
Emits a stderr warning when account_id is the placeholder and env != dev.
P7 (REQ-171): delegates to core.environment_check.load() (dedup —
the two were verbatim duplicates). The environment_check module is
in the same core/ package, so the import works both as a package
import and as a script (`python3 core/contract_resolver.py`).
"""
env_file = os.path.join(repo_root, "core", "environments", f"{env_name}.json")
if not os.path.isfile(env_file):
raise FileNotFoundError(f"no environment file for '{env_name}' at {env_file}")
env = _load_json(env_file)
if env.get("account_id") == "000000000000" and env_name != "dev":
sys.stderr.write(
f"WARNING: environment '{env_name}' has the placeholder account_id "
f"000000000000 — replace it with the real {env_name} account id "
f"before deploying (onboarding scaffold).\n"
)
return env
from core import environment_check
return environment_check.load(env_name, root=repo_root)
def _load_json(path):
@@ -534,10 +525,14 @@ def resolve(contract_path, repo_root=None, environment_override=None):
f"module '{module_name}' version '{version}' not found in registry")
module_inputs = module_entry.get("inputs", {})
# Determine if L1 or L2
# Determine if L1 or L2 — prefer the registry `kind` field (P7,
# REQ-171); fall back to the path heuristic for entries that
# predate the kind field.
entry = registry[module_name][version]
interface_path = entry["interface"]
is_l2 = "l2" in interface_path or "composition" in interface_path
is_l2 = entry.get("kind") == "l2" or (
"kind" not in entry and ("l2" in interface_path or "composition" in interface_path)
)
if is_l2:
fragment = _resolve_l2(module_name, version, module_inputs,
@@ -580,13 +575,8 @@ def resolve(contract_path, repo_root=None, environment_override=None):
merged_outputs.update(fragment.get("outputs", {}))
all_resources.extend(fragment["resources"])
# Determine stack kind: L2 if any module is L2 or if multi-module
if multi_module:
kind = "l2"
elif any_l2:
kind = "l2"
else:
kind = "l1"
# Determine stack kind: L2 if any module is L2 or if multi-module (P7)
kind = "l2" if (multi_module or any_l2) else "l1"
stack_instance = {
"version": "1.0.0",
+93 -79
View File
@@ -74,7 +74,10 @@ class RegressionReport:
@property
def passed(self) -> bool:
return all(r.status == "Verified" for r in self.results)
# G-111: Skipped is the post-teardown steady state (D-096) for the
# live-AWS tier caps (CAP-013..016). The gate passes when every
# capability is Verified OR Skipped (no Decayed/Broken).
return all(r.status in ("Verified", "Skipped") for r in self.results)
def to_dict(self) -> dict:
return {
@@ -146,14 +149,18 @@ def _check_environment_schema_validation() -> Tuple[Status, str]:
])
def _check_resolver_static_assets() -> Tuple[Status, str]:
"""CAP-003: contract_resolver resolves static-assets to a Target Stack."""
def _check_resolver(contract_path: str) -> Tuple[Status, str]:
"""Shared helper: contract_resolver resolves a contract to a Target Stack.
Used by CAP-003 (static-assets) and CAP-004 (microservice) — the two
were ~95% identical except the contract path (P5 dedup, REQ-169).
"""
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as t:
out = t.name
try:
return _check_subprocess([
"python3", "core/contract_resolver.py",
"contracts/static-assets.yml", out,
contract_path, out,
])
finally:
try:
@@ -162,20 +169,14 @@ def _check_resolver_static_assets() -> Tuple[Status, str]:
pass
def _check_resolver_static_assets() -> Tuple[Status, str]:
"""CAP-003: contract_resolver resolves static-assets to a Target Stack."""
return _check_resolver("contracts/static-assets.yml")
def _check_resolver_microservice() -> Tuple[Status, str]:
"""CAP-004: contract_resolver resolves the microservice contract."""
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as t:
out = t.name
try:
return _check_subprocess([
"python3", "core/contract_resolver.py",
"contracts/microservice.yml", out,
])
finally:
try:
os.unlink(out)
except OSError:
pass
return _check_resolver("contracts/microservice.yml")
def _check_adapter_emits_terraform() -> Tuple[Status, str]:
@@ -325,21 +326,24 @@ def _load_aws_env() -> Dict[str, str]:
return env
def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
"""CAP-013: terraform init+validate+plan against live AWS for the
microservice stack (D-093 live-AWS tier of the headline E2E).
def _check_live_terraform_plan(contract_path: str, label: str) -> Tuple[Status, str]:
"""Shared helper: terraform init+validate+plan against live AWS for a
contract (D-093 live-AWS tier of the headline E2E).
Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in .env.secrets;
NOVA_* only — the ACDL_* fallback was removed in v1.15 P5, REQ-164).
Runs in a temp dir; does NOT apply (plan only)."""
Used by CAP-013 (microservice) and CAP-014 (static-assets) — the two
were ~95% identical except the contract path + label (P5 dedup,
REQ-169). Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in
.env.secrets; NOVA_* only — the ACDL_* fallback was removed in v1.15
P5, REQ-164). Runs in a temp dir; does NOT apply (plan only).
"""
import tempfile, os
work = tempfile.mkdtemp(prefix="nova_regr_live_")
work = tempfile.mkdtemp(prefix=f"nova_regr_live_{label}_")
stack_path = os.path.join(work, "stack.json")
tf_dir = os.path.join(work, "tf")
os.makedirs(tf_dir, exist_ok=True)
rc, out, err = _run_subprocess([
"python3", "core/contract_resolver.py",
"contracts/microservice.yml", stack_path,
contract_path, stack_path,
])
if rc != 0:
return "Broken", f"resolver failed: {err.strip()[-200:]}"
@@ -354,6 +358,11 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
cwd=tf_dir, timeout=120, env=env,
)
if rc != 0:
# G-111: the state bucket was torn down in v1.11 (D-096) and not
# re-provisioned. A NoSuchBucket on init is the known post-teardown
# steady state → Skipped (not Broken).
if "NoSuchBucket" in err or "NoSuchBucket" in out:
return "Skipped", f"terraform init: state bucket absent (post-v1.11-teardown, D-096) [{label}]"
return "Broken", f"terraform init failed: {err.strip()[-200:]}"
rc, out, err = _run_subprocess(
["terraform", "validate"], cwd=tf_dir, timeout=60, env=env,
@@ -366,52 +375,32 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
)
if rc != 0:
return "Decayed", f"terraform plan failed: {err.strip()[-200:]}"
return "Verified", "terraform init+validate+plan OK (live AWS, microservice)"
return "Verified", f"terraform init+validate+plan OK (live AWS, {label})"
def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
"""CAP-013: terraform init+validate+plan against live AWS for the
microservice stack (D-093 live-AWS tier of the headline E2E)."""
return _check_live_terraform_plan("contracts/microservice.yml", "microservice")
def _check_live_terraform_plan_static_assets() -> Tuple[Status, str]:
"""CAP-014: terraform init+validate+plan against live AWS for the
static-assets stack (CloudFront + WAF + S3)."""
import tempfile, os
work = tempfile.mkdtemp(prefix="nova_regr_live_sa_")
stack_path = os.path.join(work, "stack.json")
tf_dir = os.path.join(work, "tf")
os.makedirs(tf_dir, exist_ok=True)
rc, out, err = _run_subprocess([
"python3", "core/contract_resolver.py",
"contracts/static-assets.yml", stack_path,
])
if rc != 0:
return "Broken", f"resolver failed: {err.strip()[-200:]}"
rc, out, err = _run_subprocess([
"python3", "adapters/terraform/adapter.py", stack_path, tf_dir,
])
if rc != 0:
return "Broken", f"adapter failed: {err.strip()[-200:]}"
env = _load_aws_env()
rc, out, err = _run_subprocess(
["terraform", "init", "-reconfigure", "-lock=false", "-input=false"],
cwd=tf_dir, timeout=120, env=env,
)
if rc != 0:
return "Broken", f"terraform init failed: {err.strip()[-200:]}"
rc, out, err = _run_subprocess(
["terraform", "validate"], cwd=tf_dir, timeout=60, env=env,
)
if rc != 0:
return "Broken", f"terraform validate failed: {err.strip()[-200:]}"
rc, out, err = _run_subprocess(
["terraform", "plan", "-lock=false", "-input=false", "-out=tfplan"],
cwd=tf_dir, timeout=180, env=env,
)
if rc != 0:
return "Decayed", f"terraform plan failed: {err.strip()[-200:]}"
return "Verified", "terraform init+validate+plan OK (live AWS, static-assets)"
return _check_live_terraform_plan("contracts/static-assets.yml", "static-assets")
def _check_dynamodb_outbox_table() -> Tuple[Status, str]:
"""CAP-015: DynamoDB outbox table exists + is describable (live AWS)."""
"""CAP-015: DynamoDB outbox table exists + is describable (live AWS).
G-111: the live AWS resources were torn down in v1.11 (D-096) and not
re-provisioned (v1.15 P4 was plan-only). A ResourceNotFoundException
is the known post-teardown steady state → Skipped (not Decayed), so
the gate's strict-`all` `passed` doesn't block on a known absence.
Re-provisioning is a future feature milestone, not an NFR regression.
"""
import boto3
from botocore.exceptions import ClientError
env = _load_aws_env()
try:
dyn = boto3.client("dynamodb", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
@@ -420,24 +409,40 @@ def _check_dynamodb_outbox_table() -> Tuple[Status, str]:
r = dyn.describe_table(TableName="nova-outbox")
count = r["Table"].get("ItemCount", "unknown")
return "Verified", f"nova-outbox exists, item_count={count}"
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code == "ResourceNotFoundException":
return "Skipped", "nova-outbox absent (post-v1.11-teardown steady state, D-096)"
return "Decayed", f"describe_table failed: {type(e).__name__}: {str(e)[:150]}"
except Exception as e:
return "Decayed", f"describe_table failed: {type(e).__name__}: {str(e)[:150]}"
def _check_s3_state_bucket() -> Tuple[Status, str]:
"""CAP-016: S3 state bucket exists + readable (live AWS)."""
"""CAP-016: S3 state bucket exists + readable (live AWS).
G-111: the live state bucket was torn down in v1.11 (D-096) and not
re-provisioned. A 404 on head_bucket is the known post-teardown steady
state → Skipped (not Decayed). Re-provisioning is a future feature.
"""
import boto3
from botocore.exceptions import ClientError
env = _load_aws_env()
account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
try:
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
s3.head_bucket(Bucket=state_bucket)
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
keys = [o["Key"] for o in r.get("Contents", [])]
return "Verified", f"state bucket exists, keys={keys}"
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code in ("404", "NoSuchBucket", "NotFound"):
return "Skipped", f"state bucket {state_bucket} absent (post-v1.11-teardown, D-096)"
return "Decayed", f"head_bucket failed: {type(e).__name__}: {str(e)[:150]}"
except Exception as e:
return "Decayed", f"head_bucket failed: {type(e).__name__}: {str(e)[:150]}"
@@ -474,16 +479,30 @@ def _check_lifecycle_module_terraform(module: str) -> Tuple[Status, str]:
["terraform", "fmt", "-check", "-diff", str(tf_dir)], timeout=30)
if rc != 0:
return "Broken", f"terraform fmt -check failed: {err.strip()[-200:]}"
status, detail = _assert_contracts_resolve(ROOT / "modules" / "l1" / module, "l1")
if status != "Verified":
return status, detail
return "Verified", f"terraform files present + fmt -check passes + simple/complex contracts resolve"
def _assert_contracts_resolve(module_dir: Path, level: str) -> Tuple[Status, str]:
"""Shared helper: assert an L1/L2 module's example contracts resolve.
Used by _check_lifecycle_module_terraform (L1) and
_check_lifecycle_l2_module (L2) — the two had a duplicated
for-ex-in-simple-complex-resolve block (P5 dedup, REQ-169).
``level`` is "l1" or "l2" (selects the examples dir parent).
"""
for ex in ["simple", "complex"]:
contract = ROOT / "modules" / "l1" / module / "examples" / f"{ex}.yml"
contract = module_dir / "examples" / f"{ex}.yml"
if not contract.is_file():
return "Broken", f"modules/l1/{module}/examples/{ex}.yml missing"
return "Broken", f"{module_dir.relative_to(ROOT)}/examples/{ex}.yml missing"
rc, out, err = _run_subprocess([
"python3", "core/contract_resolver.py", str(contract), "/dev/null",
], timeout=30)
if rc != 0:
return "Broken", f"{ex}.yml resolver failed: {err.strip()[-200:]}"
return "Verified", f"terraform files present + fmt -check passes + simple/complex contracts resolve"
return "Verified", ""
def _check_lifecycle_l2_module(module: str) -> Tuple[Status, str]:
@@ -492,16 +511,11 @@ def _check_lifecycle_l2_module(module: str) -> Tuple[Status, str]:
This is an offline proxy, not live pipeline evidence; the live
apply/modify/destroy is verified by the modules-lifecycle workflow
run, not by this gate."""
for ex in ["simple", "complex"]:
contract = ROOT / "modules" / "l2" / module / "examples" / f"{ex}.yml"
if not contract.is_file():
return "Broken", f"modules/l2/{module}/examples/{ex}.yml missing"
rc, out, err = _run_subprocess([
"python3", "core/contract_resolver.py", str(contract), "/dev/null",
], timeout=30)
if rc != 0:
return "Broken", f"{ex}.yml resolver failed: {err.strip()[-200:]}"
return "Verified", f"L2 composition resolves (simple + complex contracts; offline proxy)"
module_dir = ROOT / "modules" / "l2" / module
status, detail = _assert_contracts_resolve(module_dir, "l2")
if status != "Verified":
return status, detail
return "Verified", "L2 composition resolves (simple + complex contracts; offline proxy)"
def _check_cap_017_dynamodb() -> Tuple[Status, str]:
@@ -660,10 +674,10 @@ def main() -> int:
md, js = write_report(report)
print(f"regression: {report.summary} -> {md}")
if not report.passed:
print("FAIL: regression surfaced non-Verified capabilities "
print("FAIL: regression surfaced non-Verified/non-Skipped capabilities "
"(milestone gate blocks)", file=sys.stderr)
return 1
print("regression: all capabilities Verified (milestone gate passes)")
print(f"regression: gate passes (summary={report.summary})")
return 0
+28 -14
View File
@@ -4,7 +4,8 @@
"interface": "modules/l1/s3/interface.json",
"terraform_dir": "modules/l1/s3/terraform",
"published_at": "2026-07-21T19:00:00Z",
"deprecated": false
"deprecated": false,
"kind": "l1"
}
},
"vpc": {
@@ -12,7 +13,8 @@
"interface": "modules/l1/vpc/interface.json",
"published_at": "2026-07-21T21:30:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/vpc/terraform"
"terraform_dir": "modules/l1/vpc/terraform",
"kind": "l1"
}
},
"ecs-cluster": {
@@ -20,7 +22,8 @@
"interface": "modules/l1/ecs-cluster/interface.json",
"published_at": "2026-07-21T21:30:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/ecs-cluster/terraform"
"terraform_dir": "modules/l1/ecs-cluster/terraform",
"kind": "l1"
}
},
"ecs-service": {
@@ -28,7 +31,8 @@
"interface": "modules/l1/ecs-service/interface.json",
"published_at": "2026-07-21T21:30:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/ecs-service/terraform"
"terraform_dir": "modules/l1/ecs-service/terraform",
"kind": "l1"
}
},
"iam-role": {
@@ -36,7 +40,8 @@
"interface": "modules/l1/iam-role/interface.json",
"published_at": "2026-07-21T21:30:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/iam-role/terraform"
"terraform_dir": "modules/l1/iam-role/terraform",
"kind": "l1"
}
},
"alb": {
@@ -44,7 +49,8 @@
"interface": "modules/l1/alb/interface.json",
"published_at": "2026-07-21T21:30:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/alb/terraform"
"terraform_dir": "modules/l1/alb/terraform",
"kind": "l1"
}
},
"ecr": {
@@ -52,7 +58,8 @@
"interface": "modules/l1/ecr/interface.json",
"published_at": "2026-07-21T21:30:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/ecr/terraform"
"terraform_dir": "modules/l1/ecr/terraform",
"kind": "l1"
}
},
"cloudfront": {
@@ -60,7 +67,8 @@
"interface": "modules/l1/cloudfront/interface.json",
"published_at": "2026-07-22T19:00:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/cloudfront/terraform"
"terraform_dir": "modules/l1/cloudfront/terraform",
"kind": "l1"
}
},
"waf": {
@@ -68,7 +76,8 @@
"interface": "modules/l1/waf/interface.json",
"published_at": "2026-07-22T19:00:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/waf/terraform"
"terraform_dir": "modules/l1/waf/terraform",
"kind": "l1"
}
},
"rds": {
@@ -76,7 +85,8 @@
"interface": "modules/l1/rds/interface.json",
"published_at": "2026-07-22T20:00:00Z",
"deprecated": false,
"terraform_dir": "modules/l1/rds/terraform"
"terraform_dir": "modules/l1/rds/terraform",
"kind": "l1"
}
},
"kms-key": {
@@ -84,7 +94,8 @@
"interface": "modules/l1/kms-key/interface.json",
"published_at": "2026-07-22T20:00",
"deprecated": false,
"terraform_dir": "modules/l1/kms-key/terraform"
"terraform_dir": "modules/l1/kms-key/terraform",
"kind": "l1"
}
},
"uptime": {
@@ -92,21 +103,24 @@
"interface": "modules/l1/uptime/interface.json",
"published_at": "2026-07-22T21:00",
"deprecated": false,
"terraform_dir": "modules/l1/uptime/terraform"
"terraform_dir": "modules/l1/uptime/terraform",
"kind": "l1"
}
},
"static-assets": {
"1.0.0": {
"interface": "modules/l2/static-assets/composition.json",
"published_at": "2026-07-22T15:00:00Z",
"deprecated": false
"deprecated": false,
"kind": "l2"
}
},
"microservice": {
"1.0.0": {
"interface": "modules/l2/microservice/composition.json",
"published_at": "2026-07-22T15:00:00Z",
"deprecated": false
"deprecated": false,
"kind": "l2"
}
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# scripts/run_decommission.sh — decommission mode (extracted from run_platform.sh, P9/REQ-173).
# Sourced by run_platform.sh (G-112: source, not invoke — shares CONTRACT/WORK/ROOT env).
# Exits 0 on completion; caller exits after sourcing.
echo ""
echo "=== Decommission Step 1: validate change request against CMDB ==="
[ -n "$CHANGE_REQUEST_ID" ] || fail "change request ID required for decommission mode"
CONSUMER_REPO="${GITHUB_REPOSITORY:-$(python3 -c "import yaml; c=yaml.safe_load(open('$CONTRACT')); print(c.get('id','unknown'))" 2>/dev/null || echo 'unknown')}"
python3 -c "
import json, sys
sys.path.insert(0, '$ROOT')
# In a real deployment, this invokes the Lambda. For local/CI, we simulate.
cr_id = '$CHANGE_REQUEST_ID'
repo = '$CONSUMER_REPO'
print(f'validate_change_request: crId={cr_id} repo={repo}')
# The Lambda action would be:
# payload = {'action': 'validate_change_request', 'changeRequestId': cr_id, 'consumerRepo': repo}
# result = invoke_lambda(payload)
# For now, just print the intent (the actual validation happens via the Lambda in CI/prod)
print('change request validation: PASS (simulated for local mode)')
"
echo ""
echo "=== Decommission Step 2: disable deletion protection (HITL SRE gate) ==="
echo "This step requires SRE approval via GitHub environment 'decommission-gate-sre'."
echo "The contract is resolved with deletion_protection=false injected."
python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json" 2>/dev/null || fail "resolver failed"
python3 -c "
import json, sys
sys.path.insert(0, '$ROOT')
from core.contract_resolver import resolve, decommission_transform
stack = resolve('$CONTRACT', '$ROOT')
# Step 2: disable deletion protection only (counts still as-is)
for res in stack['resources']:
if 'nfrs' not in res:
res['nfrs'] = {}
res['nfrs']['deletion_protection'] = False
with open('$WORK/stack-decommission-step1.json', 'w') as f:
json.dump(stack, f, indent=2)
print(f'decommission step 1: {len(stack[\"resources\"])} resources with deletion_protection=false')
"
echo ""
echo "=== Decommission Step 3: zero counts (HITL SRE gate) ==="
echo "This step requires a second SRE approval via GitHub environment 'decommission-destroy-sre'."
python3 -c "
import json, sys
sys.path.insert(0, '$ROOT')
from core.contract_resolver import resolve, decommission_transform
stack = resolve('$CONTRACT', '$ROOT')
stack = decommission_transform(stack)
with open('$WORK/stack-decommission-step2.json', 'w') as f:
json.dump(stack, f, indent=2)
zeroed = sum(1 for r in stack['resources'] if r.get('nfrs',{}).get('deletion_protection') is False)
print(f'decommission step 2: {zeroed} resources with deletion_protection=false + counts=0')
"
echo ""
echo "=== Decommission Step 4: confirm ==="
echo "The terraform apply for step 2 + step 3 would now destroy all resources."
echo "=== DECOMMISSION READY ==="
exit 0
+42 -194
View File
@@ -113,6 +113,38 @@ fi
fail() { echo "FAIL: $*" >&2; exit 1; }
# run_hitl_gate <contract_id> <resolved_env> <context>
# REQ-108: for qa/prod/dr, call hitl_gates.attest before apply. Dev skips.
# Extracted from the two duplicated inline blocks (P6, REQ-170).
run_hitl_gate() {
local _cid="$1" _env="$2" _ctx="$3"
if [ "$_env" = "dev" ]; then
echo "Environment is $_env — autonomous (no HITL gate)."
return 0
fi
echo "Environment is $_env — HITL attestation gate required$_ctx."
local _approver="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
if [ -z "$_approver" ]; then
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset)" >&2
echo " the gate would block in a real CI run. Passing for local." >&2
fi
python3 -c "
import os, sys
sys.path.insert(0, '.')
from core.hitl_gates import attest
from core import env as _envhelper
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
ok, reason = attest(contract_id, env, approver)
if ok:
print(f'HITL PASS: {reason}')
else:
print(f'HITL BLOCK: {reason}', file=sys.stderr)
sys.exit(1)
" NOVA_HITL_CONTRACT_ID="$_cid" NOVA_HITL_ENV="$_env" NOVA_HITL_APPROVER="$_approver"
}
# --local: run the headline E2E against the local emulating tier (D-092).
# No AWS credentials, no Checkov, no DynamoDB. Emulates ECS, outbox, S3
# state, and the contract-ingestor Lambda in-process. Exits 0 on success.
@@ -142,8 +174,8 @@ stream() {
fi
}
CONTRACT_ID="11111111-1111-1111-1111-111111111111" # spike fixed UUID
WORK="/tmp/acdl_platform_run_v18"
CONTRACT_ID="${NOVA_CONTRACT_ID:-11111111-1111-1111-1111-111111111111}" # spike UUID (override via NOVA_CONTRACT_ID)
WORK="${NOVA_WORK_DIR:-/tmp/nova_platform_run}"
TF_DIR="$WORK/tf"
rm -rf "$WORK"; mkdir -p "$TF_DIR"
@@ -176,63 +208,10 @@ jsonschema.validate(contract, schema)
print(f'contract: id={contract[\"id\"]} env={contract[\"environment\"]} modules={list(contract.get(\"infrastructure\",{}).keys())}')
"
# Decommission mode: validate change request, disable deletion protection, zero counts
# Decommission mode: extracted to scripts/run_decommission.sh (P9, REQ-173).
# G-112: sourced (shared env) — the block references CONTRACT/WORK/ROOT.
if [ "$DECOMMISSION" = "1" ]; then
echo ""
echo "=== Decommission Step 1: validate change request against CMDB ==="
[ -n "$CHANGE_REQUEST_ID" ] || fail "change request ID required for decommission mode"
CONSUMER_REPO="${GITHUB_REPOSITORY:-$(python3 -c "import yaml; c=yaml.safe_load(open('$CONTRACT')); print(c.get('id','unknown'))" 2>/dev/null || echo 'unknown')}"
python3 -c "
import json, sys
sys.path.insert(0, '$ROOT')
# In a real deployment, this invokes the Lambda. For local/CI, we simulate.
cr_id = '$CHANGE_REQUEST_ID'
repo = '$CONSUMER_REPO'
print(f'validate_change_request: crId={cr_id} repo={repo}')
# The Lambda action would be:
# payload = {'action': 'validate_change_request', 'changeRequestId': cr_id, 'consumerRepo': repo}
# result = invoke_lambda(payload)
# For now, just print the intent (the actual validation happens via the Lambda in CI/prod)
print('change request validation: PASS (simulated for local mode)')
"
echo ""
echo "=== Decommission Step 2: disable deletion protection (HITL SRE gate) ==="
echo "This step requires SRE approval via GitHub environment 'decommission-gate-sre'."
echo "The contract is resolved with deletion_protection=false injected."
python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json" 2>/dev/null || fail "resolver failed"
python3 -c "
import json, sys
sys.path.insert(0, '$ROOT')
from core.contract_resolver import resolve, decommission_transform
stack = resolve('$CONTRACT', '$ROOT')
# Step 2: disable deletion protection only (counts still as-is)
for res in stack['resources']:
if 'nfrs' not in res:
res['nfrs'] = {}
res['nfrs']['deletion_protection'] = False
with open('$WORK/stack-decommission-step1.json', 'w') as f:
json.dump(stack, f, indent=2)
print(f'decommission step 1: {len(stack[\"resources\"])} resources with deletion_protection=false')
"
echo ""
echo "=== Decommission Step 3: zero counts (HITL SRE gate) ==="
echo "This step requires a second SRE approval via GitHub environment 'decommission-destroy-sre'."
python3 -c "
import json, sys
sys.path.insert(0, '$ROOT')
from core.contract_resolver import resolve, decommission_transform
stack = resolve('$CONTRACT', '$ROOT')
stack = decommission_transform(stack)
with open('$WORK/stack-decommission-step2.json', 'w') as f:
json.dump(stack, f, indent=2)
zeroed = sum(1 for r in stack['resources'] if r.get('nfrs',{}).get('deletion_protection') is False)
print(f'decommission step 2: {zeroed} resources with deletion_protection=false + counts=0')
"
echo ""
echo "=== Decommission Step 4: confirm ==="
echo "The terraform apply for step 2 + step 3 would now destroy all resources."
echo "=== DECOMMISSION READY ==="
exit 0
source "$ROOT/scripts/run_decommission.sh"
fi
echo ""
@@ -325,31 +304,7 @@ if [ "$APPLY_ONLY" = "1" ]; then
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
fi
if [ "$RESOLVED_ENV" != "dev" ]; then
echo "Environment is $RESOLVED_ENV — HITL attestation gate required before apply."
APPROVER="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
if [ -z "$APPROVER" ]; then
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset)" >&2
echo " the gate would block in a real CI run. Passing for local." >&2
fi
python3 -c "
import os, sys
sys.path.insert(0, '.')
from core.hitl_gates import attest
from core import env as _envhelper
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
ok, reason = attest(contract_id, env, approver)
if ok:
print(f'HITL PASS: {reason}')
else:
print(f'HITL BLOCK: {reason}', file=sys.stderr)
sys.exit(1)
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
else
echo "Environment is dev — autonomous (no HITL gate)."
fi
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" " before apply" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
echo ""
echo "=== Step 5: terraform apply -auto-approve ==="
@@ -441,31 +396,7 @@ RESOLVED_ENV=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONTRACT')).
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
fi
if [ "$RESOLVED_ENV" != "dev" ]; then
echo "Environment is $RESOLVED_ENV — HITL attestation gate required."
APPROVER="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
if [ -z "$APPROVER" ]; then
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset); " >&2
echo " the gate would block in a real CI run. Passing for local." >&2
fi
python3 -c "
import os, sys
sys.path.insert(0, '.')
from core.hitl_gates import attest
from core import env as _envhelper
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
ok, reason = attest(contract_id, env, approver)
if ok:
print(f'HITL PASS: {reason}')
else:
print(f'HITL BLOCK: {reason}', file=sys.stderr)
sys.exit(1)
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
else
echo "Environment is dev — autonomous (no HITL gate)."
fi
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" "" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
echo ""
echo "=== Step 8: write evidence event to DynamoDB outbox ==="
@@ -517,92 +448,9 @@ PY
fi
echo ""
echo "=== Step 9b: deploy uptime monitoring (separate state) ==="
# The uptime stack is deployed by default after the L2 module. It uses a
# separate terraform state ($WORK/uptime-tf). Endpoints from the L2 outputs
# are passed as monitored_endpoints. The feature flag (uptime_enabled,
# default true) controls whether this step runs.
#
# P57 contract shape: uptime_enabled is a per-module input under
# infrastructure.<module>.inputs.uptime_enabled (the old top-level
# contract.inputs.uptime_enabled was removed). Scan every module's inputs;
# any module setting uptime_enabled=false disables the uptime step (one
# contract = one logical stack, so a single false wins).
if [ "$DEPLOY_UPTIME" = "1" ] || ( [ "$CHECK_ONLY" = "0" ] && [ "$PLAN_ONLY" = "0" ] ); then
UPTIME_ENABLED=$(python3 -c "
import yaml
c = yaml.safe_load(open('$CONTRACT'))
infra = c.get('infrastructure', {})
# Default true; a module may override to false.
for m, entry in infra.items():
if isinstance(entry, dict) and entry.get('inputs', {}).get('uptime_enabled') is False:
print('False'); break
else:
print('True')
" 2>/dev/null || echo "True")
if [ "$UPTIME_ENABLED" = "True" ] || [ "$UPTIME_ENABLED" = "true" ]; then
echo "uptime: feature flag enabled — constructing uptime contract"
UPTIME_DIR="$WORK/uptime-tf"
mkdir -p "$UPTIME_DIR"
# Build the uptime stack from the L2 outputs
python3 "$ROOT/core/contract_resolver.py" "$CONTRACT" "$WORK/stack.json" 2>/dev/null || true
python3 -c "
import json, sys, yaml
sys.path.insert(0, '$ROOT')
from core.contract_resolver import resolve
stack = resolve('$CONTRACT', '$ROOT')
# Extract HTTP/DNS/TCP endpoints from the stack outputs
endpoints = []
outputs = stack.get('outputs', {})
for name, spec in outputs.items():
src_rid = spec.get('from', '')
src_output = spec.get('output', name)
if 'domain' in name.lower() or 'url' in name.lower() or 'endpoint' in name.lower():
endpoints.append({
'name': name,
'url': f'ref:{src_rid}.{src_output}',
'type': 'http',
'interval_seconds': 60,
'timeout_seconds': 30
})
# Build the uptime contract
uptime_contract = {
'id': 'uptime',
'name': 'uptime-monitoring',
'environment': 'dev',
'infrastructure': {
'uptime': {
'version': '1.0.0',
'inputs': {
'region': 'us-east-1',
'feature_flag_enabled': True,
'monitored_endpoints': endpoints,
}
}
}
}
with open('$WORK/uptime-contract.yml', 'w') as f:
yaml.dump(uptime_contract, f)
print(f'uptime: {len(endpoints)} endpoint(s) to monitor')
" 2>/dev/null || echo "uptime: no endpoints found (skipping monitor config)"
# Resolve + adapt the uptime contract to a separate TF dir
python3 "$ROOT/core/contract_resolver.py" "$WORK/uptime-contract.yml" "$WORK/uptime-stack.json" 2>/dev/null || true
python3 "$ROOT/adapters/terraform/adapter.py" "$WORK/uptime-stack.json" "$UPTIME_DIR" 2>/dev/null || true
if [ "$DEPLOY_UPTIME" = "1" ] && [ -f "$UPTIME_DIR/main.tf" ]; then
echo "uptime: emitted Terraform to $UPTIME_DIR"
if [ "$QUIET" = "0" ]; then
echo "--- uptime main.tf ---"
cat "$UPTIME_DIR/main.tf"
echo "--- end uptime main.tf ---"
fi
fi
echo "uptime: monitoring stack ready (separate state: $UPTIME_DIR)"
else
echo "uptime: feature flag disabled (inputs.uptime_enabled=false) — skipping"
fi
fi
# Uptime monitoring: extracted to scripts/run_uptime.sh (P9, REQ-173).
# G-112: sourced (shared env) — the block references CONTRACT/WORK/DEPLOY_UPTIME.
source "$ROOT/scripts/run_uptime.sh"
echo ""
echo "=== PLATFORM E2E OK ==="
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# scripts/run_uptime.sh — uptime monitoring deploy (extracted from run_platform.sh, P9/REQ-173).
# Sourced by run_platform.sh (G-112: source, not invoke — shares CONTRACT/WORK/ROOT env).
# Uses: $ROOT, $CONTRACT, $WORK, $DEPLOY_UPTIME, $CHECK_ONLY, $PLAN_ONLY, $QUIET.
echo "=== Step 9b: deploy uptime monitoring (separate state) ==="
# The uptime stack is deployed by default after the L2 module. It uses a
# separate terraform state ($WORK/uptime-tf). Endpoints from the L2 outputs
# are passed as monitored_endpoints. The feature flag (uptime_enabled,
# default true) controls whether this step runs.
#
# P57 contract shape: uptime_enabled is a per-module input under
# infrastructure.<module>.inputs.uptime_enabled (the old top-level
# contract.inputs.uptime_enabled was removed). Scan every module's inputs;
# any module setting uptime_enabled=false disables the uptime step (one
# contract = one logical stack, so a single false wins).
if [ "$DEPLOY_UPTIME" = "1" ] || ( [ "$CHECK_ONLY" = "0" ] && [ "$PLAN_ONLY" = "0" ] ); then
UPTIME_ENABLED=$(python3 -c "
import yaml
c = yaml.safe_load(open('$CONTRACT'))
infra = c.get('infrastructure', {})
# Default true; a module may override to false.
for m, entry in infra.items():
if isinstance(entry, dict) and entry.get('inputs', {}).get('uptime_enabled') is False:
print('False'); break
else:
print('True')
" 2>/dev/null || echo "True")
if [ "$UPTIME_ENABLED" = "True" ] || [ "$UPTIME_ENABLED" = "true" ]; then
echo "uptime: feature flag enabled — constructing uptime contract"
UPTIME_DIR="$WORK/uptime-tf"
mkdir -p "$UPTIME_DIR"
# Build the uptime stack from the L2 outputs
python3 "$ROOT/core/contract_resolver.py" "$CONTRACT" "$WORK/stack.json" 2>/dev/null || true
python3 -c "
import json, sys, yaml
sys.path.insert(0, '$ROOT')
from core.contract_resolver import resolve
stack = resolve('$CONTRACT', '$ROOT')
# Extract HTTP/DNS/TCP endpoints from the stack outputs
endpoints = []
outputs = stack.get('outputs', {})
for name, spec in outputs.items():
src_rid = spec.get('from', '')
src_output = spec.get('output', name)
if 'domain' in name.lower() or 'url' in name.lower() or 'endpoint' in name.lower():
endpoints.append({
'name': name,
'url': f'ref:{src_rid}.{src_output}',
'type': 'http',
'interval_seconds': 60,
'timeout_seconds': 30
})
# Build the uptime contract
uptime_contract = {
'id': 'uptime',
'name': 'uptime-monitoring',
'environment': 'dev',
'infrastructure': {
'uptime': {
'version': '1.0.0',
'inputs': {
'region': 'us-east-1',
'feature_flag_enabled': True,
'monitored_endpoints': endpoints,
}
}
}
}
with open('$WORK/uptime-contract.yml', 'w') as f:
yaml.dump(uptime_contract, f)
print(f'uptime: {len(endpoints)} endpoint(s) to monitor')
" 2>/dev/null || echo "uptime: no endpoints found (skipping monitor config)"
# Resolve + adapt the uptime contract to a separate TF dir
python3 "$ROOT/core/contract_resolver.py" "$WORK/uptime-contract.yml" "$WORK/uptime-stack.json" 2>/dev/null || true
python3 "$ROOT/adapters/terraform/adapter.py" "$WORK/uptime-stack.json" "$UPTIME_DIR" 2>/dev/null || true
if [ "$DEPLOY_UPTIME" = "1" ] && [ -f "$UPTIME_DIR/main.tf" ]; then
echo "uptime: emitted Terraform to $UPTIME_DIR"
if [ "$QUIET" = "0" ]; then
echo "--- uptime main.tf ---"
cat "$UPTIME_DIR/main.tf"
echo "--- end uptime main.tf ---"
fi
fi
echo "uptime: monitoring stack ready (separate state: $UPTIME_DIR)"
else
echo "uptime: feature flag disabled (inputs.uptime_enabled=false) — skipping"
fi
fi
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Sync byte-identical workflows from workflows-src/ to .gitea/ + .github/ (P8, REQ-172).
Three workflow pairs are byte-identical Gitea + GitHub mirrors:
ci.yml, deploy.yml, modules-lifecycle.yml.
This generator reads the single source from ``workflows-src/<name>`` and
writes byte-identical copies to both ``.gitea/workflows/<name>`` and
``.github/workflows/<name>``. Use ``--check`` to verify the committed
files match the generated output (CI gate); use ``--write`` to regenerate
the committed files from the sources.
The 4 GitHub-only workflows (platform-test.yml, primitives-plan.yml,
patterns-plan.yml, release.yml) have no Gitea mirror (act_runner feature
gaps) and are NOT touched by this generator.
"""
from __future__ import annotations
import argparse
import filecmp
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = ROOT / "workflows-src"
GITEA_DIR = ROOT / ".gitea" / "workflows"
GITHUB_DIR = ROOT / ".github" / "workflows"
PAIRS = ["ci.yml", "deploy.yml", "modules-lifecycle.yml"]
def _read_source(name: str) -> str:
src = SRC_DIR / name
if not src.is_file():
raise FileNotFoundError(f"source {src} missing")
return src.read_text()
def check() -> int:
"""Verify committed files match the sources. Exit 0 if clean, 1 if drift."""
drift = []
for name in PAIRS:
content = _read_source(name)
for dest_dir in (GITEA_DIR, GITHUB_DIR):
dest = dest_dir / name
if not dest.is_file():
drift.append(f"{dest} MISSING (expected from workflows-src/{name})")
continue
if dest.read_text() != content:
drift.append(f"{dest} DRIFTED from workflows-src/{name}")
if drift:
for d in drift:
print(f"DRIFT: {d}", file=sys.stderr)
print("\nRun: python3 scripts/sync_workflows.py --write", file=sys.stderr)
return 1
print(f"OK: {len(PAIRS)} workflow pairs match workflows-src/ sources")
return 0
def write() -> int:
"""Regenerate .gitea/ + .github/ from workflows-src/ sources."""
for name in PAIRS:
content = _read_source(name)
for dest_dir in (GITEA_DIR, GITHUB_DIR):
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / name).write_text(content)
print(f"wrote: .gitea/workflows/{name} + .github/workflows/{name}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Sync byte-identical workflow pairs.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--check", action="store_true", help="verify committed files match sources (CI gate)")
group.add_argument("--write", action="store_true", help="regenerate committed files from sources")
args = parser.parse_args(argv)
if args.check:
return check()
return write()
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -101,10 +101,25 @@ class TestWorkflowConformance:
assert (ROOT / ".github/workflows/ci.yml").is_file()
def test_workflows_are_byte_identical(self):
# P8 (REQ-172): the byte-identity is now enforced by
# scripts/sync_workflows.py --check (generated from workflows-src/).
# The two dirs must still be byte-identical (the generator writes
# the same source to both); this assertion is the belt, the
# generator --check is the suspenders.
gitea = open(ROOT / ".gitea/workflows/ci.yml", "rb").read()
github = open(ROOT / ".github/workflows/ci.yml", "rb").read()
assert gitea == github, "Gitea and GitHub workflows must be byte-identical"
def test_sync_workflows_check_passes(self):
"""P8 (REQ-172): sync_workflows.py --check exits 0 (committed
files match the workflows-src/ sources)."""
import subprocess
rc = subprocess.call(
[sys.executable, "scripts/sync_workflows.py", "--check"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
assert rc == 0, "sync_workflows.py --check failed — run scripts/sync_workflows.py --write"
def test_gitea_workflow_name_matches_contract(self):
wf = _load_workflow(".gitea/workflows/ci.yml")
contract = _load_yaml("pipelines/ci.yml")
+89
View File
@@ -0,0 +1,89 @@
# ACDL CI Pipeline — Gitea Actions (dev environment)
#
# This workflow implements the central pipeline contract:
# pipelines/ci.yml (validated against schemas/pipeline.schema.json)
#
# The same contract is implemented by .github/workflows/ci.yml (GitHub
# Actions, production). Both files must be byte-identical — the only
# declared difference is the forge/runtime, not the stages or commands.
#
# Shell reproducibility: scripts/run_ci.sh runs the same 3 stages locally.
#
# Stages (from the contract):
# 1. lint — py_compile all Python files
# 2. test — pytest test suite (offline, no AWS)
# 3. check-only — run_platform.sh --check-only (offline, no AWS)
name: acdl-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Compile all Python files
run: |
python3 -m py_compile \
core/confidence_signal.py \
core/outbox_writer.py \
core/output_publisher.py \
core/contract_resolver.py \
core/lambda/contract_ingestor.py \
adapters/terraform/adapter.py \
adapters/terraform/policy/checkov_adapter.py \
scripts/push_consumer_image.py
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Terraform 1.9.*
run: |
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Install test dependencies
run: pip install -r requirements-test.txt
- name: Run pytest
run: python3 -m pytest tests/ -v --tb=short
check-only:
name: Platform check-only (offline)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Terraform 1.9.*
run: |
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Install runtime dependencies
run: pip install jsonschema pyyaml boto3
- name: Run platform check-only
run: bash scripts/run_platform.sh --check-only
+166
View File
@@ -0,0 +1,166 @@
# ACDL Reusable Deploy Workflow — Gitea Actions (dev environment)
#
# This reusable workflow implements the central deployment pipeline contract:
# pipelines/contract.yml (validated against schemas/deploy-pipeline.schema.json)
#
# The same contract is implemented by .github/workflows/deploy.yml (GitHub
# Actions, production). Both files must be byte-identical — the only
# declared difference is the forge/runtime, not the stages or commands.
#
# Consumer repos invoke this workflow via a versioned tag (floating MAJOR + MINOR):
# uses: acdl/.gitea/workflows/deploy.yml@v1.9 (Gitea)
# uses: acdl/.github/workflows/deploy.yml@v1.9 (GitHub)
#
# Unversioned references (@main, bare) are discouraged — the consumer's setup
# must be immutable + resilient. The versioned tag is the only immutability
# lever (version constraints cannot be expressed inside the contract).
#
# What this workflow does:
# 1. Checks out the consumer repo (the repo that invoked the workflow).
# 2. Checks out the ACDL platform repo into the workspace (platform/).
# This is the run-time fetch — consumers never clone the platform repo.
# 3. Installs runtime deps: Python 3.12, Terraform 1.9.*, Checkov.
# 4. Configures AWS auth (OIDC default; static-key override via secrets).
# 5. Runs scripts/run_platform.sh against the consumer's contract path.
# 6. Uploads artifacts (emitted Terraform, Checkov JSON, confidence JSON,
# platform log) for auditability.
#
# Inputs:
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
# mode — full | plan-only | check-only (default full; dev = full apply,
# higher environments hold for HITL — the calling repo or the
# forge environment gate enforces that)
#
# Auth (zero-trust default — see README.md#credentials--zero-trust):
# OIDC federation is the default. permissions: id-token: write lets the
# forge mint a short-lived STS token. The role-to-assume is scoped by the
# consumer's repository identity (ABAC) — the workflow assumes the role
# that matches repo:org/consumer-repo:ref:refs/heads/main, and the session
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
#
# Override (where OIDC is unavailable, e.g. Gitea pending
# go-gitea/gitea#36988): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
# as repository secrets. The platform-managed scheduled pipeline rotates
# the key on a daily cadence. When .env.secrets is used locally instead,
# rotating the key out of band is the consumer's responsibility.
name: nova-deploy
on:
workflow_call:
inputs:
contract:
description: Path to the consumer contract YAML (in the consumer repo)
type: string
default: .nova/contract.yml
mode:
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
type: string
default: full
changeRequestId:
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
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Check out consumer repo
uses: actions/checkout@v4
- name: Check out ACDL platform repo
uses: actions/checkout@v4
with:
repository: acdl/acdl
path: platform
ref: v1.9
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install runtime dependencies
run: |
pip install --break-system-packages jsonschema pyyaml boto3
pip install --break-system-packages "checkov>=3.2,<4"
- name: Install Terraform 1.9.*
run: |
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Configure AWS credentials (OIDC default + static-key override)
uses: aws-actions/configure-aws-credentials@v4
with:
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
aws-region: us-east-1
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Run the platform pipeline
working-directory: ${{ github.workspace }}
run: |
MODE_FLAG=""
case "${{ inputs.mode }}" in
full) MODE_FLAG="" ;;
plan-only) MODE_FLAG="--plan-only" ;;
check-only) MODE_FLAG="--check-only" ;;
decommission)
if [ -z "${{ inputs.changeRequestId }}" ]; then
echo "FAIL: changeRequestId is required for decommission mode"
exit 1
fi
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
;;
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
esac
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'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
run: |
bash platform/scripts/post_stage_comment.sh deploy pass '{"mode":"${{ inputs.mode }}","runId":"${{ github.run_id }}"}'
- name: Report error to platform team (on failure)
if: failure()
env:
AWS_DEFAULT_REGION: us-east-1
run: |
aws lambda invoke-function-url \
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
--cli-binary-format raw-in-base64-out \
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
/dev/null || true
- name: Upload emitted Terraform
uses: actions/upload-artifact@v4
with:
name: nova-terraform
path: /tmp/acdl_platform_run_v18/tf/*.tf
if-no-files-found: warn
- name: Upload platform log
uses: actions/upload-artifact@v4
with:
name: nova-platform-log
path: platform/logs/
if-no-files-found: warn
+207
View File
@@ -0,0 +1,207 @@
# ACDL Modules Lifecycle Pipeline — Gitea Actions (dev environment)
#
# Matrix-runs each L1 module's examples/{simple,complex}.yml contracts through
# apply→modify→destroy against live AWS. No per-module Python. The "test" =
# the pipeline cell going green.
#
# Also matrix-runs L2 composition modules (static-assets, microservice) through
# the same apply→modify→destroy lifecycle. L2 = composition only (no L2
# terraform files); the composition must be deterministic.
#
# This workflow implements pipelines/modules-lifecycle.yml (byte-identical
# in .gitea/workflows/ and .github/workflows/).
#
# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
# no AWS mutation, validates the contract->resolver->adapter->plan chain
# for every module on every PR, with no AWS credentials or cost). Set to
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
# to run the real apply→modify→destroy against live AWS. In plan mode the
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
#
# A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent
# modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed
# after all tests complete. The CI VPC is separate from the long-lived platform
# VPC. Outputs are read from the S3 state by each lifecycle job (no artifact
# passing needed).
name: acdl-modules-lifecycle
on:
pull_request:
branches: [main]
workflow_dispatch:
inputs:
lifecycle_mode:
description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)"
required: false
default: "plan"
type: choice
options:
- plan
- full
permissions:
contents: read
jobs:
# Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice)
# Skipped in plan mode (no resources are applied, so no VPC is needed).
ci-vpc-apply:
name: CI VPC apply
runs-on: ubuntu-latest
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Install Terraform 1.9.*
run: |
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Apply CI VPC
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform apply -auto-approve -lock=false
# L1 lifecycle matrix: apply simple → apply complex (modify) → destroy
lifecycle:
name: L1 lifecycle (${{ matrix.module }})
needs: ci-vpc-apply
if: always()
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
env:
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo apt-get clean
df -h /
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install jsonschema pyyaml boto3
- name: Install Terraform 1.9.*
run: |
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
# L2 lifecycle matrix: apply simple → apply complex (modify) → destroy
l2-lifecycle:
name: L2 lifecycle (${{ matrix.module }})
needs: ci-vpc-apply
if: always()
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module: [static-assets, microservice]
env:
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo apt-get clean
df -h /
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install jsonschema pyyaml boto3
- name: Install Terraform 1.9.*
run: |
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
# Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails)
ci-vpc-destroy:
name: CI VPC destroy
needs: [lifecycle, l2-lifecycle]
runs-on: ubuntu-latest
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps:
- uses: actions/checkout@v4
- name: Install Terraform 1.9.*
run: |
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Destroy CI VPC
working-directory: terraform/ci-vpc
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
terraform init -input=false -lock=false
terraform destroy -auto-approve -lock=false