Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da0de6068a | |||
| 51c3edf458 | |||
| e998d9fa6b | |||
| 7ea58ec1c9 | |||
| d5bae868a4 | |||
| 0bc70a3d95 |
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,48 @@
|
|||||||
|
"""Dual-read environment helper (D-108, REQ-159, G-106).
|
||||||
|
|
||||||
|
During the Nova rebrand transition window (P2–P4), 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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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 P2–P4 — but it must be
|
dual-read, you can do this incrementally across P2–P4 — 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 P3–P4 the platform **writes
|
- **Grace period — parallel-write:** during P3–P4 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 P3–P4 the platform
|
- **Grace period — parallel-tag period:** during P3–P4 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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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">"CHG0678912"</span>
|
<span class="hljs-attr">changeRequestId:</span> <span class="hljs-string">"CHG0678912"</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"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (P3–P4)
|
||||||
|
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())
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user