Compare commits

...

6 Commits

Author SHA1 Message Date
Jon Chery da0de6068a verify(P3): Nova rebrand SSM path + tag keys — 4-layer verify PASS
VERIFY: structural — SSM_PREFIX /acdl→/nova, tag keys acdl:*→nova:* in
terraform + tagging-standard.json, nova_tagging hard mode (_WARN_MODE=False);
behavioral — 598 pytest PASS (+32 new) + run_ci.sh 3-stage PASS + regression
gate 22/22 Verified; security — ABAC PrincipalTag/acdl:owner→nova:owner,
migration scripts dry-run-default; quality — grep-zero on acdl: tag keys
(remaining = legacy-detection constant + migration guide). REQ-161/162
complete. New scripts/migrate_ssm_paths.py + untag_acdl_keys.py (+32 tests).

---ci---
project: acdl
phase: 3
milestone: v1.15
status: verify
requirements_covered: [REQ-161, REQ-162]
---/ci---
2026-07-30 01:40:12 +00:00
Jon Chery 51c3edf458 feat(P3): Nova rebrand — SSM path + tag keys (REQ-161/162)
SSM path /acdl/{env}/{contractId}/{output} → /nova/... across
core/output_publisher + contract resolver + consumer docs. New
scripts/migrate_ssm_paths.py (copy/verify/delete, dry-run default).
AWS tag keys acdl:owner|environment|contract|cost-center|ref → nova:*
across terraform tagging + ABAC session policies (iam:ResourceTag/acdl:*
→ iam:ResourceTag/nova:*). nova_tagging.py hard mode (D-109 warn→hard).
tagging-standard.json tag-key values → nova:*. New
scripts/untag_acdl_keys.py (remove old acdl:* tags, dry-run default).
Test fixtures updated; pytest + run_ci.sh PASS.

---ci---
project: acdl
phase: 3
milestone: v1.15
status: execute
---/ci---
2026-07-30 01:38:30 +00:00
Jon Chery e998d9fa6b Merge phase/02-code-envvars-consumer-path — v1.15.2 (Nova P2 complete) 2026-07-30 01:27:09 +00:00
Jon Chery 7ea58ec1c9 verify(P2): Nova rebrand code/env-vars/consumer-path — 4-layer verify PASS
VERIFY: structural — core/env.py dual-read helper + nova_tagging.py
warn-mode + .acdl/→.nova/ + 21 NOVA_* env vars; behavioral — 566 pytest
PASS + run_ci.sh 3-stage PASS; security — G-106 dual-read in both
.env.secrets load paths (run_platform.sh:294-295 + regression_verify.py
:322-325), G-108 Gitea NOVA_* secrets created (4/5 HTTP 201/204, 1
skip-no-source) + workflow secrets: refs updated; quality — grep-zero
on stray ACDL_ code reads (remaining = intentional dual-read fallback).
REQ-158/159/160 complete.

---ci---
project: acdl
phase: 2
milestone: v1.15
status: verify
requirements_covered: [REQ-158, REQ-159, REQ-160]
---/ci---
2026-07-30 01:27:06 +00:00
Jon Chery d5bae868a4 feat(P2): Nova rebrand — code/env-vars/consumer-path (REQ-158/159/160)
core/env.py dual-read helper (D-108); 21 ACDL_*→NOVA_* env vars migrated
across core/scripts/adapters/tests/workflows + .env/.env.secrets (key
rename, values stay). G-106 binding: run_platform.sh:288-289 +
regression_verify.py:309-312 dual-read (NOVA first, ACDL fallback).
G-108 binding: Gitea NOVA_* secrets created via API + workflow secrets:
refs updated (deploy.yml + modules-lifecycle.yml, .gitea + .github).
acdl_tagging.py→nova_tagging.py (D-109 warn mode, nova:* enforced).
.acdl/→.nova/ consumer path (resolver + deploy workflow + schema +
tests + docs). Test fixtures updated; pytest + run_ci.sh PASS.

---ci---
project: acdl
phase: 2
milestone: v1.15
status: execute
---/ci---
2026-07-30 01:25:24 +00:00
Jon Chery 0bc70a3d95 Merge phase/01-docs-decks-prose — v1.15.1 (Nova P1 docs/decks/prose complete) 2026-07-30 00:58:22 +00:00
63 changed files with 1430 additions and 450 deletions
+4 -4
View File
@@ -1,11 +1,11 @@
{ {
"phase": 1, "phase": 3,
"stage": "complete", "stage": "complete",
"milestone": "v1.15", "milestone": "v1.15",
"phase_role": "execution", "phase_role": "execution",
"attempts": 0, "attempts": 0,
"updated_at": "2026-07-30T00:07:00Z", "updated_at": "2026-07-30T00:09:00Z",
"milestone_complete": false, "milestone_complete": false,
"requirements": ["REQ-155", "REQ-156", "REQ-157"], "requirements": ["REQ-161", "REQ-162"],
"tag": "v1.15.1" "tag": "v1.15.3"
} }
+33 -33
View File
@@ -1,6 +1,6 @@
{ {
"run_id": "regr-1785329757", "run_id": "regr-1785375318",
"run_at_utc": "2026-07-29T12:55:57Z", "run_at_utc": "2026-07-30T01:35:18Z",
"milestone": "v1.10", "milestone": "v1.10",
"phase": 52, "phase": 52,
"summary": { "summary": {
@@ -16,7 +16,7 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; 2 sample contracts validate", "detail": "exit 0; 2 sample contracts validate",
"tier": "local", "tier": "local",
"duration_ms": 252 "duration_ms": 230
}, },
{ {
"capability_id": "CAP-002", "capability_id": "CAP-002",
@@ -24,7 +24,7 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; env schema validates", "detail": "exit 0; env schema validates",
"tier": "local", "tier": "local",
"duration_ms": 196 "duration_ms": 204
}, },
{ {
"capability_id": "CAP-003", "capability_id": "CAP-003",
@@ -32,7 +32,7 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; ", "detail": "exit 0; ",
"tier": "local", "tier": "local",
"duration_ms": 258 "duration_ms": 247
}, },
{ {
"capability_id": "CAP-004", "capability_id": "CAP-004",
@@ -40,7 +40,7 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; ", "detail": "exit 0; ",
"tier": "local", "tier": "local",
"duration_ms": 264 "duration_ms": 241
}, },
{ {
"capability_id": "CAP-005", "capability_id": "CAP-005",
@@ -48,7 +48,7 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; ", "detail": "exit 0; ",
"tier": "local", "tier": "local",
"duration_ms": 314 "duration_ms": 326
}, },
{ {
"capability_id": "CAP-006", "capability_id": "CAP-006",
@@ -56,7 +56,7 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; interpolation ok", "detail": "exit 0; interpolation ok",
"tier": "local", "tier": "local",
"duration_ms": 223 "duration_ms": 216
}, },
{ {
"capability_id": "CAP-007", "capability_id": "CAP-007",
@@ -64,7 +64,7 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; confidence band=pass", "detail": "exit 0; confidence band=pass",
"tier": "local", "tier": "local",
"duration_ms": 80 "duration_ms": 79
}, },
{ {
"capability_id": "CAP-008", "capability_id": "CAP-008",
@@ -72,15 +72,15 @@
"status": "Verified", "status": "Verified",
"detail": "exit 0; outbox hash chain ok", "detail": "exit 0; outbox hash chain ok",
"tier": "local", "tier": "local",
"duration_ms": 358 "duration_ms": 333
}, },
{ {
"capability_id": "CAP-009", "capability_id": "CAP-009",
"name": "offline pytest suite passes", "name": "offline pytest suite passes",
"status": "Verified", "status": "Verified",
"detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n====================== 462 passed, 2 deselected in 34.63s ======================", "detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n====================== 555 passed, 2 deselected in 51.11s ======================",
"tier": "local", "tier": "local",
"duration_ms": 36065 "duration_ms": 52574
}, },
{ {
"capability_id": "CAP-010", "capability_id": "CAP-010",
@@ -88,23 +88,23 @@
"status": "Verified", "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", "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", "tier": "local",
"duration_ms": 40668 "duration_ms": 59608
}, },
{ {
"capability_id": "CAP-011", "capability_id": "CAP-011",
"name": "headline E2E runs against the local emulating tier (microservice)", "name": "headline E2E runs against the local emulating tier (microservice)",
"status": "Verified", "status": "Verified",
"detail": "exit 0; al-emulator\",\n \"desired_count\": 1,\n \"running_count\": 1\n },\n \"outbox_dir\": \"/tmp/acdl_local_e2e_416d0fmr/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/acdl_local_e2e_0v1bpi48/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
"tier": "local", "tier": "local",
"duration_ms": 583 "duration_ms": 1072
}, },
{ {
"capability_id": "CAP-012", "capability_id": "CAP-012",
"name": "local E2E on the static-assets stack (no ECS)", "name": "local E2E on the static-assets stack (no ECS)",
"status": "Verified", "status": "Verified",
"detail": "exit 0; acdl_local_e2e_ijhcj1z8/tf\",\n \"backend\": \"local\",\n \"ecs\": null,\n \"outbox_dir\": \"/tmp/acdl_local_e2e_ijhcj1z8/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}", "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}",
"tier": "local", "tier": "local",
"duration_ms": 489 "duration_ms": 490
}, },
{ {
"capability_id": "CAP-013", "capability_id": "CAP-013",
@@ -112,7 +112,7 @@
"status": "Verified", "status": "Verified",
"detail": "terraform init+validate+plan OK (live AWS, microservice)", "detail": "terraform init+validate+plan OK (live AWS, microservice)",
"tier": "live-aws", "tier": "live-aws",
"duration_ms": 28811 "duration_ms": 28176
}, },
{ {
"capability_id": "CAP-014", "capability_id": "CAP-014",
@@ -120,7 +120,7 @@
"status": "Verified", "status": "Verified",
"detail": "terraform init+validate+plan OK (live AWS, static-assets)", "detail": "terraform init+validate+plan OK (live AWS, static-assets)",
"tier": "live-aws", "tier": "live-aws",
"duration_ms": 31772 "duration_ms": 31892
}, },
{ {
"capability_id": "CAP-015", "capability_id": "CAP-015",
@@ -128,23 +128,23 @@
"status": "Verified", "status": "Verified",
"detail": "acdl-outbox exists, item_count=9", "detail": "acdl-outbox exists, item_count=9",
"tier": "live-aws", "tier": "live-aws",
"duration_ms": 477 "duration_ms": 507
}, },
{ {
"capability_id": "CAP-016", "capability_id": "CAP-016",
"name": "S3 state bucket exists + readable (live AWS)", "name": "S3 state bucket exists + readable (live AWS)",
"status": "Verified", "status": "Verified",
"detail": "state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfstate', 'spike/ci-vpc/terraform.tfstate', 'spike/clus/dev/terraform.tfstate']", "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']",
"tier": "live-aws", "tier": "live-aws",
"duration_ms": 324 "duration_ms": 329
}, },
{ {
"capability_id": "CAP-017", "capability_id": "CAP-017",
"name": "DynamoDB acdl-contracts table (lifecycle pipeline evidence)", "name": "DynamoDB acdl-contracts table (lifecycle pipeline evidence)",
"status": "Verified", "status": "Verified",
"detail": "terraform files present + simple/complex contracts resolve", "detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
"tier": "lifecycle-pipeline", "tier": "lifecycle-pipeline",
"duration_ms": 520 "duration_ms": 588
}, },
{ {
"capability_id": "CAP-018", "capability_id": "CAP-018",
@@ -152,39 +152,39 @@
"status": "Verified", "status": "Verified",
"detail": "LocalLambdaStub instantiates (local tier evidence)", "detail": "LocalLambdaStub instantiates (local tier evidence)",
"tier": "lifecycle-pipeline", "tier": "lifecycle-pipeline",
"duration_ms": 137 "duration_ms": 135
}, },
{ {
"capability_id": "CAP-019", "capability_id": "CAP-019",
"name": "ECS cluster + service (L2 microservice lifecycle evidence)", "name": "ECS cluster + service (L2 microservice lifecycle evidence)",
"status": "Verified", "status": "Verified",
"detail": "L2 composition resolves (simple + complex contracts)", "detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
"tier": "lifecycle-pipeline", "tier": "lifecycle-pipeline",
"duration_ms": 534 "duration_ms": 498
}, },
{ {
"capability_id": "CAP-020", "capability_id": "CAP-020",
"name": "CloudFront + WAF (L2 static-assets lifecycle evidence)", "name": "CloudFront + WAF (L2 static-assets lifecycle evidence)",
"status": "Verified", "status": "Verified",
"detail": "L2 composition resolves (simple + complex contracts)", "detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
"tier": "lifecycle-pipeline", "tier": "lifecycle-pipeline",
"duration_ms": 567 "duration_ms": 510
}, },
{ {
"capability_id": "CAP-021", "capability_id": "CAP-021",
"name": "uptime-kuma (L1 uptime lifecycle evidence)", "name": "uptime-kuma (L1 uptime lifecycle evidence)",
"status": "Verified", "status": "Verified",
"detail": "terraform files present + simple/complex contracts resolve", "detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
"tier": "lifecycle-pipeline", "tier": "lifecycle-pipeline",
"duration_ms": 606 "duration_ms": 562
}, },
{ {
"capability_id": "CAP-022", "capability_id": "CAP-022",
"name": "OIDC role (L1 iam-role lifecycle evidence)", "name": "OIDC role (L1 iam-role lifecycle evidence)",
"status": "Verified", "status": "Verified",
"detail": "terraform files present + simple/complex contracts resolve", "detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
"tier": "lifecycle-pipeline", "tier": "lifecycle-pipeline",
"duration_ms": 529 "duration_ms": 554
} }
] ]
} }
+27 -27
View File
@@ -1,51 +1,51 @@
# Regression Report — v1.10 Phase 52 # Regression Report — v1.10 Phase 52
- **Run ID:** `regr-1785329757` - **Run ID:** `regr-1785375318`
- **Run at (UTC):** 2026-07-29T12:55:57Z - **Run at (UTC):** 2026-07-30T01:35:18Z
- **Summary:** {'Verified': 22, 'Decayed': 0, 'Broken': 0} - **Summary:** {'Verified': 22, 'Decayed': 0, 'Broken': 0}
- **Passed (milestone gate):** True - **Passed (milestone gate):** True
| Capability | Name | Tier | Status | Duration (ms) | Detail | | Capability | Name | Tier | Status | Duration (ms) | Detail |
|-----------|------|------|--------|--------------|--------| |-----------|------|------|--------|--------------|--------|
| CAP-001 | contract.schema.json validates sample contracts | local | **Verified** | 252 | exit 0; 2 sample contracts validate | | 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** | 196 | exit 0; env schema validates | | 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** | 258 | exit 0; | | CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 247 | exit 0; |
| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 264 | exit 0; | | CAP-004 | contract_resolver resolves microservice | local | **Verified** | 241 | exit 0; |
| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 314 | exit 0; | | CAP-005 | terraform adapter emits .tf files | local | **Verified** | 326 | exit 0; |
| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 223 | exit 0; interpolation ok | | 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** | 80 | exit 0; confidence band=pass | | 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** | 358 | exit 0; outbox hash chain ok | | 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** | 36065 | exit 0; [ 98%] | CAP-009 | offline pytest suite passes | local | **Verified** | 52574 | exit 0; [ 98%]
tests/test_wiz_adapter_real_client.py ......... [100%] tests/test_wiz_adapter_real_client.py ......... [100%]
====================== 462 passe | ====================== 555 passe |
| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 40668 | exit 0; resource(s)) | CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 59608 | exit 0; resource(s))
=== PLATFORM CHECK OK === === PLATFORM CHECK OK ===
contract -> resolver -> stack -> adapter -> structure validated (offline, no AWS) contract -> resolver -> stack -> adapter -> structure validated (offline, no AWS)
check-only: OK check-only: OK
=== CI PIPELIN | === CI PIPELIN |
| CAP-011 | headline E2E runs against the local emulating tier (microservice) | local | **Verified** | 583 | exit 0; al-emulator", | CAP-011 | headline E2E runs against the local emulating tier (microservice) | local | **Verified** | 1072 | exit 0; al-emulator",
"desired_count": 1, "desired_count": 1,
"running_count": 1 "running_count": 1
}, },
"outbox_dir": "/tmp/acdl_local_e2e_416d0fmr/outbox", "outbox_dir": "/tmp/acdl_local_e2e_0v1bpi48/outbox",
"outbox_events": 2, "outbox_events": 2,
"outbox | "outbox |
| CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 489 | exit 0; acdl_local_e2e_ijhcj1z8/tf", | CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 490 | exit 0; acdl_local_e2e_0cjcizgd/tf",
"backend": "local", "backend": "local",
"ecs": null, "ecs": null,
"outbox_dir": "/tmp/acdl_local_e2e_ijhcj1z8/outbox", "outbox_dir": "/tmp/acdl_local_e2e_0cjcizgd/outbox",
"outbox_events": 2, "outbox_events": 2,
"outbox | "outbox |
| CAP-013 | terraform init+validate+plan live AWS (microservice) | live-aws | **Verified** | 28811 | terraform init+validate+plan OK (live AWS, microservice) | | 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** | 31772 | terraform init+validate+plan OK (live AWS, static-assets) | | 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** | 477 | acdl-outbox exists, item_count=9 | | 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** | 324 | state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfstate', 'spike/ci-vpc/terraform.tfstate', | | 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** | 520 | terraform files present + simple/complex contracts resolve | | 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** | 137 | LocalLambdaStub instantiates (local tier evidence) | | 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** | 534 | L2 composition resolves (simple + complex contracts) | | 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** | 567 | L2 composition resolves (simple + complex contracts) | | 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** | 606 | terraform files present + simple/complex contracts resolve | | 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** | 529 | terraform files present + 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 |
+5 -5
View File
@@ -822,11 +822,11 @@ IDEATE-01..IDEATE-10, mapped to REQ-155..REQ-164.
| REQ-155 | P1 | complete | | REQ-155 | P1 | complete |
| REQ-156 | P1 | complete | | REQ-156 | P1 | complete |
| REQ-157 | P1 | complete | | REQ-157 | P1 | complete |
| REQ-158 | P2 | pending | | REQ-158 | P2 | complete |
| REQ-159 | P2 | pending | | REQ-159 | P2 | complete |
| REQ-160 | P2 | pending | | REQ-160 | P2 | complete |
| REQ-161 | P3 | pending | | REQ-161 | P3 | complete |
| REQ-162 | P3 | pending | | REQ-162 | P3 | complete |
| REQ-163 | P4 | pending | | REQ-163 | P4 | pending |
| REQ-164 | P5 | pending | | REQ-164 | P5 | pending |
+11 -7
View File
@@ -26,7 +26,7 @@
# platform log) for auditability. # platform log) for auditability.
# #
# Inputs: # Inputs:
# contract — path to the consumer's contract YAML (default .acdl/contract.yml) # contract — path to the consumer's contract YAML (default .nova/contract.yml)
# mode — full | plan-only | check-only (default full; dev = full apply, # mode — full | plan-only | check-only (default full; dev = full apply,
# higher environments hold for HITL — the calling repo or the # higher environments hold for HITL — the calling repo or the
# forge environment gate enforces that) # forge environment gate enforces that)
@@ -39,7 +39,7 @@
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>. # policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
# #
# Override (where OIDC is unavailable, e.g. Gitea pending # Override (where OIDC is unavailable, e.g. Gitea pending
# go-gitea/gitea#36988): set ACDL_AWS_ACCESS_KEY_ID + ACDL_AWS_SECRET_ACCESS_KEY # go-gitea/gitea#36988): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
# as repository secrets. The platform-managed scheduled pipeline rotates # as repository secrets. The platform-managed scheduled pipeline rotates
# the key on a daily cadence. When .env.secrets is used locally instead, # the key on a daily cadence. When .env.secrets is used locally instead,
# rotating the key out of band is the consumer's responsibility. # rotating the key out of band is the consumer's responsibility.
@@ -51,7 +51,7 @@ on:
contract: contract:
description: Path to the consumer contract YAML (in the consumer repo) description: Path to the consumer contract YAML (in the consumer repo)
type: string type: string
default: .acdl/contract.yml default: .nova/contract.yml
mode: mode:
description: Pipeline mode — full (apply), plan-only, check-only, or decommission description: Pipeline mode — full (apply), plan-only, check-only, or decommission
type: string type: string
@@ -102,10 +102,14 @@ jobs:
- name: Configure AWS credentials (OIDC default + static-key override) - name: Configure AWS credentials (OIDC default + static-key override)
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v4
with: with:
role-to-assume: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.ACDL_AWS_ACCOUNT_ID, github.repository_id) || '' }} # TODO(P4, REQ-163): rename the IAM role acdl-deploy- → nova-deploy-.
# The role ARN string is left as acdl-deploy- until P4 (IAM role
# rename territory); only the secret REFERENCES are updated to
# NOVA_* in P2 (G-108 binding).
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
aws-region: us-east-1 aws-region: us-east-1
access-key-id: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Run the platform pipeline - name: Run the platform pipeline
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
@@ -145,7 +149,7 @@ jobs:
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
aws lambda invoke-function-url \ aws lambda invoke-function-url \
--function-url "${{ secrets.ACDL_LAMBDA_URL }}" \ --function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
--cli-binary-format raw-in-base64-out \ --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'}))")" \ --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 /dev/null || true
+27 -27
View File
@@ -15,7 +15,7 @@
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast, # "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
# no AWS mutation, validates the contract->resolver->adapter->plan chain # no AWS mutation, validates the contract->resolver->adapter->plan chain
# for every module on every PR, with no AWS credentials or cost). Set to # for every module on every PR, with no AWS credentials or cost). Set to
# "full" via workflow_dispatch (or the ACDL_LIFECYCLE_MODE repo variable) # "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 # 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). # short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
# #
@@ -49,7 +49,7 @@ jobs:
ci-vpc-apply: ci-vpc-apply:
name: CI VPC apply name: CI VPC apply
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Terraform 1.9.* - name: Install Terraform 1.9.*
@@ -60,8 +60,8 @@ jobs:
- name: Apply CI VPC - name: Apply CI VPC
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
@@ -78,7 +78,7 @@ jobs:
matrix: matrix:
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime] module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
env: env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Free disk space - name: Free disk space
@@ -97,31 +97,31 @@ jobs:
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 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.* sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs - name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple) - name: Apply (simple)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex) - name: Modify (complex)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy - name: Destroy
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -136,7 +136,7 @@ jobs:
matrix: matrix:
module: [static-assets, microservice] module: [static-assets, microservice]
env: env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Free disk space - name: Free disk space
@@ -155,31 +155,31 @@ jobs:
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 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.* sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs - name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple) - name: Apply (simple)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex) - name: Modify (complex)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy - name: Destroy
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -188,7 +188,7 @@ jobs:
name: CI VPC destroy name: CI VPC destroy
needs: [lifecycle, l2-lifecycle] needs: [lifecycle, l2-lifecycle]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Terraform 1.9.* - name: Install Terraform 1.9.*
@@ -199,8 +199,8 @@ jobs:
- name: Destroy CI VPC - name: Destroy CI VPC
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
+11 -7
View File
@@ -26,7 +26,7 @@
# platform log) for auditability. # platform log) for auditability.
# #
# Inputs: # Inputs:
# contract — path to the consumer's contract YAML (default .acdl/contract.yml) # contract — path to the consumer's contract YAML (default .nova/contract.yml)
# mode — full | plan-only | check-only (default full; dev = full apply, # mode — full | plan-only | check-only (default full; dev = full apply,
# higher environments hold for HITL — the calling repo or the # higher environments hold for HITL — the calling repo or the
# forge environment gate enforces that) # forge environment gate enforces that)
@@ -39,7 +39,7 @@
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>. # policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
# #
# Override (where OIDC is unavailable, e.g. Gitea pending # Override (where OIDC is unavailable, e.g. Gitea pending
# go-gitea/gitea#36988): set ACDL_AWS_ACCESS_KEY_ID + ACDL_AWS_SECRET_ACCESS_KEY # go-gitea/gitea#36988): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
# as repository secrets. The platform-managed scheduled pipeline rotates # as repository secrets. The platform-managed scheduled pipeline rotates
# the key on a daily cadence. When .env.secrets is used locally instead, # the key on a daily cadence. When .env.secrets is used locally instead,
# rotating the key out of band is the consumer's responsibility. # rotating the key out of band is the consumer's responsibility.
@@ -51,7 +51,7 @@ on:
contract: contract:
description: Path to the consumer contract YAML (in the consumer repo) description: Path to the consumer contract YAML (in the consumer repo)
type: string type: string
default: .acdl/contract.yml default: .nova/contract.yml
mode: mode:
description: Pipeline mode — full (apply), plan-only, check-only, or decommission description: Pipeline mode — full (apply), plan-only, check-only, or decommission
type: string type: string
@@ -102,10 +102,14 @@ jobs:
- name: Configure AWS credentials (OIDC default + static-key override) - name: Configure AWS credentials (OIDC default + static-key override)
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v4
with: with:
role-to-assume: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.ACDL_AWS_ACCOUNT_ID, github.repository_id) || '' }} # TODO(P4, REQ-163): rename the IAM role acdl-deploy- → nova-deploy-.
# The role ARN string is left as acdl-deploy- until P4 (IAM role
# rename territory); only the secret REFERENCES are updated to
# NOVA_* in P2 (G-108 binding).
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/acdl-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
aws-region: us-east-1 aws-region: us-east-1
access-key-id: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Run the platform pipeline - name: Run the platform pipeline
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
@@ -145,7 +149,7 @@ jobs:
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
aws lambda invoke-function-url \ aws lambda invoke-function-url \
--function-url "${{ secrets.ACDL_LAMBDA_URL }}" \ --function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
--cli-binary-format raw-in-base64-out \ --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'}))")" \ --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 /dev/null || true
+27 -27
View File
@@ -15,7 +15,7 @@
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast, # "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
# no AWS mutation, validates the contract->resolver->adapter->plan chain # no AWS mutation, validates the contract->resolver->adapter->plan chain
# for every module on every PR, with no AWS credentials or cost). Set to # for every module on every PR, with no AWS credentials or cost). Set to
# "full" via workflow_dispatch (or the ACDL_LIFECYCLE_MODE repo variable) # "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 # 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). # short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
# #
@@ -49,7 +49,7 @@ jobs:
ci-vpc-apply: ci-vpc-apply:
name: CI VPC apply name: CI VPC apply
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Terraform 1.9.* - name: Install Terraform 1.9.*
@@ -60,8 +60,8 @@ jobs:
- name: Apply CI VPC - name: Apply CI VPC
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
@@ -78,7 +78,7 @@ jobs:
matrix: matrix:
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime] module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
env: env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Free disk space - name: Free disk space
@@ -97,31 +97,31 @@ jobs:
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 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.* sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs - name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple) - name: Apply (simple)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex) - name: Modify (complex)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy - name: Destroy
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -136,7 +136,7 @@ jobs:
matrix: matrix:
module: [static-assets, microservice] module: [static-assets, microservice]
env: env:
ACDL_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.ACDL_LIFECYCLE_MODE || 'plan' }} NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Free disk space - name: Free disk space
@@ -155,31 +155,31 @@ jobs:
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 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.* sudo apt-get update && sudo apt-get install -y terraform=1.9.*
- name: Read CI VPC outputs - name: Read CI VPC outputs
if: ${{ env.ACDL_LIFECYCLE_MODE == 'full' }} if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
terraform output -json > /tmp/ci-vpc-outputs.json terraform output -json > /tmp/ci-vpc-outputs.json
- name: Apply (simple) - name: Apply (simple)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
- name: Modify (complex) - name: Modify (complex)
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
- name: Destroy - name: Destroy
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
@@ -188,7 +188,7 @@ jobs:
name: CI VPC destroy name: CI VPC destroy
needs: [lifecycle, l2-lifecycle] needs: [lifecycle, l2-lifecycle]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.ACDL_LIFECYCLE_MODE != 'plan' }} if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Terraform 1.9.* - name: Install Terraform 1.9.*
@@ -199,8 +199,8 @@ jobs:
- name: Destroy CI VPC - name: Destroy CI VPC
working-directory: terraform/ci-vpc working-directory: terraform/ci-vpc
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACDL_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ACDL_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
terraform init -input=false -lock=false terraform init -input=false -lock=false
+6 -5
View File
@@ -10,9 +10,10 @@ lives in the per-module terraform/ subdir, NOT in this file.
CLI: adapter.py <instance.json> <out_dir> CLI: adapter.py <instance.json> <out_dir>
""" """
import json import json, os, sys
import os _R = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import sys sys.path.insert(0, _R) if _R not in sys.path else None
from core import env
def _load_registry(repo_root): def _load_registry(repo_root):
@@ -112,7 +113,7 @@ def adapt(stack_instance, out_dir):
stack_name = stack.get("name", "spike") stack_name = stack.get("name", "spike")
environment = stack.get("environment", "dev") environment = stack.get("environment", "dev")
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199") account_id = env.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1" state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
terraform_tf = ( terraform_tf = (
'terraform {\n' 'terraform {\n'
@@ -134,7 +135,7 @@ def adapt(stack_instance, out_dir):
data_source_names = stack_instance.get("data_sources", []) data_source_names = stack_instance.get("data_sources", [])
parts = [] parts = []
if data_source_names: if data_source_names:
remote_state_key = os.environ.get("ACDL_REMOTE_STATE_KEY", "platform/terraform.tfstate") remote_state_key = env.get_env("REMOTE_STATE_KEY", "platform/terraform.tfstate")
parts.append( parts.append(
'data "terraform_remote_state" "platform" {\n' 'data "terraform_remote_state" "platform" {\n'
' backend = "s3"\n' ' backend = "s3"\n'
+14 -9
View File
@@ -1,4 +1,4 @@
"""Translate Checkov JSON output to ACDL PolicyCheckResult records. """Translate Checkov JSON output to Nova PolicyCheckResult records.
Reads Checkov's JSON output (one framework key, e.g. terraform_plan), Reads Checkov's JSON output (one framework key, e.g. terraform_plan),
emits a list of PolicyCheckResult dicts conforming to emits a list of PolicyCheckResult dicts conforming to
@@ -6,10 +6,13 @@ schemas/policy_check_result.schema.json. Run Checkov with --soft-fail so
Checkov never exits non-zero; the confidence signal decides the gate, not Checkov never exits non-zero; the confidence signal decides the gate, not
Checkov's exit code. Checkov's exit code.
The ACDL tagging standard (D-054, D-043 closure) is enforced by a custom The Nova tagging standard (D-054, D-043 closure, D-109 hard mode in P3)
Checkov rule at adapters/terraform/policy/custom_rules/acdl_tagging.py, is enforced by a custom Checkov rule at
loaded via --external-checks-dir. The adapter therefore maps adapters/terraform/policy/custom_rules/nova_tagging.py, loaded via
ACDL_TAG_NAMING as a real rule (no synthetic SKIPPED record is emitted). --external-checks-dir. The adapter therefore maps NOVA_TAG_NAMING as a
real rule (no synthetic SKIPPED record is emitted). Renamed from
ACDL_TAG_NAMING in P2 (REQ-158); the rule is in hard mode as of P3
(REQ-162: hard-fail on missing nova:* or acdl:*-only tags).
""" """
import datetime import datetime
@@ -29,10 +32,12 @@ RULE_MAP = {
"CKV_AWS_40": ("iam-wildcard", "medium"), "CKV_AWS_40": ("iam-wildcard", "medium"),
"CKV_AWS_7": ("kms-key-reference", "medium"), "CKV_AWS_7": ("kms-key-reference", "medium"),
"CKV_AWS_33": ("kms-key-reference", "medium"), "CKV_AWS_33": ("kms-key-reference", "medium"),
# D-054 / D-043 closure: ACDL_TAG_NAMING is now a real custom Checkov # D-054 / D-043 closure, D-109 hard mode (P3): NOVA_TAG_NAMING is a real
# rule (adapters/terraform/policy/custom_rules/acdl_tagging.py), loaded # custom Checkov rule (adapters/terraform/policy/custom_rules/nova_tagging.py),
# via --external-checks-dir. No synthetic SKIPPED record is emitted. # loaded via --external-checks-dir. No synthetic SKIPPED record is emitted.
"ACDL_TAG_NAMING": ("tagging-standard", "medium"), # Renamed from ACDL_TAG_NAMING in P2 (REQ-158). Hard mode as of P3
# (REQ-162: hard-fail on missing nova:* or acdl:*-only tags).
"NOVA_TAG_NAMING": ("tagging-standard", "medium"),
} }
_RESULT_MAP = {"PASSED": "pass", "FAILED": "fail", "SKIPPED": "skipped"} _RESULT_MAP = {"PASSED": "pass", "FAILED": "fail", "SKIPPED": "skipped"}
@@ -1,16 +1,24 @@
# ACDL Custom Checkov Rules # Nova Custom Checkov Rules
This directory holds ACDL-authored Checkov custom rules, written in the This directory holds Nova-authored Checkov custom rules, written in the
[Checkov Python custom-rule framework](https://www.checkov.io/4.Contributing/Custom%20Policies.html). [Checkov Python custom-rule framework](https://www.checkov.io/4.Contributing/Custom%20Policies.html).
## Files ## Files
- `acdl_tagging.py``ACDL_TAG_NAMING` (D-054): ensures every taggable AWS - `nova_tagging.py``NOVA_TAG_NAMING` (D-054, D-109 warn mode in P2):
resource carries the four required ACDL tags ensures every taggable AWS resource carries the four required Nova tags
(`acdl:owner`, `acdl:contract`, `acdl:environment`, `acdl:cost-center`). (`nova:owner`, `nova:contract`, `nova:environment`, `nova:cost-center`).
This rule replaces the synthetic SKIPPED `ACDL_TAG_NAMING` record that the This rule replaces the synthetic SKIPPED `NOVA_TAG_NAMING` record that the
Checkov adapter previously emitted (D-043 closure). The canonical tag set Checkov adapter previously emitted (D-043 closure). Renamed from
is declared in [`schemas/tagging-standard.json`](../../../schemas/tagging-standard.json). `acdl_tagging.py` / `ACDL_TAG_NAMING` in P2 (REQ-158). The canonical tag
set is declared in [`schemas/tagging-standard.json`](../../../schemas/tagging-standard.json).
**P2 warn mode (D-109):** existing resources still carry `acdl:*` tag-key
values (left for P3). When a resource has only `acdl:*`-style tags and no
`nova:*` tags, the rule logs a WARNING instead of failing, so the
regression gate stays green during the parallel-tag transition window.
P3 flips to hard-fail once `nova:*` tags are emitted in parallel and the
ABAC policy is swapped.
## How Checkov loads them ## How Checkov loads them
@@ -23,12 +31,12 @@ checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail \
``` ```
Checkov imports each `*.py` file in the directory and instantiates the Checkov imports each `*.py` file in the directory and instantiates the
module-level `check` object (see the `check = AcdlTaggingStandard()` line at module-level `check` object (see the `check = NovaTaggingStandard()` line at
the bottom of `acdl_tagging.py`). the bottom of `nova_tagging.py`).
## Severity / result mapping ## Severity / result mapping
The Checkov adapter (`adapters/terraform/policy/checkov_adapter.py`) The Checkov adapter (`adapters/terraform/policy/checkov_adapter.py`)
maps `ACDL_TAG_NAMING` to `(tagging-standard, medium)` in `RULE_MAP`. The maps `NOVA_TAG_NAMING` to `(tagging-standard, medium)` in `RULE_MAP`. The
custom rule therefore produces real `PASS`/`FAIL` PolicyCheckResult records, custom rule therefore produces real `PASS`/`FAIL` PolicyCheckResult records,
feeding the confidence signal instead of the old SKIPPED placeholder. feeding the confidence signal instead of the old SKIPPED placeholder.
@@ -1,54 +0,0 @@
"""ACDL tagging standard custom Checkov rule (D-054).
Checks that all taggable AWS resources have the required ACDL tags:
acdl:owner, acdl:contract, acdl:environment, acdl:cost-center
Fails (severity medium) when any required tag is missing.
Closes the D-043 deferral (the SKIPPED ACDL_TAG_NAMING placeholder
becomes a real check).
"""
from __future__ import annotations
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories
REQUIRED_TAGS = ("acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center")
# Resources that support tags (exclude resources that have no tags attribute)
NON_TAGGABLE_TYPES = (
"aws_cloudfront_origin_access_control",
"aws_lambda_function_url",
"aws_route_table_association",
"aws_internet_gateway",
)
class AcdlTaggingStandard(BaseResourceCheck):
def __init__(self):
name = "Ensure all taggable AWS resources have required ACDL tags"
check_id = "ACDL_TAG_NAMING"
supported_resources = ["*"] # all resources
categories = [CheckCategories.GENERAL_SECURITY]
super().__init__(name=name, check_id=check_id, categories=categories, supported_resources=supported_resources)
def scan_resource_conf(self, conf, entity_type):
# Skip non-taggable resources
if entity_type in NON_TAGGABLE_TYPES:
return CheckResult.PASSED
# Check for a tags block
tags = conf.get("tags")
if not tags:
return CheckResult.FAILED
tag_keys = set()
if isinstance(tags, list) and tags:
tag_block = tags[0]
if isinstance(tag_block, dict):
tag_keys = set(tag_block.keys())
elif isinstance(tags, dict):
tag_keys = set(tags.keys())
missing = [t for t in REQUIRED_TAGS if t not in tag_keys]
if missing:
return CheckResult.FAILED
return CheckResult.PASSED
check = AcdlTaggingStandard()
@@ -0,0 +1,91 @@
"""Nova tagging standard custom Checkov rule (D-054, D-109 hard mode).
Checks that all taggable AWS resources have the required Nova tags:
nova:owner, nova:contract, nova:environment, nova:cost-center
In **hard mode** (P3, REQ-162): the rule hard-fails when a taggable resource
is missing any required `nova:*` tag, OR when a resource carries only the
legacy `acdl:*` tag keys (and no `nova:*` keys). P2 shipped warn mode
(`_WARN_MODE = True`) so the regression gate stayed green during the
parallel-tag transition window; P3 flips to hard-fail (`_WARN_MODE = False`)
once `nova:*` tags are emitted in terraform and the ABAC policy is swapped
to match `nova:*`. P5 keeps hard mode and additionally hard-fails on any
`acdl:*` tag key present at all (no legacy tolerated post-cutoff).
Closes the D-043 deferral (the SKIPPED NOVA_TAG_NAMING placeholder
becomes a real check). Renamed from acdl_tagging.py in P2 (REQ-158);
the Checkov rule ID ACDL_TAG_NAMING → NOVA_TAG_NAMING.
"""
from __future__ import annotations
import sys
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories
REQUIRED_TAGS = ("nova:owner", "nova:contract", "nova:environment", "nova:cost-center")
# Legacy acdl:* tag keys — the parallel-tag period (P3) emits both nova:*
# and acdl:*; P2 warn mode treats acdl:*-only tags as a warning, not a
# failure. The acdl:* VALUES in tagging-standard.json are left for P3.
LEGACY_TAGS = ("acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center")
# Resources that support tags (exclude resources that have no tags attribute)
NON_TAGGABLE_TYPES = (
"aws_cloudfront_origin_access_control",
"aws_lambda_function_url",
"aws_route_table_association",
"aws_internet_gateway",
)
# P3 hard mode (D-109): hard-fail when a taggable resource is missing any
# required nova:* tag, or when a resource carries only legacy acdl:* tag
# keys and no nova:* tags. P2 shipped warn mode (`_WARN_MODE = True`); P3
# flips to `False` (hard-fail) once terraform emits nova:* and the ABAC
# policy is swapped to nova:*. P5 keeps hard mode and additionally fails
# on any acdl:* tag key present at all.
_WARN_MODE = False
class NovaTaggingStandard(BaseResourceCheck):
def __init__(self):
name = "Ensure all taggable AWS resources have required Nova tags"
check_id = "NOVA_TAG_NAMING"
supported_resources = ["*"] # all resources
categories = [CheckCategories.GENERAL_SECURITY]
super().__init__(name=name, check_id=check_id, categories=categories, supported_resources=supported_resources)
def scan_resource_conf(self, conf, entity_type):
# Skip non-taggable resources
if entity_type in NON_TAGGABLE_TYPES:
return CheckResult.PASSED
# Check for a tags block
tags = conf.get("tags")
if not tags:
return CheckResult.FAILED
tag_keys = set()
if isinstance(tags, list) and tags:
tag_block = tags[0]
if isinstance(tag_block, dict):
tag_keys = set(tag_block.keys())
elif isinstance(tags, dict):
tag_keys = set(tags.keys())
missing = [t for t in REQUIRED_TAGS if t not in tag_keys]
if not missing:
return CheckResult.PASSED
# Warn mode (D-109, P2): if the resource carries the legacy acdl:*
# tag keys for every required tag, emit a warning rather than a
# hard fail — existing resources still carry acdl:* until P3.
if _WARN_MODE:
has_all_legacy = all(t in tag_keys for t in LEGACY_TAGS)
if has_all_legacy:
sys.stderr.write(
f"[nova_tagging] WARN: {entity_type} has legacy acdl:* tags "
f"but no nova:* tags (P2 warn mode, D-109). Migrate to "
f"nova:* tags before P5.\n"
)
return CheckResult.PASSED
return CheckResult.FAILED
check = NovaTaggingStandard()
+14 -4
View File
@@ -14,7 +14,8 @@ concerns split into two tiers:
The operator-supplied evidence artifact is a JSON blob with `timestamp`, The operator-supplied evidence artifact is a JSON blob with `timestamp`,
`type`, `payload`, and an optional `signature` (JWS detached). Freshness `type`, `payload`, and an optional `signature` (JWS detached). Freshness
is validated against the window from §10.4. Signature verification runs is validated against the window from §10.4. Signature verification runs
when `ACDL_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged when `NOVA_ATTESTATION_SIGNING_KEY_ID` is set (dual-read via core/env.py:
NOVA_* preferred, ACDL_* fallback until P5); it is skipped + logged
when unset (dev/CI — D-089). The matrix fails loud if an operator-supplied when unset (dev/CI — D-089). The matrix fails loud if an operator-supplied
concern is missing or expired for prod/dr. concern is missing or expired for prod/dr.
""" """
@@ -24,6 +25,14 @@ import os
import sys import sys
from typing import Optional, Tuple from typing import Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
# Freshness windows (days) from hitl_matrix_design.md §10.4. # Freshness windows (days) from hitl_matrix_design.md §10.4.
FRESHNESS_DAYS = { FRESHNESS_DAYS = {
@@ -81,14 +90,15 @@ def _is_fresh(artifact: dict, concern: str) -> bool:
def _verify_signature(artifact: dict) -> bool: def _verify_signature(artifact: dict) -> bool:
"""Verify the JWS detached signature when ACDL_ATTESTATION_SIGNING_KEY_ID is set. """Verify the JWS detached signature when NOVA_ATTESTATION_SIGNING_KEY_ID is set.
When unset (dev/CI — D-089), signature verification is skipped + logged. When unset (dev/CI — D-089), signature verification is skipped + logged.
Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
""" """
key_id = os.environ.get("ACDL_ATTESTATION_SIGNING_KEY_ID", "") key_id = env.get_env("ATTESTATION_SIGNING_KEY_ID", "") or ""
if not key_id: if not key_id:
sys.stderr.write( sys.stderr.write(
"[attestation] ACDL_ATTESTATION_SIGNING_KEY_ID unset — " "[attestation] NOVA_ATTESTATION_SIGNING_KEY_ID unset — "
"signature verification skipped (dev/CI, D-089)\n" "signature verification skipped (dev/CI, D-089)\n"
) )
return True return True
+14 -3
View File
@@ -36,6 +36,16 @@ import sys
import yaml import yaml
import jsonschema import jsonschema
# Ensure the repo root (parent of core/) is on sys.path so `from core
# import env` resolves to THIS package when contract_resolver.py is run
# as a script (python3 core/contract_resolver.py) — otherwise an
# editable-installed third-party `core` package can shadow it.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
def _load_env(env_name, repo_root): def _load_env(env_name, repo_root):
"""Load the environment onboarding JSON for env_name. """Load the environment onboarding JSON for env_name.
@@ -620,9 +630,10 @@ if __name__ == "__main__":
idx = sys.argv.index("--environment") idx = sys.argv.index("--environment")
if idx + 1 < len(sys.argv): if idx + 1 < len(sys.argv):
env_override = sys.argv[idx + 1] env_override = sys.argv[idx + 1]
# Also honor the ACDL_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh). # Also honor the NOVA_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
if env_override is None and os.environ.get("ACDL_ENVIRONMENT_OVERRIDE"): # Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
env_override = os.environ["ACDL_ENVIRONMENT_OVERRIDE"] if env_override is None and env.get_env("ENVIRONMENT_OVERRIDE"):
env_override = env.get_env("ENVIRONMENT_OVERRIDE")
result = resolve(contract_path, environment_override=env_override) result = resolve(contract_path, environment_override=env_override)
with open(out_path, "w") as fh: with open(out_path, "w") as fh:
json.dump(result, fh, indent=2) json.dump(result, fh, indent=2)
+48
View File
@@ -0,0 +1,48 @@
"""Dual-read environment helper (D-108, REQ-159, G-106).
During the Nova rebrand transition window (P2P4), every `NOVA_*`
environment variable is the preferred source, with the legacy `ACDL_*`
name as the fallback. This keeps deployments from breaking while the
keys are rotated across `.env`, `.env.secrets`, Gitea repo secrets, and
operator-managed process environments.
`get_env(name, default=None)` resolves `NOVA_<name>` first, then falls
back to `ACDL_<name>`, then returns `default` if neither is set.
This helper is removed (NOVA-only) in P5 (REQ-164). Direct-read paths
that bypass this helper (the `.env.secrets` shell export in
`scripts/run_platform.sh` and the Python parser in
`core/regression_verify.py`) mirror this contract inline per the G-106
binding — see those sites for the dual-read shell/Python forms.
"""
from __future__ import annotations
import os
from typing import Optional
__all__ = ["get_env"]
def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
"""Resolve a config value with a NOVA-preferred / ACDL-fallback read.
`name` is the bare key WITHOUT the prefix (e.g. ``"AWS_ACCOUNT_ID"``).
The lookup order is:
1. ``NOVA_<name>`` (preferred)
2. ``ACDL_<name>`` (legacy fallback, removed in P5)
3. ``default``
Returns the first value that is present and non-empty, or ``default``
if neither env var is set. An explicitly-set empty string is treated
as "unset" so an operator cannot accidentally shadow the fallback
with a blank NOVA key.
"""
nova_val = os.environ.get(f"NOVA_{name}")
if nova_val:
return nova_val
acdl_val = os.environ.get(f"ACDL_{name}")
if acdl_val:
return acdl_val
return default
+3 -3
View File
@@ -93,7 +93,7 @@ The full table (lifted verbatim from §10.4):
The operator-supplied evidence artifact is a JSON blob with `timestamp`, The operator-supplied evidence artifact is a JSON blob with `timestamp`,
`type`, `payload`, and an optional `signature` (JWS detached). Freshness `type`, `payload`, and an optional `signature` (JWS detached). Freshness
is validated against the window above. Signature verification runs when is validated against the window above. Signature verification runs when
`ACDL_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged when `NOVA_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged when
unset (dev/CI — D-089). The matrix fails loud if an operator-supplied unset (dev/CI — D-089). The matrix fails loud if an operator-supplied
concern is missing or expired for prod/dr. concern is missing or expired for prod/dr.
@@ -140,7 +140,7 @@ not Kyverno (in v1). Sequence:
in the same process that has authority to block the promotion. in the same process that has authority to block the promotion.
v1.9 implements `route_halt_artifact` as a real SNS publish (topic v1.9 implements `route_halt_artifact` as a real SNS publish (topic
`acdl-sod-halt`, ARN from `ACDL_SOD_HALT_TOPIC_ARN`) with an outbox-event `acdl-sod-halt`, ARN from `NOVA_SOD_HALT_TOPIC_ARN`) with an outbox-event
fallback when the topic ARN is unset (REQ-107). The attestation gate fallback when the topic ARN is unset (REQ-107). The attestation gate
itself is `core/hitl_gates.py` (`attest(contract_id, env, approver, itself is `core/hitl_gates.py` (`attest(contract_id, env, approver,
evidence)`), which records the approver to the outbox, runs the SoD evidence)`), which records the approver to the outbox, runs the SoD
@@ -171,5 +171,5 @@ v1.9 (Phase 41 + Phase 42) wires the gates end-to-end:
concerns run for real; operator-supplied concerns accept signed concerns run for real; operator-supplied concerns accept signed
evidence artifacts validated for freshness + schema. evidence artifacts validated for freshness + schema.
- **D-089** (v1.9) — attestation artifact signature verification is - **D-089** (v1.9) — attestation artifact signature verification is
skipped when `ACDL_ATTESTATION_SIGNING_KEY_ID` is unset (dev/CI); skipped when `NOVA_ATTESTATION_SIGNING_KEY_ID` is unset (dev/CI);
required for prod/dr. required for prod/dr.
+16 -4
View File
@@ -12,7 +12,8 @@ evidence event) runs end-to-end against the local tier with no AWS:
Each adapter exposes the same interface as the live counterpart so the Each adapter exposes the same interface as the live counterpart so the
caller code path is unchanged; only the I/O target swaps. Selection is caller code path is unchanged; only the I/O target swaps. Selection is
gated on the ACDL_LOCAL_TIER env var (set by run_platform.sh --local). gated on the NOVA_LOCAL_TIER env var (set by run_platform.sh --local).
Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
""" """
from __future__ import annotations from __future__ import annotations
@@ -32,12 +33,20 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
def is_local_tier() -> bool: def is_local_tier() -> bool:
"""True when the local emulating tier is active.""" """True when the local emulating tier is active."""
return os.environ.get("ACDL_LOCAL_TIER", "") == "1" return env.get_env("LOCAL_TIER", "") == "1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -286,7 +295,7 @@ class LocalLambdaStub:
Returns the handler's response dict Returns the handler's response dict
({statusCode, body}). The handler's DynamoDB calls are ({statusCode, body}). The handler's DynamoDB calls are
intercepted via the ACDL_LOCAL_TIER env var (the handler checks intercepted via the NOVA_LOCAL_TIER env var (the handler checks
_get_dynamodb(); under local tier it would need patching - we _get_dynamodb(); under local tier it would need patching - we
patch the module's _get_dynamodb to return a local stub).""" patch the module's _get_dynamodb to return a local stub)."""
# Import the handler module (the dir is named `lambda`, a Python # Import the handler module (the dir is named `lambda`, a Python
@@ -490,6 +499,9 @@ def run_local_e2e(contract_path: str, repo_root: Optional[Path] = None) -> Dict[
if __name__ == "__main__": if __name__ == "__main__":
contract = sys.argv[1] if len(sys.argv) > 1 else "contracts/microservice.yml" contract = sys.argv[1] if len(sys.argv) > 1 else "contracts/microservice.yml"
os.environ["ACDL_LOCAL_TIER"] = "1" # Set both so the dual-read in is_local_tier() finds NOVA_* (preferred);
# the ACDL_* alias stays for any unmigrated reader until P5.
os.environ["NOVA_LOCAL_TIER"] = "1"
os.environ["ACDL_LOCAL_TIER"] = "1" # legacy alias (dual-read fallback), removed in P5
result = run_local_e2e(contract) result = run_local_e2e(contract)
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))
+24 -12
View File
@@ -8,8 +8,10 @@ Two canonical mechanisms:
strings, ALB DNS, S3 bucket URL, CloudFront domain). No raw secrets in strings, ALB DNS, S3 bucket URL, CloudFront domain). No raw secrets in
the comment — only non-sensitive outputs (DNS names, ARNs, bucket names). the comment — only non-sensitive outputs (DNS names, ARNs, bucket names).
The namespace is /acdl/{environment}/{contractId}/{output_name} so consumers The namespace is /nova/{environment}/{contractId}/{output_name} so consumers
can query their own outputs via aws ssm get-parameter --name /acdl/dev/<id>/... can query their own outputs via aws ssm get-parameter --name /nova/dev/<id>/...
(REQ-161, P3: migrated from /acdl/... ; scripts/migrate_ssm_paths.py copies
existing /acdl/... parameters to /nova/... and deletes the old ones.)
""" """
import json import json
@@ -21,8 +23,16 @@ try:
except ImportError: except ImportError:
boto3 = None boto3 = None
SSM_PREFIX = "/acdl" # Repo root on sys.path so `from core import env` resolves to THIS package
KMS_KEY_ID_ENV = "ACDL_KMS_KEY_ID" # when run as a script (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env as _envhelper
SSM_PREFIX = "/nova"
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
# Outputs that are safe to display in a PR comment (no secrets). # Outputs that are safe to display in a PR comment (no secrets).
SAFE_OUTPUT_NAMES = { SAFE_OUTPUT_NAMES = {
@@ -54,20 +64,22 @@ def _ssm_client():
def _kms_key_id(): def _kms_key_id():
"""Return the KMS key ID for SSM SecureString encryption. """Return the KMS key ID for SSM SecureString encryption.
P1-3: Fail loud when ACDL_KMS_KEY_ID is not set — silently falling back P1-3: Fail loud when NOVA_KMS_KEY_ID is not set — silently falling back
to the AWS-managed key (`alias/aws/ssm`) was a security gap. The platform to the AWS-managed key (`alias/aws/ssm`) was a security gap. The platform
CMK must be explicitly configured. Set ACDL_ALLOW_DEFAULT_KMS=1 to use CMK must be explicitly configured. Set NOVA_ALLOW_DEFAULT_KMS=1 to use
the AWS-managed key as an escape hatch for local testing. the AWS-managed key as an escape hatch for local testing. (Dual-read
via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.)
""" """
key_id = os.environ.get(KMS_KEY_ID_ENV) key_id = _envhelper.get_env("KMS_KEY_ID")
if key_id: if key_id:
return key_id return key_id
if os.environ.get("ACDL_ALLOW_DEFAULT_KMS") == "1": if _envhelper.get_env("ALLOW_DEFAULT_KMS") == "1":
return "alias/aws/ssm" return "alias/aws/ssm"
raise RuntimeError( raise RuntimeError(
f"{KMS_KEY_ID_ENV} is not set — refusing to use the AWS-managed SSM key " f"{KMS_KEY_ID_ENV} is not set — refusing to use the AWS-managed SSM key "
f"silently. Set {KMS_KEY_ID_ENV} to your platform CMK ARN, or set " f"silently. Set {KMS_KEY_ID_ENV} to your platform CMK ARN, or set "
f"ACDL_ALLOW_DEFAULT_KMS=1 to use alias/aws/ssm (escape hatch for local testing)." f"NOVA_ALLOW_DEFAULT_KMS=1 (ACDL_ALLOW_DEFAULT_KMS=1 fallback) to use "
f"alias/aws/ssm (escape hatch for local testing)."
) )
@@ -112,7 +124,7 @@ def format_comment(outputs, environment, contract_id, ssm_results=None):
outputs are noted as 'published to SSM' without their values. outputs are noted as 'published to SSM' without their values.
""" """
lines = [ lines = [
f"### ACDL Deploy Outputs ({environment})", f"### Nova Deploy Outputs ({environment})",
"", "",
f"**Contract:** `{contract_id}`", f"**Contract:** `{contract_id}`",
f"**Environment:** `{environment}`", f"**Environment:** `{environment}`",
@@ -134,7 +146,7 @@ def format_comment(outputs, environment, contract_id, ssm_results=None):
ssm_path = "" ssm_path = ""
lines.append(f"| `{name}` | {display} | {ssm_path} |") lines.append(f"| `{name}` | {display} | {ssm_path} |")
lines.append("") lines.append("")
lines.append("> Sensitive outputs are available via `aws ssm get-parameter --name /acdl/" + environment + "/" + contract_id + "/<output_name>` (KMS-encrypted SecureString).") lines.append("> Sensitive outputs are available via `aws ssm get-parameter --name /nova/" + environment + "/" + contract_id + "/<output_name>` (KMS-encrypted SecureString).")
return "\n".join(lines) return "\n".join(lines)
+20 -6
View File
@@ -32,6 +32,15 @@ from dataclasses import dataclass, field, asdict
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple from typing import Callable, Dict, List, Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when regression_verify.py is run as a script (avoids editable-installed
# third-party `core` shadow).
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env as _envhelper
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
CIAgent = ROOT / ".ciagent" CIAgent = ROOT / ".ciagent"
@@ -306,9 +315,13 @@ def _load_aws_env() -> Dict[str, str]:
continue continue
if "=" in line: if "=" in line:
k, v = line.split("=", 1) k, v = line.split("=", 1)
if k == "ACDL_AWS_ACCESS_KEY_ID": # G-106 binding: dual-read NOVA_* first, ACDL_* fallback.
# The .env.secrets keys are renamed to NOVA_* in P2; the
# ACDL_* fallback covers operators who haven't rotated
# their local .env.secrets yet. Removed in P5.
if k == "NOVA_AWS_ACCESS_KEY_ID" or k == "ACDL_AWS_ACCESS_KEY_ID":
env["AWS_ACCESS_KEY_ID"] = v env["AWS_ACCESS_KEY_ID"] = v
elif k == "ACDL_AWS_SECRET_ACCESS_KEY": elif k == "NOVA_AWS_SECRET_ACCESS_KEY" or k == "ACDL_AWS_SECRET_ACCESS_KEY":
env["AWS_SECRET_ACCESS_KEY"] = v env["AWS_SECRET_ACCESS_KEY"] = v
elif k == "AWS_DEFAULT_REGION": elif k == "AWS_DEFAULT_REGION":
env["AWS_DEFAULT_REGION"] = v env["AWS_DEFAULT_REGION"] = v
@@ -319,7 +332,8 @@ def _check_live_terraform_plan_microservice() -> Tuple[Status, str]:
"""CAP-013: terraform init+validate+plan against live AWS for the """CAP-013: terraform init+validate+plan against live AWS for the
microservice stack (D-093 live-AWS tier of the headline E2E). microservice stack (D-093 live-AWS tier of the headline E2E).
Requires AWS credentials (ACDL_AWS_ACCESS_KEY_ID etc. in .env.secrets). Requires AWS credentials (NOVA_AWS_ACCESS_KEY_ID etc. in .env.secrets;
dual-read NOVA_* first, ACDL_* fallback per G-106).
Runs in a temp dir; does NOT apply (plan only).""" Runs in a temp dir; does NOT apply (plan only)."""
import tempfile, os import tempfile, os
work = tempfile.mkdtemp(prefix="acdl_regr_live_") work = tempfile.mkdtemp(prefix="acdl_regr_live_")
@@ -421,7 +435,7 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"), 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_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY")) aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199") account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1" state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
s3.head_bucket(Bucket=state_bucket) s3.head_bucket(Bucket=state_bucket)
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5) r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
@@ -643,8 +657,8 @@ def write_report(report: RegressionReport,
def main() -> int: def main() -> int:
milestone = os.environ.get("ACDL_REGRESSION_MILESTONE", "v1.10") milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
phase = int(os.environ.get("ACDL_REGRESSION_PHASE", "52")) phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
report = run_regression(milestone=milestone, phase=phase) report = run_regression(milestone=milestone, phase=phase)
md, js = write_report(report) md, js = write_report(report)
print(f"regression: {report.summary} -> {md}") print(f"regression: {report.summary} -> {md}")
+15 -4
View File
@@ -5,16 +5,27 @@ Blocks on equality, emits `SEPARATION_OF_DUTIES_VIOLATION`, routes a halt
artifact to SRE on-call. artifact to SRE on-call.
v1.9 (REQ-107, D-085): route_halt_artifact is a real implementation — v1.9 (REQ-107, D-085): route_halt_artifact is a real implementation —
publishes to SNS topic `acdl-sod-halt` (ARN from ACDL_SOD_HALT_TOPIC_ARN) publishes to SNS topic `acdl-sod-halt` (ARN from NOVA_SOD_HALT_TOPIC_ARN)
when set; falls back to a structured stderr emission + a when set; falls back to a structured stderr emission + a
SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox when SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox when
unset. No silent print-only stub. unset. No silent print-only stub. (Dual-read via core/env.py: NOVA_*
preferred, ACDL_* fallback until P5; the SNS topic ARN is the AWS
resource `acdl-sod-halt` → renamed `nova-sod-halt` in P4.)
""" """
import os import os
import sys import sys
from typing import Optional, Tuple from typing import Optional, Tuple
# Repo root on sys.path so `from core import env` resolves to THIS package
# when imported/run in a context where an editable-installed third-party
# `core` package would otherwise shadow it.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
def check(outbox_client, contract_id: str, def check(outbox_client, contract_id: str,
current_prod_approver: Optional[str]) -> Tuple[bool, str]: current_prod_approver: Optional[str]) -> Tuple[bool, str]:
@@ -40,13 +51,13 @@ def route_halt_artifact(contract_id: str, violation_reason: str,
oncall_client=None) -> None: oncall_client=None) -> None:
"""Route a halt artifact to SRE on-call (REQ-107, D-085). """Route a halt artifact to SRE on-call (REQ-107, D-085).
When ACDL_SOD_HALT_TOPIC_ARN is set, publish to the SNS topic via When NOVA_SOD_HALT_TOPIC_ARN is set, publish to the SNS topic via
boto3. When unset (dev/CI), fall back to a structured stderr emission boto3. When unset (dev/CI), fall back to a structured stderr emission
+ a SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox + a SEPARATION_OF_DUTIES_VIOLATION event write to the DynamoDB outbox
via outbox_writer.write_event (so the halt is in the audit chain). via outbox_writer.write_event (so the halt is in the audit chain).
The oncall_client, when provided, is the SNS client (test injection). The oncall_client, when provided, is the SNS client (test injection).
""" """
topic_arn = os.environ.get("ACDL_SOD_HALT_TOPIC_ARN", "") topic_arn = env.get_env("SOD_HALT_TOPIC_ARN", "") or ""
halt_payload = { halt_payload = {
"contractId": contract_id, "contractId": contract_id,
"reason": violation_reason, "reason": violation_reason,
+12 -6
View File
@@ -63,15 +63,18 @@ scheduled into a phase, ships with a grace period, and has a cutoff.
dual-read, you can do this incrementally across P2P4 — but it must be dual-read, you can do this incrementally across P2P4 — but it must be
complete before P5. complete before P5.
### 3. SSM parameter path — Phase P3 ### 3. SSM parameter path — Phase P3 (DONE)
- **Old:** `/acdl/{env}/{contractId}/{output}` - **Old:** `/acdl/{env}/{contractId}/{output}`
- **New:** `/nova/{env}/{contractId}/{output}` - **New:** `/nova/{env}/{contractId}/{output}`
- **Phase:** P3 (SSM paths + tag keys) - **Phase:** P3 (SSM paths + tag keys)**shipped in P3**
- **Grace period — parallel-write:** during P3P4 the platform **writes - **Grace period — parallel-write:** during P3P4 the platform **writes
every output to both** the `/acdl/…` and `/nova/…` SSM paths, and reads every output to both** the `/acdl/…` and `/nova/…` SSM paths, and reads
from `/nova/…` first (falling back to `/acdl/…`). Any hardcoded SSM path from `/nova/…` first (falling back to `/acdl/…`). Any hardcoded SSM path
reads in your application code keep resolving through P4. reads in your application code keep resolving through P4. The P3
migration script (`scripts/migrate_ssm_paths.py`) copies existing
`/acdl/…` parameters to `/nova/…`, verifies the copy, and deletes the
old ones.
- **Cutoff:** P5 stops writing to `/acdl/…` and removes the read fallback. - **Cutoff:** P5 stops writing to `/acdl/…` and removes the read fallback.
After P5 only `/nova/…` exists. After P5 only `/nova/…` exists.
- **What you must do:** if your application code or runbooks read deploy - **What you must do:** if your application code or runbooks read deploy
@@ -80,19 +83,22 @@ scheduled into a phase, ships with a grace period, and has a cutoff.
issue surface, you do nothing — the platform republishes under the new issue surface, you do nothing — the platform republishes under the new
path automatically. path automatically.
### 4. AWS tag keys — Phase P3 ### 4. AWS tag keys — Phase P3 (DONE)
- **Old:** `acdl:owner`, `acdl:environment`, `acdl:contract`, - **Old:** `acdl:owner`, `acdl:environment`, `acdl:contract`,
`acdl:cost-center`, `acdl:ref` `acdl:cost-center`, `acdl:ref`
- **New:** `nova:owner`, `nova:environment`, `nova:contract`, - **New:** `nova:owner`, `nova:environment`, `nova:contract`,
`nova:cost-center`, `nova:ref` `nova:cost-center`, `nova:ref`
- **Phase:** P3 (SSM paths + tag keys) - **Phase:** P3 (SSM paths + tag keys)**shipped in P3**
- **Grace period — parallel-tag period:** during P3P4 the platform - **Grace period — parallel-tag period:** during P3P4 the platform
**tags every resource with both** the `acdl:*` and `nova:*` keys (same **tags every resource with both** the `acdl:*` and `nova:*` keys (same
values). The ABAC session policy matches on **either** key set, so your values). The ABAC session policy matches on **either** key set, so your
existing scoped permissions keep working. The default cost-center value existing scoped permissions keep working. The default cost-center value
moves from `acdl-default` to `nova-default` (both written during the moves from `acdl-default` to `nova-default` (both written during the
parallel-tag period). parallel-tag period). Terraform now emits `nova:*` keys; old `acdl:*`
tags on pre-P3 live resources are removed by the P4 runbook's
`scripts/untag_acdl_keys.py` step after the `nova:*` tags are applied
live.
- **Cutoff:** P5 stops writing the `acdl:*` keys and the ABAC policy matches - **Cutoff:** P5 stops writing the `acdl:*` keys and the ABAC policy matches
only on `nova:*`. After P5, resources created before P5 still carry the only on `nova:*`. After P5, resources created before P5 still carry the
old `acdl:*` tags (tags are not retroactively rewritten) but **new** old `acdl:*` tags (tags are not retroactively rewritten) but **new**
+13 -13
View File
@@ -53,7 +53,7 @@ platform-managed. See [Environments](environments/).
## Step 1 — Create a consumer repo ## Step 1 — Create a consumer repo
Create a repository for your application. The top level holds your app Create a repository for your application. The top level holds your app
code; your contract lives at `.acdl/contract.yml`. Example for a static code; your contract lives at `.nova/contract.yml`. Example for a static
site: site:
``` ```
@@ -62,7 +62,7 @@ my-static-site/
assets/ assets/
style.css style.css
logo.png logo.png
.acdl/ .nova/
contract.yaml contract.yaml
.github/ .github/
workflows/ workflows/
@@ -75,7 +75,7 @@ Example for a microservice:
my-microservice/ my-microservice/
app.py app.py
Dockerfile Dockerfile
.acdl/ .nova/
contract.yaml contract.yaml
.github/ .github/
workflows/ workflows/
@@ -83,7 +83,7 @@ my-microservice/
``` ```
Your app code lives at the top level. Your contract lives at Your app code lives at the top level. Your contract lives at
`.acdl/contract.yml` regardless of the module you deploy. Your CI `.nova/contract.yml` regardless of the module you deploy. Your CI
definition lives at `.github/workflows/deploy.yml`. definition lives at `.github/workflows/deploy.yml`.
## Step 2 — Reference the central pipeline ## Step 2 — Reference the central pipeline
@@ -96,7 +96,7 @@ jobs:
deploy: deploy:
uses: acdl/.github/workflows/deploy.yml@v1.13 uses: acdl/.github/workflows/deploy.yml@v1.13
with: with:
contract: .acdl/contract.yml contract: .nova/contract.yml
environment: dev environment: dev
``` ```
@@ -106,7 +106,7 @@ field; the version pin lives in the CI workflow reference.
## Step 3 — Define the contract ## Step 3 — Define the contract
Write `.acdl/contract.yml`. The `static-assets` example: Write `.nova/contract.yml`. The `static-assets` example:
```yaml ```yaml
environment: dev environment: dev
@@ -179,7 +179,7 @@ jobs:
deploy: deploy:
uses: acdl/.github/workflows/deploy.yml@v1.13 uses: acdl/.github/workflows/deploy.yml@v1.13
with: with:
contract: .acdl/contract.yml contract: .nova/contract.yml
``` ```
That is the entire consumer-side workflow. When you push to `main`: That is the entire consumer-side workflow. When you push to `main`:
@@ -192,7 +192,7 @@ That is the entire consumer-side workflow. When you push to `main`:
never clone the platform repo yourself. never clone the platform repo yourself.
4. The runner installs the runtime dependencies the platform requires. 4. The runner installs the runtime dependencies the platform requires.
5. The runner invokes `scripts/run_platform.sh` against your 5. The runner invokes `scripts/run_platform.sh` against your
`.acdl/contract.yml`. `.nova/contract.yml`.
You see the streamed output (infrastructure plan, policy-check results, You see the streamed output (infrastructure plan, policy-check results,
confidence signal) in your run logs. The `--check-only` and `--plan-only` confidence signal) in your run logs. The `--check-only` and `--plan-only`
@@ -213,7 +213,7 @@ static key in `.env.secrets` (gitignored) is rotated **out of band by you**
locally-held copies. locally-held copies.
```bash ```bash
bash scripts/run_platform.sh --check-only path/to/your/.acdl/contract.yml bash scripts/run_platform.sh --check-only path/to/your/.nova/contract.yml
``` ```
## Step 5 — What the pipeline does ## Step 5 — What the pipeline does
@@ -355,7 +355,7 @@ destruction:
```yaml ```yaml
uses: acdl/.github/workflows/deploy.yml@v1.13 uses: acdl/.github/workflows/deploy.yml@v1.13
with: with:
contract: .acdl/contract.yml contract: .nova/contract.yml
mode: decommission mode: decommission
changeRequestId: "CHG0678912" changeRequestId: "CHG0678912"
``` ```
@@ -404,8 +404,8 @@ input). Promotion = running the matching job.
### Two shapes (both supported) ### Two shapes (both supported)
**Shape 1 — per-environment contract files:** a consumer repo has one **Shape 1 — per-environment contract files:** a consumer repo has one
contract per environment (e.g. `.acdl/static-assets.dev.yml`, contract per environment (e.g. `.nova/static-assets.dev.yml`,
`.acdl/static-assets.qa.yml`, …). Each sets `environment:` to its own `.nova/static-assets.qa.yml`, …). Each sets `environment:` to its own
name and uses interpolation so env-specific values differ automatically: name and uses interpolation so env-specific values differ automatically:
```yaml ```yaml
@@ -439,7 +439,7 @@ jobs:
uses: acdl/.github/workflows/deploy.yml@v1.13 uses: acdl/.github/workflows/deploy.yml@v1.13
with: with:
environment: qa environment: qa
contract: .acdl/contract.yml contract: .nova/contract.yml
``` ```
### One job per environment ### One job per environment
+1 -1
View File
@@ -7,7 +7,7 @@ deploys it.
## The contract file ## The contract file
A consumer repo keeps its contract at `.acdl/contract.yml`. A minimal A consumer repo keeps its contract at `.nova/contract.yml`. A minimal
example (the `static-assets` module): example (the `static-assets` module):
```yaml ```yaml
+1 -1
View File
@@ -18,7 +18,7 @@ There are two kinds of repository in the Nova model:
and the reusable workflow files. Platform engineers work here. A consumer and the reusable workflow files. Platform engineers work here. A consumer
never clones it. never clones it.
- **Consumer repo (yours).** A consumer repo contains only its application - **Consumer repo (yours).** A consumer repo contains only its application
code, one or more contracts (`.acdl/contract.yml`), and one or more CI code, one or more contracts (`.nova/contract.yml`), and one or more CI
definitions (a thin `.github/workflows/deploy.yml` that `uses:` the central definitions (a thin `.github/workflows/deploy.yml` that `uses:` the central
reusable workflow, pointing at the appropriate environment + contract). reusable workflow, pointing at the appropriate environment + contract).
The consumer does not write infrastructure modules, workflow YAML, or The consumer does not write infrastructure modules, workflow YAML, or
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
deploy: deploy:
uses: acdl/.github/workflows/deploy.yml@v1.13 uses: acdl/.github/workflows/deploy.yml@v1.13
with: with:
contract: .acdl/contract.yml contract: .nova/contract.yml
``` ```
The version pin lives in the CI workflow reference (not in the contract The version pin lives in the CI workflow reference (not in the contract
@@ -2,7 +2,7 @@
flowchart LR flowchart LR
A["1. App code<br/>(top level of the repo)"] --> D["Push to main"] A["1. App code<br/>(top level of the repo)"] --> D["Push to main"]
B["2. Contract<br/>(.acdl/contract.yml)"] --> D B["2. Contract<br/>(.nova/contract.yml)"] --> D
C["3. CI definition<br/>(.github/workflows/deploy.yml<br/>— one 'uses:' line)"] --> D C["3. CI definition<br/>(.github/workflows/deploy.yml<br/>— one 'uses:' line)"] --> D
D --> E["Platform does the rest"] D --> E["Platform does the rest"]
classDef accent fill:#1B1B1B,color:#fff,stroke:#D6002A,stroke-width:2px classDef accent fill:#1B1B1B,color:#fff,stroke:#D6002A,stroke-width:2px
@@ -140,7 +140,7 @@ code { font-size: 13px; }
```yaml ```yaml
uses: acdl/.github/workflows/deploy.yml@v1.12 uses: acdl/.github/workflows/deploy.yml@v1.12
with: with:
contract: .acdl/contract.yml contract: .nova/contract.yml
mode: decommission mode: decommission
changeRequestId: "CHG0678912" changeRequestId: "CHG0678912"
``` ```
@@ -456,7 +456,7 @@ img { display: block; margin: 0 auto; max-height: 280px; }
<p><img src="assets/png/developer-experience-07-decommission.png" alt="" style="width:1100px;" /></p> <p><img src="assets/png/developer-experience-07-decommission.png" alt="" style="width:1100px;" /></p>
<pre is="marp-pre" data-auto-scaling="downscale-only"><code class="language-yaml"><span class="hljs-attr">uses:</span> <span class="hljs-string">acdl/.github/workflows/deploy.yml@v1.12</span> <pre is="marp-pre" data-auto-scaling="downscale-only"><code class="language-yaml"><span class="hljs-attr">uses:</span> <span class="hljs-string">acdl/.github/workflows/deploy.yml@v1.12</span>
<span class="hljs-attr">with:</span> <span class="hljs-attr">with:</span>
<span class="hljs-attr">contract:</span> <span class="hljs-string">.acdl/contract.yml</span> <span class="hljs-attr">contract:</span> <span class="hljs-string">.nova/contract.yml</span>
<span class="hljs-attr">mode:</span> <span class="hljs-string">decommission</span> <span class="hljs-attr">mode:</span> <span class="hljs-string">decommission</span>
<span class="hljs-attr">changeRequestId:</span> <span class="hljs-string">&quot;CHG0678912&quot;</span> <span class="hljs-attr">changeRequestId:</span> <span class="hljs-string">&quot;CHG0678912&quot;</span>
</code></pre> </code></pre>
@@ -233,7 +233,7 @@ flowchart LR
```yaml ```yaml
uses: acdl/.github/workflows/deploy.yml@v1.12 uses: acdl/.github/workflows/deploy.yml@v1.12
with: with:
contract: .acdl/contract.yml contract: .nova/contract.yml
mode: decommission mode: decommission
changeRequestId: "CHG0678912" changeRequestId: "CHG0678912"
``` ```
+2 -2
View File
@@ -6,8 +6,8 @@ locals {
# Tags: merge caller-supplied tags with the module defaults. # Tags: merge caller-supplied tags with the module defaults.
tags = merge( tags = merge(
{ {
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:environment" = "dev" "nova:environment" = "dev"
}, },
var.tags var.tags
) )
+1 -1
View File
@@ -13,7 +13,7 @@ Nova uses JSON Schema draft 2020-12 for all declarative contracts. Schemas are t
| Nova Central Pipeline Contract | `pipeline.schema.json` | Central CI pipeline contract (stages, commands, triggers, runner) | `tests/test_pipeline_contract.py` | | Nova Central Pipeline Contract | `pipeline.schema.json` | Central CI pipeline contract (stages, commands, triggers, runner) | `tests/test_pipeline_contract.py` |
| Nova Central Deployment Pipeline Contract | `deploy-pipeline.schema.json` | Central deploy pipeline contract (validate → resolve → plan → checkov → confidence → apply → publish → uptime → comment) | `tests/test_pipeline_contract.py` | | Nova Central Deployment Pipeline Contract | `deploy-pipeline.schema.json` | Central deploy pipeline contract (validate → resolve → plan → checkov → confidence → apply → publish → uptime → comment) | `tests/test_pipeline_contract.py` |
| Nova PolicyCheckResult | `policy_check_result.schema.json` | Normalized policy check result schema (the contract between policy engines and the confidence signal) | `tests/conftest.py`, all adapter tests | | Nova PolicyCheckResult | `policy_check_result.schema.json` | Normalized policy check result schema (the contract between policy engines and the confidence signal) | `tests/conftest.py`, all adapter tests |
| Nova Tagging Standard | `tagging-standard.json` | Required tag set for all taggable AWS resources | `adapters/terraform/policy/custom_rules/acdl_tagging.py` | | Nova Tagging Standard | `tagging-standard.json` | Required tag set for all taggable AWS resources | `adapters/terraform/policy/custom_rules/nova_tagging.py` |
## How to Write a Schema ## How to Write a Schema
+1 -1
View File
@@ -24,7 +24,7 @@
}, },
"ruleId": { "ruleId": {
"type": "string", "type": "string",
"description": "Rule identifier (e.g. CKV_AWS_24, KYVERNO_NO_PRIVILEGED, ACDL_TAG_NAMING)." "description": "Rule identifier (e.g. CKV_AWS_24, KYVERNO_NO_PRIVILEGED, NOVA_TAG_NAMING)."
}, },
"severity": { "severity": {
"type": "string", "type": "string",
+13 -9
View File
@@ -2,41 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://nova.dev/schemas/tagging-standard.json", "$id": "https://nova.dev/schemas/tagging-standard.json",
"title": "Nova Tagging Standard", "title": "Nova Tagging Standard",
"description": "Required tags for all taggable AWS resources created by the platform. Enforced by a Checkov custom Python rule (adapters/terraform/policy/custom_rules/acdl_tagging.py). The checkov adapter maps ACDL_TAG_NAMING as a real rule (D-054, D-043 closure).", "description": "Required tags for all taggable AWS resources created by the platform. Enforced by a Checkov custom Python rule (adapters/terraform/policy/custom_rules/nova_tagging.py, D-109 hard mode in P3 — tag-key values are nova:*; legacy acdl:* tag keys are rejected by the hard-mode rule). The checkov adapter maps NOVA_TAG_NAMING as a real rule (D-054, D-043 closure; renamed from ACDL_TAG_NAMING in P2, REQ-158).",
"type": "object", "type": "object",
"properties": { "properties": {
"required_tags": { "required_tags": {
"type": "object", "type": "object",
"description": "The set of tags that must be present on every taggable AWS resource.", "description": "The set of tags that must be present on every taggable AWS resource.",
"properties": { "properties": {
"acdl:owner": { "nova:owner": {
"type": "string", "type": "string",
"description": "The consumer repository name (e.g. 'consumer-repo'). Injected from the ABAC session." "description": "The consumer repository name (e.g. 'consumer-repo'). Injected from the ABAC session."
}, },
"acdl:contract": { "nova:contract": {
"type": "string", "type": "string",
"description": "The contract ID (UUID)." "description": "The contract ID (UUID)."
}, },
"acdl:environment": { "nova:environment": {
"type": "string", "type": "string",
"enum": ["dev", "qa", "prod", "dr"], "enum": ["dev", "qa", "prod", "dr"],
"description": "The environment name." "description": "The environment name."
}, },
"acdl:cost-center": { "nova:cost-center": {
"type": "string", "type": "string",
"description": "The cost center (consumer-provided or platform-default 'acdl-default')." "description": "The cost center (consumer-provided or platform-default 'nova-default')."
},
"nova:ref": {
"type": "string",
"description": "Optional reference tag (e.g. a change-request ID or external tracker)."
} }
}, },
"required": ["acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center"], "required": ["nova:owner", "nova:contract", "nova:environment", "nova:cost-center"],
"additionalProperties": false "additionalProperties": false
}, },
"default_values": { "default_values": {
"type": "object", "type": "object",
"description": "Default values used when the consumer does not supply the tag.", "description": "Default values used when the consumer does not supply the tag.",
"properties": { "properties": {
"acdl:cost-center": { "nova:cost-center": {
"type": "string", "type": "string",
"default": "acdl-default" "default": "nova-default"
} }
} }
} }
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Migrate SSM parameter paths from /acdl/... → /nova/... (REQ-161, P3).
The Nova rebrand (v1.15) moves the SSM parameter namespace prefix from
`/acdl/{env}/{contractId}/{output}` to `/nova/{env}/{contractId}/{output}`.
This script copies every existing `/acdl/...` parameter to its `/nova/...`
twin (same value, same Type, SecureString preserved, same KMS key), verifies
the copy round-trips, then deletes the old `/acdl/...` parameter.
Design:
- **Dry-run by default.** Prints the planned copy/delete operations without
touching AWS. Pass ``--apply`` to execute.
- **Idempotent.** If the `/nova/...` target already exists with the same
value, the copy is skipped (and reported as a no-op); the old `/acdl/...`
parameter is still deleted (the migration is re-runnable). If the target
exists with a *different* value, the copy is skipped with a WARNING and
the old parameter is NOT deleted (manual review required) unless
``--force`` is passed.
- **Path-mapping logic is unit-tested** (see ``tests/test_migrate_ssm_paths.py``);
the AWS I/O is thin boto3 glue around ``map_path()``.
Usage:
python3 scripts/migrate_ssm_paths.py # dry-run, /acdl → /nova
python3 scripts/migrate_ssm_paths.py --apply # execute
python3 scripts/migrate_ssm_paths.py --source /acdl --dest /nova --apply
python3 scripts/migrate_ssm_paths.py --region us-east-1 --apply
This script does NOT need live AWS to be importable; the boto3 client is
constructed lazily inside ``run()`` so the module can be imported + the
path-mapping logic unit-tested without credentials.
"""
from __future__ import annotations
import argparse
import sys
from typing import Optional
try:
import boto3
except ImportError: # pragma: no cover - boto3 is a test dep
boto3 = None # type: ignore
# ---------------------------------------------------------------------------
# Path-mapping logic (pure, unit-tested)
# ---------------------------------------------------------------------------
def map_path(source_path: str, source_prefix: str = "/acdl", dest_prefix: str = "/nova") -> str:
"""Map an SSM parameter path from the source prefix to the dest prefix.
The match is on a *path-segment* boundary: ``/acdl`` matches ``/acdl/dev/...``
but a literal like ``/acdl-platform`` is left untouched (it does not start
with the ``/acdl/`` segment). A path that does not start with the source
prefix (as a leading segment) raises ``ValueError`` so callers can filter
or surface stray parameters.
Examples:
>>> map_path("/acdl/dev/svc-x/output")
'/nova/dev/svc-x/output'
>>> map_path("/acdl/dev/c-1/vpc_id", "/acdl", "/nova")
'/nova/dev/c-1/vpc_id'
>>> map_path("/acdl/qa/c-2/db_endpoint")
'/nova/qa/c-2/db_endpoint'
"""
if not source_path.startswith(source_prefix + "/"):
raise ValueError(
f"path {source_path!r} does not start with source prefix "
f"{source_prefix!r} (as a path segment)"
)
return dest_prefix + source_path[len(source_prefix):]
def list_acdl_params(client, source_prefix: str = "/acdl"):
"""List all SSM parameters whose Name starts with ``source_prefix/``.
Uses ``DescribeParameters`` with a ParameterFilters Path prefix (the
documented, pagination-friendly way to scope by path). Returns a list of
parameter-summary dicts (Name, Type, KeyId, ...).
"""
params: list[dict] = []
paginator = client.get_paginator("describe_parameters")
iterator = paginator.paginate(
ParameterFilters=[
{"Key": "Path", "Option": "Recursive", "Values": [source_prefix + "/"]}
]
)
for page in iterator:
for p in page.get("Parameters", []):
params.append(p)
return params
def copy_one_param(client, source_name: str, dest_name: str, force: bool = False) -> str:
"""Copy a single SSM parameter from source to dest.
Returns one of: ``"copied"``, ``"skipped-equal"`` (already migrated),
``"skipped-mismatch"`` (dest exists with a different value; needs --force
to overwrite), ``"overwritten"`` (force=True overwrote a mismatching dest).
"""
src = client.get_parameter(Name=source_name, WithDecryption=True)
value = src["Parameter"]["Value"]
ptype = src["Parameter"]["Type"]
key_id = src["Parameter"].get("KeyId")
# Check if dest already exists
try:
dst = client.get_parameter(Name=dest_name, WithDecryption=True)
if dst["Parameter"]["Value"] == value:
return "skipped-equal"
if not force:
return "skipped-mismatch"
except Exception: # ParameterNotFound → proceed to put
pass
put_kwargs = {
"Name": dest_name,
"Value": value,
"Type": ptype,
"Overwrite": True,
}
if ptype == "SecureString" and key_id:
put_kwargs["KeyId"] = key_id
client.put_parameter(**put_kwargs)
return "overwritten" if force else "copied"
def verify_one_param(client, source_name: str, dest_name: str) -> bool:
"""Verify the dest parameter holds the same value as the source."""
src = client.get_parameter(Name=source_name, WithDecryption=True)
dst = client.get_parameter(Name=dest_name, WithDecryption=True)
return src["Parameter"]["Value"] == dst["Parameter"]["Value"]
def delete_one_param(client, name: str) -> None:
"""Delete a single SSM parameter."""
client.delete_parameter(Name=name)
def run(
source_prefix: str = "/acdl",
dest_prefix: str = "/nova",
region: Optional[str] = None,
apply: bool = False,
force: bool = False,
client=None,
) -> dict:
"""Run the migration. Returns a summary dict.
When ``apply`` is False (default, dry-run), no AWS mutations happen the
function lists the source parameters and reports the planned copy/delete
operations. When ``apply`` is True, it copies, verifies, and deletes.
A pre-built boto3 SSM ``client`` may be injected for testing.
"""
if apply and client is None:
if boto3 is None:
raise RuntimeError("boto3 is required for --apply (live AWS)")
client = boto3.client("ssm", region_name=region) if region else boto3.client("ssm")
if client is None and apply:
raise RuntimeError("boto3 SSM client required for --apply")
summary = {"listed": 0, "copied": 0, "skipped_equal": 0, "skipped_mismatch": 0,
"verified": 0, "deleted": 0, "errors": 0, "plan": []}
params = list_acdl_params(client, source_prefix) if apply else _dry_run_list(source_prefix, client)
summary["listed"] = len(params)
for p in params:
src_name = p["Name"]
try:
dest_name = map_path(src_name, source_prefix, dest_prefix)
except ValueError:
summary["errors"] += 1
summary["plan"].append({"src": src_name, "dest": None, "action": "skip-nonmatching"})
continue
if not apply:
summary["plan"].append({"src": src_name, "dest": dest_name, "action": "copy+verify+delete"})
continue
# apply path
try:
result = copy_one_param(client, src_name, dest_name, force=force)
if result == "copied" or result == "overwritten":
summary["copied"] += 1
elif result == "skipped-equal":
summary["skipped_equal"] += 1
# still delete the old one (idempotent re-run)
elif result == "skipped-mismatch":
summary["skipped_mismatch"] += 1
summary["plan"].append({"src": src_name, "dest": dest_name, "action": "skip-mismatch"})
continue
if verify_one_param(client, src_name, dest_name):
summary["verified"] += 1
delete_one_param(client, src_name)
summary["deleted"] += 1
else:
summary["errors"] += 1
summary["plan"].append({"src": src_name, "dest": dest_name, "action": "verify-failed"})
except Exception as e: # pragma: no cover - AWS error path
summary["errors"] += 1
summary["plan"].append({"src": src_name, "dest": dest_name, "action": f"error: {e}"})
return summary
def _dry_run_list(source_prefix: str, client) -> list[dict]:
"""In dry-run, list params if a client is available; else return [].
Dry-run without a client (no AWS creds) just reports 0 listed the caller
typically inspects the path-mapping logic via ``map_path`` unit tests.
"""
if client is None:
return []
return list_acdl_params(client, source_prefix)
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(
description="Migrate SSM parameter paths /acdl/... → /nova/... (REQ-161, P3)."
)
parser.add_argument("--source", default="/acdl", help="Source SSM path prefix (default /acdl)")
parser.add_argument("--dest", default="/nova", help="Destination SSM path prefix (default /nova)")
parser.add_argument("--region", default=None, help="AWS region (default: boto3 default)")
parser.add_argument("--apply", action="store_true", help="Execute the migration (default: dry-run)")
parser.add_argument("--force", action="store_true",
help="Overwrite a dest parameter that exists with a different value (default: skip)")
args = parser.parse_args(argv)
mode = "APPLY" if args.apply else "DRY-RUN"
print(f"[migrate_ssm_paths] {mode}: {args.source}{args.dest} (region={args.region or 'default'})")
summary = run(
source_prefix=args.source,
dest_prefix=args.dest,
region=args.region,
apply=args.apply,
force=args.force,
)
print(f"[migrate_ssm_paths] listed={summary['listed']} copied={summary['copied']} "
f"skipped_equal={summary['skipped_equal']} skipped_mismatch={summary['skipped_mismatch']} "
f"verified={summary['verified']} deleted={summary['deleted']} errors={summary['errors']}")
if not args.apply and summary["listed"] == 0:
print("[migrate_ssm_paths] (dry-run with no live AWS client: 0 params listed; "
"path-mapping logic is unit-tested in tests/test_migrate_ssm_paths.py)")
return 0 if summary["errors"] == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+17 -6
View File
@@ -3,7 +3,8 @@
Steps performed by this script: Steps performed by this script:
1. Load AWS creds from /root/acdl/.env.secrets 1. Load AWS creds from /root/acdl/.env.secrets
(ACDL_AWS_ACCESS_KEY_ID, ACDL_AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION). (NOVA_AWS_ACCESS_KEY_ID, NOVA_AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION;
dual-read ACDL_* fallback until P5).
2. Create the ECR repo `acdl-microservice` if it doesn't exist 2. Create the ECR repo `acdl-microservice` if it doesn't exist
(ecr:DescribeRepositories / ecr:CreateRepository). Region: us-east-1. (ecr:DescribeRepositories / ecr:CreateRepository). Region: us-east-1.
3. Get the ECR login password (ecr:GetAuthorizationToken) and run 3. Get the ECR login password (ecr:GetAuthorizationToken) and run
@@ -26,17 +27,26 @@ import pathlib
import boto3 import boto3
# Repo root on sys.path so `from core import env` resolves to THIS package
# (avoids editable-installed third-party `core` shadow).
_REPO_ROOT = str(pathlib.Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from core import env
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
ENV_FILE = REPO_ROOT / ".env.secrets" ENV_FILE = REPO_ROOT / ".env.secrets"
AWS_ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199") AWS_ACCOUNT_ID = env.get_env("AWS_ACCOUNT_ID", "581513795199")
AWS_REGION = "us-east-1" AWS_REGION = "us-east-1"
ECR_REPO_NAME = "acdl-microservice" ECR_REPO_NAME = "acdl-microservice"
IMAGE_TAG = "latest" IMAGE_TAG = "latest"
def _load_env(path): def _load_env(path):
"""Load ACDL_AWS_* + AWS_DEFAULT_REGION from a flat KEY=VALUE file.""" """Load NOVA_AWS_* (preferred) / ACDL_AWS_* (fallback) + AWS_DEFAULT_REGION
from a flat KEY=VALUE file (dual-read per G-106, until P5)."""
creds = {} creds = {}
with open(path, "r") as fh: with open(path, "r") as fh:
for line in fh: for line in fh:
@@ -54,11 +64,12 @@ def main():
return 2 return 2
creds = _load_env(ENV_FILE) creds = _load_env(ENV_FILE)
access_key = creds.get("ACDL_AWS_ACCESS_KEY_ID") # Dual-read: NOVA_* preferred, ACDL_* fallback (G-106, removed in P5).
secret_key = creds.get("ACDL_AWS_SECRET_ACCESS_KEY") access_key = creds.get("NOVA_AWS_ACCESS_KEY_ID") or creds.get("ACDL_AWS_ACCESS_KEY_ID")
secret_key = creds.get("NOVA_AWS_SECRET_ACCESS_KEY") or creds.get("ACDL_AWS_SECRET_ACCESS_KEY")
region = creds.get("AWS_DEFAULT_REGION", AWS_REGION) region = creds.get("AWS_DEFAULT_REGION", AWS_REGION)
if not access_key or not secret_key: if not access_key or not secret_key:
print("FAIL: ACDL_AWS_ACCESS_KEY_ID / ACDL_AWS_SECRET_ACCESS_KEY missing", print("FAIL: NOVA_AWS_ACCESS_KEY_ID / NOVA_AWS_SECRET_ACCESS_KEY missing",
file=sys.stderr) file=sys.stderr)
return 2 return 2
+24 -12
View File
@@ -1,18 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# scripts/rotate_spike_key.sh - rotate the acdl-spike-runner IAM access key. # scripts/rotate_spike_key.sh - rotate the acdl-spike-runner IAM access key.
# #
# Uses the bootstrap root key (ACDL_BOOTSTRAP_AWS_*) from the env to: # Uses the bootstrap root key (NOVA_BOOTSTRAP_AWS_*, ACDL_BOOTSTRAP_AWS_*
# fallback) from the env to:
# 1. List acdl-spike-runner's access keys. # 1. List acdl-spike-runner's access keys.
# 2. Create a new key. # 2. Create a new key.
# 3. Deactivate + delete the old key(s). # 3. Deactivate + delete the old key(s).
# 4. Write the new key to gitignored .env.secrets (chmod 600). # 4. Write the new key to gitignored .env.secrets (chmod 600).
# 5. Optionally upload to Gitea secrets if ACDL_GITEA_TOKEN is set. # 5. Optionally upload to Gitea secrets if NOVA_GITEA_TOKEN is set.
# #
# Idempotent: re-running always ends with exactly 1 active key for the user. # Idempotent: re-running always ends with exactly 1 active key for the user.
# Does NOT rotate the bootstrap root key (D-034 closure = manual user step). # Does NOT rotate the bootstrap root key (D-034 closure = manual user step).
# #
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is # Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
# v1.2 (blocked on go-gitea/gitea#36988). # v1.2 (blocked on go-gitea/gitea#36988).
# Nova rebrand (P2): writes NOVA_* keys; ACDL_* bootstrap fallback kept
# until P5 (the AWS user/role rename acdl-spike-runner → nova-spike-runner
# is P4 territory — left unchanged here).
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
@@ -20,8 +24,9 @@ ENV_FILE="$ROOT/.env.secrets"
fail() { echo "FAIL: $*" >&2; exit 1; } fail() { echo "FAIL: $*" >&2; exit 1; }
: "${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID to the root key}" # Dual-read bootstrap creds: NOVA_* preferred, ACDL_* fallback (removed in P5).
: "${ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY to the root key}" : "${NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID:-${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID (or ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID) to the root key}}"
: "${NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:-${ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY (or ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY) to the root key}}"
REGION="${AWS_DEFAULT_REGION:-us-east-1}" REGION="${AWS_DEFAULT_REGION:-us-east-1}"
USER_NAME="acdl-spike-runner" USER_NAME="acdl-spike-runner"
@@ -38,9 +43,13 @@ region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
user = "acdl-spike-runner" user = "acdl-spike-runner"
env_file = os.path.join(os.getcwd(), ".env.secrets") env_file = os.path.join(os.getcwd(), ".env.secrets")
# Dual-read bootstrap creds: NOVA_* preferred, ACDL_* fallback (G-106, removed in P5).
bootstrap_key = os.environ.get("NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID") or os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"]
bootstrap_secret = os.environ.get("NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY") or os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"]
session = boto3.Session( session = boto3.Session(
aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"], aws_access_key_id=bootstrap_key,
aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"], aws_secret_access_key=bootstrap_secret,
region_name=region, region_name=region,
) )
iam = session.client("iam") iam = session.client("iam")
@@ -65,20 +74,23 @@ for k in active:
print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr) print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr)
# Write the new key to gitignored .env.secrets (chmod 600). # Write the new key to gitignored .env.secrets (chmod 600).
# Nova rebrand (P2): keys are NOVA_*; the ACDL_* legacy keys are the
# dual-read fallback source until P5 (kept as comments in .env.secrets).
with open(env_file, "w") as fh: with open(env_file, "w") as fh:
fh.write(f"ACDL_AWS_ACCESS_KEY_ID={new_id}\n") fh.write(f"NOVA_AWS_ACCESS_KEY_ID={new_id}\n")
fh.write(f"ACDL_AWS_SECRET_ACCESS_KEY={new_secret}\n") fh.write(f"NOVA_AWS_SECRET_ACCESS_KEY={new_secret}\n")
fh.write(f"AWS_DEFAULT_REGION={region}\n") fh.write(f"AWS_DEFAULT_REGION={region}\n")
os.chmod(env_file, 0o600) os.chmod(env_file, 0o600)
print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr) print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr)
# Optionally upload to Gitea secrets. # Optionally upload to Gitea secrets.
gitea_token = os.environ.get("ACDL_GITEA_TOKEN") # Dual-read token: NOVA_GITEA_TOKEN preferred, ACDL_GITEA_TOKEN fallback (G-106).
gitea_token = os.environ.get("NOVA_GITEA_TOKEN") or os.environ.get("ACDL_GITEA_TOKEN")
if gitea_token: if gitea_token:
import urllib.request import urllib.request
base = "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/secrets" base = "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/secrets"
for name, value in [("ACDL_AWS_ACCESS_KEY_ID", new_id), for name, value in [("NOVA_AWS_ACCESS_KEY_ID", new_id),
("ACDL_AWS_SECRET_ACCESS_KEY", new_secret)]: ("NOVA_AWS_SECRET_ACCESS_KEY", new_secret)]:
req = urllib.request.Request( req = urllib.request.Request(
f"{base}/{name}", f"{base}/{name}",
data=json.dumps({"value": value}).encode(), data=json.dumps({"value": value}).encode(),
@@ -92,7 +104,7 @@ if gitea_token:
except Exception as e: except Exception as e:
print(f"gitea: secret {name} upload FAILED: {e}", file=sys.stderr) print(f"gitea: secret {name} upload FAILED: {e}", file=sys.stderr)
else: else:
print("gitea: ACDL_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr) print("gitea: NOVA_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr)
print(f"OK: {user} now has exactly 1 active key: {new_id}") print(f"OK: {user} now has exactly 1 active key: {new_id}")
PY PY
+2 -1
View File
@@ -45,11 +45,12 @@ python3 -m py_compile \
core/confidence_signal.py \ core/confidence_signal.py \
core/outbox_writer.py \ core/outbox_writer.py \
core/output_publisher.py \ core/output_publisher.py \
core/env.py \
core/contract_resolver.py \ core/contract_resolver.py \
core/lambda/contract_ingestor.py \ core/lambda/contract_ingestor.py \
adapters/terraform/adapter.py \ adapters/terraform/adapter.py \
adapters/terraform/policy/checkov_adapter.py \ adapters/terraform/policy/checkov_adapter.py \
adapters/terraform/policy/custom_rules/acdl_tagging.py \ adapters/terraform/policy/custom_rules/nova_tagging.py \
adapters/wiz/wiz_adapter.py \ adapters/wiz/wiz_adapter.py \
adapters/kyverno/kyverno_adapter.py \ adapters/kyverno/kyverno_adapter.py \
scripts/push_consumer_image.py \ scripts/push_consumer_image.py \
+10 -5
View File
@@ -4,7 +4,8 @@
# Usage: run_l2_lifecycle_destroy.sh <module> # Usage: run_l2_lifecycle_destroy.sh <module>
# #
# Wraps run_platform.sh for L2 composition modules in the modules-lifecycle # Wraps run_platform.sh for L2 composition modules in the modules-lifecycle
# pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state. # pipeline. Sets NOVA_REMOTE_STATE_KEY (ACDL_REMOTE_STATE_KEY fallback until
# P5) to point to the CI VPC state.
# #
# NOTE: unlike the L1 scripts (run_lifecycle_destroy.sh), the L2 path does # NOTE: unlike the L1 scripts (run_lifecycle_destroy.sh), the L2 path does
# NOT take a ci-vpc-outputs.json argument. L2 compositions reference the # NOT take a ci-vpc-outputs.json argument. L2 compositions reference the
@@ -13,7 +14,8 @@
# parity with the L1 matrix, but $2 is accepted-but-ignored here (documented, # parity with the L1 matrix, but $2 is accepted-but-ignored here (documented,
# not a bug). # not a bug).
# #
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" = no-op # Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
# ACDL_* fallback until P5) default "plan" = no-op
# (plan mode never applies resources, so there is nothing to destroy). # (plan mode never applies resources, so there is nothing to destroy).
# Set to "full" for the real `--destroy` against live AWS. # Set to "full" for the real `--destroy` against live AWS.
set -euo pipefail set -euo pipefail
@@ -23,7 +25,8 @@ cd "$ROOT"
MODULE="$1" MODULE="$1"
# Lifecycle mode: "plan" (default) skips destroy; "full" runs the real destroy. # Lifecycle mode: "plan" (default) skips destroy; "full" runs the real destroy.
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" # Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
if [ "$LIFECYCLE_MODE" != "full" ]; then if [ "$LIFECYCLE_MODE" != "full" ]; then
echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0" echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0"
@@ -32,8 +35,10 @@ fi
CONTRACT="modules/l2/${MODULE}/examples/complex.yml" CONTRACT="modules/l2/${MODULE}/examples/complex.yml"
# Point terraform_remote_state to the CI VPC state (not the platform VPC) # Point terraform_remote_state to the CI VPC state (not the platform VPC).
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate" # Set both NOVA_* (preferred) and ACDL_* (legacy fallback) until P5.
export NOVA_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate" # legacy fallback, removed in P5
# Run the platform lifecycle destroy command # Run the platform lifecycle destroy command
bash scripts/run_platform.sh --destroy "$CONTRACT" bash scripts/run_platform.sh --destroy "$CONTRACT"
+14 -8
View File
@@ -4,19 +4,21 @@
# Usage: run_l2_lifecycle_test.sh <module> <example> # Usage: run_l2_lifecycle_test.sh <module> <example>
# #
# Wraps run_platform.sh for L2 composition modules in the modules-lifecycle # Wraps run_platform.sh for L2 composition modules in the modules-lifecycle
# pipeline. Sets ACDL_REMOTE_STATE_KEY to point to the CI VPC state so the # pipeline. Sets NOVA_REMOTE_STATE_KEY (ACDL_REMOTE_STATE_KEY fallback until
# microservice composition's terraform_remote_state data source reads from # P5) to point to the CI VPC state so the microservice composition's
# the short-lived CI VPC (not the long-lived platform VPC). # terraform_remote_state data source reads from the short-lived CI VPC
# (not the long-lived platform VPC).
# #
# NOTE: unlike the L1 scripts (run_lifecycle_test.sh), the L2 path does NOT # NOTE: unlike the L1 scripts (run_lifecycle_test.sh), the L2 path does NOT
# take a ci-vpc-outputs.json argument. L2 compositions reference the platform # take a ci-vpc-outputs.json argument. L2 compositions reference the platform
# VPC via terraform_remote_state (a data source), not by injecting VPC # VPC via terraform_remote_state (a data source), not by injecting VPC
# outputs into the contract. The ACDL_REMOTE_STATE_KEY env var points the # outputs into the contract. The NOVA_REMOTE_STATE_KEY env var points the
# data source at the correct CI VPC state key. The workflow passes 3 # data source at the correct CI VPC state key. The workflow passes 3
# positional args for parity with the L1 matrix, but $3 is accepted-but- # positional args for parity with the L1 matrix, but $3 is accepted-but-
# ignored here (documented, not a bug). # ignored here (documented, not a bug).
# #
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" runs # Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
# ACDL_* fallback until P5) default "plan" runs
# `run_platform.sh --plan-only` (fast, no AWS mutation). Set to "full" for # `run_platform.sh --plan-only` (fast, no AWS mutation). Set to "full" for
# the real `--apply` against live AWS. # the real `--apply` against live AWS.
set -euo pipefail set -euo pipefail
@@ -27,12 +29,16 @@ MODULE="$1"
EXAMPLE="$2" # simple or complex EXAMPLE="$2" # simple or complex
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS). # Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" # Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
CONTRACT="modules/l2/${MODULE}/examples/${EXAMPLE}.yml" CONTRACT="modules/l2/${MODULE}/examples/${EXAMPLE}.yml"
# Point terraform_remote_state to the CI VPC state (not the platform VPC) # Point terraform_remote_state to the CI VPC state (not the platform VPC).
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate" # Set both NOVA_* (preferred by the dual-read helper) and ACDL_* (legacy
# fallback) so any unmigrated reader finds the key until P5.
export NOVA_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate"
export ACDL_REMOTE_STATE_KEY="spike/ci-vpc/terraform.tfstate" # legacy fallback, removed in P5
# Run the platform lifecycle command (plan-only by default; full = apply). # Run the platform lifecycle command (plan-only by default; full = apply).
if [ "$LIFECYCLE_MODE" = "full" ]; then if [ "$LIFECYCLE_MODE" = "full" ]; then
+4 -2
View File
@@ -6,7 +6,8 @@
# For VPC-dependent modules, injects CI VPC outputs into the complex contract # For VPC-dependent modules, injects CI VPC outputs into the complex contract
# before destroy (so terraform can find the resources in the right VPC). # before destroy (so terraform can find the resources in the right VPC).
# #
# Lifecycle mode (REQ-134): ACDL_LIFECYCLE_MODE default "plan" = no-op # Lifecycle mode (REQ-134): NOVA_LIFECYCLE_MODE (dual-read NOVA_* preferred,
# ACDL_* fallback until P5) default "plan" = no-op
# (plan mode never applies resources, so there is nothing to destroy; the # (plan mode never applies resources, so there is nothing to destroy; the
# script exits 0 so the pipeline matrix cell stays green). Set to "full" # script exits 0 so the pipeline matrix cell stays green). Set to "full"
# for the real `--destroy` against live AWS. # for the real `--destroy` against live AWS.
@@ -19,7 +20,8 @@ CI_VPC_OUTPUTS="${2:-}"
# Lifecycle mode: "plan" (default) skips destroy (nothing was applied); # Lifecycle mode: "plan" (default) skips destroy (nothing was applied);
# "full" runs the real terraform destroy. # "full" runs the real terraform destroy.
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" # Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
if [ "$LIFECYCLE_MODE" != "full" ]; then if [ "$LIFECYCLE_MODE" != "full" ]; then
echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0" echo "lifecycle mode=$LIFECYCLE_MODE — nothing to destroy (plan-only run), exiting 0"
+5 -3
View File
@@ -10,8 +10,9 @@
# The CI VPC is short-lived (created/destroyed by the pipeline), separate # The CI VPC is short-lived (created/destroyed by the pipeline), separate
# from the long-lived platform VPC. # from the long-lived platform VPC.
# #
# Lifecycle mode (REQ-134): the ACDL_LIFECYCLE_MODE env var selects the # Lifecycle mode (REQ-134): the NOVA_LIFECYCLE_MODE env var selects the
# tier. Default "plan" runs `run_platform.sh --plan-only` (fast, no AWS # tier (dual-read NOVA_* preferred, ACDL_* fallback until P5). Default
# "plan" runs `run_platform.sh --plan-only` (fast, no AWS
# mutation, validates the contract->resolver->adapter->plan chain for # mutation, validates the contract->resolver->adapter->plan chain for
# every module). Set to "full" to run the real `--apply` (terraform apply # every module). Set to "full" to run the real `--apply` (terraform apply
# against live AWS). The CI variable is passed via the workflow input # against live AWS). The CI variable is passed via the workflow input
@@ -25,7 +26,8 @@ EXAMPLE="$2" # simple or complex
CI_VPC_OUTPUTS="${3:-}" CI_VPC_OUTPUTS="${3:-}"
# Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS). # Lifecycle mode: "plan" (default, fast) or "full" (real apply against AWS).
LIFECYCLE_MODE="${ACDL_LIFECYCLE_MODE:-plan}" # Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
LIFECYCLE_MODE="${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}"
CONTRACT="modules/l1/${MODULE}/examples/${EXAMPLE}.yml" CONTRACT="modules/l1/${MODULE}/examples/${EXAMPLE}.yml"
+22 -14
View File
@@ -40,7 +40,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Capture the caller's CWD before we cd to ROOT. The reusable deploy workflow # Capture the caller's CWD before we cd to ROOT. The reusable deploy workflow
# invokes this script from the CONSUMER repo's workspace root with a relative # invokes this script from the CONSUMER repo's workspace root with a relative
# contract path (e.g. .acdl/contract.yml); the contract must resolve against # contract path (e.g. .nova/contract.yml); the contract must resolve against
# the consumer repo, not the platform repo (platform/). Without this, the # the consumer repo, not the platform repo (platform/). Without this, the
# `[ -f "$CONTRACT" ]` check below looks for the contract inside the platform # `[ -f "$CONTRACT" ]` check below looks for the contract inside the platform
# repo and fails (P0 fix — see docs/CONSUMER_GUIDE.md Step 4). # repo and fails (P0 fix — see docs/CONSUMER_GUIDE.md Step 4).
@@ -118,10 +118,12 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
# state, and the contract-ingestor Lambda in-process. Exits 0 on success. # state, and the contract-ingestor Lambda in-process. Exits 0 on success.
if [ "$LOCAL_TIER" = "1" ]; then if [ "$LOCAL_TIER" = "1" ]; then
[ -n "$CONTRACT" ] || CONTRACT="contracts/microservice.yml" [ -n "$CONTRACT" ] || CONTRACT="contracts/microservice.yml"
echo "=== ACDL Local Emulating Tier (D-092) ===" echo "=== Nova Local Emulating Tier (D-092) ==="
echo "contract: $CONTRACT (no AWS credentials required)" echo "contract: $CONTRACT (no AWS credentials required)"
echo "" echo ""
ACDL_LOCAL_TIER=1 python3 core/local_emulators.py "$CONTRACT" \ # Dual-read: set NOVA_LOCAL_TIER (preferred); ACDL_LOCAL_TIER fallback
# kept for any unmigrated reader until P5 (removed in P5).
NOVA_LOCAL_TIER=1 ACDL_LOCAL_TIER=1 python3 core/local_emulators.py "$CONTRACT" \
|| fail "local E2E failed" || fail "local E2E failed"
echo "" echo ""
echo "=== LOCAL E2E OK ===" echo "=== LOCAL E2E OK ==="
@@ -147,7 +149,8 @@ rm -rf "$WORK"; mkdir -p "$TF_DIR"
echo "=== Step 0: environment onboarding check ===" echo "=== Step 0: environment onboarding check ==="
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
export ACDL_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE" export NOVA_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE"
export ACDL_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE" # legacy fallback, removed in P5
python3 core/environment_check.py --env="$ENVIRONMENT_OVERRIDE" || { python3 core/environment_check.py --env="$ENVIRONMENT_OVERRIDE" || {
echo "FAIL: environment not bound — see the onboarding prompt above" >&2 echo "FAIL: environment not bound — see the onboarding prompt above" >&2
exit 1 exit 1
@@ -285,8 +288,11 @@ if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
set -a set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
export AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID" # G-106 binding: dual-read NOVA_* first, ACDL_* fallback. The .env.secrets
export AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY" # keys are renamed to NOVA_* in P2; the ACDL_* fallback covers operators
# who haven't rotated their local .env.secrets yet. Removed in P5.
export AWS_ACCESS_KEY_ID="${NOVA_AWS_ACCESS_KEY_ID:-$ACDL_AWS_ACCESS_KEY_ID}"
export AWS_SECRET_ACCESS_KEY="${NOVA_AWS_SECRET_ACCESS_KEY:-$ACDL_AWS_SECRET_ACCESS_KEY}"
export AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" export AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION"
fi fi
@@ -333,16 +339,17 @@ if [ "$APPLY_ONLY" = "1" ]; then
import os, sys import os, sys
sys.path.insert(0, '.') sys.path.insert(0, '.')
from core.hitl_gates import attest from core.hitl_gates import attest
contract_id = os.environ['ACDL_HITL_CONTRACT_ID'] from core import env as _envhelper
env = os.environ['ACDL_HITL_ENV'] contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
approver = os.environ.get('ACDL_HITL_APPROVER', '') or 'local-test' 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) ok, reason = attest(contract_id, env, approver)
if ok: if ok:
print(f'HITL PASS: {reason}') print(f'HITL PASS: {reason}')
else: else:
print(f'HITL BLOCK: {reason}', file=sys.stderr) print(f'HITL BLOCK: {reason}', file=sys.stderr)
sys.exit(1) sys.exit(1)
" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; } " NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
else else
echo "Environment is dev — autonomous (no HITL gate)." echo "Environment is dev — autonomous (no HITL gate)."
fi fi
@@ -448,16 +455,17 @@ if [ "$RESOLVED_ENV" != "dev" ]; then
import os, sys import os, sys
sys.path.insert(0, '.') sys.path.insert(0, '.')
from core.hitl_gates import attest from core.hitl_gates import attest
contract_id = os.environ['ACDL_HITL_CONTRACT_ID'] from core import env as _envhelper
env = os.environ['ACDL_HITL_ENV'] contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
approver = os.environ.get('ACDL_HITL_APPROVER', '') or 'local-test' 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) ok, reason = attest(contract_id, env, approver)
if ok: if ok:
print(f'HITL PASS: {reason}') print(f'HITL PASS: {reason}')
else: else:
print(f'HITL BLOCK: {reason}', file=sys.stderr) print(f'HITL BLOCK: {reason}', file=sys.stderr)
sys.exit(1) sys.exit(1)
" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; } " NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" ACDL_HITL_CONTRACT_ID="$CONTRACT_ID" ACDL_HITL_ENV="$RESOLVED_ENV" ACDL_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
else else
echo "Environment is dev — autonomous (no HITL gate)." echo "Environment is dev — autonomous (no HITL gate)."
fi fi
+5 -3
View File
@@ -7,8 +7,9 @@
# #
# Usage: # Usage:
# bash scripts/run_regression.sh # run all checks # bash scripts/run_regression.sh # run all checks
# ACDL_REGRESSION_MILESTONE=v1.10 ACDL_REGRESSION_PHASE=52 \ # NOVA_REGRESSION_MILESTONE=v1.10 NOVA_REGRESSION_PHASE=52 \
# bash scripts/run_regression.sh # override metadata # bash scripts/run_regression.sh # override metadata
# (ACDL_REGRESSION_* legacy fallback kept until P5)
# #
# Output: # Output:
# .ciagent/REGRESSION_REPORT.md human-readable report # .ciagent/REGRESSION_REPORT.md human-readable report
@@ -17,8 +18,9 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
echo "=== ACDL Regression VERIFY (D-091) ===" echo "=== Nova Regression VERIFY (D-091) ==="
echo "milestone: ${ACDL_REGRESSION_MILESTONE:-v1.10} phase: ${ACDL_REGRESSION_PHASE:-52}" # Dual-read: NOVA_* preferred, ACDL_* fallback (removed in P5).
echo "milestone: ${NOVA_REGRESSION_MILESTONE:-${ACDL_REGRESSION_MILESTONE:-v1.10}} phase: ${NOVA_REGRESSION_PHASE:-${ACDL_REGRESSION_PHASE:-52}}"
echo "" echo ""
python3 core/regression_verify.py python3 core/regression_verify.py
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Remove legacy `acdl:*` tag keys from all tagged AWS resources (REQ-162, P3).
The Nova rebrand (v1.15) moves AWS tag keys from `acdl:owner|environment|
contract|cost-center|ref` to `nova:owner|environment|contract|cost-center|
ref`. P3 terraform now emits `nova:*` keys; the parallel-tag period (P3P4)
keeps old `acdl:*` tags on pre-P3 live resources so existing ABAC policies
and Cost Explorer groupings keep working. Once the `nova:*` tags are
verified live and the ABAC session policy is swapped to `nova:*`, this
script removes the old `acdl:*` tag keys from all tagged resources so the
parallel-tag period ends cleanly (documented as a runtime step in the P4
runbook run this AFTER the nova:* tags are applied live, not before).
Design:
- **Dry-run by default.** Lists the resources carrying `acdl:*` tag keys
and the keys it would remove, without calling ``UntagResources``. Pass
``--apply`` to execute.
- **Idempotent.** Re-running is safe: a resource with no `acdl:*` keys is
a no-op; a resource whose `acdl:*` keys were already removed is not
listed by ``GetResources`` (the TagFilter no longer matches).
- **Key-list logic is unit-tested** (see ``tests/test_untag_acdl_keys.py``);
the AWS I/O is thin boto3 glue around ``acdl_keys_in()`` +
``keys_to_untag()``.
Usage:
python3 scripts/untag_acdl_keys.py # dry-run (all acdl:* keys)
python3 scripts/untag_acdl_keys.py --apply # execute
python3 scripts/untag_acdl_keys.py --region us-east-1 --apply
python3 scripts/untag_acdl_keys.py --key acdl:owner --key acdl:ref --apply
This script does NOT need live AWS to be importable; the boto3 client is
constructed lazily inside ``run()`` so the module can be imported + the
key-list logic unit-tested without credentials.
"""
from __future__ import annotations
import argparse
import sys
from typing import Iterable, Optional
try:
import boto3
except ImportError: # pragma: no cover - boto3 is a test dep
boto3 = None # type: ignore
# The full legacy tag-key set (mirrors nova_tagging.py LEGACY_TAGS + acdl:ref).
DEFAULT_LEGACY_KEYS = (
"acdl:owner",
"acdl:contract",
"acdl:environment",
"acdl:cost-center",
"acdl:ref",
)
# ---------------------------------------------------------------------------
# Key-list logic (pure, unit-tested)
# ---------------------------------------------------------------------------
def acdl_keys_in(tag_keys: Iterable[str], legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[str]:
"""Return the subset of ``tag_keys`` that are legacy ``acdl:*`` keys.
Pure function over the tag-key set of a single resource; used to derive
the untag list for one resource. Order is preserved (input order).
Examples:
>>> acdl_keys_in(["acdl:owner", "nova:owner", "Name", "acdl:cost-center"])
['acdl:owner', 'acdl:cost-center']
>>> acdl_keys_in(["nova:owner", "nova:contract", "Name"])
[]
>>> acdl_keys_in([])
[]
"""
legacy_set = set(legacy_keys)
return [k for k in tag_keys if k in legacy_set]
def keys_to_untag(resource: dict, legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[str]:
"""Extract the acdl:* keys to untag from a resourcegroupstaggingapi resource blob.
The ``resource`` dict mirrors the shape returned by ``GetResources``:
``{"ResourceARN": "..., "Tags": [{"Key": "...", "Value": "..."}, ...]}``.
Returns the list of legacy acdl:* keys present on that resource.
"""
tags = resource.get("Tags", []) or []
tag_keys = [t.get("Key") for t in tags if isinstance(t, dict) and t.get("Key")]
return acdl_keys_in(tag_keys, legacy_keys=legacy_keys)
def list_tagged_resources(client, legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[dict]:
"""List all resources carrying any legacy ``acdl:*`` tag key.
Uses ``resourcegroupstaggingapi:GetResources`` with a TagFilter per legacy
key (the API filters are OR'd across the TagFilter list). Pagination
handled via the built-in paginator. Returns a list of resource blobs
``{"ResourceARN": ..., "Tags": [...]}``.
"""
tag_filters = [{"Key": k} for k in legacy_keys]
resources: list[dict] = []
paginator = client.get_paginator("get_resources")
for page in paginator.paginate(TagFilters=tag_filters):
for r in page.get("ResourceMappingList", []):
resources.append(r)
return resources
def untag_one(client, resource_arn: str, keys: list[str]) -> None:
"""Remove the given tag keys from a single resource."""
client.untag_resources(ResourceARNList=[resource_arn], TagKeys=keys)
def run(
legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS,
region: Optional[str] = None,
apply: bool = False,
client=None,
) -> dict:
"""Run the untag pass. Returns a summary dict.
When ``apply`` is False (default, dry-run), no AWS mutations happen
the function lists resources carrying acdl:* keys and reports the keys
it would remove. When ``apply`` is True, it calls ``UntagResources`` per
resource.
A pre-built boto3 resourcegroupstaggingapi ``client`` may be injected
for testing.
"""
if apply and client is None:
if boto3 is None:
raise RuntimeError("boto3 is required for --apply (live AWS)")
client = boto3.client("resourcegroupstaggingapi", region_name=region) if region else boto3.client("resourcegroupstaggingapi")
if client is None and apply:
raise RuntimeError("boto3 resourcegroupstaggingapi client required for --apply")
summary = {"listed": 0, "untagged": 0, "keys_removed": 0, "errors": 0, "plan": []}
legacy_list = list(legacy_keys)
resources = list_tagged_resources(client, legacy_keys=legacy_list) if apply else _dry_run_list(legacy_list, client)
summary["listed"] = len(resources)
for r in resources:
arn = r.get("ResourceARN", "")
keys = keys_to_untag(r, legacy_keys=legacy_list)
if not keys:
continue
if not apply:
summary["plan"].append({"arn": arn, "keys": keys, "action": "untag"})
continue
try:
untag_one(client, arn, keys)
summary["untagged"] += 1
summary["keys_removed"] += len(keys)
except Exception as e: # pragma: no cover - AWS error path
summary["errors"] += 1
summary["plan"].append({"arn": arn, "keys": keys, "action": f"error: {e}"})
return summary
def _dry_run_list(legacy_keys: list[str], client) -> list[dict]:
"""In dry-run, list resources if a client is available; else return []."""
if client is None:
return []
return list_tagged_resources(client, legacy_keys=legacy_keys)
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(
description="Remove legacy acdl:* tag keys from all tagged AWS resources (REQ-162, P3)."
)
parser.add_argument("--key", action="append", default=None,
help="Legacy acdl:* key to remove (repeatable; default: all 5 acdl:* keys)")
parser.add_argument("--region", default=None, help="AWS region (default: boto3 default)")
parser.add_argument("--apply", action="store_true", help="Execute the untag pass (default: dry-run)")
args = parser.parse_args(argv)
legacy_keys = tuple(args.key) if args.key else DEFAULT_LEGACY_KEYS
mode = "APPLY" if args.apply else "DRY-RUN"
print(f"[untag_acdl_keys] {mode}: removing keys {list(legacy_keys)} (region={args.region or 'default'})")
summary = run(
legacy_keys=legacy_keys,
region=args.region,
apply=args.apply,
)
print(f"[untag_acdl_keys] listed={summary['listed']} untagged={summary['untagged']} "
f"keys_removed={summary['keys_removed']} errors={summary['errors']}")
if not args.apply and summary["listed"] == 0:
print("[untag_acdl_keys] (dry-run with no live AWS client: 0 resources listed; "
"key-list logic is unit-tested in tests/test_untag_acdl_keys.py)")
return 0 if summary["errors"] == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+4 -4
View File
@@ -33,8 +33,8 @@ resource "aws_vpc" "ci" {
cidr_block = "10.1.0.0/16" cidr_block = "10.1.0.0/16"
tags = { tags = {
Name = "acdl-ci-vpc" Name = "acdl-ci-vpc"
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:environment" = "ci" "nova:environment" = "ci"
} }
} }
@@ -45,8 +45,8 @@ resource "aws_subnet" "ci" {
availability_zone = data.aws_availability_zones.available.names[count.index] availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { tags = {
Name = "acdl-ci-subnet-${count.index}" Name = "acdl-ci-subnet-${count.index}"
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:environment" = "ci" "nova:environment" = "ci"
} }
} }
+1 -1
View File
@@ -59,7 +59,7 @@ flow:
applies [`consumer_invoke_policy.json`](./consumer_invoke_policy.json) applies [`consumer_invoke_policy.json`](./consumer_invoke_policy.json)
to the consumer's deploy role. The policy grants to the consumer's deploy role. The policy grants
`lambda:InvokeFunctionUrl` on the Lambda ARN, scoped via ABAC — the `lambda:InvokeFunctionUrl` on the Lambda ARN, scoped via ABAC — the
condition `aws:PrincipalTag/acdl:owner == ${consumerRepo}` ensures a condition `aws:PrincipalTag/nova:owner == ${consumerRepo}` ensures a
repo can only invoke when it is the owner it claims to be. repo can only invoke when it is the owner it claims to be.
2. **Runtime.** The consumer's deploy workflow (running in the consumer 2. **Runtime.** The consumer's deploy workflow (running in the consumer
AWS account under the consumer's deploy role) signs the Function URL AWS account under the consumer's deploy role) signs the Function URL
@@ -7,7 +7,7 @@
"Resource": "arn:aws:lambda:${region}:${account_id}:function:acdl-contract-ingestor", "Resource": "arn:aws:lambda:${region}:${account_id}:function:acdl-contract-ingestor",
"Condition": { "Condition": {
"StringEquals": { "StringEquals": {
"aws:PrincipalTag/acdl:owner": "${consumerRepo}" "aws:PrincipalTag/nova:owner": "${consumerRepo}"
} }
} }
} }
+40 -40
View File
@@ -75,10 +75,10 @@ resource "aws_dynamodb_table" "acdl_contracts" {
} }
tags = { tags = {
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "prod" "nova:environment" = "prod"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -89,10 +89,10 @@ resource "aws_secretsmanager_secret" "github_token" {
kms_key_id = aws_kms_key.acdl_platform.arn kms_key_id = aws_kms_key.acdl_platform.arn
tags = { tags = {
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "prod" "nova:environment" = "prod"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -168,10 +168,10 @@ resource "aws_lambda_function" "contract_ingestor" {
} }
tags = { tags = {
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "prod" "nova:environment" = "prod"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -230,10 +230,10 @@ resource "aws_dynamodb_table" "acdl_change_requests" {
} }
tags = { tags = {
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "prod" "nova:environment" = "prod"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
# REQ-107: SNS topic for separation-of-duties halt artifacts. # REQ-107: SNS topic for separation-of-duties halt artifacts.
@@ -242,10 +242,10 @@ resource "aws_sns_topic" "acdl_sod_halt" {
name = "acdl-sod-halt" name = "acdl-sod-halt"
kms_master_key_id = aws_kms_key.acdl_platform.id kms_master_key_id = aws_kms_key.acdl_platform.id
tags = { tags = {
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "prod" "nova:environment" = "prod"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -262,10 +262,10 @@ resource "aws_vpc" "acdl_shared" {
cidr_block = var.vpc_cidr cidr_block = var.vpc_cidr
tags = { tags = {
Name = "acdl-shared" Name = "acdl-shared"
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "shared" "nova:environment" = "shared"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -276,10 +276,10 @@ resource "aws_subnet" "acdl_shared" {
availability_zone = data.aws_availability_zones.available.names[count.index] availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { tags = {
Name = "acdl-shared-subnet-${count.index}" Name = "acdl-shared-subnet-${count.index}"
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "shared" "nova:environment" = "shared"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -291,10 +291,10 @@ resource "aws_internet_gateway" "acdl_shared" {
vpc_id = aws_vpc.acdl_shared.id vpc_id = aws_vpc.acdl_shared.id
tags = { tags = {
Name = "acdl-shared-igw" Name = "acdl-shared-igw"
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "shared" "nova:environment" = "shared"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -306,10 +306,10 @@ resource "aws_route_table" "acdl_shared" {
} }
tags = { tags = {
Name = "acdl-shared-rt" Name = "acdl-shared-rt"
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "shared" "nova:environment" = "shared"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
@@ -344,10 +344,10 @@ resource "aws_security_group" "ecs" {
tags = { tags = {
Name = "acdl-ecs-sg" Name = "acdl-ecs-sg"
"acdl:owner" = "acdl" "nova:owner" = "acdl"
"acdl:contract" = "platform" "nova:contract" = "platform"
"acdl:environment" = "shared" "nova:environment" = "shared"
"acdl:cost-center" = "acdl-default" "nova:cost-center" = "nova-default"
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@
"policy": "require-resource-labels", "policy": "require-resource-labels",
"severity": "medium", "severity": "medium",
"result": "fail", "result": "fail",
"message": "Pod missing required label acdl:owner.", "message": "Pod missing required label nova:owner.",
"resource": "default/Pod/acdl-bad-app", "resource": "default/Pod/acdl-bad-app",
"namespace": "default", "namespace": "default",
"kind": "Pod", "kind": "Pod",
+7 -5
View File
@@ -408,14 +408,16 @@ class TestAdapterDedupMergesSameModule:
class TestAdapterRemoteStateKeyOverride: class TestAdapterRemoteStateKeyOverride:
"""P2-2 (v1.14, REQ-139): ACDL_REMOTE_STATE_KEY env var overrides the """P2-2 (v1.14, REQ-139): NOVA_REMOTE_STATE_KEY env var (P2 renamed from
default 'platform/terraform.tfstate' key in the emitted ACDL_REMOTE_STATE_KEY; dual-read NOVA_* preferred, ACDL_* fallback until
P5) overrides the default 'platform/terraform.tfstate' key in the emitted
data terraform_remote_state block. This is the load-bearing correctness data terraform_remote_state block. This is the load-bearing correctness
mechanism for the microservice L2 lifecycle (remote state points at the mechanism for the microservice L2 lifecycle (remote state points at the
CI VPC, not the platform VPC).""" CI VPC, not the platform VPC)."""
def test_default_remote_state_key(self, tmp_path, monkeypatch): def test_default_remote_state_key(self, tmp_path, monkeypatch):
"""When ACDL_REMOTE_STATE_KEY is unset, the default key is used.""" """When NOVA_REMOTE_STATE_KEY is unset, the default key is used."""
monkeypatch.delenv("NOVA_REMOTE_STATE_KEY", raising=False)
monkeypatch.delenv("ACDL_REMOTE_STATE_KEY", raising=False) monkeypatch.delenv("ACDL_REMOTE_STATE_KEY", raising=False)
stack = { stack = {
"resources": [ "resources": [
@@ -431,9 +433,9 @@ class TestAdapterRemoteStateKeyOverride:
assert "platform/terraform.tfstate" in main_tf assert "platform/terraform.tfstate" in main_tf
def test_env_override_remote_state_key(self, tmp_path, monkeypatch): def test_env_override_remote_state_key(self, tmp_path, monkeypatch):
"""When ACDL_REMOTE_STATE_KEY is set, the emitted data block uses """When NOVA_REMOTE_STATE_KEY is set, the emitted data block uses
the overridden key (e.g. 'spike/ci-vpc/terraform.tfstate').""" the overridden key (e.g. 'spike/ci-vpc/terraform.tfstate')."""
monkeypatch.setenv("ACDL_REMOTE_STATE_KEY", "spike/ci-vpc/terraform.tfstate") monkeypatch.setenv("NOVA_REMOTE_STATE_KEY", "spike/ci-vpc/terraform.tfstate")
stack = { stack = {
"resources": [ "resources": [
{"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test", "region": "us-east-1"}} {"id": "s3", "type": "aws:s3:bucket", "module": "s3@1.0.0", "inputs": {"bucket_name": "test", "region": "us-east-1"}}
+3 -1
View File
@@ -102,6 +102,8 @@ def test_dr_blocks_on_missing_dr_drill():
def test_signature_skip_when_key_unset(monkeypatch, capsys): def test_signature_skip_when_key_unset(monkeypatch, capsys):
"""D-089: signature verification is skipped when the signing key is unset.""" """D-089: signature verification is skipped when the signing key is unset."""
# P2: dual-read — both NOVA_* and ACDL_* must be unset for the skip.
monkeypatch.delenv("NOVA_ATTESTATION_SIGNING_KEY_ID", raising=False)
monkeypatch.delenv("ACDL_ATTESTATION_SIGNING_KEY_ID", raising=False) monkeypatch.delenv("ACDL_ATTESTATION_SIGNING_KEY_ID", raising=False)
artifact = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), artifact = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"type": "x", "payload": {}, "signature": "sig"} "type": "x", "payload": {}, "signature": "sig"}
@@ -112,7 +114,7 @@ def test_signature_skip_when_key_unset(monkeypatch, capsys):
def test_signature_required_when_key_set(monkeypatch): def test_signature_required_when_key_set(monkeypatch):
"""When the signing key is set, a missing signature fails.""" """When the signing key is set, a missing signature fails."""
monkeypatch.setenv("ACDL_ATTESTATION_SIGNING_KEY_ID", "kms-key-id") monkeypatch.setenv("NOVA_ATTESTATION_SIGNING_KEY_ID", "kms-key-id")
artifact = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), artifact = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"type": "x", "payload": {}} # no signature "type": "x", "payload": {}} # no signature
assert _verify_signature(artifact) is False assert _verify_signature(artifact) is False
+6 -6
View File
@@ -67,10 +67,10 @@ class TestToPcr:
class TestRuleMapTagging: class TestRuleMapTagging:
def test_acdl_tag_naming_is_real_rule(self): def test_nova_tag_naming_is_real_rule(self):
# D-054 / D-043 closure: ACDL_TAG_NAMING is now a real custom Checkov # D-054 / D-043 closure, P2 rename (REQ-158): NOVA_TAG_NAMING is now
# rule, not a synthetic SKIPPED record. # a real custom Checkov rule, not a synthetic SKIPPED record.
assert RULE_MAP["ACDL_TAG_NAMING"] == ("tagging-standard", "medium") assert RULE_MAP["NOVA_TAG_NAMING"] == ("tagging-standard", "medium")
class TestAdapt: class TestAdapt:
@@ -100,13 +100,13 @@ class TestAdapt:
def test_adapt_does_not_emit_synthetic_tag_naming(self, tmp_path): def test_adapt_does_not_emit_synthetic_tag_naming(self, tmp_path):
# D-043 closure: adapt() no longer appends a synthetic SKIPPED # D-043 closure: adapt() no longer appends a synthetic SKIPPED
# ACDL_TAG_NAMING record. The custom Checkov rule (loaded via # NOVA_TAG_NAMING record. The custom Checkov rule (loaded via
# --external-checks-dir) produces real PASS/FAIL records instead. # --external-checks-dir) produces real PASS/FAIL records instead.
data = self._sample_checkov_json() data = self._sample_checkov_json()
f = tmp_path / "checkov.json" f = tmp_path / "checkov.json"
f.write_text(json.dumps(data)) f.write_text(json.dumps(data))
results = adapt(str(f), "c-1") results = adapt(str(f), "c-1")
tag = [r for r in results if r["ruleId"] == "ACDL_TAG_NAMING"] tag = [r for r in results if r["ruleId"] == "NOVA_TAG_NAMING"]
assert tag == [] # no synthetic record assert tag == [] # no synthetic record
def test_adapt_has_passed_and_failed(self, tmp_path): def test_adapt_has_passed_and_failed(self, tmp_path):
+3 -1
View File
@@ -78,4 +78,6 @@ def test_run_platform_sh_has_environment_flag():
text = (ROOT / "scripts" / "run_platform.sh").read_text() text = (ROOT / "scripts" / "run_platform.sh").read_text()
assert "--environment" in text assert "--environment" in text
assert "ENVIRONMENT_OVERRIDE" in text assert "ENVIRONMENT_OVERRIDE" in text
assert "ACDL_ENVIRONMENT_OVERRIDE" in text # P2 (REQ-159): NOVA_* preferred; ACDL_* kept as dual-read fallback until P5.
assert "NOVA_ENVIRONMENT_OVERRIDE" in text
assert "ACDL_ENVIRONMENT_OVERRIDE" in text # legacy fallback, removed in P5
+56
View File
@@ -0,0 +1,56 @@
"""Unit tests for the dual-read env helper (core/env.py, D-108, REQ-159).
Covers the four cases:
- both NOVA_* and ACDL_* set (NOVA wins)
- only NOVA_* set
- only ACDL_* set (fallback)
- neither set (default returned)
The ACDL_* fallback is the intentional dual-read source and is removed
in P5 (REQ-164). These fixtures deliberately keep the ACDL_* names as
the fallback source they are the one allowed ACDL_* reference.
"""
from __future__ import annotations
import pytest
from core import env
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Ensure no ACDL_*/NOVA_* leakage between tests."""
for key in list(__import__("os").environ):
if key.startswith(("ACDL_", "NOVA_")):
monkeypatch.delenv(key, raising=False)
yield
def test_both_set_nova_wins(monkeypatch):
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "nova-value")
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "acdl-value")
assert env.get_env("AWS_ACCOUNT_ID") == "nova-value"
def test_only_nova_set(monkeypatch):
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "nova-value")
assert env.get_env("AWS_ACCOUNT_ID") == "nova-value"
def test_only_acdl_set_fallback(monkeypatch):
# ACDL_* is the intentional dual-read fallback source (removed in P5).
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "acdl-value")
assert env.get_env("AWS_ACCOUNT_ID") == "acdl-value"
def test_neither_set_returns_default():
assert env.get_env("AWS_ACCOUNT_ID") is None
assert env.get_env("AWS_ACCOUNT_ID", default="581513795199") == "581513795199"
def test_blank_nova_falls_back_to_acdl(monkeypatch):
# An explicitly-empty NOVA key must not shadow the ACDL fallback.
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "")
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "acdl-value")
assert env.get_env("AWS_ACCOUNT_ID") == "acdl-value"
+11 -8
View File
@@ -2,9 +2,10 @@
The lifecycle scripts (run_lifecycle_test.sh, run_lifecycle_destroy.sh, The lifecycle scripts (run_lifecycle_test.sh, run_lifecycle_destroy.sh,
run_l2_lifecycle_test.sh, run_l2_lifecycle_destroy.sh) wrap run_platform.sh. run_l2_lifecycle_test.sh, run_l2_lifecycle_destroy.sh) wrap run_platform.sh.
REQ-134 (v1.12) adds the ACDL_LIFECYCLE_MODE env var: default "plan" runs REQ-134 (v1.12) adds the NOVA_LIFECYCLE_MODE env var (P2 renamed from
`run_platform.sh --plan-only` (fast, no AWS mutation); "full" runs the real ACDL_LIFECYCLE_MODE, dual-read NOVA_* preferred / ACDL_* fallback until
`--apply`/`--destroy` against live AWS. P5): default "plan" runs `run_platform.sh --plan-only` (fast, no AWS
mutation); "full" runs the real `--apply`/`--destroy` against live AWS.
These tests verify the dispatch logic offline by inspecting script content These tests verify the dispatch logic offline by inspecting script content
(running the scripts end-to-end requires AWS credentials in full mode). (running the scripts end-to-end requires AWS credentials in full mode).
@@ -32,12 +33,14 @@ class TestLifecycleModeFlag:
@pytest.mark.parametrize("script", SCRIPTS) @pytest.mark.parametrize("script", SCRIPTS)
def test_script_reads_acdl_lifecycle_mode(self, script): def test_script_reads_acdl_lifecycle_mode(self, script):
"""Every lifecycle script reads ACDL_LIFECYCLE_MODE with a 'plan' default.""" """Every lifecycle script reads NOVA_LIFECYCLE_MODE (dual-read with
ACDL_LIFECYCLE_MODE fallback) with a 'plan' default."""
src = _read(script) src = _read(script)
# The default must be 'plan' (the speed-up default). The scripts use # P2 (REQ-159): dual-read NOVA_* preferred, ACDL_* fallback. The
# an intermediate LIFECYCLE_MODE var sourced from ACDL_LIFECYCLE_MODE. # ACDL_LIFECYCLE_MODE:-plan substring is still present inside the
assert "${ACDL_LIFECYCLE_MODE:-plan}" in src, \ # nested dual-read expression (removed in P5).
f"{script} must read ACDL_LIFECYCLE_MODE defaulting to 'plan'" assert "${NOVA_LIFECYCLE_MODE:-${ACDL_LIFECYCLE_MODE:-plan}}" in src, \
f"{script} must dual-read NOVA_/ACDL_LIFECYCLE_MODE defaulting to 'plan'"
assert "LIFECYCLE_MODE=" in src, \ assert "LIFECYCLE_MODE=" in src, \
f"{script} must assign LIFECYCLE_MODE from the env var" f"{script} must assign LIFECYCLE_MODE from the env var"
+6 -6
View File
@@ -179,11 +179,11 @@ def test_local_lambda_stub_rejects_missing_field(tmp_path):
def test_run_local_e2e_microservice(): def test_run_local_e2e_microservice():
"""Headline E2E: contract -> resolver -> adapter -> local S3 backend """Headline E2E: contract -> resolver -> adapter -> local S3 backend
-> local ECS (HTTP 200) -> flat-file outbox -> local Lambda. No AWS.""" -> local ECS (HTTP 200) -> flat-file outbox -> local Lambda. No AWS."""
os.environ["ACDL_LOCAL_TIER"] = "1" os.environ["NOVA_LOCAL_TIER"] = "1"
try: try:
result = le.run_local_e2e("contracts/microservice.yml") result = le.run_local_e2e("contracts/microservice.yml")
finally: finally:
os.environ.pop("ACDL_LOCAL_TIER", None) os.environ.pop("NOVA_LOCAL_TIER", None); os.environ.pop("ACDL_LOCAL_TIER", None)
assert result["tier"] == "local-emulator" assert result["tier"] == "local-emulator"
assert result["backend"] == "local" assert result["backend"] == "local"
assert result["ecs"] is not None assert result["ecs"] is not None
@@ -196,11 +196,11 @@ def test_run_local_e2e_microservice():
def test_run_local_e2e_static_assets(): def test_run_local_e2e_static_assets():
"""Static-assets stack has no ECS service; the local E2E must still """Static-assets stack has no ECS service; the local E2E must still
complete (ecs=None) and the outbox chain + Lambda stub must pass.""" complete (ecs=None) and the outbox chain + Lambda stub must pass."""
os.environ["ACDL_LOCAL_TIER"] = "1" os.environ["NOVA_LOCAL_TIER"] = "1"
try: try:
result = le.run_local_e2e("contracts/static-assets.yml") result = le.run_local_e2e("contracts/static-assets.yml")
finally: finally:
os.environ.pop("ACDL_LOCAL_TIER", None) os.environ.pop("NOVA_LOCAL_TIER", None); os.environ.pop("ACDL_LOCAL_TIER", None)
assert result["tier"] == "local-emulator" assert result["tier"] == "local-emulator"
assert result["ecs"] is None # no ECS service in this stack assert result["ecs"] is None # no ECS service in this stack
assert result["outbox_chain_verified"] is True assert result["outbox_chain_verified"] is True
@@ -209,9 +209,9 @@ def test_run_local_e2e_static_assets():
def test_is_local_tier_flag(): def test_is_local_tier_flag():
assert le.is_local_tier() is False assert le.is_local_tier() is False
os.environ["ACDL_LOCAL_TIER"] = "1" os.environ["NOVA_LOCAL_TIER"] = "1"
try: try:
assert le.is_local_tier() is True assert le.is_local_tier() is True
finally: finally:
os.environ.pop("ACDL_LOCAL_TIER", None) os.environ.pop("NOVA_LOCAL_TIER", None); os.environ.pop("ACDL_LOCAL_TIER", None)
assert le.is_local_tier() is False assert le.is_local_tier() is False
+77
View File
@@ -0,0 +1,77 @@
"""Unit tests for scripts/migrate_ssm_paths.py path-mapping logic (REQ-161, P3).
Tests the pure ``map_path()`` function (the AWS I/O glue is thin boto3 around
it). The script does not need live AWS to be importable.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from migrate_ssm_paths import map_path # noqa: E402
class TestMapPath:
def test_basic_dev_path(self):
assert map_path("/acdl/dev/svc-x/output") == "/nova/dev/svc-x/output"
def test_basic_contract_path(self):
assert map_path("/acdl/dev/c-1/vpc_id") == "/nova/dev/c-1/vpc_id"
def test_qa_env(self):
assert map_path("/acdl/qa/c-2/db_endpoint") == "/nova/qa/c-2/db_endpoint"
def test_prod_env(self):
assert map_path("/acdl/prod/c-3/distribution_domain_name") == "/nova/prod/c-3/distribution_domain_name"
def test_dr_env(self):
assert map_path("/acdl/dr/c-4/bucket_arn") == "/nova/dr/c-4/bucket_arn"
def test_deep_nested_path(self):
assert map_path("/acdl/dev/contract-001/nested/deep/output") == "/nova/dev/contract-001/nested/deep/output"
def test_preserves_trailing_segment(self):
# The output name segment is preserved verbatim
assert map_path("/acdl/dev/c/secret_token") == "/nova/dev/c/secret_token"
def test_custom_prefixes(self):
assert map_path("/acdl/dev/c/x", "/acdl", "/nova") == "/nova/dev/c/x"
assert map_path("/old/dev/c/x", "/old", "/new") == "/new/dev/c/x"
def test_raises_on_nonmatching_path(self):
with pytest.raises(ValueError, match="does not start with source prefix"):
map_path("/nova/dev/c/output")
def test_raises_on_path_not_segment_prefixed(self):
# /acdl-platform is NOT a path-segment match for /acdl (no trailing /)
with pytest.raises(ValueError, match="does not start with source prefix"):
map_path("/acdl-platform-key")
def test_raises_on_empty_path(self):
with pytest.raises(ValueError):
map_path("")
def test_raises_on_just_prefix(self):
# Exactly /acdl (no trailing slash) is not a valid parameter path
with pytest.raises(ValueError):
map_path("/acdl")
def test_round_trip_identity(self):
# map_path is its own inverse when source/dest are swapped
src = "/acdl/dev/svc-x/output"
mapped = map_path(src, "/acdl", "/nova")
back = map_path(mapped, "/nova", "/acdl")
assert back == src
def test_idempotent_on_already_migrated(self):
# If somehow a /nova/ path is passed with default args, it raises
# (the script filters by source prefix before mapping)
with pytest.raises(ValueError):
map_path("/nova/dev/c/output")
def test_preserves_value_segment_exactly(self):
# Hyphens, dots, underscores in output names are preserved
assert map_path("/acdl/dev/c-1/my.output-name_2") == "/nova/dev/c-1/my.output-name_2"
+27 -21
View File
@@ -50,12 +50,12 @@ class TestPublishToSsm:
outputs = {"bucket_name": "acdl-spike-bucket", "secret_token": "s3cret"} outputs = {"bucket_name": "acdl-spike-bucket", "secret_token": "s3cret"}
results = publish_to_ssm(outputs, "dev", "contract-001") results = publish_to_ssm(outputs, "dev", "contract-001")
assert results["bucket_name"] == "/acdl/dev/contract-001/bucket_name" assert results["bucket_name"] == "/nova/dev/contract-001/bucket_name"
assert results["secret_token"] == "/acdl/dev/contract-001/secret_token" assert results["secret_token"] == "/nova/dev/contract-001/secret_token"
# Verify the parameter landed in SSM correctly # Verify the parameter landed in SSM correctly
param = ssm.get_parameter( param = ssm.get_parameter(
Name="/acdl/dev/contract-001/bucket_name", WithDecryption=True Name="/nova/dev/contract-001/bucket_name", WithDecryption=True
) )
assert param["Parameter"]["Type"] == "SecureString" assert param["Parameter"]["Type"] == "SecureString"
assert param["Parameter"]["Value"] == "acdl-spike-bucket" assert param["Parameter"]["Value"] == "acdl-spike-bucket"
@@ -72,7 +72,7 @@ class TestPublishToSsm:
with mock_aws(): with mock_aws():
ssm = boto3.client("ssm", region_name="us-east-1") ssm = boto3.client("ssm", region_name="us-east-1")
publish_to_ssm({"vpc_id": "vpc-123"}, "dev", "c-1") publish_to_ssm({"vpc_id": "vpc-123"}, "dev", "c-1")
param = ssm.get_parameter(Name="/acdl/dev/c-1/vpc_id", WithDecryption=True) param = ssm.get_parameter(Name="/nova/dev/c-1/vpc_id", WithDecryption=True)
assert param["Parameter"]["Type"] == "SecureString" assert param["Parameter"]["Type"] == "SecureString"
def test_publish_skips_none_and_empty_values(self, monkeypatch): def test_publish_skips_none_and_empty_values(self, monkeypatch):
@@ -112,7 +112,7 @@ class TestPublishToSsm:
publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1") publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
# Second publish with a new value should overwrite, not error # Second publish with a new value should overwrite, not error
publish_to_ssm({"vpc_id": "vpc-2"}, "dev", "c-1") publish_to_ssm({"vpc_id": "vpc-2"}, "dev", "c-1")
param = ssm.get_parameter(Name="/acdl/dev/c-1/vpc_id", WithDecryption=True) param = ssm.get_parameter(Name="/nova/dev/c-1/vpc_id", WithDecryption=True)
assert param["Parameter"]["Value"] == "vpc-2" assert param["Parameter"]["Value"] == "vpc-2"
def test_publish_continues_on_single_failure(self, monkeypatch): def test_publish_continues_on_single_failure(self, monkeypatch):
@@ -142,7 +142,7 @@ class TestPublishToSsm:
results = publish_to_ssm( results = publish_to_ssm(
{"good": "val", "bad": "val"}, "dev", "c-1" {"good": "val", "bad": "val"}, "dev", "c-1"
) )
assert results["good"] == "/acdl/dev/c-1/good" assert results["good"] == "/nova/dev/c-1/good"
assert results["bad"] is None assert results["bad"] is None
@@ -156,7 +156,7 @@ class TestFormatComment:
comment = format_comment(outputs, "dev", "contract-001") comment = format_comment(outputs, "dev", "contract-001")
assert "acdl-spike-bucket" in comment assert "acdl-spike-bucket" in comment
assert "vpc-abc123" in comment assert "vpc-abc123" in comment
assert "### ACDL Deploy Outputs (dev)" in comment assert "### Nova Deploy Outputs (dev)" in comment
assert "`contract-001`" in comment assert "`contract-001`" in comment
def test_sensitive_outputs_show_published_to_ssm(self): def test_sensitive_outputs_show_published_to_ssm(self):
@@ -175,12 +175,12 @@ class TestFormatComment:
def test_ssm_path_included_when_results_provided(self): def test_ssm_path_included_when_results_provided(self):
outputs = {"bucket_name": "my-bucket", "secret_token": "s3cret"} outputs = {"bucket_name": "my-bucket", "secret_token": "s3cret"}
ssm_results = { ssm_results = {
"bucket_name": "/acdl/dev/contract-001/bucket_name", "bucket_name": "/nova/dev/contract-001/bucket_name",
"secret_token": "/acdl/dev/contract-001/secret_token", "secret_token": "/nova/dev/contract-001/secret_token",
} }
comment = format_comment(outputs, "dev", "contract-001", ssm_results) comment = format_comment(outputs, "dev", "contract-001", ssm_results)
assert "/acdl/dev/contract-001/bucket_name" in comment assert "/nova/dev/contract-001/bucket_name" in comment
assert "/acdl/dev/contract-001/secret_token" in comment assert "/nova/dev/contract-001/secret_token" in comment
def test_dash_shown_when_ssm_results_provided_but_missing(self): def test_dash_shown_when_ssm_results_provided_but_missing(self):
outputs = {"bucket_name": "my-bucket"} outputs = {"bucket_name": "my-bucket"}
@@ -193,12 +193,12 @@ class TestFormatComment:
outputs = {"bucket_name": "my-bucket"} outputs = {"bucket_name": "my-bucket"}
comment = format_comment(outputs, "dev", "contract-001", ssm_results=None) comment = format_comment(outputs, "dev", "contract-001", ssm_results=None)
# No SSM column content when ssm_results is None # No SSM column content when ssm_results is None
assert "/acdl/" not in comment or "get-parameter" in comment # only footer assert "/nova/" not in comment or "get-parameter" in comment # only footer
def test_ssm_footer_contains_correct_path(self): def test_ssm_footer_contains_correct_path(self):
outputs = {"bucket_name": "b"} outputs = {"bucket_name": "b"}
comment = format_comment(outputs, "dev", "contract-001") comment = format_comment(outputs, "dev", "contract-001")
assert "/acdl/dev/contract-001/<output_name>" in comment assert "/nova/dev/contract-001/<output_name>" in comment
def test_skips_none_and_empty_values(self): def test_skips_none_and_empty_values(self):
outputs = {"real": "val", "none_val": None, "empty": ""} outputs = {"real": "val", "none_val": None, "empty": ""}
@@ -355,7 +355,7 @@ class TestCli:
output = captured.getvalue() output = captured.getvalue()
assert "cli-bucket" in output assert "cli-bucket" in output
assert "vpc-1" in output assert "vpc-1" in output
assert "### ACDL Deploy Outputs (dev)" in output assert "### Nova Deploy Outputs (dev)" in output
finally: finally:
op.boto3 = saved_boto3 op.boto3 = saved_boto3
sys.argv = old_argv sys.argv = old_argv
@@ -376,7 +376,9 @@ class TestCli:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestKmsFailLoud: class TestKmsFailLoud:
"""P1-3: SSM publisher must fail loud when ACDL_KMS_KEY_ID is unset.""" """P1-3: SSM publisher must fail loud when NOVA_KMS_KEY_ID is unset
(P2 renamed from ACDL_KMS_KEY_ID; dual-read NOVA_* preferred,
ACDL_* fallback until P5)."""
def test_kms_unset_raises(self, monkeypatch): def test_kms_unset_raises(self, monkeypatch):
from moto import mock_aws from moto import mock_aws
@@ -385,11 +387,14 @@ class TestKmsFailLoud:
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
# Both NOVA_* and ACDL_* unset → helper returns default (None) → fail loud.
monkeypatch.delenv("NOVA_KMS_KEY_ID", raising=False)
monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False) monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False)
monkeypatch.delenv("NOVA_ALLOW_DEFAULT_KMS", raising=False)
monkeypatch.delenv("ACDL_ALLOW_DEFAULT_KMS", raising=False) monkeypatch.delenv("ACDL_ALLOW_DEFAULT_KMS", raising=False)
with mock_aws(): with mock_aws():
with pytest.raises(RuntimeError, match="ACDL_KMS_KEY_ID is not set"): with pytest.raises(RuntimeError, match="NOVA_KMS_KEY_ID is not set"):
publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1") publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
def test_kms_unset_allow_default_kms_escape_hatch(self, monkeypatch): def test_kms_unset_allow_default_kms_escape_hatch(self, monkeypatch):
@@ -399,14 +404,15 @@ class TestKmsFailLoud:
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.delenv("NOVA_KMS_KEY_ID", raising=False)
monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False) monkeypatch.delenv("ACDL_KMS_KEY_ID", raising=False)
monkeypatch.setenv("ACDL_ALLOW_DEFAULT_KMS", "1") monkeypatch.setenv("NOVA_ALLOW_DEFAULT_KMS", "1")
with mock_aws(): with mock_aws():
ssm = boto3.client("ssm", region_name="us-east-1") ssm = boto3.client("ssm", region_name="us-east-1")
results = publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1") results = publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
assert results["vpc_id"] == "/acdl/dev/c-1/vpc_id" assert results["vpc_id"] == "/nova/dev/c-1/vpc_id"
param = ssm.get_parameter(Name="/acdl/dev/c-1/vpc_id", WithDecryption=True) param = ssm.get_parameter(Name="/nova/dev/c-1/vpc_id", WithDecryption=True)
assert param["Parameter"]["Type"] == "SecureString" assert param["Parameter"]["Type"] == "SecureString"
def test_kms_set_takes_precedence_over_allow_default(self, monkeypatch): def test_kms_set_takes_precedence_over_allow_default(self, monkeypatch):
@@ -415,8 +421,8 @@ class TestKmsFailLoud:
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.setenv("ACDL_KMS_KEY_ID", "arn:aws:kms:us-east-1:123:key/abc") monkeypatch.setenv("NOVA_KMS_KEY_ID", "arn:aws:kms:us-east-1:123:key/abc")
monkeypatch.setenv("ACDL_ALLOW_DEFAULT_KMS", "1") monkeypatch.setenv("NOVA_ALLOW_DEFAULT_KMS", "1")
from core.output_publisher import _kms_key_id from core.output_publisher import _kms_key_id
assert _kms_key_id() == "arn:aws:kms:us-east-1:123:key/abc" assert _kms_key_id() == "arn:aws:kms:us-east-1:123:key/abc"
+7 -7
View File
@@ -310,7 +310,7 @@ class TestDeployWorkflowConformance:
wf = _load_workflow(".gitea/workflows/deploy.yml") wf = _load_workflow(".gitea/workflows/deploy.yml")
inputs = wf["on"]["workflow_call"]["inputs"] inputs = wf["on"]["workflow_call"]["inputs"]
assert "contract" in inputs assert "contract" in inputs
assert inputs["contract"]["default"] == ".acdl/contract.yml" assert inputs["contract"]["default"] == ".nova/contract.yml"
def test_deploy_workflow_has_mode_input(self): def test_deploy_workflow_has_mode_input(self):
wf = _load_workflow(".gitea/workflows/deploy.yml") wf = _load_workflow(".gitea/workflows/deploy.yml")
@@ -646,20 +646,20 @@ class TestModulesLifecyclePipeline:
assert set(inputs["lifecycle_mode"].get("options", [])) == {"plan", "full"} assert set(inputs["lifecycle_mode"].get("options", [])) == {"plan", "full"}
def test_lifecycle_job_passes_mode_env_to_steps(self): def test_lifecycle_job_passes_mode_env_to_steps(self):
"""The lifecycle job sets ACDL_LIFECYCLE_MODE env so scripts dispatch """The lifecycle job sets NOVA_LIFECYCLE_MODE env so scripts dispatch
to plan-only by default, full on override.""" to plan-only by default, full on override."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml") wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
env = wf["jobs"]["lifecycle"].get("env", {}) env = wf["jobs"]["lifecycle"].get("env", {})
assert "ACDL_LIFECYCLE_MODE" in env assert "NOVA_LIFECYCLE_MODE" in env
# The expression must resolve to 'plan' when no input/var is set. # The expression must resolve to 'plan' when no input/var is set.
assert "plan" in env["ACDL_LIFECYCLE_MODE"] assert "plan" in env["NOVA_LIFECYCLE_MODE"]
def test_l2_lifecycle_job_passes_mode_env_to_steps(self): def test_l2_lifecycle_job_passes_mode_env_to_steps(self):
"""The L2 lifecycle job also sets ACDL_LIFECYCLE_MODE env.""" """The L2 lifecycle job also sets NOVA_LIFECYCLE_MODE env."""
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml") wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
env = wf["jobs"]["l2-lifecycle"].get("env", {}) env = wf["jobs"]["l2-lifecycle"].get("env", {})
assert "ACDL_LIFECYCLE_MODE" in env assert "NOVA_LIFECYCLE_MODE" in env
assert "plan" in env["ACDL_LIFECYCLE_MODE"] assert "plan" in env["NOVA_LIFECYCLE_MODE"]
def test_ci_vpc_apply_skipped_in_plan_mode(self): def test_ci_vpc_apply_skipped_in_plan_mode(self):
"""The CI VPC apply job is skipped in plan mode (nothing is applied).""" """The CI VPC apply job is skipped in plan mode (nothing is applied)."""
+2
View File
@@ -29,6 +29,7 @@ def test_route_halt_publishes_to_sns_when_arn_set(monkeypatch):
def test_route_halt_falls_back_to_stderr_when_arn_unset(monkeypatch, capsys): def test_route_halt_falls_back_to_stderr_when_arn_unset(monkeypatch, capsys):
"""Without ACDL_SOD_HALT_TOPIC_ARN, a stderr emission occurs.""" """Without ACDL_SOD_HALT_TOPIC_ARN, a stderr emission occurs."""
monkeypatch.delenv("NOVA_SOD_HALT_TOPIC_ARN", raising=False)
monkeypatch.delenv("ACDL_SOD_HALT_TOPIC_ARN", raising=False) monkeypatch.delenv("ACDL_SOD_HALT_TOPIC_ARN", raising=False)
# Mock outbox_writer.write_event to avoid AWS calls. # Mock outbox_writer.write_event to avoid AWS calls.
with mock.patch("core.outbox_writer.write_event", return_value=None): with mock.patch("core.outbox_writer.write_event", return_value=None):
@@ -40,6 +41,7 @@ def test_route_halt_falls_back_to_stderr_when_arn_unset(monkeypatch, capsys):
def test_route_halt_outbox_fallback_writes_event(monkeypatch): def test_route_halt_outbox_fallback_writes_event(monkeypatch):
"""Without the SNS ARN, the outbox fallback writes a SEPARATION_OF_DUTIES_VIOLATION event.""" """Without the SNS ARN, the outbox fallback writes a SEPARATION_OF_DUTIES_VIOLATION event."""
monkeypatch.delenv("NOVA_SOD_HALT_TOPIC_ARN", raising=False)
monkeypatch.delenv("ACDL_SOD_HALT_TOPIC_ARN", raising=False) monkeypatch.delenv("ACDL_SOD_HALT_TOPIC_ARN", raising=False)
with mock.patch("core.outbox_writer.write_event") as mock_write: with mock.patch("core.outbox_writer.write_event") as mock_write:
route_halt_artifact("contract-789", "sod violation", oncall_client=None) route_halt_artifact("contract-789", "sod violation", oncall_client=None)
+150
View File
@@ -0,0 +1,150 @@
"""Unit tests for scripts/untag_acdl_keys.py key-list logic (REQ-162, P3).
Tests the pure ``acdl_keys_in()`` + ``keys_to_untag()`` functions (the AWS
I/O glue is thin boto3 around them). The script does not need live AWS to
be importable.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from untag_acdl_keys import acdl_keys_in, keys_to_untag, DEFAULT_LEGACY_KEYS # noqa: E402
class TestAcdlKeysIn:
def test_empty(self):
assert acdl_keys_in([]) == []
def test_no_acdl_keys(self):
assert acdl_keys_in(["nova:owner", "nova:contract", "Name"]) == []
def test_all_acdl_keys(self):
keys = ["acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center", "acdl:ref"]
assert acdl_keys_in(keys) == list(keys)
def test_mixed_keys(self):
keys = ["acdl:owner", "nova:owner", "Name", "acdl:cost-center"]
assert acdl_keys_in(keys) == ["acdl:owner", "acdl:cost-center"]
def test_preserves_input_order(self):
keys = ["acdl:ref", "nova:owner", "acdl:owner", "acdl:contract"]
assert acdl_keys_in(keys) == ["acdl:ref", "acdl:owner", "acdl:contract"]
def test_custom_legacy_set(self):
# Only removing acdl:owner + acdl:ref (subset)
legacy = ("acdl:owner", "acdl:ref")
keys = ["acdl:owner", "acdl:contract", "acdl:ref", "nova:owner"]
assert acdl_keys_in(keys, legacy_keys=legacy) == ["acdl:owner", "acdl:ref"]
def test_default_legacy_keys_all_5(self):
assert len(DEFAULT_LEGACY_KEYS) == 5
assert "acdl:owner" in DEFAULT_LEGACY_KEYS
assert "acdl:contract" in DEFAULT_LEGACY_KEYS
assert "acdl:environment" in DEFAULT_LEGACY_KEYS
assert "acdl:cost-center" in DEFAULT_LEGACY_KEYS
assert "acdl:ref" in DEFAULT_LEGACY_KEYS
def test_duplicates_not_duplicated_in_output(self):
# List comprehension preserves duplicates in input; the API dedups via set
# but the function is a faithful list filter. Duplicates are unusual but
# the function does not dedup (the UntagResources API tolerates the same
# key once; real GetResources never returns duplicate keys).
keys = ["acdl:owner", "acdl:owner"]
assert acdl_keys_in(keys) == ["acdl:owner", "acdl:owner"]
class TestKeysToUntag:
def test_empty_tags(self):
assert keys_to_untag({"ResourceARN": "arn:...", "Tags": []}) == []
def test_no_tags_key(self):
assert keys_to_untag({"ResourceARN": "arn:..."}) == []
def test_with_acdl_keys(self):
resource = {
"ResourceARN": "arn:aws:s3:::my-bucket",
"Tags": [
{"Key": "acdl:owner", "Value": "acdl"},
{"Key": "nova:owner", "Value": "acdl"},
{"Key": "acdl:cost-center", "Value": "acdl-default"},
{"Key": "Name", "Value": "my-bucket"},
],
}
assert keys_to_untag(resource) == ["acdl:owner", "acdl:cost-center"]
def test_with_only_nova_keys(self):
resource = {
"ResourceARN": "arn:aws:s3:::my-bucket",
"Tags": [
{"Key": "nova:owner", "Value": "acdl"},
{"Key": "nova:contract", "Value": "platform"},
{"Key": "Name", "Value": "my-bucket"},
],
}
assert keys_to_untag(resource) == []
def test_all_5_acdl_keys(self):
resource = {
"ResourceARN": "arn:aws:ecs:us-east-1:123:cluster/x",
"Tags": [
{"Key": "acdl:owner", "Value": "v"},
{"Key": "acdl:contract", "Value": "v"},
{"Key": "acdl:environment", "Value": "v"},
{"Key": "acdl:cost-center", "Value": "v"},
{"Key": "acdl:ref", "Value": "v"},
],
}
result = keys_to_untag(resource)
assert result == ["acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center", "acdl:ref"]
def test_custom_legacy_keys(self):
resource = {
"ResourceARN": "arn:...",
"Tags": [
{"Key": "acdl:owner", "Value": "v"},
{"Key": "acdl:contract", "Value": "v"},
],
}
# Only targeting acdl:owner
assert keys_to_untag(resource, legacy_keys=("acdl:owner",)) == ["acdl:owner"]
def test_malformed_tag_entry_skipped(self):
# A tag entry without a Key is skipped gracefully
resource = {
"ResourceARN": "arn:...",
"Tags": [
{"Value": "no-key"},
{"Key": "acdl:owner", "Value": "v"},
"not-a-dict",
],
}
assert keys_to_untag(resource) == ["acdl:owner"]
class TestRunDryRunNoClient:
def test_dry_run_returns_empty_summary_without_client(self):
from untag_acdl_keys import run
summary = run(apply=False, client=None)
assert summary["listed"] == 0
assert summary["untagged"] == 0
assert summary["keys_removed"] == 0
assert summary["errors"] == 0
def test_dry_run_apply_false_no_mutation(self):
# apply=False with a client still only lists (no untag)
from untag_acdl_keys import run
class FakeClient:
def get_paginator(self, name):
class P:
def paginate(self, **kw):
return iter([{"ResourceMappingList": []}])
return P()
summary = run(apply=False, client=FakeClient())
assert summary["listed"] == 0
assert summary["untagged"] == 0
+5 -3
View File
@@ -106,9 +106,11 @@ class TestCreateStateBackend:
"""terraform/bootstrap/create_state_backend.py — mock boto3.""" """terraform/bootstrap/create_state_backend.py — mock boto3."""
def test_state_bucket_name_construction(self, monkeypatch): def test_state_bucket_name_construction(self, monkeypatch):
"""The state bucket name is derived from ACDL_AWS_ACCOUNT_ID.""" """The state bucket name is derived from NOVA_AWS_ACCOUNT_ID
monkeypatch.setenv("ACDL_AWS_ACCOUNT_ID", "123456789012") (P2 renamed from ACDL_AWS_ACCOUNT_ID; the bucket name acdl-tfstate-*
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199") stays until P4, REQ-163)."""
monkeypatch.setenv("NOVA_AWS_ACCOUNT_ID", "123456789012")
account_id = os.environ.get("NOVA_AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1" state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
assert state_bucket == "acdl-tfstate-123456789012-us-east-1" assert state_bucket == "acdl-tfstate-123456789012-us-east-1"