Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c029b102a3 | |||
| 2806c6c3ed | |||
| e048acd4dd | |||
| 9421442afd | |||
| bb43d94563 | |||
| ee5c372e65 |
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"phase": 21,
|
||||
"stage": "complete",
|
||||
"phase": 1,
|
||||
"stage": "execute",
|
||||
"milestone": "v1.16",
|
||||
"phase_role": "final",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-07-30T16:00:00Z",
|
||||
"milestone_complete": true,
|
||||
"tag": "v1.15.26",
|
||||
"requirements": ["REQ-165", "REQ-166", "REQ-167", "REQ-168", "REQ-169", "REQ-170", "REQ-171", "REQ-172", "REQ-173", "REQ-174", "REQ-175", "REQ-176", "REQ-177", "REQ-178", "REQ-179", "REQ-180", "REQ-181", "REQ-182", "REQ-183", "REQ-184"],
|
||||
"regression": {"Verified": 18, "Decayed": 0, "Broken": 0, "Skipped": 4}
|
||||
"updated_at": "2026-07-30T15:30:00Z",
|
||||
"milestone_complete": false
|
||||
}
|
||||
+1
-1
@@ -989,7 +989,7 @@ conversation before execution; D-108..D-112 resolved at CLARIFY.
|
||||
| D-111 | Lambda env-var defaults (`CONTRACTS_TABLE` default `"acdl-contracts"`, etc.) → `nova-contracts`. | `core/lambda/contract_ingestor.py` has hardcoded `acdl-*` default table names. These become `nova-*` in P4 (resource migration). P2 changes the env-var name (`ACDL_*`→`NOVA_*`); P4 changes the default values to `nova-*`. | P4 updates Lambda defaults. |
|
||||
| D-112 | `nova` slug: no `project:` prefix on branches (single-project mode). | `config.json` has `projects[]` with one entry (slug `acdl`) but `git.branching_strategy` is `flat` and the established convention since v1.0 is flat branches (no `<slug>/` prefix). Nova rebrand does NOT change the branch prefix convention. Commit `---ci---` blocks use `project: acdl` (the config slug, unchanged). | Branches stay `milestone/v1.15-nova`, `phase/NN-*`; no `acdl/` or `nova/` prefix. |
|
||||
|
||||
## Objective for Milestone v1.16 (complete — NFR Simplification, tag `v1.15.26`)
|
||||
## Objective for Milestone v1.16 (active — NFR Simplification)
|
||||
|
||||
A 20-phase NFR sweep (no new features) themed around five axes the user
|
||||
directed during ideation: **Simplify without regressions**, **Security**,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"run_id": "regr-1785591207",
|
||||
"run_at_utc": "2026-08-01T13:33:27Z",
|
||||
"run_id": "regr-1785375318",
|
||||
"run_at_utc": "2026-07-30T01:35:18Z",
|
||||
"milestone": "v1.10",
|
||||
"phase": 52,
|
||||
"summary": {
|
||||
"Verified": 18,
|
||||
"Verified": 22,
|
||||
"Decayed": 0,
|
||||
"Broken": 0,
|
||||
"Skipped": 4
|
||||
"Broken": 0
|
||||
},
|
||||
"passed": true,
|
||||
"results": [
|
||||
@@ -17,7 +16,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; 2 sample contracts validate",
|
||||
"tier": "local",
|
||||
"duration_ms": 235
|
||||
"duration_ms": 230
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-002",
|
||||
@@ -25,7 +24,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; env schema validates",
|
||||
"tier": "local",
|
||||
"duration_ms": 201
|
||||
"duration_ms": 204
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-003",
|
||||
@@ -33,7 +32,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; ",
|
||||
"tier": "local",
|
||||
"duration_ms": 261
|
||||
"duration_ms": 247
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-004",
|
||||
@@ -41,7 +40,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; ",
|
||||
"tier": "local",
|
||||
"duration_ms": 259
|
||||
"duration_ms": 241
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-005",
|
||||
@@ -49,7 +48,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; ",
|
||||
"tier": "local",
|
||||
"duration_ms": 337
|
||||
"duration_ms": 326
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-006",
|
||||
@@ -57,7 +56,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; interpolation ok",
|
||||
"tier": "local",
|
||||
"duration_ms": 242
|
||||
"duration_ms": 216
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-007",
|
||||
@@ -65,7 +64,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; confidence band=pass",
|
||||
"tier": "local",
|
||||
"duration_ms": 91
|
||||
"duration_ms": 79
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-008",
|
||||
@@ -73,15 +72,15 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; outbox hash chain ok",
|
||||
"tier": "local",
|
||||
"duration_ms": 456
|
||||
"duration_ms": 333
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-009",
|
||||
"name": "offline pytest suite passes",
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n================= 586 passed, 2 deselected in 71.63s (0:01:11) =================",
|
||||
"detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n====================== 555 passed, 2 deselected in 51.11s ======================",
|
||||
"tier": "local",
|
||||
"duration_ms": 72988
|
||||
"duration_ms": 52574
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-010",
|
||||
@@ -89,63 +88,63 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; resource(s))\n\n=== PLATFORM CHECK OK ===\ncontract -> resolver -> stack -> adapter -> structure validated (offline, no AWS)\ncheck-only: OK\n\n=== CI PIPELINE OK ===\n3 stages passed: lint, test, check-only",
|
||||
"tier": "local",
|
||||
"duration_ms": 73275
|
||||
"duration_ms": 59608
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-011",
|
||||
"name": "headline E2E runs against the local emulating tier (microservice)",
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; al-emulator\",\n \"desired_count\": 1,\n \"running_count\": 1\n },\n \"outbox_dir\": \"/tmp/nova_local_e2e_6vnrnin1/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",
|
||||
"duration_ms": 634
|
||||
"duration_ms": 1072
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-012",
|
||||
"name": "local E2E on the static-assets stack (no ECS)",
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; nova_local_e2e_uq4kkhze/tf\",\n \"backend\": \"local\",\n \"ecs\": null,\n \"outbox_dir\": \"/tmp/nova_local_e2e_uq4kkhze/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",
|
||||
"duration_ms": 584
|
||||
"duration_ms": 490
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-013",
|
||||
"name": "terraform init+validate+plan live AWS (microservice)",
|
||||
"status": "Skipped",
|
||||
"detail": "terraform init: state bucket absent (post-v1.11-teardown, D-096) [microservice]",
|
||||
"status": "Verified",
|
||||
"detail": "terraform init+validate+plan OK (live AWS, microservice)",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 737
|
||||
"duration_ms": 28176
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-014",
|
||||
"name": "terraform init+validate+plan live AWS (static-assets)",
|
||||
"status": "Skipped",
|
||||
"detail": "terraform init: state bucket absent (post-v1.11-teardown, D-096) [static-assets]",
|
||||
"status": "Verified",
|
||||
"detail": "terraform init+validate+plan OK (live AWS, static-assets)",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 676
|
||||
"duration_ms": 31892
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-015",
|
||||
"name": "DynamoDB outbox table exists (live AWS)",
|
||||
"status": "Skipped",
|
||||
"detail": "nova-outbox absent (post-v1.11-teardown steady state, D-096)",
|
||||
"status": "Verified",
|
||||
"detail": "acdl-outbox exists, item_count=9",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 664
|
||||
"duration_ms": 507
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-016",
|
||||
"name": "S3 state bucket exists + readable (live AWS)",
|
||||
"status": "Skipped",
|
||||
"detail": "state bucket nova-tfstate-581513795199-us-east-1 absent (post-v1.11-teardown, D-096)",
|
||||
"status": "Verified",
|
||||
"detail": "state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/assets/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfstate', 'spike/ci-vpc/terraform.tfstate']",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 245
|
||||
"duration_ms": 329
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-017",
|
||||
"name": "DynamoDB nova-contracts table (lifecycle pipeline evidence)",
|
||||
"name": "DynamoDB acdl-contracts table (lifecycle pipeline evidence)",
|
||||
"status": "Verified",
|
||||
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 586
|
||||
"duration_ms": 588
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-018",
|
||||
@@ -153,7 +152,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "LocalLambdaStub instantiates (local tier evidence)",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 138
|
||||
"duration_ms": 135
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-019",
|
||||
@@ -161,7 +160,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 519
|
||||
"duration_ms": 498
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-020",
|
||||
@@ -169,7 +168,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 521
|
||||
"duration_ms": 510
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-021",
|
||||
@@ -185,7 +184,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 611
|
||||
"duration_ms": 554
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,51 +1,51 @@
|
||||
# Regression Report — v1.10 Phase 52
|
||||
|
||||
- **Run ID:** `regr-1785591207`
|
||||
- **Run at (UTC):** 2026-08-01T13:33:27Z
|
||||
- **Summary:** {'Verified': 18, 'Decayed': 0, 'Broken': 0, 'Skipped': 4}
|
||||
- **Run ID:** `regr-1785375318`
|
||||
- **Run at (UTC):** 2026-07-30T01:35:18Z
|
||||
- **Summary:** {'Verified': 22, 'Decayed': 0, 'Broken': 0}
|
||||
- **Passed (milestone gate):** True
|
||||
|
||||
| Capability | Name | Tier | Status | Duration (ms) | Detail |
|
||||
|-----------|------|------|--------|--------------|--------|
|
||||
| CAP-001 | contract.schema.json validates sample contracts | local | **Verified** | 235 | exit 0; 2 sample contracts validate |
|
||||
| CAP-002 | environment.schema.json validates env files | local | **Verified** | 201 | exit 0; env schema validates |
|
||||
| CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 261 | exit 0; |
|
||||
| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 259 | exit 0; |
|
||||
| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 337 | exit 0; |
|
||||
| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 242 | exit 0; interpolation ok |
|
||||
| CAP-007 | confidence_signal.compute returns a band | local | **Verified** | 91 | exit 0; confidence band=pass |
|
||||
| CAP-008 | outbox_writer builds a hash-chained item | local | **Verified** | 456 | exit 0; outbox hash chain ok |
|
||||
| CAP-009 | offline pytest suite passes | local | **Verified** | 72988 | exit 0; [ 98%]
|
||||
| CAP-001 | contract.schema.json validates sample contracts | local | **Verified** | 230 | exit 0; 2 sample contracts validate |
|
||||
| CAP-002 | environment.schema.json validates env files | local | **Verified** | 204 | exit 0; env schema validates |
|
||||
| CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 247 | exit 0; |
|
||||
| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 241 | exit 0; |
|
||||
| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 326 | exit 0; |
|
||||
| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 216 | exit 0; interpolation ok |
|
||||
| CAP-007 | confidence_signal.compute returns a band | local | **Verified** | 79 | exit 0; confidence band=pass |
|
||||
| CAP-008 | outbox_writer builds a hash-chained item | local | **Verified** | 333 | exit 0; outbox hash chain ok |
|
||||
| CAP-009 | offline pytest suite passes | local | **Verified** | 52574 | exit 0; [ 98%]
|
||||
tests/test_wiz_adapter_real_client.py ......... [100%]
|
||||
|
||||
================= 586 passed, 2 |
|
||||
| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 73275 | exit 0; resource(s))
|
||||
====================== 555 passe |
|
||||
| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 59608 | exit 0; resource(s))
|
||||
|
||||
=== PLATFORM CHECK OK ===
|
||||
contract -> resolver -> stack -> adapter -> structure validated (offline, no AWS)
|
||||
check-only: OK
|
||||
|
||||
=== CI PIPELIN |
|
||||
| CAP-011 | headline E2E runs against the local emulating tier (microservice) | local | **Verified** | 634 | 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,
|
||||
"running_count": 1
|
||||
},
|
||||
"outbox_dir": "/tmp/nova_local_e2e_6vnrnin1/outbox",
|
||||
"outbox_dir": "/tmp/acdl_local_e2e_0v1bpi48/outbox",
|
||||
"outbox_events": 2,
|
||||
"outbox |
|
||||
| CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 584 | exit 0; nova_local_e2e_uq4kkhze/tf",
|
||||
| CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 490 | exit 0; acdl_local_e2e_0cjcizgd/tf",
|
||||
"backend": "local",
|
||||
"ecs": null,
|
||||
"outbox_dir": "/tmp/nova_local_e2e_uq4kkhze/outbox",
|
||||
"outbox_dir": "/tmp/acdl_local_e2e_0cjcizgd/outbox",
|
||||
"outbox_events": 2,
|
||||
"outbox |
|
||||
| CAP-013 | terraform init+validate+plan live AWS (microservice) | live-aws | **Skipped** | 737 | terraform init: state bucket absent (post-v1.11-teardown, D-096) [microservice] |
|
||||
| CAP-014 | terraform init+validate+plan live AWS (static-assets) | live-aws | **Skipped** | 676 | terraform init: state bucket absent (post-v1.11-teardown, D-096) [static-assets] |
|
||||
| CAP-015 | DynamoDB outbox table exists (live AWS) | live-aws | **Skipped** | 664 | nova-outbox absent (post-v1.11-teardown steady state, D-096) |
|
||||
| CAP-016 | S3 state bucket exists + readable (live AWS) | live-aws | **Skipped** | 245 | state bucket nova-tfstate-581513795199-us-east-1 absent (post-v1.11-teardown, D-096) |
|
||||
| CAP-017 | DynamoDB nova-contracts table (lifecycle pipeline evidence) | lifecycle-pipeline | **Verified** | 586 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
| CAP-018 | Lambda contract-ingestor (local stub + lifecycle evidence) | lifecycle-pipeline | **Verified** | 138 | LocalLambdaStub instantiates (local tier evidence) |
|
||||
| CAP-019 | ECS cluster + service (L2 microservice lifecycle evidence) | lifecycle-pipeline | **Verified** | 519 | L2 composition resolves (simple + complex contracts; offline proxy) |
|
||||
| CAP-020 | CloudFront + WAF (L2 static-assets lifecycle evidence) | lifecycle-pipeline | **Verified** | 521 | L2 composition resolves (simple + complex contracts; offline proxy) |
|
||||
| CAP-013 | terraform init+validate+plan live AWS (microservice) | live-aws | **Verified** | 28176 | terraform init+validate+plan OK (live AWS, microservice) |
|
||||
| CAP-014 | terraform init+validate+plan live AWS (static-assets) | live-aws | **Verified** | 31892 | terraform init+validate+plan OK (live AWS, static-assets) |
|
||||
| CAP-015 | DynamoDB outbox table exists (live AWS) | live-aws | **Verified** | 507 | acdl-outbox exists, item_count=9 |
|
||||
| CAP-016 | S3 state bucket exists + readable (live AWS) | live-aws | **Verified** | 329 | state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/assets/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfsta |
|
||||
| CAP-017 | DynamoDB acdl-contracts table (lifecycle pipeline evidence) | lifecycle-pipeline | **Verified** | 588 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
| CAP-018 | Lambda contract-ingestor (local stub + lifecycle evidence) | lifecycle-pipeline | **Verified** | 135 | LocalLambdaStub instantiates (local tier evidence) |
|
||||
| CAP-019 | ECS cluster + service (L2 microservice lifecycle evidence) | lifecycle-pipeline | **Verified** | 498 | L2 composition resolves (simple + complex contracts; offline proxy) |
|
||||
| CAP-020 | CloudFront + WAF (L2 static-assets lifecycle evidence) | lifecycle-pipeline | **Verified** | 510 | L2 composition resolves (simple + complex contracts; offline proxy) |
|
||||
| CAP-021 | uptime-kuma (L1 uptime lifecycle evidence) | lifecycle-pipeline | **Verified** | 562 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
| CAP-022 | OIDC role (L1 iam-role lifecycle evidence) | lifecycle-pipeline | **Verified** | 611 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
| CAP-022 | OIDC role (L1 iam-role lifecycle evidence) | lifecycle-pipeline | **Verified** | 554 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
|
||||
+20
-20
@@ -921,26 +921,26 @@ simplification and the first self-service onboarding request path.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-165 | P1 | complete |
|
||||
| REQ-166 | P2 | complete |
|
||||
| REQ-167 | P3 | complete |
|
||||
| REQ-168 | P4 | complete |
|
||||
| REQ-169 | P5 | complete |
|
||||
| REQ-170 | P6 | complete |
|
||||
| REQ-171 | P7 | complete |
|
||||
| REQ-172 | P8 | complete |
|
||||
| REQ-173 | P9 | complete |
|
||||
| REQ-174 | P10 | complete |
|
||||
| REQ-175 | P11 | complete |
|
||||
| REQ-176 | P12 | complete |
|
||||
| REQ-177 | P13 | complete |
|
||||
| REQ-178 | P14 | complete |
|
||||
| REQ-179 | P15 | complete |
|
||||
| REQ-180 | P16 | complete |
|
||||
| REQ-181 | P17 | complete |
|
||||
| REQ-182 | P18 | complete |
|
||||
| REQ-183 | P19 | complete |
|
||||
| REQ-184 | P20 | complete |
|
||||
| REQ-165 | P1 | pending |
|
||||
| REQ-166 | P2 | pending |
|
||||
| REQ-167 | P3 | pending |
|
||||
| REQ-168 | P4 | pending |
|
||||
| REQ-169 | P5 | pending |
|
||||
| REQ-170 | P6 | pending |
|
||||
| REQ-171 | P7 | pending |
|
||||
| REQ-172 | P8 | pending |
|
||||
| REQ-173 | P9 | pending |
|
||||
| REQ-174 | P10 | pending |
|
||||
| REQ-175 | P11 | pending |
|
||||
| REQ-176 | P12 | pending |
|
||||
| REQ-177 | P13 | pending |
|
||||
| REQ-178 | P14 | pending |
|
||||
| REQ-179 | P15 | pending |
|
||||
| REQ-180 | P16 | pending |
|
||||
| REQ-181 | P17 | pending |
|
||||
| REQ-182 | P18 | pending |
|
||||
| REQ-183 | P19 | pending |
|
||||
| REQ-184 | P20 | pending |
|
||||
|
||||
### Out of Scope (v1.16)
|
||||
- New features (feat phases). v1.16 is NFR-only.
|
||||
|
||||
@@ -1630,56 +1630,3 @@ milestone release). (G-104 binding.)
|
||||
- Tag `v1.15.4` created; milestone merged to main.
|
||||
|
||||
After Phase P5: milestone COMPLETE — `v1.15.4` IS the v1.15 release.
|
||||
|
||||
---
|
||||
|
||||
## v1.16 (complete — Nova Simplification, tag `v1.15.26`)
|
||||
|
||||
A 20-phase NFR sweep (no new features) themed around five user-directed
|
||||
axes: **Simplify without regressions**, **Security**, **Maintainability**,
|
||||
**User/Developer Experience**, **No Humans Onboarding Flow**. The v1.15
|
||||
rebrand left a fresh debt layer (stale brand strings, a state-bucket
|
||||
drift, a Kyverno policy contradicting the Nova tagging standard, dead
|
||||
code) that this milestone cleared, alongside genuine simplification
|
||||
(dedup helpers, a workflow generator, file splits) and the first
|
||||
self-service onboarding request path (request-path only; real AWS
|
||||
provisioning deferred, D-113).
|
||||
|
||||
**Milestone type:** NFR (all phases fix/chore/docs/refactor/test). The
|
||||
final phase's patch IS the deliverable. Tags on the v1.15.x line:
|
||||
`v1.15.5` (P0) → `v1.15.6..v1.15.25` (P1–P20) → `v1.15.26` (P21 final =
|
||||
milestone release).
|
||||
|
||||
**Regression gate (D-118, G-111):** 18 Verified + 4 Skipped (CAP-013..016
|
||||
live-AWS caps are the post-v1.11-teardown steady state, D-096; re-
|
||||
provisioning is a future feature). 0 Decayed/Broken at P9 + P21.
|
||||
|
||||
**Grill:** PASS-with-binding (G-111..G-113, E-002 deferred to P21).
|
||||
G-111: gate criterion restated 18V+4S + Skipped logic. G-112: P9 source
|
||||
model pinned. G-113: P12/P13 import direction documented.
|
||||
|
||||
**Wave outcomes:**
|
||||
- Wave 1 (P1–P4): state-bucket + Kyverno rebrand fix (correctness
|
||||
regression), user-facing ACDL→Nova sweep, dead-code cleanup, except
|
||||
narrowing.
|
||||
- Wave 2 (P5–P9): regression-verify dedup (~70 lines), run-platform
|
||||
HITL fn + config, contract-resolver envloader + registry kind, workflow
|
||||
generator (sync_workflows.py + workflows-src/), run-platform split
|
||||
(decommission + uptime helpers). Gate PASS at P9.
|
||||
- Wave 3 (P10–P14): ingestor defense-in-depth (fail closed on missing
|
||||
IAM), payload validation (size cap + schema), split contract-resolver
|
||||
(decommission + CLI modules), split regression-verify (CLI module),
|
||||
schema-driven outputs + schema cache. Mid-milestone checkpoint clean.
|
||||
- Wave 4 (P15–P17): run-platform --help + flags doc, workflows README
|
||||
catalog (7 workflows), getting-started consolidation (offline-first).
|
||||
- Wave 5 (P18–P20): onboarding schema + onboard_consumer Lambda action,
|
||||
env-file autogen (core/onboarding.py), cross-account role Terraform
|
||||
(offline-proven, D-114).
|
||||
|
||||
**Outcome:** 20 requirements (REQ-165..184) satisfied; ~630 tests pass;
|
||||
regression gate 18V+4S; the onboarding request path is self-service (no
|
||||
"contact the platform team" handoff); real AWS provisioning explicitly
|
||||
deferred (D-113/D-114).
|
||||
|
||||
Ship tag at milestone COMPLETE: `v1.15.26` (NFR milestone; final patch IS
|
||||
the release). **DONE.**
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# GitHub Workflows — Nova Platform CI/CD Catalog
|
||||
|
||||
This directory contains the 7 GitHub Actions workflows for the Nova
|
||||
platform. 3 are byte-identical Gitea mirrors (generated from
|
||||
`workflows-src/` by `scripts/sync_workflows.py`, P8/REQ-172); 4 are
|
||||
GitHub-only (Gitea act_runner feature gaps).
|
||||
|
||||
## Shared workflows (byte-identical Gitea + GitHub)
|
||||
|
||||
These 3 are generated from `workflows-src/<name>` by
|
||||
`scripts/sync_workflows.py`; the `.gitea/workflows/<name>` mirror is kept
|
||||
byte-identical. Run `python3 scripts/sync_workflows.py --check` to verify
|
||||
no drift.
|
||||
|
||||
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
||||
|----------|---------|--------|------------------|---------|
|
||||
| `ci.yml` | `pull_request: [main]` | — | — | Lint + test + check-only (runs on every PR) |
|
||||
| `deploy.yml` | `workflow_call` (reusable) + `push: [main]` | `contract` (string, required), `mode` (string, default `deploy`), `changeRequestId` (string), `environment` (string) | `NOVA_AWS_ACCESS_KEY_ID`, `NOVA_AWS_SECRET_ACCESS_KEY`, `NOVA_AWS_DEFAULT_REGION`, `NOVA_KMS_KEY_ID`, `NOVA_LAMBDA_URL` | Reusable deploy workflow (invoked by consumer repos via `uses: acdl/.github/workflows/deploy.yml@v1.15`) |
|
||||
| `modules-lifecycle.yml` | `pull_request: [main]` + `workflow_dispatch` | `lifecycle_mode` (string, default `plan` — `plan` or `full`) | `NOVA_AWS_ACCESS_KEY_ID`, `NOVA_AWS_SECRET_ACCESS_KEY`, `NOVA_AWS_DEFAULT_REGION`, `NOVA_AWS_ACCOUNT_ID` | L1 + L2 module lifecycle pipeline (plan-only default; full apply/modify/destroy on override) |
|
||||
|
||||
## GitHub-only workflows (no Gitea mirror)
|
||||
|
||||
These 4 have no Gitea counterpart (Gitea act_runner lacks the features
|
||||
they require — reusable workflows, matrix `needs`, release API). See
|
||||
`.gitea/workflows/README.md` for the limitation rationale.
|
||||
|
||||
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
||||
|----------|---------|--------|------------------|---------|
|
||||
| `platform-test.yml` | `pull_request: [main]` | — | — | Lint + unit + integration + schema-validation (replaces `ci.yml` for PRs) |
|
||||
| `primitives-plan.yml` | `pull_request: [main]` | — | `NOVA_AWS_*` | Plan-only for all L1 primitives (matrix) |
|
||||
| `patterns-plan.yml` | `pull_request: [main]` | — | `NOVA_AWS_*` | Plan-only for all L2 modules (matrix) |
|
||||
| `release.yml` | `push: [main]` | — | `NOVA_GITEA_TOKEN` (for Gitea release API) | Semver tag + MAJOR.MINOR/MAJOR floating-tag maintenance + release creation on merge to main |
|
||||
|
||||
## Reusable deploy workflow (`deploy.yml`)
|
||||
|
||||
Consumer repos invoke the deploy workflow via a versioned tag:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
deploy:
|
||||
uses: acdl/.github/workflows/deploy.yml@v1.15
|
||||
with:
|
||||
contract: .nova/contract.yml
|
||||
environment: dev
|
||||
secrets: inherit
|
||||
```
|
||||
|
||||
The workflow checks out the consumer repo + the Nova platform repo, runs
|
||||
`scripts/run_platform.sh`, and posts deploy outputs as a PR comment +
|
||||
to SSM Parameter Store.
|
||||
@@ -126,46 +126,20 @@ engine-specific code. `modules/`, `schemas/`, `contracts/`,
|
||||
|
||||
## How to run
|
||||
|
||||
### Quick start (offline, no AWS required)
|
||||
### Prerequisites
|
||||
|
||||
The fastest way to verify the platform works — no AWS credentials, no
|
||||
bootstrap, no cost. See the [Consumer guide](docs/consumer-guide.md)
|
||||
for the consumer happy path (a consumer owns only a contract + app code).
|
||||
> These prerequisites are for running the **platform repo** locally. A
|
||||
> consumer does not need any of these — see the
|
||||
> [Consumer guide](docs/consumer-guide.md) for the consumer happy path.
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
pip install -r requirements-test.txt
|
||||
- A platform-managed environment (see [docs/environments/](docs/environments/)).
|
||||
For local testing, `core/environments/dev.json` is provided as the sample.
|
||||
- AWS credentials for the dev environment (in `.env.secrets`, gitignored;
|
||||
see [Credentials & zero-trust](#credentials--zero-trust)).
|
||||
- `terraform` (pin `1.9.*`), `checkov` (pin `>=3.2,<4`), `python3` + `boto3`
|
||||
+ `jsonschema`.
|
||||
|
||||
# 1. Run the test suite (all offline — uses moto for DynamoDB mocking)
|
||||
python3 -m pytest tests/ -v
|
||||
|
||||
# 2. Run the platform in check-only mode (offline — contract -> resolver ->
|
||||
# adapter -> structure validation). Uses the default sample contract
|
||||
# (contracts/static-assets.yaml) + sample dev environment.
|
||||
bash scripts/run_platform.sh --check-only
|
||||
# Expected: "=== PLATFORM CHECK OK ==="
|
||||
|
||||
# 3. Run the headline E2E against the local emulating tier (emulates ECS,
|
||||
# outbox, S3 state, Lambda in-process; D-092).
|
||||
bash scripts/run_platform.sh --local
|
||||
# Expected: "=== LOCAL E2E OK ==="
|
||||
|
||||
# 4. Reproduce the full CI pipeline locally (lint -> test -> check-only)
|
||||
bash scripts/run_ci.sh
|
||||
# Expected: "=== CI PIPELINE OK ==="
|
||||
|
||||
# Show all run_platform.sh flags:
|
||||
bash scripts/run_platform.sh --help
|
||||
```
|
||||
|
||||
### Run against live AWS (requires credentials + bootstrap)
|
||||
|
||||
> Prerequisites: a platform-managed environment (see
|
||||
> [docs/environments/](docs/environments/); `core/environments/dev.json`
|
||||
> is the sample), AWS credentials for dev (in `.env.secrets`, gitignored;
|
||||
> see [Credentials & zero-trust](#credentials--zero-trust)), `terraform`
|
||||
> (pin `1.9.*`), `checkov` (pin `>=3.2,<4`), `python3` + `boto3` +
|
||||
> `jsonschema`.
|
||||
### Run the platform pipeline end-to-end
|
||||
|
||||
```bash
|
||||
# 1. Bootstrap the AWS state backend + runner IAM user (one-time, idempotent)
|
||||
@@ -194,6 +168,26 @@ bash scripts/run_platform.sh --plan-only contracts/static-assets.yaml
|
||||
bash scripts/run_platform.sh --quiet contracts/static-assets.yaml
|
||||
```
|
||||
|
||||
### Test the platform (offline, no AWS required)
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
pip install -r requirements-test.txt
|
||||
|
||||
# Run the test suite (all offline — uses moto for DynamoDB mocking)
|
||||
python3 -m pytest tests/ -v
|
||||
|
||||
# Run the platform in check-only mode (offline — no AWS, no policy checks,
|
||||
# no outbox). Uses the default sample contract (contracts/static-assets.yaml)
|
||||
# and the sample dev environment (core/environments/dev.json).
|
||||
bash scripts/run_platform.sh --check-only
|
||||
# Expected: "=== PLATFORM CHECK OK ==="
|
||||
|
||||
# Reproduce the full CI pipeline locally (lint -> test -> check-only)
|
||||
bash scripts/run_ci.sh
|
||||
# Expected: "=== CI PIPELINE OK ==="
|
||||
```
|
||||
|
||||
### CI/CD pipelines
|
||||
|
||||
The CI/CD pipeline is defined by a **central pipeline contract** — a
|
||||
|
||||
+61
-37
@@ -50,13 +50,22 @@ from core import env
|
||||
def _load_env(env_name, repo_root):
|
||||
"""Load the environment onboarding JSON for env_name.
|
||||
|
||||
P7 (REQ-171): delegates to core.environment_check.load() (dedup —
|
||||
the two were verbatim duplicates). The environment_check module is
|
||||
in the same core/ package, so the import works both as a package
|
||||
import and as a script (`python3 core/contract_resolver.py`).
|
||||
Mirrors core.environment_check.load() but is self-contained so the
|
||||
resolver works both as a package import (`from core.contract_resolver
|
||||
import resolve`) and as a script (`python3 core/contract_resolver.py`).
|
||||
Emits a stderr warning when account_id is the placeholder and env != dev.
|
||||
"""
|
||||
from core import environment_check
|
||||
return environment_check.load(env_name, root=repo_root)
|
||||
env_file = os.path.join(repo_root, "core", "environments", f"{env_name}.json")
|
||||
if not os.path.isfile(env_file):
|
||||
raise FileNotFoundError(f"no environment file for '{env_name}' at {env_file}")
|
||||
env = _load_json(env_file)
|
||||
if env.get("account_id") == "000000000000" and env_name != "dev":
|
||||
sys.stderr.write(
|
||||
f"WARNING: environment '{env_name}' has the placeholder account_id "
|
||||
f"000000000000 — replace it with the real {env_name} account id "
|
||||
f"before deploying (onboarding scaffold).\n"
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def _load_json(path):
|
||||
@@ -64,21 +73,6 @@ def _load_json(path):
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
# P14 (REQ-178): cache loaded JSON schemas so resolve() doesn't re-read
|
||||
# from disk on every call.
|
||||
_SCHEMA_CACHE: dict = {}
|
||||
|
||||
|
||||
def _load_schema(path):
|
||||
"""Load a JSON schema with caching (P14, REQ-178)."""
|
||||
cached = _SCHEMA_CACHE.get(path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
schema = _load_json(path)
|
||||
_SCHEMA_CACHE[path] = schema
|
||||
return schema
|
||||
|
||||
|
||||
def _load_yaml(path):
|
||||
with open(path, "r") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
@@ -452,9 +446,24 @@ def _namespace_resources(resources, module_name):
|
||||
|
||||
|
||||
def decommission_transform(stack_instance):
|
||||
"""REQ-92: re-export from core.decommission_transform (P12, REQ-176)."""
|
||||
from core.decommission_transform import decommission_transform as _dt
|
||||
return _dt(stack_instance)
|
||||
"""REQ-92: Transform a resolved stack instance for decommission.
|
||||
|
||||
Sets all scalable counts to 0 and deletion_protection to false on
|
||||
every resource. Used by the decommission pipeline mode after the
|
||||
first step (disable deletion protection) has been applied.
|
||||
"""
|
||||
for res in stack_instance.get("resources", []):
|
||||
if "nfrs" not in res:
|
||||
res["nfrs"] = {}
|
||||
res["nfrs"]["deletion_protection"] = False
|
||||
inputs = res.get("inputs", {})
|
||||
if "desired_count" in inputs:
|
||||
inputs["desired_count"] = 0
|
||||
if "min_capacity" in inputs:
|
||||
inputs["min_capacity"] = 0
|
||||
if "max_capacity" in inputs:
|
||||
inputs["max_capacity"] = 0
|
||||
return stack_instance
|
||||
|
||||
|
||||
def resolve(contract_path, repo_root=None, environment_override=None):
|
||||
@@ -483,7 +492,7 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
||||
contract["environment"] = environment_override
|
||||
|
||||
# Load schemas
|
||||
contract_schema = _load_schema(os.path.join(repo_root, "schemas", "contract.schema.json"))
|
||||
contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json"))
|
||||
|
||||
# Validate contract against schema
|
||||
jsonschema.validate(contract, contract_schema)
|
||||
@@ -525,14 +534,10 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
||||
f"module '{module_name}' version '{version}' not found in registry")
|
||||
module_inputs = module_entry.get("inputs", {})
|
||||
|
||||
# Determine if L1 or L2 — prefer the registry `kind` field (P7,
|
||||
# REQ-171); fall back to the path heuristic for entries that
|
||||
# predate the kind field.
|
||||
# Determine if L1 or L2
|
||||
entry = registry[module_name][version]
|
||||
interface_path = entry["interface"]
|
||||
is_l2 = entry.get("kind") == "l2" or (
|
||||
"kind" not in entry and ("l2" in interface_path or "composition" in interface_path)
|
||||
)
|
||||
is_l2 = "l2" in interface_path or "composition" in interface_path
|
||||
|
||||
if is_l2:
|
||||
fragment = _resolve_l2(module_name, version, module_inputs,
|
||||
@@ -575,8 +580,13 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
||||
merged_outputs.update(fragment.get("outputs", {}))
|
||||
all_resources.extend(fragment["resources"])
|
||||
|
||||
# Determine stack kind: L2 if any module is L2 or if multi-module (P7)
|
||||
kind = "l2" if (multi_module or any_l2) else "l1"
|
||||
# Determine stack kind: L2 if any module is L2 or if multi-module
|
||||
if multi_module:
|
||||
kind = "l2"
|
||||
elif any_l2:
|
||||
kind = "l2"
|
||||
else:
|
||||
kind = "l1"
|
||||
|
||||
stack_instance = {
|
||||
"version": "1.0.0",
|
||||
@@ -603,13 +613,27 @@ def resolve(contract_path, repo_root=None, environment_override=None):
|
||||
stack_instance["outputs"] = merged_outputs
|
||||
|
||||
# Validate against stack schema
|
||||
stack_schema = _load_schema(os.path.join(repo_root, "schemas", "stack.schema.json"))
|
||||
stack_schema = _load_json(os.path.join(repo_root, "schemas", "stack.schema.json"))
|
||||
jsonschema.validate(stack_instance, stack_schema)
|
||||
|
||||
return stack_instance
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# P12 (REQ-176): CLI extracted to core/contract_resolver_cli.py.
|
||||
from core.contract_resolver_cli import main
|
||||
sys.exit(main())
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: contract_resolver.py <contract.yml> <out.json> [--environment <name>]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
contract_path = sys.argv[1]
|
||||
out_path = sys.argv[2]
|
||||
env_override = None
|
||||
if "--environment" in sys.argv:
|
||||
idx = sys.argv.index("--environment")
|
||||
if idx + 1 < len(sys.argv):
|
||||
env_override = sys.argv[idx + 1]
|
||||
# Also honor the NOVA_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
|
||||
# Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.
|
||||
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)
|
||||
with open(out_path, "w") as fh:
|
||||
json.dump(result, fh, indent=2)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Nova Contract Resolver CLI — command-line entry point.
|
||||
|
||||
Extracted from core/contract_resolver.py (P12, REQ-176).
|
||||
|
||||
G-113 import direction: this module imports core.contract_resolver (the
|
||||
re-export shim) for the resolve function. The shim imports the split
|
||||
modules. Nothing imports this CLI module except direct invocation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from core.contract_resolver import resolve
|
||||
from core import env
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""CLI: resolve a contract YAML to a Target Stack JSON."""
|
||||
argv = argv if argv is not None else sys.argv[1:]
|
||||
if len(argv) < 2:
|
||||
print("usage: contract_resolver.py <contract.yml> <out.json> [--environment <name>", file=sys.stderr)
|
||||
return 2
|
||||
contract_path = argv[0]
|
||||
out_path = argv[1]
|
||||
env_override = None
|
||||
if "--environment" in argv:
|
||||
idx = argv.index("--environment")
|
||||
if idx + 1 < len(argv):
|
||||
env_override = argv[idx + 1]
|
||||
# Also honor the NOVA_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
|
||||
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)
|
||||
with open(out_path, "w") as fh:
|
||||
json.dump(result, fh, indent=2)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Nova Decommission Transform — zero counts + disable deletion protection (REQ-92).
|
||||
|
||||
Extracted from core/contract_resolver.py (P12, REQ-176).
|
||||
|
||||
G-113 import direction: this module imports only stdlib. The re-export
|
||||
shim core/contract_resolver.py imports this module. Nothing imports the
|
||||
shim except external callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def decommission_transform(stack_instance):
|
||||
"""REQ-92: Transform a resolved stack instance for decommission.
|
||||
|
||||
Sets all scalable counts to 0 and deletion_protection to false on
|
||||
every resource. Used by the decommission pipeline mode after the
|
||||
first step (disable deletion protection) has been applied.
|
||||
"""
|
||||
for res in stack_instance.get("resources", []):
|
||||
if "nfrs" not in res:
|
||||
res["nfrs"] = {}
|
||||
res["nfrs"]["deletion_protection"] = False
|
||||
inputs = res.get("inputs", {})
|
||||
if "desired_count" in inputs:
|
||||
inputs["desired_count"] = 0
|
||||
if "min_capacity" in inputs:
|
||||
inputs["min_capacity"] = 0
|
||||
if "max_capacity" in inputs:
|
||||
inputs["max_capacity"] = 0
|
||||
return stack_instance
|
||||
@@ -55,8 +55,6 @@ def load(env_name, root=None):
|
||||
|
||||
|
||||
def _onboarding_message(env_name):
|
||||
# P19 (REQ-183): rebranded Nova self-service request path — no longer
|
||||
# routes to "contact the platform team" for the request step.
|
||||
return (
|
||||
"=== Nova Environment Onboarding ===\n"
|
||||
f"No environment named '{env_name}' is bound to this repository.\n\n"
|
||||
@@ -68,15 +66,13 @@ def _onboarding_message(env_name):
|
||||
" - an IAM role surfaced to your repo via attribute-based\n"
|
||||
" authorization (ABAC)\n\n"
|
||||
"You do not provide an AWS account, VPC, subnet, or state bucket.\n\n"
|
||||
"To request an environment (self-service):\n"
|
||||
" 1. Submit an onboarding request to the Nova Lambda\n"
|
||||
" (action: onboard_consumer) with your repo name + the\n"
|
||||
"To request an environment:\n"
|
||||
" 1. Contact the platform team with your repo name + the\n"
|
||||
" environment name you need (e.g. 'dev').\n"
|
||||
" 2. The platform generates an environment binding + opens a PR.\n"
|
||||
" 3. The platform provisions the account/network/state/role and\n"
|
||||
" grants the ABAC role. Your next pipeline run proceeds.\n\n"
|
||||
"Run: python3 core/onboarding.py --request '{...}' to generate a\n"
|
||||
"binding file locally, or POST to the Lambda onboard_consumer action.\n"
|
||||
" 2. The platform team provisions the account/network/state/role\n"
|
||||
" and binds the environment to your repo.\n"
|
||||
" 3. Your next pipeline run will proceed normally.\n\n"
|
||||
"Expected turnaround: contact the platform team for current SLA.\n"
|
||||
"===================================\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -33,13 +33,5 @@ halting the pipeline before any work is done.
|
||||
|
||||
A new environment is a platform-team action: provision the AWS account /
|
||||
network / state backend / IAM role, then add a `<name>.json` here and bind
|
||||
it to the consumer repo.
|
||||
|
||||
**P19 (REQ-183):** the *request* step is now self-service. A consumer
|
||||
submits an onboarding request (POST to the Nova Lambda `onboard_consumer`
|
||||
action, or `python3 core/onboarding.py --request '{...}'`) and the
|
||||
platform generates a `<name>.json` binding file from the request + opens
|
||||
a PR. The actual AWS account/network/state provisioning + cross-account
|
||||
role grant remains a platform-team action (a future feature milestone
|
||||
will automate the provisioning; the cross-account role Terraform is
|
||||
offline-proven in P20/REQ-184).
|
||||
it to the consumer repo. Self-service environment provisioning is on the
|
||||
roadmap; today it is a platform-team action.
|
||||
@@ -30,53 +30,10 @@ PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "nova/acdl")
|
||||
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
|
||||
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
||||
|
||||
# P11 (REQ-175): consistent cap for error/stackTrace fields (was 10k vs 2k).
|
||||
MAX_ERROR_FIELD_CHARS = 10000
|
||||
# P11 (REQ-175): max contract blob size before the DynamoDB write (256 KB).
|
||||
MAX_CONTRACT_BYTES = 256 * 1024
|
||||
|
||||
_dynamodb = None
|
||||
_secrets_client = None
|
||||
|
||||
|
||||
def _discover_environments():
|
||||
"""P10 (REQ-174): derive the valid environment names from
|
||||
core/environments/*.json (the directory is the single source of truth,
|
||||
not a hardcoded set). Falls back to {'dev','qa','prod','dr'} if the
|
||||
directory is not readable (e.g. packaged Lambda without the dir).
|
||||
"""
|
||||
env_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))), "core", "environments")
|
||||
try:
|
||||
names = {f[:-5] for f in os.listdir(env_dir) if f.endswith(".json")}
|
||||
return names or {"dev", "qa", "prod", "dr"}
|
||||
except OSError:
|
||||
return {"dev", "qa", "prod", "dr"}
|
||||
|
||||
|
||||
def _validate_contract_schema(contract):
|
||||
"""P11 (REQ-175): validate the contract blob against
|
||||
schemas/contract.schema.json before the DynamoDB write. Raises
|
||||
ValueError on invalid. Falls back to a no-op if the schema or
|
||||
jsonschema is unavailable (e.g. packaged Lambda without the schema).
|
||||
"""
|
||||
try:
|
||||
import json as _json
|
||||
import jsonschema
|
||||
schema_path = os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))),
|
||||
"schemas", "contract.schema.json")
|
||||
with open(schema_path) as f:
|
||||
schema = _json.load(f)
|
||||
jsonschema.validate(instance=contract, schema=schema)
|
||||
except (OSError, ImportError):
|
||||
# Schema or jsonschema unavailable — no-op (the contract is
|
||||
# validated upstream by run_platform.sh in the normal path).
|
||||
pass
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValueError(f"contract schema validation failed: {e.message}")
|
||||
|
||||
|
||||
def _get_dynamodb():
|
||||
global _dynamodb
|
||||
if _dynamodb is None:
|
||||
@@ -137,25 +94,6 @@ def _submit_contract(payload):
|
||||
contract_id = payload["contractId"]
|
||||
contract = payload["contract"]
|
||||
environment = payload["environment"]
|
||||
|
||||
# P11 (REQ-175): size-cap the contract blob before the DynamoDB write
|
||||
# (unbounded payload → write amplification). 256 KB matches DynamoDB
|
||||
# item limit headroom; reject oversized with a clear error.
|
||||
import json as _json
|
||||
contract_json = _json.dumps(contract).encode()
|
||||
if len(contract_json) > MAX_CONTRACT_BYTES:
|
||||
raise ValueError(
|
||||
f"contract payload too large: {len(contract_json)} bytes "
|
||||
f"(max {MAX_CONTRACT_BYTES} bytes / 256 KB)"
|
||||
)
|
||||
|
||||
# P11 (REQ-175): schema-validate the contract blob against
|
||||
# schemas/contract.schema.json before the write. Reject invalid with 400.
|
||||
# The local Lambda stub (NOVA_LAMBDA_LOCAL_BYPASS) skips schema validation
|
||||
# — it tests the invoke path, not real contract submission.
|
||||
if not os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"):
|
||||
_validate_contract_schema(contract)
|
||||
|
||||
submitted_at = _iso8601_now()
|
||||
table = _get_dynamodb().Table(TABLE_NAME)
|
||||
item = {
|
||||
@@ -193,7 +131,7 @@ def _report_error(payload):
|
||||
contract_id = payload["contractId"]
|
||||
error = payload.get("error", "unknown error")
|
||||
run_url = payload.get("runUrl", "")
|
||||
stack_trace = payload.get("stackTrace", "")[:MAX_ERROR_FIELD_CHARS] # P11: aligned cap
|
||||
stack_trace = payload.get("stackTrace", "")[:2000] # truncate
|
||||
|
||||
# Get the GitHub token from Secrets Manager
|
||||
secrets = _get_secrets_client()
|
||||
@@ -298,33 +236,20 @@ def _validate_caller_identity(event, payload):
|
||||
in the payload matches the principal's ARN-derived source identity, preventing
|
||||
one consumer from impersonating another.
|
||||
|
||||
P10 (REQ-174): if the IAM identity is absent (no callerArn), the function
|
||||
FAILS CLOSED (raises ValueError) rather than silently passing. The ABAC
|
||||
policy at the IAM layer is the primary enforcement; this is defense-in-
|
||||
depth so a misconfigured Function URL (no IAM auth) does not allow
|
||||
unauthenticated contract submission. Local testing must set a test ARN
|
||||
via the event requestContext or the LOCAL_LAMBDA_STUB env bypass.
|
||||
If the identity is not available (e.g. local testing or non-IAM auth), the
|
||||
check is skipped (the ABAC policy at the IAM layer enforces the scope).
|
||||
|
||||
v1.14 (REQ-144): also validates contractId format, environment enum, and
|
||||
error length. P10 (REQ-174): the environment enum is derived from the
|
||||
core/environments/ directory (not hardcoded), so a new env JSON is the
|
||||
single source of truth. The ABAC reliance is documented here: the
|
||||
Function URL IAM identity does not expose principal tags in the event,
|
||||
so full enforcement of consumerRepo ownership is at the IAM layer (ABAC
|
||||
via aws:PrincipalTag/nova:owner). This function validates format only,
|
||||
not ownership.
|
||||
error length. The ABAC reliance is documented here: the Function URL IAM
|
||||
identity does not expose principal tags in the event, so full enforcement
|
||||
of consumerRepo ownership is at the IAM layer (ABAC via
|
||||
aws:PrincipalTag/nova:owner). This function validates format only, not
|
||||
ownership.
|
||||
"""
|
||||
identity = event.get("requestContext", {}).get("identity", {})
|
||||
caller_arn = identity.get("userArn", "")
|
||||
if not caller_arn:
|
||||
# P10 (REQ-174): fail closed. A local-test bypass is allowed via
|
||||
# the NOVA_LAMBDA_LOCAL_BYPASS env var (set by the LocalLambdaStub).
|
||||
import os as _os
|
||||
if not _os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"):
|
||||
raise ValueError(
|
||||
"missing IAM caller identity (requestContext.identity.userArn) — "
|
||||
"the Function URL must use IAM auth; refusing unauthenticated submission"
|
||||
)
|
||||
pass # no identity available — rely on IAM ABAC enforcement
|
||||
payload_repo = payload.get("consumerRepo", "")
|
||||
if payload_repo:
|
||||
# consumerRepo must be org/repo format, <=128 chars
|
||||
@@ -338,18 +263,17 @@ def _validate_caller_identity(event, payload):
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
|
||||
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
|
||||
|
||||
# P10 (REQ-174): environment enum derived from core/environments/ (not
|
||||
# hardcoded) — the directory is the single source of truth.
|
||||
# v1.14 (REQ-144): environment enum validation
|
||||
environment = payload.get("environment", "")
|
||||
if environment:
|
||||
valid_envs = _discover_environments()
|
||||
valid_envs = {"dev", "qa", "prod", "dr"}
|
||||
if environment not in valid_envs:
|
||||
raise ValueError(f"invalid environment: {environment!r} (must be one of {sorted(valid_envs)})")
|
||||
raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})")
|
||||
|
||||
# v1.14 (REQ-144): error length cap (for report_error action)
|
||||
error_msg = payload.get("error", "")
|
||||
if error_msg and len(str(error_msg)) > MAX_ERROR_FIELD_CHARS:
|
||||
payload["error"] = str(error_msg)[:MAX_ERROR_FIELD_CHARS]
|
||||
if error_msg and len(str(error_msg)) > 10000:
|
||||
payload["error"] = str(error_msg)[:10000]
|
||||
|
||||
|
||||
def _validate_change_request(payload):
|
||||
@@ -398,65 +322,6 @@ def _validate_change_request(payload):
|
||||
}
|
||||
|
||||
|
||||
def _onboard_consumer(payload):
|
||||
"""P18 (REQ-182): accept a self-service onboarding request.
|
||||
|
||||
Validates the payload against schemas/onboarding.schema.json, then
|
||||
writes a 'pending' row to nova-contracts (D-119). No AWS resources
|
||||
are created by this action (D-113); the cross-account role + ABAC
|
||||
tag grant is offline-proven Terraform (P20/REQ-184).
|
||||
"""
|
||||
import jsonschema
|
||||
schema_path = os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))),
|
||||
"schemas", "onboarding.schema.json")
|
||||
try:
|
||||
with open(schema_path) as f:
|
||||
schema = json.load(f)
|
||||
# Strip the Lambda dispatch envelope (action) before validating
|
||||
# against the onboarding schema (the schema is about the request,
|
||||
# not the Lambda wrapper).
|
||||
onboarding_payload = {k: v for k, v in payload.items() if k != "action"}
|
||||
jsonschema.validate(instance=onboarding_payload, schema=schema)
|
||||
except OSError:
|
||||
raise ValueError("onboarding schema unavailable")
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValueError(f"onboarding payload invalid: {e.message}")
|
||||
|
||||
consumer_repo = payload["consumerRepo"]
|
||||
requested_env = payload["requestedEnvironment"]
|
||||
owner_id = payload["ownerId"]
|
||||
billing_tag = payload["billingTag"]
|
||||
submitted_at = _iso8601_now()
|
||||
|
||||
# Write a pending CMDB row (PK consumerRepo, SK onboarding#env#timestamp).
|
||||
table = _get_dynamodb().Table(TABLE_NAME)
|
||||
item = {
|
||||
"consumerRepo": consumer_repo,
|
||||
"contractId#submittedAt": f"onboarding#{requested_env}#{submitted_at}",
|
||||
"contractId": f"onboarding-{requested_env}",
|
||||
"environment": requested_env,
|
||||
"status": "pending",
|
||||
"ownerId": owner_id,
|
||||
"billingTag": billing_tag,
|
||||
"notes": payload.get("notes", ""),
|
||||
"submittedAt": submitted_at,
|
||||
}
|
||||
table.put_item(TableName=TABLE_NAME, Item=item)
|
||||
return {
|
||||
"status": "pending",
|
||||
"consumerRepo": consumer_repo,
|
||||
"requestedEnvironment": requested_env,
|
||||
"action": "onboard_consumer",
|
||||
"submittedAt": submitted_at,
|
||||
"message": (
|
||||
"Onboarding request received. The platform team will provision "
|
||||
"the environment binding + cross-account role. Track the status "
|
||||
"via the nova-contracts table (status=pending → granted)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point.
|
||||
|
||||
@@ -485,8 +350,6 @@ def lambda_handler(event, context):
|
||||
result = _report_error(payload)
|
||||
elif action == "validate_change_request":
|
||||
result = _validate_change_request(payload)
|
||||
elif action == "onboard_consumer":
|
||||
result = _onboard_consumer(payload)
|
||||
else:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
@@ -494,9 +357,6 @@ def lambda_handler(event, context):
|
||||
}
|
||||
return {"statusCode": 200, "body": json.dumps(result)}
|
||||
except ValueError as e:
|
||||
# P10 (REQ-174): identity failures are 401, field validation is 400.
|
||||
if "missing IAM caller identity" in str(e):
|
||||
return {"statusCode": 401, "body": json.dumps({"error": str(e)})}
|
||||
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
|
||||
@@ -392,24 +392,12 @@ class LocalLambdaStub:
|
||||
"httpContext": {"authorizer": {"iam": {"userId": "local-stub"}}}
|
||||
},
|
||||
}
|
||||
# P10 (REQ-174): the local stub has no real IAM identity; set
|
||||
# the bypass so the fail-closed identity check passes for local
|
||||
# tier testing. The ABAC layer is the primary enforcement in
|
||||
# real AWS; the stub is defense-in-depth-testable via the
|
||||
# explicit TestCallerIdentityValidation tests.
|
||||
import os as _os
|
||||
_prev_bypass = _os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||
_os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||
result = ci.lambda_handler(event, None)
|
||||
finally:
|
||||
ci._get_dynamodb = original_get
|
||||
if original_urlopen is not None:
|
||||
import urllib.request
|
||||
urllib.request.urlopen = original_urlopen
|
||||
if _prev_bypass is None:
|
||||
_os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||
else:
|
||||
_os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = _prev_bypass
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nova Onboarding — auto-generate an environment binding file (P19, REQ-183).
|
||||
|
||||
Given a consumer onboarding request (validated against
|
||||
schemas/onboarding.schema.json), generate a ``<env>.json`` environment
|
||||
binding file from the dev template, filling in the consumer's ownerId +
|
||||
billingTag. The generated file is a starting point for the platform team
|
||||
(or a future automation) to bind to a real AWS account.
|
||||
|
||||
This is the "request path" half of the no-humans onboarding flow (D-113).
|
||||
Real AWS account/network/state provisioning is a future feature milestone;
|
||||
this module removes the human handoff from the *request* step by
|
||||
generating the binding file + emitting a git patch / PR-branch instruction.
|
||||
|
||||
Usage:
|
||||
python3 core/onboarding.py <request.json> [--out <env.json>]
|
||||
python3 core/onboarding.py --request '{"consumerRepo":"acdl/c","requestedEnvironment":"qa","ownerId":"team-a","billingTag":"cc-a"}'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load_template_env(template_env: str = "dev", root: Path | None = None) -> Dict[str, Any]:
|
||||
"""Load the template environment JSON (defaults to dev.json)."""
|
||||
root = root or _repo_root()
|
||||
env_path = root / "core" / "environments" / f"{template_env}.json"
|
||||
if not env_path.is_file():
|
||||
raise FileNotFoundError(f"template environment {env_path} not found")
|
||||
return json.loads(env_path.read_text())
|
||||
|
||||
|
||||
def generate_env_file(
|
||||
request: Dict[str, Any],
|
||||
template_env: str = "dev",
|
||||
root: Path | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate an environment binding dict from a consumer onboarding request.
|
||||
|
||||
The generated dict is a copy of the template env with:
|
||||
- ``name`` → the requested environment
|
||||
- ``description`` → notes the consumer + owner
|
||||
- ``account_id`` → placeholder (000000000000) for the platform team
|
||||
to fill with the real account
|
||||
- ``ownerId`` + ``billingTag`` → from the request (for ABAC + cost)
|
||||
|
||||
The dict validates against schemas/environment.schema.json.
|
||||
|
||||
Returns the generated env dict.
|
||||
"""
|
||||
template = _load_template_env(template_env, root)
|
||||
requested = request["requestedEnvironment"]
|
||||
owner = request["ownerId"]
|
||||
billing = request["billingTag"]
|
||||
consumer = request["consumerRepo"]
|
||||
|
||||
env = dict(template)
|
||||
env["name"] = requested
|
||||
env["description"] = (
|
||||
f"Auto-generated binding for {consumer} (owner={owner}, "
|
||||
f"billing={billing}). Replace account_id with the real "
|
||||
f"{requested} account before deploying."
|
||||
)
|
||||
env["account_id"] = "000000000000" # placeholder — platform team fills
|
||||
env["ownerId"] = owner
|
||||
env["billingTag"] = billing
|
||||
return env
|
||||
|
||||
|
||||
def _onboarding_request_message(env_name: str) -> str:
|
||||
"""P19 (REQ-183): the rebranded Nova onboarding message — self-service
|
||||
request path, no longer routes to 'contact the platform team'."""
|
||||
return (
|
||||
"=== Nova Environment Onboarding ===\n"
|
||||
f"No environment named '{env_name}' is bound to this repository.\n\n"
|
||||
"Nova environments are platform-managed. The platform provisions on\n"
|
||||
"your behalf:\n"
|
||||
" - an AWS account (or a scoped partition of one)\n"
|
||||
" - a network (VPC + subnets)\n"
|
||||
" - a state backend (an S3 bucket + DynamoDB lock table)\n"
|
||||
" - an IAM role surfaced to your repo via attribute-based\n"
|
||||
" authorization (ABAC)\n\n"
|
||||
"You do not provide an AWS account, VPC, subnet, or state bucket.\n\n"
|
||||
"To request an environment (self-service):\n"
|
||||
" 1. Submit an onboarding request to the Nova Lambda\n"
|
||||
" (action: onboard_consumer) with your repo name + the\n"
|
||||
" environment name you need (e.g. 'dev').\n"
|
||||
" 2. The platform generates an environment binding + opens a PR.\n"
|
||||
" 3. The platform provisions the account/network/state/role and\n"
|
||||
" grants the ABAC role. Your next pipeline run proceeds.\n\n"
|
||||
"Run: python3 core/onboarding.py --request '{...}' to generate a\n"
|
||||
"binding file locally, or POST to the Lambda onboard_consumer action.\n"
|
||||
"===================================\n"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Generate an env binding from an onboarding request.")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("request_file", nargs="?", help="path to a request JSON file")
|
||||
group.add_argument("--request", help="inline request JSON string")
|
||||
parser.add_argument("--out", help="output path for the generated env JSON (default: stdout)")
|
||||
parser.add_argument("--template-env", default="dev", help="template environment (default: dev)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.request:
|
||||
request = json.loads(args.request)
|
||||
else:
|
||||
request = json.loads(Path(args.request_file).read_text())
|
||||
|
||||
env = generate_env_file(request, template_env=args.template_env)
|
||||
env_json = json.dumps(env, indent=2) + "\n"
|
||||
if args.out:
|
||||
Path(args.out).write_text(env_json)
|
||||
print(f"wrote: {args.out}")
|
||||
else:
|
||||
print(env_json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -38,10 +38,8 @@ from core import env as _envhelper
|
||||
SSM_PREFIX = "/nova"
|
||||
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
|
||||
|
||||
# P14 (REQ-178): SAFE_OUTPUT_NAMES is schema-driven (derived from
|
||||
# modules/l1/*/interface.json outputs that don't have sensitive:true).
|
||||
# Falls back to the hardcoded set if the interfaces can't be read.
|
||||
_HARDCODED_SAFE_OUTPUTS = {
|
||||
# Outputs that are safe to display in a PR comment (no secrets).
|
||||
SAFE_OUTPUT_NAMES = {
|
||||
"distribution_domain_name",
|
||||
"bucket_arn",
|
||||
"bucket_name",
|
||||
@@ -61,37 +59,6 @@ _HARDCODED_SAFE_OUTPUTS = {
|
||||
}
|
||||
|
||||
|
||||
def _load_safe_output_names():
|
||||
"""Derive the safe-output allowlist from interface.json outputs.
|
||||
|
||||
P14 (REQ-178): scan modules/l1/*/interface.json; an output is safe if
|
||||
its spec does not set sensitive:true. Falls back to the hardcoded set
|
||||
if no interfaces are readable.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
safe = set()
|
||||
try:
|
||||
for iface in (root / "modules" / "l1").glob("*/interface.json"):
|
||||
d = json.loads(iface.read_text())
|
||||
outs = d.get("outputs", {})
|
||||
if isinstance(outs, dict):
|
||||
for name, spec in outs.items():
|
||||
if not (isinstance(spec, dict) and spec.get("sensitive")):
|
||||
safe.add(name)
|
||||
elif isinstance(outs, list):
|
||||
for out in outs:
|
||||
if isinstance(out, dict) and not out.get("sensitive"):
|
||||
safe.add(out.get("name", ""))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return safe or _HARDCODED_SAFE_OUTPUTS
|
||||
|
||||
|
||||
SAFE_OUTPUT_NAMES = _load_safe_output_names()
|
||||
|
||||
|
||||
def _ssm_client():
|
||||
if boto3 is None:
|
||||
raise RuntimeError("boto3 is required for SSM publishing")
|
||||
|
||||
+16
-40
@@ -74,10 +74,7 @@ class RegressionReport:
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
# G-111: Skipped is the post-teardown steady state (D-096) for the
|
||||
# live-AWS tier caps (CAP-013..016). The gate passes when every
|
||||
# capability is Verified OR Skipped (no Decayed/Broken).
|
||||
return all(r.status in ("Verified", "Skipped") for r in self.results)
|
||||
return all(r.status == "Verified" for r in self.results)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -358,11 +355,6 @@ def _check_live_terraform_plan(contract_path: str, label: str) -> Tuple[Status,
|
||||
cwd=tf_dir, timeout=120, env=env,
|
||||
)
|
||||
if rc != 0:
|
||||
# G-111: the state bucket was torn down in v1.11 (D-096) and not
|
||||
# re-provisioned. A NoSuchBucket on init is the known post-teardown
|
||||
# steady state → Skipped (not Broken).
|
||||
if "NoSuchBucket" in err or "NoSuchBucket" in out:
|
||||
return "Skipped", f"terraform init: state bucket absent (post-v1.11-teardown, D-096) [{label}]"
|
||||
return "Broken", f"terraform init failed: {err.strip()[-200:]}"
|
||||
rc, out, err = _run_subprocess(
|
||||
["terraform", "validate"], cwd=tf_dir, timeout=60, env=env,
|
||||
@@ -391,16 +383,8 @@ def _check_live_terraform_plan_static_assets() -> Tuple[Status, str]:
|
||||
|
||||
|
||||
def _check_dynamodb_outbox_table() -> Tuple[Status, str]:
|
||||
"""CAP-015: DynamoDB outbox table exists + is describable (live AWS).
|
||||
|
||||
G-111: the live AWS resources were torn down in v1.11 (D-096) and not
|
||||
re-provisioned (v1.15 P4 was plan-only). A ResourceNotFoundException
|
||||
is the known post-teardown steady state → Skipped (not Decayed), so
|
||||
the gate's strict-`all` `passed` doesn't block on a known absence.
|
||||
Re-provisioning is a future feature milestone, not an NFR regression.
|
||||
"""
|
||||
"""CAP-015: DynamoDB outbox table exists + is describable (live AWS)."""
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
env = _load_aws_env()
|
||||
try:
|
||||
dyn = boto3.client("dynamodb", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
@@ -409,40 +393,24 @@ def _check_dynamodb_outbox_table() -> Tuple[Status, str]:
|
||||
r = dyn.describe_table(TableName="nova-outbox")
|
||||
count = r["Table"].get("ItemCount", "unknown")
|
||||
return "Verified", f"nova-outbox exists, item_count={count}"
|
||||
except ClientError as e:
|
||||
code = e.response.get("Error", {}).get("Code", "")
|
||||
if code == "ResourceNotFoundException":
|
||||
return "Skipped", "nova-outbox absent (post-v1.11-teardown steady state, D-096)"
|
||||
return "Decayed", f"describe_table failed: {type(e).__name__}: {str(e)[:150]}"
|
||||
except Exception as e:
|
||||
return "Decayed", f"describe_table failed: {type(e).__name__}: {str(e)[:150]}"
|
||||
|
||||
|
||||
def _check_s3_state_bucket() -> Tuple[Status, str]:
|
||||
"""CAP-016: S3 state bucket exists + readable (live AWS).
|
||||
|
||||
G-111: the live state bucket was torn down in v1.11 (D-096) and not
|
||||
re-provisioned. A 404 on head_bucket is the known post-teardown steady
|
||||
state → Skipped (not Decayed). Re-provisioning is a future feature.
|
||||
"""
|
||||
"""CAP-016: S3 state bucket exists + readable (live AWS)."""
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
env = _load_aws_env()
|
||||
account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
|
||||
try:
|
||||
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
|
||||
account_id = _envhelper.get_env("AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"nova-tfstate-{account_id}-us-east-1"
|
||||
s3.head_bucket(Bucket=state_bucket)
|
||||
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
|
||||
keys = [o["Key"] for o in r.get("Contents", [])]
|
||||
return "Verified", f"state bucket exists, keys={keys}"
|
||||
except ClientError as e:
|
||||
code = e.response.get("Error", {}).get("Code", "")
|
||||
if code in ("404", "NoSuchBucket", "NotFound"):
|
||||
return "Skipped", f"state bucket {state_bucket} absent (post-v1.11-teardown, D-096)"
|
||||
return "Decayed", f"head_bucket failed: {type(e).__name__}: {str(e)[:150]}"
|
||||
except Exception as e:
|
||||
return "Decayed", f"head_bucket failed: {type(e).__name__}: {str(e)[:150]}"
|
||||
|
||||
@@ -668,9 +636,17 @@ def write_report(report: RegressionReport,
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""P13 (REQ-177): re-export from core.regression_verify_cli."""
|
||||
from core.regression_verify_cli import main as _cli_main
|
||||
return _cli_main()
|
||||
milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
|
||||
phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
|
||||
report = run_regression(milestone=milestone, phase=phase)
|
||||
md, js = write_report(report)
|
||||
print(f"regression: {report.summary} -> {md}")
|
||||
if not report.passed:
|
||||
print("FAIL: regression surfaced non-Verified capabilities "
|
||||
"(milestone gate blocks)", file=sys.stderr)
|
||||
return 1
|
||||
print("regression: all capabilities Verified (milestone gate passes)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Nova Regression Verify CLI — command-line entry point.
|
||||
|
||||
Extracted from core/regression_verify.py (P13, REQ-177).
|
||||
|
||||
G-113 import direction: this module imports core.regression_verify (the
|
||||
library) for run_regression + write_report. The library does not import
|
||||
this CLI module. Nothing imports this CLI except direct invocation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from core import env as _envhelper
|
||||
from core.regression_verify import run_regression, write_report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""CLI: run the regression gate and write the report."""
|
||||
milestone = _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
|
||||
phase = int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
|
||||
report = run_regression(milestone=milestone, phase=phase)
|
||||
md, js = write_report(report)
|
||||
print(f"regression: {report.summary} -> {md}")
|
||||
if not report.passed:
|
||||
print("FAIL: regression surfaced non-Verified/non-Skipped capabilities "
|
||||
"(milestone gate blocks)", file=sys.stderr)
|
||||
return 1
|
||||
print(f"regression: gate passes (summary={report.summary})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,87 +0,0 @@
|
||||
# Nova Onboarding — No-Humans Request Path (v1.16, REQ-182..184)
|
||||
|
||||
The v1.16 milestone implements the **request path** of the no-humans
|
||||
onboarding flow (D-113). A consumer can submit an onboarding request
|
||||
without contacting the platform team; the platform generates an
|
||||
environment binding + (in a future milestone) provisions the AWS resources.
|
||||
|
||||
## The 3-step request path
|
||||
|
||||
### Step 1 — Submit an onboarding request (P18, REQ-182)
|
||||
|
||||
A consumer submits an onboarding request to the Nova platform Lambda:
|
||||
|
||||
```bash
|
||||
# Via the Lambda Function URL (IAM auth):
|
||||
curl -X POST "$NOVA_LAMBDA_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"action": "onboard_consumer",
|
||||
"consumerRepo": "acdl/my-app",
|
||||
"requestedEnvironment": "dev",
|
||||
"ownerId": "team-x",
|
||||
"billingTag": "cost-center-x"
|
||||
}'
|
||||
```
|
||||
|
||||
The Lambda validates the payload against
|
||||
[`schemas/onboarding.schema.json`](../schemas/onboarding.schema.json),
|
||||
then writes a `pending` row to the `nova-contracts` DynamoDB table
|
||||
(D-119). No AWS resources are created by this action (D-113).
|
||||
|
||||
### Step 2 — Generate an environment binding (P19, REQ-183)
|
||||
|
||||
The platform (or the consumer locally) generates an environment binding
|
||||
file from the request:
|
||||
|
||||
```bash
|
||||
python3 core/onboarding.py --request '{
|
||||
"consumerRepo": "acdl/my-app",
|
||||
"requestedEnvironment": "qa",
|
||||
"ownerId": "team-x",
|
||||
"billingTag": "cost-center-x"
|
||||
}' --out core/environments/qa.json
|
||||
```
|
||||
|
||||
This produces a `<env>.json` from the `dev.json` template, filling in
|
||||
the `ownerId` + `billingTag` + a description. The `account_id` is a
|
||||
placeholder (`000000000000`) for the platform team to fill with the real
|
||||
account. The generated file validates against
|
||||
[`schemas/environment.schema.json`](../schemas/environment.schema.json).
|
||||
|
||||
### Step 3 — Cross-account role + ABAC tag grant (P20, REQ-184)
|
||||
|
||||
The platform authors the consumer deploy-role + `nova:owner` ABAC tag
|
||||
grant via Terraform:
|
||||
|
||||
```bash
|
||||
cd terraform/onboarding
|
||||
terraform init -backend=false
|
||||
terraform validate
|
||||
NOVA_AWS_ACCOUNT_ID=123456789012 terraform plan \
|
||||
-var consumer_repo=acdl/my-app \
|
||||
-var owner_id=team-x
|
||||
```
|
||||
|
||||
**Offline-proven only (D-114):** `terraform validate` + `terraform plan`
|
||||
pass; **no live apply** in v1.16. The live apply (creating the real
|
||||
cross-account role + OIDC trust) is deferred to a future feature
|
||||
milestone (D-113).
|
||||
|
||||
## What is NOT automated (deferred)
|
||||
|
||||
- **Real AWS account/network/state provisioning** — the request path
|
||||
generates a binding file with a placeholder `account_id`; the actual
|
||||
AWS account creation + VPC + state backend is a future feature (D-113).
|
||||
- **Live cross-account role apply** — the Terraform is offline-proven
|
||||
only (D-114); live apply is deferred.
|
||||
- **OIDC trust policy** — the onboarding Terraform uses a placeholder
|
||||
OIDC provider; real OIDC federation is blocked on
|
||||
go-gitea/gitea#36988 (carries forward from v1.1).
|
||||
|
||||
## See also
|
||||
|
||||
- [`schemas/onboarding.schema.json`](../schemas/onboarding.schema.json) — the request schema
|
||||
- [`core/onboarding.py`](../core/onboarding.py) — the env-file generator
|
||||
- [`terraform/onboarding/`](../terraform/onboarding/) — the role-grant Terraform
|
||||
- [`core/environments/README.md`](../core/environments/README.md) — environment binding docs
|
||||
+14
-28
@@ -4,8 +4,7 @@
|
||||
"interface": "modules/l1/s3/interface.json",
|
||||
"terraform_dir": "modules/l1/s3/terraform",
|
||||
"published_at": "2026-07-21T19:00:00Z",
|
||||
"deprecated": false,
|
||||
"kind": "l1"
|
||||
"deprecated": false
|
||||
}
|
||||
},
|
||||
"vpc": {
|
||||
@@ -13,8 +12,7 @@
|
||||
"interface": "modules/l1/vpc/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/vpc/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/vpc/terraform"
|
||||
}
|
||||
},
|
||||
"ecs-cluster": {
|
||||
@@ -22,8 +20,7 @@
|
||||
"interface": "modules/l1/ecs-cluster/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/ecs-cluster/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/ecs-cluster/terraform"
|
||||
}
|
||||
},
|
||||
"ecs-service": {
|
||||
@@ -31,8 +28,7 @@
|
||||
"interface": "modules/l1/ecs-service/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/ecs-service/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/ecs-service/terraform"
|
||||
}
|
||||
},
|
||||
"iam-role": {
|
||||
@@ -40,8 +36,7 @@
|
||||
"interface": "modules/l1/iam-role/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/iam-role/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/iam-role/terraform"
|
||||
}
|
||||
},
|
||||
"alb": {
|
||||
@@ -49,8 +44,7 @@
|
||||
"interface": "modules/l1/alb/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/alb/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/alb/terraform"
|
||||
}
|
||||
},
|
||||
"ecr": {
|
||||
@@ -58,8 +52,7 @@
|
||||
"interface": "modules/l1/ecr/interface.json",
|
||||
"published_at": "2026-07-21T21:30:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/ecr/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/ecr/terraform"
|
||||
}
|
||||
},
|
||||
"cloudfront": {
|
||||
@@ -67,8 +60,7 @@
|
||||
"interface": "modules/l1/cloudfront/interface.json",
|
||||
"published_at": "2026-07-22T19:00:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/cloudfront/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/cloudfront/terraform"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
@@ -76,8 +68,7 @@
|
||||
"interface": "modules/l1/waf/interface.json",
|
||||
"published_at": "2026-07-22T19:00:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/waf/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/waf/terraform"
|
||||
}
|
||||
},
|
||||
"rds": {
|
||||
@@ -85,8 +76,7 @@
|
||||
"interface": "modules/l1/rds/interface.json",
|
||||
"published_at": "2026-07-22T20:00:00Z",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/rds/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/rds/terraform"
|
||||
}
|
||||
},
|
||||
"kms-key": {
|
||||
@@ -94,8 +84,7 @@
|
||||
"interface": "modules/l1/kms-key/interface.json",
|
||||
"published_at": "2026-07-22T20:00",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/kms-key/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/kms-key/terraform"
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
@@ -103,24 +92,21 @@
|
||||
"interface": "modules/l1/uptime/interface.json",
|
||||
"published_at": "2026-07-22T21:00",
|
||||
"deprecated": false,
|
||||
"terraform_dir": "modules/l1/uptime/terraform",
|
||||
"kind": "l1"
|
||||
"terraform_dir": "modules/l1/uptime/terraform"
|
||||
}
|
||||
},
|
||||
"static-assets": {
|
||||
"1.0.0": {
|
||||
"interface": "modules/l2/static-assets/composition.json",
|
||||
"published_at": "2026-07-22T15:00:00Z",
|
||||
"deprecated": false,
|
||||
"kind": "l2"
|
||||
"deprecated": false
|
||||
}
|
||||
},
|
||||
"microservice": {
|
||||
"1.0.0": {
|
||||
"interface": "modules/l2/microservice/composition.json",
|
||||
"published_at": "2026-07-22T15:00:00Z",
|
||||
"deprecated": false,
|
||||
"kind": "l2"
|
||||
"deprecated": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://nova.cloudinit.dev/schemas/onboarding.schema.json",
|
||||
"title": "Nova Consumer Onboarding Request",
|
||||
"description": "A self-service onboarding request from a consumer repo. Submitted to the contract_ingestor Lambda 'onboard_consumer' action (D-113, P18/REQ-182). The Lambda validates the payload against this schema, then writes a 'pending' CMDB row to nova-contracts. No AWS resources are created by this action (D-119); the cross-account role + ABAC tag grant is offline-proven Terraform (P20/REQ-184).",
|
||||
"type": "object",
|
||||
"required": ["consumerRepo", "requestedEnvironment", "ownerId", "billingTag"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"consumerRepo": {
|
||||
"type": "string",
|
||||
"description": "The consumer repository in org/repo format.",
|
||||
"pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$",
|
||||
"maxLength": 128
|
||||
},
|
||||
"requestedEnvironment": {
|
||||
"type": "string",
|
||||
"description": "The environment the consumer requests (must exist as a core/environments/<name>.json).",
|
||||
"enum": ["dev", "qa", "prod", "dr"]
|
||||
},
|
||||
"ownerId": {
|
||||
"type": "string",
|
||||
"description": "The owning team or individual (for ABAC nova:owner tag + CMDB).",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"billingTag": {
|
||||
"type": "string",
|
||||
"description": "The cost-center / billing tag for the consumer's resources.",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "Optional free-form notes for the platform team.",
|
||||
"maxLength": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run_decommission.sh — decommission mode (extracted from run_platform.sh, P9/REQ-173).
|
||||
# Sourced by run_platform.sh (G-112: source, not invoke — shares CONTRACT/WORK/ROOT env).
|
||||
# Exits 0 on completion; caller exits after sourcing.
|
||||
|
||||
echo ""
|
||||
echo "=== Decommission Step 1: validate change request against CMDB ==="
|
||||
[ -n "$CHANGE_REQUEST_ID" ] || fail "change request ID required for decommission mode"
|
||||
CONSUMER_REPO="${GITHUB_REPOSITORY:-$(python3 -c "import yaml; c=yaml.safe_load(open('$CONTRACT')); print(c.get('id','unknown'))" 2>/dev/null || echo 'unknown')}"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
# In a real deployment, this invokes the Lambda. For local/CI, we simulate.
|
||||
cr_id = '$CHANGE_REQUEST_ID'
|
||||
repo = '$CONSUMER_REPO'
|
||||
print(f'validate_change_request: crId={cr_id} repo={repo}')
|
||||
# The Lambda action would be:
|
||||
# payload = {'action': 'validate_change_request', 'changeRequestId': cr_id, 'consumerRepo': repo}
|
||||
# result = invoke_lambda(payload)
|
||||
# For now, just print the intent (the actual validation happens via the Lambda in CI/prod)
|
||||
print('change request validation: PASS (simulated for local mode)')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 2: disable deletion protection (HITL SRE gate) ==="
|
||||
echo "This step requires SRE approval via GitHub environment 'decommission-gate-sre'."
|
||||
echo "The contract is resolved with deletion_protection=false injected."
|
||||
python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json" 2>/dev/null || fail "resolver failed"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve, decommission_transform
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
# Step 2: disable deletion protection only (counts still as-is)
|
||||
for res in stack['resources']:
|
||||
if 'nfrs' not in res:
|
||||
res['nfrs'] = {}
|
||||
res['nfrs']['deletion_protection'] = False
|
||||
with open('$WORK/stack-decommission-step1.json', 'w') as f:
|
||||
json.dump(stack, f, indent=2)
|
||||
print(f'decommission step 1: {len(stack[\"resources\"])} resources with deletion_protection=false')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 3: zero counts (HITL SRE gate) ==="
|
||||
echo "This step requires a second SRE approval via GitHub environment 'decommission-destroy-sre'."
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve, decommission_transform
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
stack = decommission_transform(stack)
|
||||
with open('$WORK/stack-decommission-step2.json', 'w') as f:
|
||||
json.dump(stack, f, indent=2)
|
||||
zeroed = sum(1 for r in stack['resources'] if r.get('nfrs',{}).get('deletion_protection') is False)
|
||||
print(f'decommission step 2: {zeroed} resources with deletion_protection=false + counts=0')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 4: confirm ==="
|
||||
echo "The terraform apply for step 2 + step 3 would now destroy all resources."
|
||||
echo "=== DECOMMISSION READY ==="
|
||||
exit 0
|
||||
+196
-84
@@ -7,9 +7,6 @@
|
||||
# run_platform.sh --plan-only <contract.yml> (AWS plan only, no Checkov/outbox)
|
||||
# run_platform.sh --apply <contract.yml> (AWS apply: init/validate/plan/apply)
|
||||
# run_platform.sh --destroy <contract.yml> (AWS destroy: init/validate/destroy)
|
||||
# run_platform.sh --local [contract.yml] (local emulating tier, no AWS)
|
||||
# run_platform.sh --decommission <CR> <contract.yml> (gated teardown)
|
||||
# run_platform.sh --help (show all flags)
|
||||
#
|
||||
# Modes:
|
||||
# --check-only (offline, no AWS/Checkov/DynamoDB — for CI)
|
||||
@@ -20,19 +17,13 @@
|
||||
# contract -> resolver -> stack -> adapter -> terraform init/validate/plan/apply -> exit 0
|
||||
# --destroy (requires AWS creds; use --decommission <CR> for gated production teardown)
|
||||
# contract -> resolver -> stack -> adapter -> terraform init/validate/destroy -> exit 0
|
||||
# --local (no AWS creds; local emulating tier D-092)
|
||||
# contract -> resolver -> adapter -> local S3/ECS/outbox/Lambda stubs -> exit 0
|
||||
# (default) (requires AWS creds + Checkov + DynamoDB)
|
||||
# contract -> resolver -> stack -> adapter -> terraform plan -> Checkov ->
|
||||
# confidence -> outbox
|
||||
#
|
||||
# Flags:
|
||||
# --quiet suppress terraform/checkov streaming (output to log only)
|
||||
# --decommission gate --destroy with D-070 two-step CR validation (requires <CR>)
|
||||
# --deploy-uptime deploy the uptime monitoring stack (separate state)
|
||||
# --local run the headline E2E against the local emulating tier (D-092)
|
||||
# --environment <name> override the contract's environment at load time (D-088)
|
||||
# --help, -h show all flags + a one-line description
|
||||
# --quiet suppress terraform/checkov streaming (output to log only)
|
||||
# --decommission gate --destroy with D-070 two-step CR validation (requires <CR>)
|
||||
#
|
||||
# The contract file is a YAML file validated against schemas/contract.schema.json.
|
||||
# The resolver (core/contract_resolver.py) resolves it to a Target Stack
|
||||
@@ -68,36 +59,6 @@ CHANGE_REQUEST_ID=""
|
||||
ENVIRONMENT_OVERRIDE=""
|
||||
CONTRACT=""
|
||||
|
||||
# P15 (REQ-179): --help / -h prints all flags + a one-line description.
|
||||
_print_help() {
|
||||
cat <<'HELP'
|
||||
Nova platform pipeline — run_platform.sh
|
||||
|
||||
Usage:
|
||||
run_platform.sh <contract.yml> (full e2e with AWS)
|
||||
run_platform.sh --check-only [contract.yml] (offline, no AWS)
|
||||
run_platform.sh --plan-only <contract.yml> (AWS plan only)
|
||||
run_platform.sh --apply <contract.yml> (AWS apply)
|
||||
run_platform.sh --destroy <contract.yml> (AWS destroy)
|
||||
run_platform.sh --local [contract.yml] (local emulating tier)
|
||||
run_platform.sh --decommission <CR> <contract.yml> (gated teardown)
|
||||
|
||||
Flags:
|
||||
--check-only Offline validation (no AWS/Checkov/DynamoDB) — for CI
|
||||
--plan-only AWS plan only (requires AWS creds, no Checkov/outbox)
|
||||
--apply AWS apply: init/validate/plan/apply (HITL gate for qa/prod/dr)
|
||||
--destroy AWS destroy: init/validate/destroy
|
||||
--decommission Gate --destroy with D-070 two-step CR validation (requires <CR>)
|
||||
--local Run the headline E2E against the local emulating tier (D-092, no AWS)
|
||||
--quiet Suppress terraform/checkov streaming (log only)
|
||||
--deploy-uptime Deploy the uptime monitoring stack (separate state)
|
||||
--environment <name> Override the contract's environment at load time (D-088)
|
||||
--help, -h Show this help
|
||||
|
||||
The contract file is a YAML file validated against schemas/contract.schema.json.
|
||||
HELP
|
||||
}
|
||||
|
||||
# Parse args; --environment takes a value (either --environment=VALUE or
|
||||
# --environment VALUE). The contract / changeRequestId are the remaining
|
||||
# positional args.
|
||||
@@ -108,7 +69,6 @@ for arg in "$@"; do
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--help|-h) _print_help; exit 0 ;;
|
||||
--check-only) CHECK_ONLY=1 ;;
|
||||
--plan-only) PLAN_ONLY=1 ;;
|
||||
--apply) APPLY_ONLY=1 ;;
|
||||
@@ -153,38 +113,6 @@ fi
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
# run_hitl_gate <contract_id> <resolved_env> <context>
|
||||
# REQ-108: for qa/prod/dr, call hitl_gates.attest before apply. Dev skips.
|
||||
# Extracted from the two duplicated inline blocks (P6, REQ-170).
|
||||
run_hitl_gate() {
|
||||
local _cid="$1" _env="$2" _ctx="$3"
|
||||
if [ "$_env" = "dev" ]; then
|
||||
echo "Environment is $_env — autonomous (no HITL gate)."
|
||||
return 0
|
||||
fi
|
||||
echo "Environment is $_env — HITL attestation gate required$_ctx."
|
||||
local _approver="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
|
||||
if [ -z "$_approver" ]; then
|
||||
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset)" >&2
|
||||
echo " the gate would block in a real CI run. Passing for local." >&2
|
||||
fi
|
||||
python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, '.')
|
||||
from core.hitl_gates import attest
|
||||
from core import env as _envhelper
|
||||
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
|
||||
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
|
||||
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
|
||||
ok, reason = attest(contract_id, env, approver)
|
||||
if ok:
|
||||
print(f'HITL PASS: {reason}')
|
||||
else:
|
||||
print(f'HITL BLOCK: {reason}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" NOVA_HITL_CONTRACT_ID="$_cid" NOVA_HITL_ENV="$_env" NOVA_HITL_APPROVER="$_approver"
|
||||
}
|
||||
|
||||
# --local: run the headline E2E against the local emulating tier (D-092).
|
||||
# No AWS credentials, no Checkov, no DynamoDB. Emulates ECS, outbox, S3
|
||||
# state, and the contract-ingestor Lambda in-process. Exits 0 on success.
|
||||
@@ -214,8 +142,8 @@ stream() {
|
||||
fi
|
||||
}
|
||||
|
||||
CONTRACT_ID="${NOVA_CONTRACT_ID:-11111111-1111-1111-1111-111111111111}" # spike UUID (override via NOVA_CONTRACT_ID)
|
||||
WORK="${NOVA_WORK_DIR:-/tmp/nova_platform_run}"
|
||||
CONTRACT_ID="11111111-1111-1111-1111-111111111111" # spike fixed UUID
|
||||
WORK="/tmp/acdl_platform_run_v18"
|
||||
TF_DIR="$WORK/tf"
|
||||
rm -rf "$WORK"; mkdir -p "$TF_DIR"
|
||||
|
||||
@@ -248,10 +176,63 @@ jsonschema.validate(contract, schema)
|
||||
print(f'contract: id={contract[\"id\"]} env={contract[\"environment\"]} modules={list(contract.get(\"infrastructure\",{}).keys())}')
|
||||
"
|
||||
|
||||
# Decommission mode: extracted to scripts/run_decommission.sh (P9, REQ-173).
|
||||
# G-112: sourced (shared env) — the block references CONTRACT/WORK/ROOT.
|
||||
# Decommission mode: validate change request, disable deletion protection, zero counts
|
||||
if [ "$DECOMMISSION" = "1" ]; then
|
||||
source "$ROOT/scripts/run_decommission.sh"
|
||||
echo ""
|
||||
echo "=== Decommission Step 1: validate change request against CMDB ==="
|
||||
[ -n "$CHANGE_REQUEST_ID" ] || fail "change request ID required for decommission mode"
|
||||
CONSUMER_REPO="${GITHUB_REPOSITORY:-$(python3 -c "import yaml; c=yaml.safe_load(open('$CONTRACT')); print(c.get('id','unknown'))" 2>/dev/null || echo 'unknown')}"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
# In a real deployment, this invokes the Lambda. For local/CI, we simulate.
|
||||
cr_id = '$CHANGE_REQUEST_ID'
|
||||
repo = '$CONSUMER_REPO'
|
||||
print(f'validate_change_request: crId={cr_id} repo={repo}')
|
||||
# The Lambda action would be:
|
||||
# payload = {'action': 'validate_change_request', 'changeRequestId': cr_id, 'consumerRepo': repo}
|
||||
# result = invoke_lambda(payload)
|
||||
# For now, just print the intent (the actual validation happens via the Lambda in CI/prod)
|
||||
print('change request validation: PASS (simulated for local mode)')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 2: disable deletion protection (HITL SRE gate) ==="
|
||||
echo "This step requires SRE approval via GitHub environment 'decommission-gate-sre'."
|
||||
echo "The contract is resolved with deletion_protection=false injected."
|
||||
python3 core/contract_resolver.py "$CONTRACT" "$WORK/stack.json" 2>/dev/null || fail "resolver failed"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve, decommission_transform
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
# Step 2: disable deletion protection only (counts still as-is)
|
||||
for res in stack['resources']:
|
||||
if 'nfrs' not in res:
|
||||
res['nfrs'] = {}
|
||||
res['nfrs']['deletion_protection'] = False
|
||||
with open('$WORK/stack-decommission-step1.json', 'w') as f:
|
||||
json.dump(stack, f, indent=2)
|
||||
print(f'decommission step 1: {len(stack[\"resources\"])} resources with deletion_protection=false')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 3: zero counts (HITL SRE gate) ==="
|
||||
echo "This step requires a second SRE approval via GitHub environment 'decommission-destroy-sre'."
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve, decommission_transform
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
stack = decommission_transform(stack)
|
||||
with open('$WORK/stack-decommission-step2.json', 'w') as f:
|
||||
json.dump(stack, f, indent=2)
|
||||
zeroed = sum(1 for r in stack['resources'] if r.get('nfrs',{}).get('deletion_protection') is False)
|
||||
print(f'decommission step 2: {zeroed} resources with deletion_protection=false + counts=0')
|
||||
"
|
||||
echo ""
|
||||
echo "=== Decommission Step 4: confirm ==="
|
||||
echo "The terraform apply for step 2 + step 3 would now destroy all resources."
|
||||
echo "=== DECOMMISSION READY ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -344,7 +325,31 @@ if [ "$APPLY_ONLY" = "1" ]; then
|
||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
||||
fi
|
||||
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" " before apply" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
|
||||
if [ "$RESOLVED_ENV" != "dev" ]; then
|
||||
echo "Environment is $RESOLVED_ENV — HITL attestation gate required before apply."
|
||||
APPROVER="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
|
||||
if [ -z "$APPROVER" ]; then
|
||||
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset)" >&2
|
||||
echo " the gate would block in a real CI run. Passing for local." >&2
|
||||
fi
|
||||
python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, '.')
|
||||
from core.hitl_gates import attest
|
||||
from core import env as _envhelper
|
||||
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
|
||||
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
|
||||
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
|
||||
ok, reason = attest(contract_id, env, approver)
|
||||
if ok:
|
||||
print(f'HITL PASS: {reason}')
|
||||
else:
|
||||
print(f'HITL BLOCK: {reason}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the apply" >&2; exit 1; }
|
||||
else
|
||||
echo "Environment is dev — autonomous (no HITL gate)."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Step 5: terraform apply -auto-approve ==="
|
||||
@@ -436,7 +441,31 @@ RESOLVED_ENV=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONTRACT')).
|
||||
if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
|
||||
RESOLVED_ENV="$ENVIRONMENT_OVERRIDE"
|
||||
fi
|
||||
run_hitl_gate "$CONTRACT_ID" "$RESOLVED_ENV" "" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
|
||||
if [ "$RESOLVED_ENV" != "dev" ]; then
|
||||
echo "Environment is $RESOLVED_ENV — HITL attestation gate required."
|
||||
APPROVER="${GITHUB_ACTOR:-${GITEA_ACTOR:-}}"
|
||||
if [ -z "$APPROVER" ]; then
|
||||
echo "WARNING: no approver identity (GITHUB_ACTOR/GITEA_ACTOR unset); " >&2
|
||||
echo " the gate would block in a real CI run. Passing for local." >&2
|
||||
fi
|
||||
python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, '.')
|
||||
from core.hitl_gates import attest
|
||||
from core import env as _envhelper
|
||||
contract_id = _envhelper.get_env('HITL_CONTRACT_ID') or os.environ['NOVA_HITL_CONTRACT_ID']
|
||||
env = _envhelper.get_env('HITL_ENV') or os.environ['NOVA_HITL_ENV']
|
||||
approver = _envhelper.get_env('HITL_APPROVER', '') or 'local-test'
|
||||
ok, reason = attest(contract_id, env, approver)
|
||||
if ok:
|
||||
print(f'HITL PASS: {reason}')
|
||||
else:
|
||||
print(f'HITL BLOCK: {reason}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" NOVA_HITL_CONTRACT_ID="$CONTRACT_ID" NOVA_HITL_ENV="$RESOLVED_ENV" NOVA_HITL_APPROVER="$APPROVER" || { echo "FAIL: HITL attestation gate blocked the promotion" >&2; exit 1; }
|
||||
else
|
||||
echo "Environment is dev — autonomous (no HITL gate)."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Step 8: write evidence event to DynamoDB outbox ==="
|
||||
@@ -488,9 +517,92 @@ PY
|
||||
fi
|
||||
|
||||
echo ""
|
||||
# Uptime monitoring: extracted to scripts/run_uptime.sh (P9, REQ-173).
|
||||
# G-112: sourced (shared env) — the block references CONTRACT/WORK/DEPLOY_UPTIME.
|
||||
source "$ROOT/scripts/run_uptime.sh"
|
||||
echo "=== Step 9b: deploy uptime monitoring (separate state) ==="
|
||||
# The uptime stack is deployed by default after the L2 module. It uses a
|
||||
# separate terraform state ($WORK/uptime-tf). Endpoints from the L2 outputs
|
||||
# are passed as monitored_endpoints. The feature flag (uptime_enabled,
|
||||
# default true) controls whether this step runs.
|
||||
#
|
||||
# P57 contract shape: uptime_enabled is a per-module input under
|
||||
# infrastructure.<module>.inputs.uptime_enabled (the old top-level
|
||||
# contract.inputs.uptime_enabled was removed). Scan every module's inputs;
|
||||
# any module setting uptime_enabled=false disables the uptime step (one
|
||||
# contract = one logical stack, so a single false wins).
|
||||
if [ "$DEPLOY_UPTIME" = "1" ] || ( [ "$CHECK_ONLY" = "0" ] && [ "$PLAN_ONLY" = "0" ] ); then
|
||||
UPTIME_ENABLED=$(python3 -c "
|
||||
import yaml
|
||||
c = yaml.safe_load(open('$CONTRACT'))
|
||||
infra = c.get('infrastructure', {})
|
||||
# Default true; a module may override to false.
|
||||
for m, entry in infra.items():
|
||||
if isinstance(entry, dict) and entry.get('inputs', {}).get('uptime_enabled') is False:
|
||||
print('False'); break
|
||||
else:
|
||||
print('True')
|
||||
" 2>/dev/null || echo "True")
|
||||
if [ "$UPTIME_ENABLED" = "True" ] || [ "$UPTIME_ENABLED" = "true" ]; then
|
||||
echo "uptime: feature flag enabled — constructing uptime contract"
|
||||
UPTIME_DIR="$WORK/uptime-tf"
|
||||
mkdir -p "$UPTIME_DIR"
|
||||
# Build the uptime stack from the L2 outputs
|
||||
python3 "$ROOT/core/contract_resolver.py" "$CONTRACT" "$WORK/stack.json" 2>/dev/null || true
|
||||
python3 -c "
|
||||
import json, sys, yaml
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
# Extract HTTP/DNS/TCP endpoints from the stack outputs
|
||||
endpoints = []
|
||||
outputs = stack.get('outputs', {})
|
||||
for name, spec in outputs.items():
|
||||
src_rid = spec.get('from', '')
|
||||
src_output = spec.get('output', name)
|
||||
if 'domain' in name.lower() or 'url' in name.lower() or 'endpoint' in name.lower():
|
||||
endpoints.append({
|
||||
'name': name,
|
||||
'url': f'ref:{src_rid}.{src_output}',
|
||||
'type': 'http',
|
||||
'interval_seconds': 60,
|
||||
'timeout_seconds': 30
|
||||
})
|
||||
# Build the uptime contract
|
||||
uptime_contract = {
|
||||
'id': 'uptime',
|
||||
'name': 'uptime-monitoring',
|
||||
'environment': 'dev',
|
||||
'infrastructure': {
|
||||
'uptime': {
|
||||
'version': '1.0.0',
|
||||
'inputs': {
|
||||
'region': 'us-east-1',
|
||||
'feature_flag_enabled': True,
|
||||
'monitored_endpoints': endpoints,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
with open('$WORK/uptime-contract.yml', 'w') as f:
|
||||
yaml.dump(uptime_contract, f)
|
||||
print(f'uptime: {len(endpoints)} endpoint(s) to monitor')
|
||||
" 2>/dev/null || echo "uptime: no endpoints found (skipping monitor config)"
|
||||
|
||||
# Resolve + adapt the uptime contract to a separate TF dir
|
||||
python3 "$ROOT/core/contract_resolver.py" "$WORK/uptime-contract.yml" "$WORK/uptime-stack.json" 2>/dev/null || true
|
||||
python3 "$ROOT/adapters/terraform/adapter.py" "$WORK/uptime-stack.json" "$UPTIME_DIR" 2>/dev/null || true
|
||||
|
||||
if [ "$DEPLOY_UPTIME" = "1" ] && [ -f "$UPTIME_DIR/main.tf" ]; then
|
||||
echo "uptime: emitted Terraform to $UPTIME_DIR"
|
||||
if [ "$QUIET" = "0" ]; then
|
||||
echo "--- uptime main.tf ---"
|
||||
cat "$UPTIME_DIR/main.tf"
|
||||
echo "--- end uptime main.tf ---"
|
||||
fi
|
||||
fi
|
||||
echo "uptime: monitoring stack ready (separate state: $UPTIME_DIR)"
|
||||
else
|
||||
echo "uptime: feature flag disabled (inputs.uptime_enabled=false) — skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== PLATFORM E2E OK ==="
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run_uptime.sh — uptime monitoring deploy (extracted from run_platform.sh, P9/REQ-173).
|
||||
# Sourced by run_platform.sh (G-112: source, not invoke — shares CONTRACT/WORK/ROOT env).
|
||||
# Uses: $ROOT, $CONTRACT, $WORK, $DEPLOY_UPTIME, $CHECK_ONLY, $PLAN_ONLY, $QUIET.
|
||||
|
||||
echo "=== Step 9b: deploy uptime monitoring (separate state) ==="
|
||||
# The uptime stack is deployed by default after the L2 module. It uses a
|
||||
# separate terraform state ($WORK/uptime-tf). Endpoints from the L2 outputs
|
||||
# are passed as monitored_endpoints. The feature flag (uptime_enabled,
|
||||
# default true) controls whether this step runs.
|
||||
#
|
||||
# P57 contract shape: uptime_enabled is a per-module input under
|
||||
# infrastructure.<module>.inputs.uptime_enabled (the old top-level
|
||||
# contract.inputs.uptime_enabled was removed). Scan every module's inputs;
|
||||
# any module setting uptime_enabled=false disables the uptime step (one
|
||||
# contract = one logical stack, so a single false wins).
|
||||
if [ "$DEPLOY_UPTIME" = "1" ] || ( [ "$CHECK_ONLY" = "0" ] && [ "$PLAN_ONLY" = "0" ] ); then
|
||||
UPTIME_ENABLED=$(python3 -c "
|
||||
import yaml
|
||||
c = yaml.safe_load(open('$CONTRACT'))
|
||||
infra = c.get('infrastructure', {})
|
||||
# Default true; a module may override to false.
|
||||
for m, entry in infra.items():
|
||||
if isinstance(entry, dict) and entry.get('inputs', {}).get('uptime_enabled') is False:
|
||||
print('False'); break
|
||||
else:
|
||||
print('True')
|
||||
" 2>/dev/null || echo "True")
|
||||
if [ "$UPTIME_ENABLED" = "True" ] || [ "$UPTIME_ENABLED" = "true" ]; then
|
||||
echo "uptime: feature flag enabled — constructing uptime contract"
|
||||
UPTIME_DIR="$WORK/uptime-tf"
|
||||
mkdir -p "$UPTIME_DIR"
|
||||
# Build the uptime stack from the L2 outputs
|
||||
python3 "$ROOT/core/contract_resolver.py" "$CONTRACT" "$WORK/stack.json" 2>/dev/null || true
|
||||
python3 -c "
|
||||
import json, sys, yaml
|
||||
sys.path.insert(0, '$ROOT')
|
||||
from core.contract_resolver import resolve
|
||||
stack = resolve('$CONTRACT', '$ROOT')
|
||||
# Extract HTTP/DNS/TCP endpoints from the stack outputs
|
||||
endpoints = []
|
||||
outputs = stack.get('outputs', {})
|
||||
for name, spec in outputs.items():
|
||||
src_rid = spec.get('from', '')
|
||||
src_output = spec.get('output', name)
|
||||
if 'domain' in name.lower() or 'url' in name.lower() or 'endpoint' in name.lower():
|
||||
endpoints.append({
|
||||
'name': name,
|
||||
'url': f'ref:{src_rid}.{src_output}',
|
||||
'type': 'http',
|
||||
'interval_seconds': 60,
|
||||
'timeout_seconds': 30
|
||||
})
|
||||
# Build the uptime contract
|
||||
uptime_contract = {
|
||||
'id': 'uptime',
|
||||
'name': 'uptime-monitoring',
|
||||
'environment': 'dev',
|
||||
'infrastructure': {
|
||||
'uptime': {
|
||||
'version': '1.0.0',
|
||||
'inputs': {
|
||||
'region': 'us-east-1',
|
||||
'feature_flag_enabled': True,
|
||||
'monitored_endpoints': endpoints,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
with open('$WORK/uptime-contract.yml', 'w') as f:
|
||||
yaml.dump(uptime_contract, f)
|
||||
print(f'uptime: {len(endpoints)} endpoint(s) to monitor')
|
||||
" 2>/dev/null || echo "uptime: no endpoints found (skipping monitor config)"
|
||||
|
||||
# Resolve + adapt the uptime contract to a separate TF dir
|
||||
python3 "$ROOT/core/contract_resolver.py" "$WORK/uptime-contract.yml" "$WORK/uptime-stack.json" 2>/dev/null || true
|
||||
python3 "$ROOT/adapters/terraform/adapter.py" "$WORK/uptime-stack.json" "$UPTIME_DIR" 2>/dev/null || true
|
||||
|
||||
if [ "$DEPLOY_UPTIME" = "1" ] && [ -f "$UPTIME_DIR/main.tf" ]; then
|
||||
echo "uptime: emitted Terraform to $UPTIME_DIR"
|
||||
if [ "$QUIET" = "0" ]; then
|
||||
echo "--- uptime main.tf ---"
|
||||
cat "$UPTIME_DIR/main.tf"
|
||||
echo "--- end uptime main.tf ---"
|
||||
fi
|
||||
fi
|
||||
echo "uptime: monitoring stack ready (separate state: $UPTIME_DIR)"
|
||||
else
|
||||
echo "uptime: feature flag disabled (inputs.uptime_enabled=false) — skipping"
|
||||
fi
|
||||
fi
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync byte-identical workflows from workflows-src/ to .gitea/ + .github/ (P8, REQ-172).
|
||||
|
||||
Three workflow pairs are byte-identical Gitea + GitHub mirrors:
|
||||
ci.yml, deploy.yml, modules-lifecycle.yml.
|
||||
|
||||
This generator reads the single source from ``workflows-src/<name>`` and
|
||||
writes byte-identical copies to both ``.gitea/workflows/<name>`` and
|
||||
``.github/workflows/<name>``. Use ``--check`` to verify the committed
|
||||
files match the generated output (CI gate); use ``--write`` to regenerate
|
||||
the committed files from the sources.
|
||||
|
||||
The 4 GitHub-only workflows (platform-test.yml, primitives-plan.yml,
|
||||
patterns-plan.yml, release.yml) have no Gitea mirror (act_runner feature
|
||||
gaps) and are NOT touched by this generator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC_DIR = ROOT / "workflows-src"
|
||||
GITEA_DIR = ROOT / ".gitea" / "workflows"
|
||||
GITHUB_DIR = ROOT / ".github" / "workflows"
|
||||
|
||||
PAIRS = ["ci.yml", "deploy.yml", "modules-lifecycle.yml"]
|
||||
|
||||
|
||||
def _read_source(name: str) -> str:
|
||||
src = SRC_DIR / name
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(f"source {src} missing")
|
||||
return src.read_text()
|
||||
|
||||
|
||||
def check() -> int:
|
||||
"""Verify committed files match the sources. Exit 0 if clean, 1 if drift."""
|
||||
drift = []
|
||||
for name in PAIRS:
|
||||
content = _read_source(name)
|
||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
||||
dest = dest_dir / name
|
||||
if not dest.is_file():
|
||||
drift.append(f"{dest} MISSING (expected from workflows-src/{name})")
|
||||
continue
|
||||
if dest.read_text() != content:
|
||||
drift.append(f"{dest} DRIFTED from workflows-src/{name}")
|
||||
if drift:
|
||||
for d in drift:
|
||||
print(f"DRIFT: {d}", file=sys.stderr)
|
||||
print("\nRun: python3 scripts/sync_workflows.py --write", file=sys.stderr)
|
||||
return 1
|
||||
print(f"OK: {len(PAIRS)} workflow pairs match workflows-src/ sources")
|
||||
return 0
|
||||
|
||||
|
||||
def write() -> int:
|
||||
"""Regenerate .gitea/ + .github/ from workflows-src/ sources."""
|
||||
for name in PAIRS:
|
||||
content = _read_source(name)
|
||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
(dest_dir / name).write_text(content)
|
||||
print(f"wrote: .gitea/workflows/{name} + .github/workflows/{name}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Sync byte-identical workflow pairs.")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true", help="verify committed files match sources (CI gate)")
|
||||
group.add_argument("--write", action="store_true", help="regenerate committed files from sources")
|
||||
args = parser.parse_args(argv)
|
||||
if args.check:
|
||||
return check()
|
||||
return write()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,42 +0,0 @@
|
||||
# terraform/onboarding/ — Consumer deploy-role + ABAC tag grant (P20, REQ-184)
|
||||
|
||||
Offline-proven Terraform for the cross-account consumer deploy-role +
|
||||
`nova:owner` ABAC tag grant. This is the "role grant" half of the
|
||||
no-humans onboarding flow (D-113); the "request" half is P18 (Lambda
|
||||
action) + P19 (env-file autogen).
|
||||
|
||||
## Scope (D-114)
|
||||
|
||||
This Terraform is **offline-proven only** in v1.16:
|
||||
- `terraform validate` passes.
|
||||
- `terraform plan` (with `NOVA_AWS_ACCOUNT_ID` set) produces the expected
|
||||
role + policy.
|
||||
- **No live apply** — `NOVA_LIFECYCLE_MODE=plan` default. Live apply is
|
||||
deferred to a future feature milestone (D-113/D-114).
|
||||
|
||||
## Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `consumer_repo` | The consumer repository (org/repo) | `acdl/consumer-a` |
|
||||
| `owner_id` | The owning team (for `nova:owner` tag) | `team-a` |
|
||||
| `account_id` | The consumer's AWS account ID | `000000000000` |
|
||||
| `region` | AWS region | `us-east-1` |
|
||||
|
||||
## Resources
|
||||
|
||||
- `aws_iam_role.consumer_deploy` — the consumer's deploy role with a
|
||||
trust policy (assumed by the consumer's CI runner).
|
||||
- `aws_iam_role_policy.consumer_invoke` — inline policy granting
|
||||
`lambda:InvokeFunctionUrl` on the platform Lambda, scoped via
|
||||
`aws:PrincipalTag/nova:owner == var.owner_id` (ABAC).
|
||||
- `aws_iam_tag.owner` — tags the role with `nova:owner` + `nova:contract`.
|
||||
|
||||
## Usage (offline)
|
||||
|
||||
```bash
|
||||
cd terraform/onboarding
|
||||
terraform init -backend=false
|
||||
terraform validate
|
||||
NOVA_AWS_ACCOUNT_ID=123456789012 terraform plan -var consumer_repo=acdl/my-app -var owner_id=team-x
|
||||
```
|
||||
@@ -1,120 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.9, < 1.10"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "consumer_repo" {
|
||||
description = "The consumer repository (org/repo) — for the nova:contract tag."
|
||||
type = string
|
||||
default = "acdl/consumer-a"
|
||||
}
|
||||
|
||||
variable "owner_id" {
|
||||
description = "The owning team (for the nova:owner ABAC tag)."
|
||||
type = string
|
||||
default = "team-a"
|
||||
}
|
||||
|
||||
variable "account_id" {
|
||||
description = "The consumer's AWS account ID (where the deploy role is created)."
|
||||
type = string
|
||||
default = "000000000000"
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "AWS region."
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# P20 (REQ-184): consumer deploy role — the role the consumer's CI runner
|
||||
# assumes to invoke the platform Lambda + deploy via the reusable workflow.
|
||||
# The trust policy allows the consumer's CI runner (GitHub Actions /
|
||||
# Gitea act_runner) to assume this role. In a real deployment, the trust
|
||||
# policy is scoped to the consumer's OIDC provider; for offline-proven
|
||||
# mode, a placeholder trust is used.
|
||||
resource "aws_iam_role" "consumer_deploy" {
|
||||
name = "nova-${replace(var.consumer_repo, "/", "-")}-deploy"
|
||||
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
# Placeholder: in a real deployment, this is the consumer's
|
||||
# OIDC provider ARN. Offline-proven mode uses a wildcard.
|
||||
Federated = "arn:aws:iam::${var.account_id}:oidc-provider/token.actions.githubusercontent.com"
|
||||
}
|
||||
Action = "sts:AssumeRoleWithWebIdentity"
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
|
||||
}
|
||||
StringLike = {
|
||||
"token.actions.githubusercontent.com:sub" = "repo:${var.consumer_repo}:*"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
tags = {
|
||||
"nova:owner" = var.owner_id
|
||||
"nova:contract" = var.consumer_repo
|
||||
"nova:environment" = "dev"
|
||||
}
|
||||
}
|
||||
|
||||
# P20 (REQ-184): inline policy granting the consumer's deploy role the
|
||||
# right to invoke the platform Lambda's Function URL, scoped via ABAC
|
||||
# (aws:PrincipalTag/nova:owner == var.owner_id). The platform Lambda's
|
||||
# resource-based policy + the consumer_invoke_policy.json template
|
||||
# enforce the ABAC scope at the Lambda side; this policy grants the
|
||||
# invoke permission on the consumer side.
|
||||
resource "aws_iam_role_policy" "consumer_invoke" {
|
||||
name = "nova-consumer-invoke"
|
||||
role = aws_iam_role.consumer_deploy.id
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"lambda:InvokeFunctionUrl",
|
||||
]
|
||||
Resource = [
|
||||
# The platform Lambda ARN (cross-account). The account_id is
|
||||
# the platform account, not the consumer account. For offline-
|
||||
# proven mode, a placeholder ARN is used.
|
||||
"arn:aws:lambda:${var.region}:000000000000:function:nova-contract-ingestor"
|
||||
]
|
||||
Condition = {
|
||||
StringEquals = {
|
||||
"aws:PrincipalTag/nova:owner" = var.owner_id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
output "consumer_deploy_role_arn" {
|
||||
description = "The ARN of the consumer deploy role."
|
||||
value = aws_iam_role.consumer_deploy.arn
|
||||
}
|
||||
|
||||
output "consumer_deploy_role_name" {
|
||||
description = "The name of the consumer deploy role."
|
||||
value = aws_iam_role.consumer_deploy.name
|
||||
}
|
||||
+27
-129
@@ -37,29 +37,12 @@ _spec.loader.exec_module(ingestor)
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _local_lambda_bypass(monkeypatch):
|
||||
"""P10 (REQ-174): set NOVA_LAMBDA_LOCAL_BYPASS for all ingestor tests
|
||||
so the fail-closed identity check doesn't block handler-routing tests.
|
||||
Tests that explicitly exercise the identity check (TestCallerIdentity
|
||||
Validation) override this per-test."""
|
||||
monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_payload():
|
||||
# P11 (REQ-175): the contract blob must validate against
|
||||
# contract.schema.json (requires id/name/environment/infrastructure;
|
||||
# id matches ^[a-z][a-z0-9-]{2,5}$).
|
||||
return {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"contractId": "contract-001",
|
||||
"contract": {
|
||||
"id": "test",
|
||||
"name": "test-contract",
|
||||
"environment": "dev",
|
||||
"infrastructure": {"s3": {"version": "1.0.0", "inputs": {}}},
|
||||
},
|
||||
"contract": {"stack": "s3", "environment": "dev"},
|
||||
"environment": "dev",
|
||||
"action": "submit_contract",
|
||||
}
|
||||
@@ -67,8 +50,7 @@ def sample_payload():
|
||||
|
||||
@pytest.fixture
|
||||
def function_url_event(sample_payload):
|
||||
# P10 (REQ-174): include a test IAM identity so the fail-closed check passes.
|
||||
return {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/test"}}}
|
||||
return {"body": json.dumps(sample_payload)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -141,15 +123,16 @@ class TestSubmitContract:
|
||||
assert item["submittedAt"]["S"] == result["submittedAt"]
|
||||
# The contract attribute holds the full contract object. boto3's
|
||||
# resource API serializes a dict as a DynamoDB Map (type "M"); each
|
||||
# leaf scalar is wrapped in its own type tag. P11 (REQ-175): the
|
||||
# fixture contract has a nested infrastructure map; assert the
|
||||
# top-level keys are present (full deep-equality is fragile with
|
||||
# moto's recursive type wrapping).
|
||||
actual_contract = item["contract"]["M"]
|
||||
assert set(actual_contract.keys()) == set(sample_payload["contract"].keys())
|
||||
assert actual_contract["id"]["S"] == sample_payload["contract"]["id"]
|
||||
assert actual_contract["name"]["S"] == sample_payload["contract"]["name"]
|
||||
assert actual_contract["environment"]["S"] == sample_payload["contract"]["environment"]
|
||||
# leaf scalar is wrapped in its own type tag.
|
||||
expected_contract = sample_payload["contract"]
|
||||
actual_contract = item["contract"]
|
||||
# The resource API stores scalars inside the map with their own type
|
||||
# tags (e.g. {"S": ...}); unwrap one level for the two known leaves.
|
||||
unwrapped = {
|
||||
k: list(v.values())[0] if isinstance(v, dict) and len(v) == 1 else v
|
||||
for k, v in actual_contract["M"].items()
|
||||
}
|
||||
assert unwrapped == expected_contract
|
||||
|
||||
def test_submit_contract_sk_contains_contract_id_and_timestamp(self, moto_contracts_table, sample_payload):
|
||||
result = ingestor._submit_contract(sample_payload)
|
||||
@@ -160,24 +143,6 @@ class TestSubmitContract:
|
||||
ts = sk.split("#", 1)[1]
|
||||
datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
def test_oversized_contract_rejected(self, moto_contracts_table, sample_payload):
|
||||
"""P11 (REQ-175): a contract blob > 256 KB is rejected."""
|
||||
sample_payload["contract"] = {"blob": "x" * (300 * 1024)}
|
||||
with pytest.raises(ValueError, match="contract payload too large"):
|
||||
ingestor._submit_contract(sample_payload)
|
||||
|
||||
def test_schema_invalid_contract_rejected(self, moto_contracts_table, sample_payload, monkeypatch):
|
||||
"""P11 (REQ-175): a contract that fails contract.schema.json
|
||||
validation is rejected with a clear error."""
|
||||
# The autouse fixture sets NOVA_LAMBDA_LOCAL_BYPASS; unset it so
|
||||
# the schema validation runs (the bypass skips schema validation).
|
||||
monkeypatch.delenv("NOVA_LAMBDA_LOCAL_BYPASS", raising=False)
|
||||
# The contract schema requires id/name/environment/infrastructure;
|
||||
# an empty dict fails validation.
|
||||
sample_payload["contract"] = {}
|
||||
with pytest.raises(ValueError, match="contract schema validation failed"):
|
||||
ingestor._submit_contract(sample_payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# report_error (D-055) — GitHub issue creation via the GitHub API
|
||||
@@ -299,21 +264,20 @@ class TestReportError:
|
||||
ingestor._report_error(error_payload)
|
||||
|
||||
def test_report_error_truncates_stack_trace(self, monkeypatch, error_payload, patched_secrets):
|
||||
# P11 (REQ-175): a very long stack trace is truncated to
|
||||
# MAX_ERROR_FIELD_CHARS (10000) in the body (was 2000; aligned).
|
||||
error_payload["stackTrace"] = "x" * 20000
|
||||
# A very long stack trace should be truncated to 2000 chars in the body.
|
||||
error_payload["stackTrace"] = "x" * 5000
|
||||
calls = self._mock_urlopen(monkeypatch, [
|
||||
(200, json.dumps({"items": []})),
|
||||
(201, json.dumps({"number": 1, "html_url": "u"})),
|
||||
])
|
||||
result = ingestor._report_error(error_payload)
|
||||
assert result["status"] == "issue_created"
|
||||
# The create request body should contain exactly 10000 'x' chars.
|
||||
# The create request body should contain exactly 2000 'x' chars.
|
||||
create_req = calls[1]
|
||||
body = json.loads(create_req.data.decode())
|
||||
# The body markdown contains the (truncated) stack trace.
|
||||
assert "x" * 10000 in body["body"]
|
||||
assert "x" * 10001 not in body["body"]
|
||||
assert "x" * 2000 in body["body"]
|
||||
assert "x" * 2001 not in body["body"]
|
||||
|
||||
def test_lambda_handler_routes_report_error(self, monkeypatch, error_payload, patched_secrets):
|
||||
# End-to-end via lambda_handler: action=report_error → 200.
|
||||
@@ -397,21 +361,9 @@ class TestLambdaHandler:
|
||||
class TestCallerIdentityValidation:
|
||||
"""P1-2: the Lambda validates consumerRepo against the invoking principal."""
|
||||
|
||||
def test_no_identity_fails_closed(self, moto_contracts_table, sample_payload, monkeypatch):
|
||||
# P10 (REQ-174): no requestContext.identity → fail closed (defense-in-
|
||||
# depth). The old behavior (silent pass) is replaced with a 401.
|
||||
monkeypatch.delenv("NOVA_LAMBDA_LOCAL_BYPASS", raising=False)
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 401
|
||||
assert "missing IAM caller identity" in json.loads(resp["body"])["error"]
|
||||
|
||||
def test_no_identity_passes_with_local_bypass(self, moto_contracts_table, sample_payload, monkeypatch):
|
||||
# P10 (REQ-174): the NOVA_LAMBDA_LOCAL_BYPASS env allows local/stub
|
||||
# testing without an IAM identity (the LocalLambdaStub sets it).
|
||||
monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
def test_no_identity_skips_check(self, moto_contracts_table, function_url_event):
|
||||
# No requestContext.identity in the event — check is skipped (relies on IAM ABAC).
|
||||
resp = ingestor.lambda_handler(function_url_event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
|
||||
def test_invalid_consumer_repo_format_rejected(self, moto_contracts_table, sample_payload):
|
||||
@@ -544,7 +496,7 @@ class TestValidateChangeRequest:
|
||||
"action": "validate_change_request",
|
||||
"changeRequestId": "CHG0678912",
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
}), "requestContext": {"identity": {"userArn": "arn:aws:sts::000:assumed-role/nova-deploy/test"}}}
|
||||
})}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
body = json.loads(resp["body"])
|
||||
@@ -553,39 +505,33 @@ class TestValidateChangeRequest:
|
||||
|
||||
class TestV14IdentityValidation:
|
||||
"""v1.14 (REQ-144): contractId format, environment enum, error length
|
||||
validation + spoofing resistance.
|
||||
|
||||
P10 (REQ-174): these tests supply a valid userArn so the fail-closed
|
||||
identity check passes and the field validation is reached."""
|
||||
|
||||
_ARN = "arn:aws:sts::000:assumed-role/nova-deploy/test-session"
|
||||
validation + spoofing resistance."""
|
||||
|
||||
def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["contractId"] = "bad contract!@#"
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid contractId" in resp["body"]
|
||||
|
||||
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["contractId"] = "a" * 65
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid contractId" in resp["body"]
|
||||
|
||||
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["environment"] = "staging"
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid environment" in resp["body"]
|
||||
|
||||
def test_valid_environments_accepted(self, moto_contracts_table, sample_payload):
|
||||
arn = "arn:aws:sts::000:assumed-role/nova-deploy/test"
|
||||
for env in ["dev", "qa", "prod", "dr"]:
|
||||
sample_payload["environment"] = env
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {"identity": {"userArn": arn}}}
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
|
||||
@@ -593,52 +539,4 @@ class TestV14IdentityValidation:
|
||||
"""The _validate_caller_identity docstring documents the ABAC reliance."""
|
||||
docstring = ingestor._validate_caller_identity.__doc__
|
||||
assert "ABAC" in docstring
|
||||
assert "PrincipalTag" in docstring
|
||||
|
||||
class TestOnboardConsumer:
|
||||
"""P18 (REQ-182): the onboard_consumer action writes a pending CMDB row."""
|
||||
|
||||
_ARN = "arn:aws:sts::000:assumed-role/nova-deploy/test"
|
||||
|
||||
def test_valid_onboarding_writes_pending_row(self, moto_contracts_table):
|
||||
payload = {
|
||||
"action": "onboard_consumer",
|
||||
"consumerRepo": "acdl/consumer-b",
|
||||
"requestedEnvironment": "dev",
|
||||
"ownerId": "team-b",
|
||||
"billingTag": "cost-center-b",
|
||||
}
|
||||
event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
body = json.loads(resp["body"])
|
||||
assert body["status"] == "pending"
|
||||
assert body["action"] == "onboard_consumer"
|
||||
assert body["requestedEnvironment"] == "dev"
|
||||
|
||||
def test_invalid_onboarding_rejected(self, moto_contracts_table):
|
||||
# An invalid consumerRepo (no /) fails the identity format check
|
||||
# (which runs for all actions) before the onboarding schema.
|
||||
payload = {
|
||||
"action": "onboard_consumer",
|
||||
"consumerRepo": "not-a-repo-format",
|
||||
"requestedEnvironment": "dev",
|
||||
"ownerId": "team-b",
|
||||
"billingTag": "cost-center-b",
|
||||
}
|
||||
event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid consumerRepo" in json.loads(resp["body"])["error"]
|
||||
|
||||
def test_missing_onboarding_field_rejected(self, moto_contracts_table):
|
||||
payload = {
|
||||
"action": "onboard_consumer",
|
||||
"consumerRepo": "acdl/consumer-b",
|
||||
"requestedEnvironment": "dev",
|
||||
# ownerId + billingTag missing
|
||||
}
|
||||
event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "onboarding payload invalid" in json.loads(resp["body"])["error"]
|
||||
assert "PrincipalTag" in docstring
|
||||
@@ -34,15 +34,4 @@ class TestDocsCoverage:
|
||||
assert "How to Write an Adapter" in content
|
||||
assert "How to Wire" in content
|
||||
assert "How to Test" in content
|
||||
assert "Existing Adapters" in content
|
||||
|
||||
def test_github_workflows_readme_catalogs_all_workflows():
|
||||
"""P16 (REQ-180): .github/workflows/README.md catalogs all 7 workflows."""
|
||||
from pathlib import Path
|
||||
readme = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "README.md"
|
||||
assert readme.is_file(), ".github/workflows/README.md missing"
|
||||
text = readme.read_text()
|
||||
for wf in ["ci.yml", "deploy.yml", "modules-lifecycle.yml",
|
||||
"platform-test.yml", "primitives-plan.yml", "patterns-plan.yml",
|
||||
"release.yml"]:
|
||||
assert wf in text, f"{wf} not cataloged in .github/workflows/README.md"
|
||||
assert "Existing Adapters" in content
|
||||
@@ -21,10 +21,7 @@ class TestEnvironmentCheck:
|
||||
assert ok is False
|
||||
assert "nonexistent-env" in msg
|
||||
assert "onboarding" in msg.lower() or "Environment Onboarding" in msg
|
||||
# P19 (REQ-183): the message now routes to the self-service
|
||||
# request path (onboard_consumer), not "contact the platform team".
|
||||
assert "platform team" not in msg.lower()
|
||||
assert "onboard_consumer" in msg or "self-service" in msg.lower()
|
||||
assert "platform team" in msg.lower()
|
||||
|
||||
def test_onboarding_message_lists_platform_provisions(self):
|
||||
msg = _onboarding_message("qa")
|
||||
@@ -105,16 +102,4 @@ class TestRunPlatformWireIn:
|
||||
)
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
assert "PLATFORM CHECK OK" in result.stdout
|
||||
assert "environment" in result.stdout.lower() or "Step 0" in result.stdout
|
||||
|
||||
class TestOnboardingMessageSelfService:
|
||||
"""P19 (REQ-183): the onboarding message is self-service, not 'contact
|
||||
the platform team'."""
|
||||
|
||||
def test_no_contact_platform_team(self):
|
||||
msg = _onboarding_message("qa")
|
||||
assert "contact the platform team" not in msg.lower()
|
||||
|
||||
def test_mentions_self_service_request(self):
|
||||
msg = _onboarding_message("qa")
|
||||
assert "self-service" in msg.lower() or "onboard_consumer" in msg
|
||||
assert "environment" in result.stdout.lower() or "Step 0" in result.stdout
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Unit tests for core/onboarding.py (P19, REQ-183)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.onboarding import generate_env_file, _onboarding_request_message
|
||||
|
||||
|
||||
class TestGenerateEnvFile:
|
||||
"""P19 (REQ-183): generate_env_file produces a valid env JSON."""
|
||||
|
||||
def test_generates_env_with_request_fields(self):
|
||||
request = {
|
||||
"consumerRepo": "acdl/consumer-b",
|
||||
"requestedEnvironment": "qa",
|
||||
"ownerId": "team-b",
|
||||
"billingTag": "cost-center-b",
|
||||
}
|
||||
env = generate_env_file(request, template_env="dev")
|
||||
assert env["name"] == "qa"
|
||||
assert env["ownerId"] == "team-b"
|
||||
assert env["billingTag"] == "cost-center-b"
|
||||
assert env["account_id"] == "000000000000" # placeholder
|
||||
assert "consumer-b" in env["description"]
|
||||
|
||||
def test_preserves_template_network_and_state(self):
|
||||
request = {
|
||||
"consumerRepo": "acdl/c",
|
||||
"requestedEnvironment": "prod",
|
||||
"ownerId": "team-a",
|
||||
"billingTag": "cc-a",
|
||||
}
|
||||
env = generate_env_file(request, template_env="dev")
|
||||
assert "vpc_cidr" in env["network"]
|
||||
assert "bucket" in env["state_backend"]
|
||||
assert env["region"] == "us-east-1"
|
||||
|
||||
|
||||
class TestOnboardingRequestMessage:
|
||||
"""P19 (REQ-183): the request message is self-service."""
|
||||
|
||||
def test_message_mentions_onboard_consumer(self):
|
||||
msg = _onboarding_request_message("dev")
|
||||
assert "onboard_consumer" in msg
|
||||
assert "Nova" in msg
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Unit tests for terraform/onboarding (P20, REQ-184)."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ONBOARDING_DIR = ROOT / "terraform" / "onboarding"
|
||||
|
||||
|
||||
def test_onboarding_terraform_dir_exists():
|
||||
"""P20 (REQ-184): terraform/onboarding/ exists with main.tf + README."""
|
||||
assert ONBOARDING_DIR.is_dir()
|
||||
assert (ONBOARDING_DIR / "main.tf").is_file()
|
||||
assert (ONBOARDING_DIR / "README.md").is_file()
|
||||
|
||||
|
||||
def test_onboarding_terraform_validates():
|
||||
"""P20 (REQ-184): terraform validate passes for the onboarding module
|
||||
(offline-proven, D-114). Skipped if terraform is not installed."""
|
||||
if not subprocess.call(["which", "terraform"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
|
||||
pytest.skip("terraform not installed")
|
||||
rc = subprocess.call(
|
||||
["terraform", "validate"],
|
||||
cwd=str(ONBOARDING_DIR),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
assert rc == 0, "terraform validate failed for terraform/onboarding/"
|
||||
|
||||
|
||||
def test_onboarding_main_tf_has_nova_tags():
|
||||
"""P20 (REQ-184): the deploy role is tagged with nova:owner + nova:contract."""
|
||||
main_tf = (ONBOARDING_DIR / "main.tf").read_text()
|
||||
assert '"nova:owner"' in main_tf
|
||||
assert '"nova:contract"' in main_tf
|
||||
assert "aws_iam_role" in main_tf
|
||||
assert "lambda:InvokeFunctionUrl" in main_tf
|
||||
@@ -101,25 +101,10 @@ class TestWorkflowConformance:
|
||||
assert (ROOT / ".github/workflows/ci.yml").is_file()
|
||||
|
||||
def test_workflows_are_byte_identical(self):
|
||||
# P8 (REQ-172): the byte-identity is now enforced by
|
||||
# scripts/sync_workflows.py --check (generated from workflows-src/).
|
||||
# The two dirs must still be byte-identical (the generator writes
|
||||
# the same source to both); this assertion is the belt, the
|
||||
# generator --check is the suspenders.
|
||||
gitea = open(ROOT / ".gitea/workflows/ci.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/ci.yml", "rb").read()
|
||||
assert gitea == github, "Gitea and GitHub workflows must be byte-identical"
|
||||
|
||||
def test_sync_workflows_check_passes(self):
|
||||
"""P8 (REQ-172): sync_workflows.py --check exits 0 (committed
|
||||
files match the workflows-src/ sources)."""
|
||||
import subprocess
|
||||
rc = subprocess.call(
|
||||
[sys.executable, "scripts/sync_workflows.py", "--check"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
assert rc == 0, "sync_workflows.py --check failed — run scripts/sync_workflows.py --write"
|
||||
|
||||
def test_gitea_workflow_name_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
contract = _load_yaml("pipelines/ci.yml")
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# ACDL CI Pipeline — Gitea Actions (dev environment)
|
||||
#
|
||||
# This workflow implements the central pipeline contract:
|
||||
# pipelines/ci.yml (validated against schemas/pipeline.schema.json)
|
||||
#
|
||||
# The same contract is implemented by .github/workflows/ci.yml (GitHub
|
||||
# Actions, production). Both files must be byte-identical — the only
|
||||
# declared difference is the forge/runtime, not the stages or commands.
|
||||
#
|
||||
# Shell reproducibility: scripts/run_ci.sh runs the same 3 stages locally.
|
||||
#
|
||||
# Stages (from the contract):
|
||||
# 1. lint — py_compile all Python files
|
||||
# 2. test — pytest test suite (offline, no AWS)
|
||||
# 3. check-only — run_platform.sh --check-only (offline, no AWS)
|
||||
name: acdl-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Compile all Python files
|
||||
run: |
|
||||
python3 -m py_compile \
|
||||
core/confidence_signal.py \
|
||||
core/outbox_writer.py \
|
||||
core/output_publisher.py \
|
||||
core/contract_resolver.py \
|
||||
core/lambda/contract_ingestor.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
scripts/push_consumer_image.py
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Install test dependencies
|
||||
run: pip install -r requirements-test.txt
|
||||
|
||||
- name: Run pytest
|
||||
run: python3 -m pytest tests/ -v --tb=short
|
||||
|
||||
check-only:
|
||||
name: Platform check-only (offline)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Install runtime dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
|
||||
- name: Run platform check-only
|
||||
run: bash scripts/run_platform.sh --check-only
|
||||
@@ -1,166 +0,0 @@
|
||||
# ACDL Reusable Deploy Workflow — Gitea Actions (dev environment)
|
||||
#
|
||||
# This reusable workflow implements the central deployment pipeline contract:
|
||||
# pipelines/contract.yml (validated against schemas/deploy-pipeline.schema.json)
|
||||
#
|
||||
# The same contract is implemented by .github/workflows/deploy.yml (GitHub
|
||||
# Actions, production). Both files must be byte-identical — the only
|
||||
# declared difference is the forge/runtime, not the stages or commands.
|
||||
#
|
||||
# Consumer repos invoke this workflow via a versioned tag (floating MAJOR + MINOR):
|
||||
# uses: acdl/.gitea/workflows/deploy.yml@v1.9 (Gitea)
|
||||
# uses: acdl/.github/workflows/deploy.yml@v1.9 (GitHub)
|
||||
#
|
||||
# Unversioned references (@main, bare) are discouraged — the consumer's setup
|
||||
# must be immutable + resilient. The versioned tag is the only immutability
|
||||
# lever (version constraints cannot be expressed inside the contract).
|
||||
#
|
||||
# What this workflow does:
|
||||
# 1. Checks out the consumer repo (the repo that invoked the workflow).
|
||||
# 2. Checks out the ACDL platform repo into the workspace (platform/).
|
||||
# This is the run-time fetch — consumers never clone the platform repo.
|
||||
# 3. Installs runtime deps: Python 3.12, Terraform 1.9.*, Checkov.
|
||||
# 4. Configures AWS auth (OIDC default; static-key override via secrets).
|
||||
# 5. Runs scripts/run_platform.sh against the consumer's contract path.
|
||||
# 6. Uploads artifacts (emitted Terraform, Checkov JSON, confidence JSON,
|
||||
# platform log) for auditability.
|
||||
#
|
||||
# Inputs:
|
||||
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
|
||||
# mode — full | plan-only | check-only (default full; dev = full apply,
|
||||
# higher environments hold for HITL — the calling repo or the
|
||||
# forge environment gate enforces that)
|
||||
#
|
||||
# Auth (zero-trust default — see README.md#credentials--zero-trust):
|
||||
# OIDC federation is the default. permissions: id-token: write lets the
|
||||
# forge mint a short-lived STS token. The role-to-assume is scoped by the
|
||||
# consumer's repository identity (ABAC) — the workflow assumes the role
|
||||
# that matches repo:org/consumer-repo:ref:refs/heads/main, and the session
|
||||
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
|
||||
#
|
||||
# Override (where OIDC is unavailable, e.g. Gitea pending
|
||||
# go-gitea/gitea#36988): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
|
||||
# as repository secrets. The platform-managed scheduled pipeline rotates
|
||||
# the key on a daily cadence. When .env.secrets is used locally instead,
|
||||
# rotating the key out of band is the consumer's responsibility.
|
||||
name: nova-deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
contract:
|
||||
description: Path to the consumer contract YAML (in the consumer repo)
|
||||
type: string
|
||||
default: .nova/contract.yml
|
||||
mode:
|
||||
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
|
||||
type: string
|
||||
default: full
|
||||
changeRequestId:
|
||||
description: Change request ID (required for decommission mode — validated against CMDB)
|
||||
type: string
|
||||
default: ""
|
||||
environment:
|
||||
description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out consumer repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check out ACDL platform repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: acdl/acdl
|
||||
path: platform
|
||||
ref: v1.9
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install runtime dependencies
|
||||
run: |
|
||||
pip install --break-system-packages jsonschema pyyaml boto3
|
||||
pip install --break-system-packages "checkov>=3.2,<4"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Configure AWS credentials (OIDC default + static-key override)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
|
||||
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
||||
aws-region: us-east-1
|
||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Run the platform pipeline
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
MODE_FLAG=""
|
||||
case "${{ inputs.mode }}" in
|
||||
full) MODE_FLAG="" ;;
|
||||
plan-only) MODE_FLAG="--plan-only" ;;
|
||||
check-only) MODE_FLAG="--check-only" ;;
|
||||
decommission)
|
||||
if [ -z "${{ inputs.changeRequestId }}" ]; then
|
||||
echo "FAIL: changeRequestId is required for decommission mode"
|
||||
exit 1
|
||||
fi
|
||||
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
|
||||
;;
|
||||
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
|
||||
esac
|
||||
ENV_FLAG=""
|
||||
if [ -n "${{ inputs.environment }}" ]; then
|
||||
ENV_FLAG="--environment ${{ inputs.environment }}"
|
||||
fi
|
||||
bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}"
|
||||
|
||||
- name: Post stage summary comment to PR
|
||||
if: success() && github.event_name == 'pull_request'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
bash platform/scripts/post_stage_comment.sh deploy pass '{"mode":"${{ inputs.mode }}","runId":"${{ github.run_id }}"}'
|
||||
|
||||
- name: Report error to platform team (on failure)
|
||||
if: failure()
|
||||
env:
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
aws lambda invoke-function-url \
|
||||
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
|
||||
--cli-binary-format raw-in-base64-out \
|
||||
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
|
||||
/dev/null || true
|
||||
|
||||
- name: Upload emitted Terraform
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nova-terraform
|
||||
path: /tmp/acdl_platform_run_v18/tf/*.tf
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload platform log
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nova-platform-log
|
||||
path: platform/logs/
|
||||
if-no-files-found: warn
|
||||
@@ -1,207 +0,0 @@
|
||||
# ACDL Modules Lifecycle Pipeline — Gitea Actions (dev environment)
|
||||
#
|
||||
# Matrix-runs each L1 module's examples/{simple,complex}.yml contracts through
|
||||
# apply→modify→destroy against live AWS. No per-module Python. The "test" =
|
||||
# the pipeline cell going green.
|
||||
#
|
||||
# Also matrix-runs L2 composition modules (static-assets, microservice) through
|
||||
# the same apply→modify→destroy lifecycle. L2 = composition only (no L2
|
||||
# terraform files); the composition must be deterministic.
|
||||
#
|
||||
# This workflow implements pipelines/modules-lifecycle.yml (byte-identical
|
||||
# in .gitea/workflows/ and .github/workflows/).
|
||||
#
|
||||
# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to
|
||||
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
|
||||
# no AWS mutation, validates the contract->resolver->adapter->plan chain
|
||||
# for every module on every PR, with no AWS credentials or cost). Set to
|
||||
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
|
||||
# to run the real apply→modify→destroy against live AWS. In plan mode the
|
||||
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
|
||||
#
|
||||
# A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent
|
||||
# modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed
|
||||
# after all tests complete. The CI VPC is separate from the long-lived platform
|
||||
# VPC. Outputs are read from the S3 state by each lifecycle job (no artifact
|
||||
# passing needed).
|
||||
name: acdl-modules-lifecycle
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
lifecycle_mode:
|
||||
description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)"
|
||||
required: false
|
||||
default: "plan"
|
||||
type: choice
|
||||
options:
|
||||
- plan
|
||||
- full
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice)
|
||||
# Skipped in plan mode (no resources are applied, so no VPC is needed).
|
||||
ci-vpc-apply:
|
||||
name: CI VPC apply
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Apply CI VPC
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform apply -auto-approve -lock=false
|
||||
|
||||
# L1 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
||||
lifecycle:
|
||||
name: L1 lifecycle (${{ matrix.module }})
|
||||
needs: ci-vpc-apply
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
|
||||
env:
|
||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Read CI VPC outputs
|
||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
||||
- name: Apply (simple)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
||||
- name: Modify (complex)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
||||
- name: Destroy
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
||||
|
||||
# L2 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
||||
l2-lifecycle:
|
||||
name: L2 lifecycle (${{ matrix.module }})
|
||||
needs: ci-vpc-apply
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [static-assets, microservice]
|
||||
env:
|
||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Read CI VPC outputs
|
||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
||||
- name: Apply (simple)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
||||
- name: Modify (complex)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
||||
- name: Destroy
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
||||
|
||||
# Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails)
|
||||
ci-vpc-destroy:
|
||||
name: CI VPC destroy
|
||||
needs: [lifecycle, l2-lifecycle]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Destroy CI VPC
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform destroy -auto-approve -lock=false
|
||||
Reference in New Issue
Block a user