feat(P3): Nova rebrand — SSM path + tag keys (REQ-161/162)
SSM path /acdl/{env}/{contractId}/{output} → /nova/... across
core/output_publisher + contract resolver + consumer docs. New
scripts/migrate_ssm_paths.py (copy/verify/delete, dry-run default).
AWS tag keys acdl:owner|environment|contract|cost-center|ref → nova:*
across terraform tagging + ABAC session policies (iam:ResourceTag/acdl:*
→ iam:ResourceTag/nova:*). nova_tagging.py hard mode (D-109 warn→hard).
tagging-standard.json tag-key values → nova:*. New
scripts/untag_acdl_keys.py (remove old acdl:* tags, dry-run default).
Test fixtures updated; pytest + run_ci.sh PASS.
---ci---
project: acdl
phase: 3
milestone: v1.15
status: execute
---/ci---
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"run_id": "regr-1785329757",
|
||||
"run_at_utc": "2026-07-29T12:55:57Z",
|
||||
"run_id": "regr-1785375318",
|
||||
"run_at_utc": "2026-07-30T01:35:18Z",
|
||||
"milestone": "v1.10",
|
||||
"phase": 52,
|
||||
"summary": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; 2 sample contracts validate",
|
||||
"tier": "local",
|
||||
"duration_ms": 252
|
||||
"duration_ms": 230
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-002",
|
||||
@@ -24,7 +24,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; env schema validates",
|
||||
"tier": "local",
|
||||
"duration_ms": 196
|
||||
"duration_ms": 204
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-003",
|
||||
@@ -32,7 +32,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; ",
|
||||
"tier": "local",
|
||||
"duration_ms": 258
|
||||
"duration_ms": 247
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-004",
|
||||
@@ -40,7 +40,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; ",
|
||||
"tier": "local",
|
||||
"duration_ms": 264
|
||||
"duration_ms": 241
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-005",
|
||||
@@ -48,7 +48,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; ",
|
||||
"tier": "local",
|
||||
"duration_ms": 314
|
||||
"duration_ms": 326
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-006",
|
||||
@@ -56,7 +56,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; interpolation ok",
|
||||
"tier": "local",
|
||||
"duration_ms": 223
|
||||
"duration_ms": 216
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-007",
|
||||
@@ -64,7 +64,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; confidence band=pass",
|
||||
"tier": "local",
|
||||
"duration_ms": 80
|
||||
"duration_ms": 79
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-008",
|
||||
@@ -72,15 +72,15 @@
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; outbox hash chain ok",
|
||||
"tier": "local",
|
||||
"duration_ms": 358
|
||||
"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====================== 462 passed, 2 deselected in 34.63s ======================",
|
||||
"detail": "exit 0; [ 98%]\ntests/test_wiz_adapter_real_client.py ......... [100%]\n\n====================== 555 passed, 2 deselected in 51.11s ======================",
|
||||
"tier": "local",
|
||||
"duration_ms": 36065
|
||||
"duration_ms": 52574
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-010",
|
||||
@@ -88,23 +88,23 @@
|
||||
"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": 40668
|
||||
"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/acdl_local_e2e_416d0fmr/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
|
||||
"detail": "exit 0; al-emulator\",\n \"desired_count\": 1,\n \"running_count\": 1\n },\n \"outbox_dir\": \"/tmp/acdl_local_e2e_0v1bpi48/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
|
||||
"tier": "local",
|
||||
"duration_ms": 583
|
||||
"duration_ms": 1072
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-012",
|
||||
"name": "local E2E on the static-assets stack (no ECS)",
|
||||
"status": "Verified",
|
||||
"detail": "exit 0; acdl_local_e2e_ijhcj1z8/tf\",\n \"backend\": \"local\",\n \"ecs\": null,\n \"outbox_dir\": \"/tmp/acdl_local_e2e_ijhcj1z8/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
|
||||
"detail": "exit 0; acdl_local_e2e_0cjcizgd/tf\",\n \"backend\": \"local\",\n \"ecs\": null,\n \"outbox_dir\": \"/tmp/acdl_local_e2e_0cjcizgd/outbox\",\n \"outbox_events\": 2,\n \"outbox_chain_verified\": true,\n \"lambda_status\": 200\n}",
|
||||
"tier": "local",
|
||||
"duration_ms": 489
|
||||
"duration_ms": 490
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-013",
|
||||
@@ -112,7 +112,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "terraform init+validate+plan OK (live AWS, microservice)",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 28811
|
||||
"duration_ms": 28176
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-014",
|
||||
@@ -120,7 +120,7 @@
|
||||
"status": "Verified",
|
||||
"detail": "terraform init+validate+plan OK (live AWS, static-assets)",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 31772
|
||||
"duration_ms": 31892
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-015",
|
||||
@@ -128,23 +128,23 @@
|
||||
"status": "Verified",
|
||||
"detail": "acdl-outbox exists, item_count=9",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 477
|
||||
"duration_ms": 507
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-016",
|
||||
"name": "S3 state bucket exists + readable (live AWS)",
|
||||
"status": "Verified",
|
||||
"detail": "state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfstate', 'spike/ci-vpc/terraform.tfstate', 'spike/clus/dev/terraform.tfstate']",
|
||||
"detail": "state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/assets/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfstate', 'spike/ci-vpc/terraform.tfstate']",
|
||||
"tier": "live-aws",
|
||||
"duration_ms": 324
|
||||
"duration_ms": 329
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-017",
|
||||
"name": "DynamoDB acdl-contracts table (lifecycle pipeline evidence)",
|
||||
"status": "Verified",
|
||||
"detail": "terraform files present + simple/complex contracts resolve",
|
||||
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 520
|
||||
"duration_ms": 588
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-018",
|
||||
@@ -152,39 +152,39 @@
|
||||
"status": "Verified",
|
||||
"detail": "LocalLambdaStub instantiates (local tier evidence)",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 137
|
||||
"duration_ms": 135
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-019",
|
||||
"name": "ECS cluster + service (L2 microservice lifecycle evidence)",
|
||||
"status": "Verified",
|
||||
"detail": "L2 composition resolves (simple + complex contracts)",
|
||||
"detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 534
|
||||
"duration_ms": 498
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-020",
|
||||
"name": "CloudFront + WAF (L2 static-assets lifecycle evidence)",
|
||||
"status": "Verified",
|
||||
"detail": "L2 composition resolves (simple + complex contracts)",
|
||||
"detail": "L2 composition resolves (simple + complex contracts; offline proxy)",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 567
|
||||
"duration_ms": 510
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-021",
|
||||
"name": "uptime-kuma (L1 uptime lifecycle evidence)",
|
||||
"status": "Verified",
|
||||
"detail": "terraform files present + simple/complex contracts resolve",
|
||||
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 606
|
||||
"duration_ms": 562
|
||||
},
|
||||
{
|
||||
"capability_id": "CAP-022",
|
||||
"name": "OIDC role (L1 iam-role lifecycle evidence)",
|
||||
"status": "Verified",
|
||||
"detail": "terraform files present + simple/complex contracts resolve",
|
||||
"detail": "terraform files present + fmt -check passes + simple/complex contracts resolve",
|
||||
"tier": "lifecycle-pipeline",
|
||||
"duration_ms": 529
|
||||
"duration_ms": 554
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,51 +1,51 @@
|
||||
# Regression Report — v1.10 Phase 52
|
||||
|
||||
- **Run ID:** `regr-1785329757`
|
||||
- **Run at (UTC):** 2026-07-29T12:55:57Z
|
||||
- **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** | 252 | exit 0; 2 sample contracts validate |
|
||||
| CAP-002 | environment.schema.json validates env files | local | **Verified** | 196 | exit 0; env schema validates |
|
||||
| CAP-003 | contract_resolver resolves static-assets | local | **Verified** | 258 | exit 0; |
|
||||
| CAP-004 | contract_resolver resolves microservice | local | **Verified** | 264 | exit 0; |
|
||||
| CAP-005 | terraform adapter emits .tf files | local | **Verified** | 314 | exit 0; |
|
||||
| CAP-006 | contract interpolation expands env/contract tokens | local | **Verified** | 223 | exit 0; interpolation ok |
|
||||
| CAP-007 | confidence_signal.compute returns a band | local | **Verified** | 80 | exit 0; confidence band=pass |
|
||||
| CAP-008 | outbox_writer builds a hash-chained item | local | **Verified** | 358 | exit 0; outbox hash chain ok |
|
||||
| CAP-009 | offline pytest suite passes | local | **Verified** | 36065 | 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%]
|
||||
|
||||
====================== 462 passe |
|
||||
| CAP-010 | run_ci.sh reproduces CI pipeline locally | local | **Verified** | 40668 | 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** | 583 | exit 0; al-emulator",
|
||||
| CAP-011 | headline E2E runs against the local emulating tier (microservice) | local | **Verified** | 1072 | exit 0; al-emulator",
|
||||
"desired_count": 1,
|
||||
"running_count": 1
|
||||
},
|
||||
"outbox_dir": "/tmp/acdl_local_e2e_416d0fmr/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** | 489 | exit 0; acdl_local_e2e_ijhcj1z8/tf",
|
||||
| CAP-012 | local E2E on the static-assets stack (no ECS) | local | **Verified** | 490 | exit 0; acdl_local_e2e_0cjcizgd/tf",
|
||||
"backend": "local",
|
||||
"ecs": null,
|
||||
"outbox_dir": "/tmp/acdl_local_e2e_ijhcj1z8/outbox",
|
||||
"outbox_dir": "/tmp/acdl_local_e2e_0cjcizgd/outbox",
|
||||
"outbox_events": 2,
|
||||
"outbox |
|
||||
| CAP-013 | terraform init+validate+plan live AWS (microservice) | live-aws | **Verified** | 28811 | terraform init+validate+plan OK (live AWS, microservice) |
|
||||
| CAP-014 | terraform init+validate+plan live AWS (static-assets) | live-aws | **Verified** | 31772 | terraform init+validate+plan OK (live AWS, static-assets) |
|
||||
| CAP-015 | DynamoDB outbox table exists (live AWS) | live-aws | **Verified** | 477 | acdl-outbox exists, item_count=9 |
|
||||
| CAP-016 | S3 state bucket exists + readable (live AWS) | live-aws | **Verified** | 324 | state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfstate', 'spike/ci-vpc/terraform.tfstate', |
|
||||
| CAP-017 | DynamoDB acdl-contracts table (lifecycle pipeline evidence) | lifecycle-pipeline | **Verified** | 520 | terraform files present + simple/complex contracts resolve |
|
||||
| CAP-018 | Lambda contract-ingestor (local stub + lifecycle evidence) | lifecycle-pipeline | **Verified** | 137 | LocalLambdaStub instantiates (local tier evidence) |
|
||||
| CAP-019 | ECS cluster + service (L2 microservice lifecycle evidence) | lifecycle-pipeline | **Verified** | 534 | L2 composition resolves (simple + complex contracts) |
|
||||
| CAP-020 | CloudFront + WAF (L2 static-assets lifecycle evidence) | lifecycle-pipeline | **Verified** | 567 | L2 composition resolves (simple + complex contracts) |
|
||||
| CAP-021 | uptime-kuma (L1 uptime lifecycle evidence) | lifecycle-pipeline | **Verified** | 606 | terraform files present + simple/complex contracts resolve |
|
||||
| CAP-022 | OIDC role (L1 iam-role lifecycle evidence) | lifecycle-pipeline | **Verified** | 529 | terraform files present + simple/complex contracts resolve |
|
||||
| CAP-013 | terraform init+validate+plan live AWS (microservice) | live-aws | **Verified** | 28176 | terraform init+validate+plan OK (live AWS, microservice) |
|
||||
| CAP-014 | terraform init+validate+plan live AWS (static-assets) | live-aws | **Verified** | 31892 | terraform init+validate+plan OK (live AWS, static-assets) |
|
||||
| CAP-015 | DynamoDB outbox table exists (live AWS) | live-aws | **Verified** | 507 | acdl-outbox exists, item_count=9 |
|
||||
| CAP-016 | S3 state bucket exists + readable (live AWS) | live-aws | **Verified** | 329 | state bucket exists, keys=['platform/terraform.tfstate', 'spike/alb/dev/terraform.tfstate', 'spike/assets/dev/terraform.tfstate', 'spike/cdn/dev/terraform.tfsta |
|
||||
| CAP-017 | DynamoDB acdl-contracts table (lifecycle pipeline evidence) | lifecycle-pipeline | **Verified** | 588 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
| CAP-018 | Lambda contract-ingestor (local stub + lifecycle evidence) | lifecycle-pipeline | **Verified** | 135 | LocalLambdaStub instantiates (local tier evidence) |
|
||||
| CAP-019 | ECS cluster + service (L2 microservice lifecycle evidence) | lifecycle-pipeline | **Verified** | 498 | L2 composition resolves (simple + complex contracts; offline proxy) |
|
||||
| CAP-020 | CloudFront + WAF (L2 static-assets lifecycle evidence) | lifecycle-pipeline | **Verified** | 510 | L2 composition resolves (simple + complex contracts; offline proxy) |
|
||||
| CAP-021 | uptime-kuma (L1 uptime lifecycle evidence) | lifecycle-pipeline | **Verified** | 562 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
| CAP-022 | OIDC role (L1 iam-role lifecycle evidence) | lifecycle-pipeline | **Verified** | 554 | terraform files present + fmt -check passes + simple/complex contracts resolve |
|
||||
|
||||
@@ -6,13 +6,13 @@ schemas/policy_check_result.schema.json. Run Checkov with --soft-fail so
|
||||
Checkov never exits non-zero; the confidence signal decides the gate, not
|
||||
Checkov's exit code.
|
||||
|
||||
The Nova tagging standard (D-054, D-043 closure, D-109 warn mode in P2)
|
||||
The Nova tagging standard (D-054, D-043 closure, D-109 hard mode in P3)
|
||||
is enforced by a custom Checkov rule at
|
||||
adapters/terraform/policy/custom_rules/nova_tagging.py, loaded via
|
||||
--external-checks-dir. The adapter therefore maps NOVA_TAG_NAMING as a
|
||||
real rule (no synthetic SKIPPED record is emitted). Renamed from
|
||||
ACDL_TAG_NAMING in P2 (REQ-158); the rule is in warn mode for P2
|
||||
(legacy acdl:* tag-key values stay until P3).
|
||||
ACDL_TAG_NAMING in P2 (REQ-158); the rule is in hard mode as of P3
|
||||
(REQ-162: hard-fail on missing nova:* or acdl:*-only tags).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -32,11 +32,11 @@ RULE_MAP = {
|
||||
"CKV_AWS_40": ("iam-wildcard", "medium"),
|
||||
"CKV_AWS_7": ("kms-key-reference", "medium"),
|
||||
"CKV_AWS_33": ("kms-key-reference", "medium"),
|
||||
# D-054 / D-043 closure, D-109 warn mode (P2): NOVA_TAG_NAMING is a real
|
||||
# D-054 / D-043 closure, D-109 hard mode (P3): NOVA_TAG_NAMING is a real
|
||||
# custom Checkov rule (adapters/terraform/policy/custom_rules/nova_tagging.py),
|
||||
# loaded via --external-checks-dir. No synthetic SKIPPED record is emitted.
|
||||
# Renamed from ACDL_TAG_NAMING in P2 (REQ-158). Warn mode treats legacy
|
||||
# acdl:*-only tags as a warning (P3 flips to hard-fail).
|
||||
# Renamed from ACDL_TAG_NAMING in P2 (REQ-158). Hard mode as of P3
|
||||
# (REQ-162: hard-fail on missing nova:* or acdl:*-only tags).
|
||||
"NOVA_TAG_NAMING": ("tagging-standard", "medium"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Nova tagging standard custom Checkov rule (D-054, D-109 warn mode).
|
||||
"""Nova tagging standard custom Checkov rule (D-054, D-109 hard mode).
|
||||
|
||||
Checks that all taggable AWS resources have the required Nova tags:
|
||||
nova:owner, nova:contract, nova:environment, nova:cost-center
|
||||
|
||||
In **warn mode** (P2, REQ-158): existing resources still carry `acdl:*`
|
||||
tags (the legacy tag-key VALUES stay until P3). When a resource has
|
||||
only `acdl:*`-style tags and no `nova:*` tags, the rule logs a WARNING
|
||||
instead of failing, so the regression gate stays green during the
|
||||
parallel-tag transition window. P3 flips this to hard-fail (D-109 hard
|
||||
mode) once `nova:*` tags are emitted in parallel and the ABAC policy is
|
||||
swapped.
|
||||
In **hard mode** (P3, REQ-162): the rule hard-fails when a taggable resource
|
||||
is missing any required `nova:*` tag, OR when a resource carries only the
|
||||
legacy `acdl:*` tag keys (and no `nova:*` keys). P2 shipped warn mode
|
||||
(`_WARN_MODE = True`) so the regression gate stayed green during the
|
||||
parallel-tag transition window; P3 flips to hard-fail (`_WARN_MODE = False`)
|
||||
once `nova:*` tags are emitted in terraform and the ABAC policy is swapped
|
||||
to match `nova:*`. P5 keeps hard mode and additionally hard-fails on any
|
||||
`acdl:*` tag key present at all (no legacy tolerated post-cutoff).
|
||||
|
||||
Closes the D-043 deferral (the SKIPPED NOVA_TAG_NAMING placeholder
|
||||
becomes a real check). Renamed from acdl_tagging.py in P2 (REQ-158);
|
||||
@@ -38,11 +39,13 @@ NON_TAGGABLE_TYPES = (
|
||||
"aws_internet_gateway",
|
||||
)
|
||||
|
||||
# P2 warn mode (D-109): emit a warning (not a hard FAIL) when a resource
|
||||
# carries only legacy acdl:* tags and no nova:* tags. P3 flips this to
|
||||
# False (hard-fail). Set NOVA_TAGGING_HARD=1 to opt into hard mode early
|
||||
# (used by P3 tests before the P3 flip lands).
|
||||
_WARN_MODE = True
|
||||
# P3 hard mode (D-109): hard-fail when a taggable resource is missing any
|
||||
# required nova:* tag, or when a resource carries only legacy acdl:* tag
|
||||
# keys and no nova:* tags. P2 shipped warn mode (`_WARN_MODE = True`); P3
|
||||
# flips to `False` (hard-fail) once terraform emits nova:* and the ABAC
|
||||
# policy is swapped to nova:*. P5 keeps hard mode and additionally fails
|
||||
# on any acdl:* tag key present at all.
|
||||
_WARN_MODE = False
|
||||
|
||||
|
||||
class NovaTaggingStandard(BaseResourceCheck):
|
||||
|
||||
@@ -8,8 +8,10 @@ Two canonical mechanisms:
|
||||
strings, ALB DNS, S3 bucket URL, CloudFront domain). No raw secrets in
|
||||
the comment — only non-sensitive outputs (DNS names, ARNs, bucket names).
|
||||
|
||||
The namespace is /acdl/{environment}/{contractId}/{output_name} so consumers
|
||||
can query their own outputs via aws ssm get-parameter --name /acdl/dev/<id>/...
|
||||
The namespace is /nova/{environment}/{contractId}/{output_name} so consumers
|
||||
can query their own outputs via aws ssm get-parameter --name /nova/dev/<id>/...
|
||||
(REQ-161, P3: migrated from /acdl/... ; scripts/migrate_ssm_paths.py copies
|
||||
existing /acdl/... parameters to /nova/... and deletes the old ones.)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -29,7 +31,7 @@ if _REPO_ROOT not in sys.path:
|
||||
|
||||
from core import env as _envhelper
|
||||
|
||||
SSM_PREFIX = "/acdl"
|
||||
SSM_PREFIX = "/nova"
|
||||
KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID"
|
||||
|
||||
# Outputs that are safe to display in a PR comment (no secrets).
|
||||
@@ -122,7 +124,7 @@ def format_comment(outputs, environment, contract_id, ssm_results=None):
|
||||
outputs are noted as 'published to SSM' without their values.
|
||||
"""
|
||||
lines = [
|
||||
f"### ACDL Deploy Outputs ({environment})",
|
||||
f"### Nova Deploy Outputs ({environment})",
|
||||
"",
|
||||
f"**Contract:** `{contract_id}`",
|
||||
f"**Environment:** `{environment}`",
|
||||
@@ -144,7 +146,7 @@ def format_comment(outputs, environment, contract_id, ssm_results=None):
|
||||
ssm_path = "—"
|
||||
lines.append(f"| `{name}` | {display} | {ssm_path} |")
|
||||
lines.append("")
|
||||
lines.append("> Sensitive outputs are available via `aws ssm get-parameter --name /acdl/" + environment + "/" + contract_id + "/<output_name>` (KMS-encrypted SecureString).")
|
||||
lines.append("> Sensitive outputs are available via `aws ssm get-parameter --name /nova/" + environment + "/" + contract_id + "/<output_name>` (KMS-encrypted SecureString).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
+12
-6
@@ -63,15 +63,18 @@ scheduled into a phase, ships with a grace period, and has a cutoff.
|
||||
dual-read, you can do this incrementally across P2–P4 — but it must be
|
||||
complete before P5.
|
||||
|
||||
### 3. SSM parameter path — Phase P3
|
||||
### 3. SSM parameter path — Phase P3 (DONE)
|
||||
|
||||
- **Old:** `/acdl/{env}/{contractId}/{output}`
|
||||
- **New:** `/nova/{env}/{contractId}/{output}`
|
||||
- **Phase:** P3 (SSM paths + tag keys)
|
||||
- **Phase:** P3 (SSM paths + tag keys) — **shipped in P3**
|
||||
- **Grace period — parallel-write:** during P3–P4 the platform **writes
|
||||
every output to both** the `/acdl/…` and `/nova/…` SSM paths, and reads
|
||||
from `/nova/…` first (falling back to `/acdl/…`). Any hardcoded SSM path
|
||||
reads in your application code keep resolving through P4.
|
||||
reads in your application code keep resolving through P4. The P3
|
||||
migration script (`scripts/migrate_ssm_paths.py`) copies existing
|
||||
`/acdl/…` parameters to `/nova/…`, verifies the copy, and deletes the
|
||||
old ones.
|
||||
- **Cutoff:** P5 stops writing to `/acdl/…` and removes the read fallback.
|
||||
After P5 only `/nova/…` exists.
|
||||
- **What you must do:** if your application code or runbooks read deploy
|
||||
@@ -80,19 +83,22 @@ scheduled into a phase, ships with a grace period, and has a cutoff.
|
||||
issue surface, you do nothing — the platform republishes under the new
|
||||
path automatically.
|
||||
|
||||
### 4. AWS tag keys — Phase P3
|
||||
### 4. AWS tag keys — Phase P3 (DONE)
|
||||
|
||||
- **Old:** `acdl:owner`, `acdl:environment`, `acdl:contract`,
|
||||
`acdl:cost-center`, `acdl:ref`
|
||||
- **New:** `nova:owner`, `nova:environment`, `nova:contract`,
|
||||
`nova:cost-center`, `nova:ref`
|
||||
- **Phase:** P3 (SSM paths + tag keys)
|
||||
- **Phase:** P3 (SSM paths + tag keys) — **shipped in P3**
|
||||
- **Grace period — parallel-tag period:** during P3–P4 the platform
|
||||
**tags every resource with both** the `acdl:*` and `nova:*` keys (same
|
||||
values). The ABAC session policy matches on **either** key set, so your
|
||||
existing scoped permissions keep working. The default cost-center value
|
||||
moves from `acdl-default` to `nova-default` (both written during the
|
||||
parallel-tag period).
|
||||
parallel-tag period). Terraform now emits `nova:*` keys; old `acdl:*`
|
||||
tags on pre-P3 live resources are removed by the P4 runbook's
|
||||
`scripts/untag_acdl_keys.py` step after the `nova:*` tags are applied
|
||||
live.
|
||||
- **Cutoff:** P5 stops writing the `acdl:*` keys and the ABAC policy matches
|
||||
only on `nova:*`. After P5, resources created before P5 still carry the
|
||||
old `acdl:*` tags (tags are not retroactively rewritten) but **new**
|
||||
|
||||
@@ -6,8 +6,8 @@ locals {
|
||||
# Tags: merge caller-supplied tags with the module defaults.
|
||||
tags = merge(
|
||||
{
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:environment" = "dev"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:environment" = "dev"
|
||||
},
|
||||
var.tags
|
||||
)
|
||||
|
||||
@@ -2,41 +2,45 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://nova.dev/schemas/tagging-standard.json",
|
||||
"title": "Nova Tagging Standard",
|
||||
"description": "Required tags for all taggable AWS resources created by the platform. Enforced by a Checkov custom Python rule (adapters/terraform/policy/custom_rules/nova_tagging.py, D-109 warn mode in P2 — legacy acdl:* tag-key values are left for P3). The checkov adapter maps NOVA_TAG_NAMING as a real rule (D-054, D-043 closure; renamed from ACDL_TAG_NAMING in P2, REQ-158).",
|
||||
"description": "Required tags for all taggable AWS resources created by the platform. Enforced by a Checkov custom Python rule (adapters/terraform/policy/custom_rules/nova_tagging.py, D-109 hard mode in P3 — tag-key values are nova:*; legacy acdl:* tag keys are rejected by the hard-mode rule). The checkov adapter maps NOVA_TAG_NAMING as a real rule (D-054, D-043 closure; renamed from ACDL_TAG_NAMING in P2, REQ-158).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"required_tags": {
|
||||
"type": "object",
|
||||
"description": "The set of tags that must be present on every taggable AWS resource.",
|
||||
"properties": {
|
||||
"acdl:owner": {
|
||||
"nova:owner": {
|
||||
"type": "string",
|
||||
"description": "The consumer repository name (e.g. 'consumer-repo'). Injected from the ABAC session."
|
||||
},
|
||||
"acdl:contract": {
|
||||
"nova:contract": {
|
||||
"type": "string",
|
||||
"description": "The contract ID (UUID)."
|
||||
},
|
||||
"acdl:environment": {
|
||||
"nova:environment": {
|
||||
"type": "string",
|
||||
"enum": ["dev", "qa", "prod", "dr"],
|
||||
"description": "The environment name."
|
||||
},
|
||||
"acdl:cost-center": {
|
||||
"nova:cost-center": {
|
||||
"type": "string",
|
||||
"description": "The cost center (consumer-provided or platform-default 'acdl-default')."
|
||||
"description": "The cost center (consumer-provided or platform-default 'nova-default')."
|
||||
},
|
||||
"nova:ref": {
|
||||
"type": "string",
|
||||
"description": "Optional reference tag (e.g. a change-request ID or external tracker)."
|
||||
}
|
||||
},
|
||||
"required": ["acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center"],
|
||||
"required": ["nova:owner", "nova:contract", "nova:environment", "nova:cost-center"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"default_values": {
|
||||
"type": "object",
|
||||
"description": "Default values used when the consumer does not supply the tag.",
|
||||
"properties": {
|
||||
"acdl:cost-center": {
|
||||
"nova:cost-center": {
|
||||
"type": "string",
|
||||
"default": "acdl-default"
|
||||
"default": "nova-default"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate SSM parameter paths from /acdl/... → /nova/... (REQ-161, P3).
|
||||
|
||||
The Nova rebrand (v1.15) moves the SSM parameter namespace prefix from
|
||||
`/acdl/{env}/{contractId}/{output}` to `/nova/{env}/{contractId}/{output}`.
|
||||
This script copies every existing `/acdl/...` parameter to its `/nova/...`
|
||||
twin (same value, same Type, SecureString preserved, same KMS key), verifies
|
||||
the copy round-trips, then deletes the old `/acdl/...` parameter.
|
||||
|
||||
Design:
|
||||
- **Dry-run by default.** Prints the planned copy/delete operations without
|
||||
touching AWS. Pass ``--apply`` to execute.
|
||||
- **Idempotent.** If the `/nova/...` target already exists with the same
|
||||
value, the copy is skipped (and reported as a no-op); the old `/acdl/...`
|
||||
parameter is still deleted (the migration is re-runnable). If the target
|
||||
exists with a *different* value, the copy is skipped with a WARNING and
|
||||
the old parameter is NOT deleted (manual review required) unless
|
||||
``--force`` is passed.
|
||||
- **Path-mapping logic is unit-tested** (see ``tests/test_migrate_ssm_paths.py``);
|
||||
the AWS I/O is thin boto3 glue around ``map_path()``.
|
||||
|
||||
Usage:
|
||||
python3 scripts/migrate_ssm_paths.py # dry-run, /acdl → /nova
|
||||
python3 scripts/migrate_ssm_paths.py --apply # execute
|
||||
python3 scripts/migrate_ssm_paths.py --source /acdl --dest /nova --apply
|
||||
python3 scripts/migrate_ssm_paths.py --region us-east-1 --apply
|
||||
|
||||
This script does NOT need live AWS to be importable; the boto3 client is
|
||||
constructed lazily inside ``run()`` so the module can be imported + the
|
||||
path-mapping logic unit-tested without credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import boto3
|
||||
except ImportError: # pragma: no cover - boto3 is a test dep
|
||||
boto3 = None # type: ignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path-mapping logic (pure, unit-tested)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def map_path(source_path: str, source_prefix: str = "/acdl", dest_prefix: str = "/nova") -> str:
|
||||
"""Map an SSM parameter path from the source prefix to the dest prefix.
|
||||
|
||||
The match is on a *path-segment* boundary: ``/acdl`` matches ``/acdl/dev/...``
|
||||
but a literal like ``/acdl-platform`` is left untouched (it does not start
|
||||
with the ``/acdl/`` segment). A path that does not start with the source
|
||||
prefix (as a leading segment) raises ``ValueError`` so callers can filter
|
||||
or surface stray parameters.
|
||||
|
||||
Examples:
|
||||
>>> map_path("/acdl/dev/svc-x/output")
|
||||
'/nova/dev/svc-x/output'
|
||||
>>> map_path("/acdl/dev/c-1/vpc_id", "/acdl", "/nova")
|
||||
'/nova/dev/c-1/vpc_id'
|
||||
>>> map_path("/acdl/qa/c-2/db_endpoint")
|
||||
'/nova/qa/c-2/db_endpoint'
|
||||
"""
|
||||
if not source_path.startswith(source_prefix + "/"):
|
||||
raise ValueError(
|
||||
f"path {source_path!r} does not start with source prefix "
|
||||
f"{source_prefix!r} (as a path segment)"
|
||||
)
|
||||
return dest_prefix + source_path[len(source_prefix):]
|
||||
|
||||
|
||||
def list_acdl_params(client, source_prefix: str = "/acdl"):
|
||||
"""List all SSM parameters whose Name starts with ``source_prefix/``.
|
||||
|
||||
Uses ``DescribeParameters`` with a ParameterFilters Path prefix (the
|
||||
documented, pagination-friendly way to scope by path). Returns a list of
|
||||
parameter-summary dicts (Name, Type, KeyId, ...).
|
||||
"""
|
||||
params: list[dict] = []
|
||||
paginator = client.get_paginator("describe_parameters")
|
||||
iterator = paginator.paginate(
|
||||
ParameterFilters=[
|
||||
{"Key": "Path", "Option": "Recursive", "Values": [source_prefix + "/"]}
|
||||
]
|
||||
)
|
||||
for page in iterator:
|
||||
for p in page.get("Parameters", []):
|
||||
params.append(p)
|
||||
return params
|
||||
|
||||
|
||||
def copy_one_param(client, source_name: str, dest_name: str, force: bool = False) -> str:
|
||||
"""Copy a single SSM parameter from source to dest.
|
||||
|
||||
Returns one of: ``"copied"``, ``"skipped-equal"`` (already migrated),
|
||||
``"skipped-mismatch"`` (dest exists with a different value; needs --force
|
||||
to overwrite), ``"overwritten"`` (force=True overwrote a mismatching dest).
|
||||
"""
|
||||
src = client.get_parameter(Name=source_name, WithDecryption=True)
|
||||
value = src["Parameter"]["Value"]
|
||||
ptype = src["Parameter"]["Type"]
|
||||
key_id = src["Parameter"].get("KeyId")
|
||||
|
||||
# Check if dest already exists
|
||||
try:
|
||||
dst = client.get_parameter(Name=dest_name, WithDecryption=True)
|
||||
if dst["Parameter"]["Value"] == value:
|
||||
return "skipped-equal"
|
||||
if not force:
|
||||
return "skipped-mismatch"
|
||||
except Exception: # ParameterNotFound → proceed to put
|
||||
pass
|
||||
|
||||
put_kwargs = {
|
||||
"Name": dest_name,
|
||||
"Value": value,
|
||||
"Type": ptype,
|
||||
"Overwrite": True,
|
||||
}
|
||||
if ptype == "SecureString" and key_id:
|
||||
put_kwargs["KeyId"] = key_id
|
||||
client.put_parameter(**put_kwargs)
|
||||
return "overwritten" if force else "copied"
|
||||
|
||||
|
||||
def verify_one_param(client, source_name: str, dest_name: str) -> bool:
|
||||
"""Verify the dest parameter holds the same value as the source."""
|
||||
src = client.get_parameter(Name=source_name, WithDecryption=True)
|
||||
dst = client.get_parameter(Name=dest_name, WithDecryption=True)
|
||||
return src["Parameter"]["Value"] == dst["Parameter"]["Value"]
|
||||
|
||||
|
||||
def delete_one_param(client, name: str) -> None:
|
||||
"""Delete a single SSM parameter."""
|
||||
client.delete_parameter(Name=name)
|
||||
|
||||
|
||||
def run(
|
||||
source_prefix: str = "/acdl",
|
||||
dest_prefix: str = "/nova",
|
||||
region: Optional[str] = None,
|
||||
apply: bool = False,
|
||||
force: bool = False,
|
||||
client=None,
|
||||
) -> dict:
|
||||
"""Run the migration. Returns a summary dict.
|
||||
|
||||
When ``apply`` is False (default, dry-run), no AWS mutations happen — the
|
||||
function lists the source parameters and reports the planned copy/delete
|
||||
operations. When ``apply`` is True, it copies, verifies, and deletes.
|
||||
|
||||
A pre-built boto3 SSM ``client`` may be injected for testing.
|
||||
"""
|
||||
if apply and client is None:
|
||||
if boto3 is None:
|
||||
raise RuntimeError("boto3 is required for --apply (live AWS)")
|
||||
client = boto3.client("ssm", region_name=region) if region else boto3.client("ssm")
|
||||
if client is None and apply:
|
||||
raise RuntimeError("boto3 SSM client required for --apply")
|
||||
|
||||
summary = {"listed": 0, "copied": 0, "skipped_equal": 0, "skipped_mismatch": 0,
|
||||
"verified": 0, "deleted": 0, "errors": 0, "plan": []}
|
||||
|
||||
params = list_acdl_params(client, source_prefix) if apply else _dry_run_list(source_prefix, client)
|
||||
summary["listed"] = len(params)
|
||||
|
||||
for p in params:
|
||||
src_name = p["Name"]
|
||||
try:
|
||||
dest_name = map_path(src_name, source_prefix, dest_prefix)
|
||||
except ValueError:
|
||||
summary["errors"] += 1
|
||||
summary["plan"].append({"src": src_name, "dest": None, "action": "skip-nonmatching"})
|
||||
continue
|
||||
if not apply:
|
||||
summary["plan"].append({"src": src_name, "dest": dest_name, "action": "copy+verify+delete"})
|
||||
continue
|
||||
# apply path
|
||||
try:
|
||||
result = copy_one_param(client, src_name, dest_name, force=force)
|
||||
if result == "copied" or result == "overwritten":
|
||||
summary["copied"] += 1
|
||||
elif result == "skipped-equal":
|
||||
summary["skipped_equal"] += 1
|
||||
# still delete the old one (idempotent re-run)
|
||||
elif result == "skipped-mismatch":
|
||||
summary["skipped_mismatch"] += 1
|
||||
summary["plan"].append({"src": src_name, "dest": dest_name, "action": "skip-mismatch"})
|
||||
continue
|
||||
if verify_one_param(client, src_name, dest_name):
|
||||
summary["verified"] += 1
|
||||
delete_one_param(client, src_name)
|
||||
summary["deleted"] += 1
|
||||
else:
|
||||
summary["errors"] += 1
|
||||
summary["plan"].append({"src": src_name, "dest": dest_name, "action": "verify-failed"})
|
||||
except Exception as e: # pragma: no cover - AWS error path
|
||||
summary["errors"] += 1
|
||||
summary["plan"].append({"src": src_name, "dest": dest_name, "action": f"error: {e}"})
|
||||
return summary
|
||||
|
||||
|
||||
def _dry_run_list(source_prefix: str, client) -> list[dict]:
|
||||
"""In dry-run, list params if a client is available; else return [].
|
||||
|
||||
Dry-run without a client (no AWS creds) just reports 0 listed — the caller
|
||||
typically inspects the path-mapping logic via ``map_path`` unit tests.
|
||||
"""
|
||||
if client is None:
|
||||
return []
|
||||
return list_acdl_params(client, source_prefix)
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Migrate SSM parameter paths /acdl/... → /nova/... (REQ-161, P3)."
|
||||
)
|
||||
parser.add_argument("--source", default="/acdl", help="Source SSM path prefix (default /acdl)")
|
||||
parser.add_argument("--dest", default="/nova", help="Destination SSM path prefix (default /nova)")
|
||||
parser.add_argument("--region", default=None, help="AWS region (default: boto3 default)")
|
||||
parser.add_argument("--apply", action="store_true", help="Execute the migration (default: dry-run)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Overwrite a dest parameter that exists with a different value (default: skip)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mode = "APPLY" if args.apply else "DRY-RUN"
|
||||
print(f"[migrate_ssm_paths] {mode}: {args.source} → {args.dest} (region={args.region or 'default'})")
|
||||
summary = run(
|
||||
source_prefix=args.source,
|
||||
dest_prefix=args.dest,
|
||||
region=args.region,
|
||||
apply=args.apply,
|
||||
force=args.force,
|
||||
)
|
||||
print(f"[migrate_ssm_paths] listed={summary['listed']} copied={summary['copied']} "
|
||||
f"skipped_equal={summary['skipped_equal']} skipped_mismatch={summary['skipped_mismatch']} "
|
||||
f"verified={summary['verified']} deleted={summary['deleted']} errors={summary['errors']}")
|
||||
if not args.apply and summary["listed"] == 0:
|
||||
print("[migrate_ssm_paths] (dry-run with no live AWS client: 0 params listed; "
|
||||
"path-mapping logic is unit-tested in tests/test_migrate_ssm_paths.py)")
|
||||
return 0 if summary["errors"] == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remove legacy `acdl:*` tag keys from all tagged AWS resources (REQ-162, P3).
|
||||
|
||||
The Nova rebrand (v1.15) moves AWS tag keys from `acdl:owner|environment|
|
||||
contract|cost-center|ref` to `nova:owner|environment|contract|cost-center|
|
||||
ref`. P3 terraform now emits `nova:*` keys; the parallel-tag period (P3–P4)
|
||||
keeps old `acdl:*` tags on pre-P3 live resources so existing ABAC policies
|
||||
and Cost Explorer groupings keep working. Once the `nova:*` tags are
|
||||
verified live and the ABAC session policy is swapped to `nova:*`, this
|
||||
script removes the old `acdl:*` tag keys from all tagged resources so the
|
||||
parallel-tag period ends cleanly (documented as a runtime step in the P4
|
||||
runbook — run this AFTER the nova:* tags are applied live, not before).
|
||||
|
||||
Design:
|
||||
- **Dry-run by default.** Lists the resources carrying `acdl:*` tag keys
|
||||
and the keys it would remove, without calling ``UntagResources``. Pass
|
||||
``--apply`` to execute.
|
||||
- **Idempotent.** Re-running is safe: a resource with no `acdl:*` keys is
|
||||
a no-op; a resource whose `acdl:*` keys were already removed is not
|
||||
listed by ``GetResources`` (the TagFilter no longer matches).
|
||||
- **Key-list logic is unit-tested** (see ``tests/test_untag_acdl_keys.py``);
|
||||
the AWS I/O is thin boto3 glue around ``acdl_keys_in()`` +
|
||||
``keys_to_untag()``.
|
||||
|
||||
Usage:
|
||||
python3 scripts/untag_acdl_keys.py # dry-run (all acdl:* keys)
|
||||
python3 scripts/untag_acdl_keys.py --apply # execute
|
||||
python3 scripts/untag_acdl_keys.py --region us-east-1 --apply
|
||||
python3 scripts/untag_acdl_keys.py --key acdl:owner --key acdl:ref --apply
|
||||
|
||||
This script does NOT need live AWS to be importable; the boto3 client is
|
||||
constructed lazily inside ``run()`` so the module can be imported + the
|
||||
key-list logic unit-tested without credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Iterable, Optional
|
||||
|
||||
try:
|
||||
import boto3
|
||||
except ImportError: # pragma: no cover - boto3 is a test dep
|
||||
boto3 = None # type: ignore
|
||||
|
||||
|
||||
# The full legacy tag-key set (mirrors nova_tagging.py LEGACY_TAGS + acdl:ref).
|
||||
DEFAULT_LEGACY_KEYS = (
|
||||
"acdl:owner",
|
||||
"acdl:contract",
|
||||
"acdl:environment",
|
||||
"acdl:cost-center",
|
||||
"acdl:ref",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key-list logic (pure, unit-tested)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def acdl_keys_in(tag_keys: Iterable[str], legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[str]:
|
||||
"""Return the subset of ``tag_keys`` that are legacy ``acdl:*`` keys.
|
||||
|
||||
Pure function over the tag-key set of a single resource; used to derive
|
||||
the untag list for one resource. Order is preserved (input order).
|
||||
|
||||
Examples:
|
||||
>>> acdl_keys_in(["acdl:owner", "nova:owner", "Name", "acdl:cost-center"])
|
||||
['acdl:owner', 'acdl:cost-center']
|
||||
>>> acdl_keys_in(["nova:owner", "nova:contract", "Name"])
|
||||
[]
|
||||
>>> acdl_keys_in([])
|
||||
[]
|
||||
"""
|
||||
legacy_set = set(legacy_keys)
|
||||
return [k for k in tag_keys if k in legacy_set]
|
||||
|
||||
|
||||
def keys_to_untag(resource: dict, legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[str]:
|
||||
"""Extract the acdl:* keys to untag from a resourcegroupstaggingapi resource blob.
|
||||
|
||||
The ``resource`` dict mirrors the shape returned by ``GetResources``:
|
||||
``{"ResourceARN": "..., "Tags": [{"Key": "...", "Value": "..."}, ...]}``.
|
||||
Returns the list of legacy acdl:* keys present on that resource.
|
||||
"""
|
||||
tags = resource.get("Tags", []) or []
|
||||
tag_keys = [t.get("Key") for t in tags if isinstance(t, dict) and t.get("Key")]
|
||||
return acdl_keys_in(tag_keys, legacy_keys=legacy_keys)
|
||||
|
||||
|
||||
def list_tagged_resources(client, legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[dict]:
|
||||
"""List all resources carrying any legacy ``acdl:*`` tag key.
|
||||
|
||||
Uses ``resourcegroupstaggingapi:GetResources`` with a TagFilter per legacy
|
||||
key (the API filters are OR'd across the TagFilter list). Pagination
|
||||
handled via the built-in paginator. Returns a list of resource blobs
|
||||
``{"ResourceARN": ..., "Tags": [...]}``.
|
||||
"""
|
||||
tag_filters = [{"Key": k} for k in legacy_keys]
|
||||
resources: list[dict] = []
|
||||
paginator = client.get_paginator("get_resources")
|
||||
for page in paginator.paginate(TagFilters=tag_filters):
|
||||
for r in page.get("ResourceMappingList", []):
|
||||
resources.append(r)
|
||||
return resources
|
||||
|
||||
|
||||
def untag_one(client, resource_arn: str, keys: list[str]) -> None:
|
||||
"""Remove the given tag keys from a single resource."""
|
||||
client.untag_resources(ResourceARNList=[resource_arn], TagKeys=keys)
|
||||
|
||||
|
||||
def run(
|
||||
legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS,
|
||||
region: Optional[str] = None,
|
||||
apply: bool = False,
|
||||
client=None,
|
||||
) -> dict:
|
||||
"""Run the untag pass. Returns a summary dict.
|
||||
|
||||
When ``apply`` is False (default, dry-run), no AWS mutations happen —
|
||||
the function lists resources carrying acdl:* keys and reports the keys
|
||||
it would remove. When ``apply`` is True, it calls ``UntagResources`` per
|
||||
resource.
|
||||
|
||||
A pre-built boto3 resourcegroupstaggingapi ``client`` may be injected
|
||||
for testing.
|
||||
"""
|
||||
if apply and client is None:
|
||||
if boto3 is None:
|
||||
raise RuntimeError("boto3 is required for --apply (live AWS)")
|
||||
client = boto3.client("resourcegroupstaggingapi", region_name=region) if region else boto3.client("resourcegroupstaggingapi")
|
||||
if client is None and apply:
|
||||
raise RuntimeError("boto3 resourcegroupstaggingapi client required for --apply")
|
||||
|
||||
summary = {"listed": 0, "untagged": 0, "keys_removed": 0, "errors": 0, "plan": []}
|
||||
legacy_list = list(legacy_keys)
|
||||
|
||||
resources = list_tagged_resources(client, legacy_keys=legacy_list) if apply else _dry_run_list(legacy_list, client)
|
||||
summary["listed"] = len(resources)
|
||||
|
||||
for r in resources:
|
||||
arn = r.get("ResourceARN", "")
|
||||
keys = keys_to_untag(r, legacy_keys=legacy_list)
|
||||
if not keys:
|
||||
continue
|
||||
if not apply:
|
||||
summary["plan"].append({"arn": arn, "keys": keys, "action": "untag"})
|
||||
continue
|
||||
try:
|
||||
untag_one(client, arn, keys)
|
||||
summary["untagged"] += 1
|
||||
summary["keys_removed"] += len(keys)
|
||||
except Exception as e: # pragma: no cover - AWS error path
|
||||
summary["errors"] += 1
|
||||
summary["plan"].append({"arn": arn, "keys": keys, "action": f"error: {e}"})
|
||||
return summary
|
||||
|
||||
|
||||
def _dry_run_list(legacy_keys: list[str], client) -> list[dict]:
|
||||
"""In dry-run, list resources if a client is available; else return []."""
|
||||
if client is None:
|
||||
return []
|
||||
return list_tagged_resources(client, legacy_keys=legacy_keys)
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Remove legacy acdl:* tag keys from all tagged AWS resources (REQ-162, P3)."
|
||||
)
|
||||
parser.add_argument("--key", action="append", default=None,
|
||||
help="Legacy acdl:* key to remove (repeatable; default: all 5 acdl:* keys)")
|
||||
parser.add_argument("--region", default=None, help="AWS region (default: boto3 default)")
|
||||
parser.add_argument("--apply", action="store_true", help="Execute the untag pass (default: dry-run)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
legacy_keys = tuple(args.key) if args.key else DEFAULT_LEGACY_KEYS
|
||||
mode = "APPLY" if args.apply else "DRY-RUN"
|
||||
print(f"[untag_acdl_keys] {mode}: removing keys {list(legacy_keys)} (region={args.region or 'default'})")
|
||||
summary = run(
|
||||
legacy_keys=legacy_keys,
|
||||
region=args.region,
|
||||
apply=args.apply,
|
||||
)
|
||||
print(f"[untag_acdl_keys] listed={summary['listed']} untagged={summary['untagged']} "
|
||||
f"keys_removed={summary['keys_removed']} errors={summary['errors']}")
|
||||
if not args.apply and summary["listed"] == 0:
|
||||
print("[untag_acdl_keys] (dry-run with no live AWS client: 0 resources listed; "
|
||||
"key-list logic is unit-tested in tests/test_untag_acdl_keys.py)")
|
||||
return 0 if summary["errors"] == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -33,8 +33,8 @@ resource "aws_vpc" "ci" {
|
||||
cidr_block = "10.1.0.0/16"
|
||||
tags = {
|
||||
Name = "acdl-ci-vpc"
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:environment" = "ci"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:environment" = "ci"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ resource "aws_subnet" "ci" {
|
||||
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||
tags = {
|
||||
Name = "acdl-ci-subnet-${count.index}"
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:environment" = "ci"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:environment" = "ci"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ flow:
|
||||
applies [`consumer_invoke_policy.json`](./consumer_invoke_policy.json)
|
||||
to the consumer's deploy role. The policy grants
|
||||
`lambda:InvokeFunctionUrl` on the Lambda ARN, scoped via ABAC — the
|
||||
condition `aws:PrincipalTag/acdl:owner == ${consumerRepo}` ensures a
|
||||
condition `aws:PrincipalTag/nova:owner == ${consumerRepo}` ensures a
|
||||
repo can only invoke when it is the owner it claims to be.
|
||||
2. **Runtime.** The consumer's deploy workflow (running in the consumer
|
||||
AWS account under the consumer's deploy role) signs the Function URL
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"Resource": "arn:aws:lambda:${region}:${account_id}:function:acdl-contract-ingestor",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:PrincipalTag/acdl:owner": "${consumerRepo}"
|
||||
"aws:PrincipalTag/nova:owner": "${consumerRepo}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-40
@@ -75,10 +75,10 @@ resource "aws_dynamodb_table" "acdl_contracts" {
|
||||
}
|
||||
|
||||
tags = {
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "prod"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "prod"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,10 @@ resource "aws_secretsmanager_secret" "github_token" {
|
||||
kms_key_id = aws_kms_key.acdl_platform.arn
|
||||
|
||||
tags = {
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "prod"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "prod"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,10 +168,10 @@ resource "aws_lambda_function" "contract_ingestor" {
|
||||
}
|
||||
|
||||
tags = {
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "prod"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "prod"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +230,10 @@ resource "aws_dynamodb_table" "acdl_change_requests" {
|
||||
}
|
||||
|
||||
tags = {
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "prod"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "prod"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
# REQ-107: SNS topic for separation-of-duties halt artifacts.
|
||||
@@ -242,10 +242,10 @@ resource "aws_sns_topic" "acdl_sod_halt" {
|
||||
name = "acdl-sod-halt"
|
||||
kms_master_key_id = aws_kms_key.acdl_platform.id
|
||||
tags = {
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "prod"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "prod"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,10 +262,10 @@ resource "aws_vpc" "acdl_shared" {
|
||||
cidr_block = var.vpc_cidr
|
||||
tags = {
|
||||
Name = "acdl-shared"
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "shared"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "shared"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,10 +276,10 @@ resource "aws_subnet" "acdl_shared" {
|
||||
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||
tags = {
|
||||
Name = "acdl-shared-subnet-${count.index}"
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "shared"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "shared"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,10 +291,10 @@ resource "aws_internet_gateway" "acdl_shared" {
|
||||
vpc_id = aws_vpc.acdl_shared.id
|
||||
tags = {
|
||||
Name = "acdl-shared-igw"
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "shared"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "shared"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,10 +306,10 @@ resource "aws_route_table" "acdl_shared" {
|
||||
}
|
||||
tags = {
|
||||
Name = "acdl-shared-rt"
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "shared"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "shared"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,10 +344,10 @@ resource "aws_security_group" "ecs" {
|
||||
|
||||
tags = {
|
||||
Name = "acdl-ecs-sg"
|
||||
"acdl:owner" = "acdl"
|
||||
"acdl:contract" = "platform"
|
||||
"acdl:environment" = "shared"
|
||||
"acdl:cost-center" = "acdl-default"
|
||||
"nova:owner" = "acdl"
|
||||
"nova:contract" = "platform"
|
||||
"nova:environment" = "shared"
|
||||
"nova:cost-center" = "nova-default"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
"policy": "require-resource-labels",
|
||||
"severity": "medium",
|
||||
"result": "fail",
|
||||
"message": "Pod missing required label acdl:owner.",
|
||||
"message": "Pod missing required label nova:owner.",
|
||||
"resource": "default/Pod/acdl-bad-app",
|
||||
"namespace": "default",
|
||||
"kind": "Pod",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for scripts/migrate_ssm_paths.py path-mapping logic (REQ-161, P3).
|
||||
|
||||
Tests the pure ``map_path()`` function (the AWS I/O glue is thin boto3 around
|
||||
it). The script does not need live AWS to be importable.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
from migrate_ssm_paths import map_path # noqa: E402
|
||||
|
||||
|
||||
class TestMapPath:
|
||||
def test_basic_dev_path(self):
|
||||
assert map_path("/acdl/dev/svc-x/output") == "/nova/dev/svc-x/output"
|
||||
|
||||
def test_basic_contract_path(self):
|
||||
assert map_path("/acdl/dev/c-1/vpc_id") == "/nova/dev/c-1/vpc_id"
|
||||
|
||||
def test_qa_env(self):
|
||||
assert map_path("/acdl/qa/c-2/db_endpoint") == "/nova/qa/c-2/db_endpoint"
|
||||
|
||||
def test_prod_env(self):
|
||||
assert map_path("/acdl/prod/c-3/distribution_domain_name") == "/nova/prod/c-3/distribution_domain_name"
|
||||
|
||||
def test_dr_env(self):
|
||||
assert map_path("/acdl/dr/c-4/bucket_arn") == "/nova/dr/c-4/bucket_arn"
|
||||
|
||||
def test_deep_nested_path(self):
|
||||
assert map_path("/acdl/dev/contract-001/nested/deep/output") == "/nova/dev/contract-001/nested/deep/output"
|
||||
|
||||
def test_preserves_trailing_segment(self):
|
||||
# The output name segment is preserved verbatim
|
||||
assert map_path("/acdl/dev/c/secret_token") == "/nova/dev/c/secret_token"
|
||||
|
||||
def test_custom_prefixes(self):
|
||||
assert map_path("/acdl/dev/c/x", "/acdl", "/nova") == "/nova/dev/c/x"
|
||||
assert map_path("/old/dev/c/x", "/old", "/new") == "/new/dev/c/x"
|
||||
|
||||
def test_raises_on_nonmatching_path(self):
|
||||
with pytest.raises(ValueError, match="does not start with source prefix"):
|
||||
map_path("/nova/dev/c/output")
|
||||
|
||||
def test_raises_on_path_not_segment_prefixed(self):
|
||||
# /acdl-platform is NOT a path-segment match for /acdl (no trailing /)
|
||||
with pytest.raises(ValueError, match="does not start with source prefix"):
|
||||
map_path("/acdl-platform-key")
|
||||
|
||||
def test_raises_on_empty_path(self):
|
||||
with pytest.raises(ValueError):
|
||||
map_path("")
|
||||
|
||||
def test_raises_on_just_prefix(self):
|
||||
# Exactly /acdl (no trailing slash) is not a valid parameter path
|
||||
with pytest.raises(ValueError):
|
||||
map_path("/acdl")
|
||||
|
||||
def test_round_trip_identity(self):
|
||||
# map_path is its own inverse when source/dest are swapped
|
||||
src = "/acdl/dev/svc-x/output"
|
||||
mapped = map_path(src, "/acdl", "/nova")
|
||||
back = map_path(mapped, "/nova", "/acdl")
|
||||
assert back == src
|
||||
|
||||
def test_idempotent_on_already_migrated(self):
|
||||
# If somehow a /nova/ path is passed with default args, it raises
|
||||
# (the script filters by source prefix before mapping)
|
||||
with pytest.raises(ValueError):
|
||||
map_path("/nova/dev/c/output")
|
||||
|
||||
def test_preserves_value_segment_exactly(self):
|
||||
# Hyphens, dots, underscores in output names are preserved
|
||||
assert map_path("/acdl/dev/c-1/my.output-name_2") == "/nova/dev/c-1/my.output-name_2"
|
||||
@@ -50,12 +50,12 @@ class TestPublishToSsm:
|
||||
outputs = {"bucket_name": "acdl-spike-bucket", "secret_token": "s3cret"}
|
||||
results = publish_to_ssm(outputs, "dev", "contract-001")
|
||||
|
||||
assert results["bucket_name"] == "/acdl/dev/contract-001/bucket_name"
|
||||
assert results["secret_token"] == "/acdl/dev/contract-001/secret_token"
|
||||
assert results["bucket_name"] == "/nova/dev/contract-001/bucket_name"
|
||||
assert results["secret_token"] == "/nova/dev/contract-001/secret_token"
|
||||
|
||||
# Verify the parameter landed in SSM correctly
|
||||
param = ssm.get_parameter(
|
||||
Name="/acdl/dev/contract-001/bucket_name", WithDecryption=True
|
||||
Name="/nova/dev/contract-001/bucket_name", WithDecryption=True
|
||||
)
|
||||
assert param["Parameter"]["Type"] == "SecureString"
|
||||
assert param["Parameter"]["Value"] == "acdl-spike-bucket"
|
||||
@@ -72,7 +72,7 @@ class TestPublishToSsm:
|
||||
with mock_aws():
|
||||
ssm = boto3.client("ssm", region_name="us-east-1")
|
||||
publish_to_ssm({"vpc_id": "vpc-123"}, "dev", "c-1")
|
||||
param = ssm.get_parameter(Name="/acdl/dev/c-1/vpc_id", WithDecryption=True)
|
||||
param = ssm.get_parameter(Name="/nova/dev/c-1/vpc_id", WithDecryption=True)
|
||||
assert param["Parameter"]["Type"] == "SecureString"
|
||||
|
||||
def test_publish_skips_none_and_empty_values(self, monkeypatch):
|
||||
@@ -112,7 +112,7 @@ class TestPublishToSsm:
|
||||
publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
|
||||
# Second publish with a new value should overwrite, not error
|
||||
publish_to_ssm({"vpc_id": "vpc-2"}, "dev", "c-1")
|
||||
param = ssm.get_parameter(Name="/acdl/dev/c-1/vpc_id", WithDecryption=True)
|
||||
param = ssm.get_parameter(Name="/nova/dev/c-1/vpc_id", WithDecryption=True)
|
||||
assert param["Parameter"]["Value"] == "vpc-2"
|
||||
|
||||
def test_publish_continues_on_single_failure(self, monkeypatch):
|
||||
@@ -142,7 +142,7 @@ class TestPublishToSsm:
|
||||
results = publish_to_ssm(
|
||||
{"good": "val", "bad": "val"}, "dev", "c-1"
|
||||
)
|
||||
assert results["good"] == "/acdl/dev/c-1/good"
|
||||
assert results["good"] == "/nova/dev/c-1/good"
|
||||
assert results["bad"] is None
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ class TestFormatComment:
|
||||
comment = format_comment(outputs, "dev", "contract-001")
|
||||
assert "acdl-spike-bucket" in comment
|
||||
assert "vpc-abc123" in comment
|
||||
assert "### ACDL Deploy Outputs (dev)" in comment
|
||||
assert "### Nova Deploy Outputs (dev)" in comment
|
||||
assert "`contract-001`" in comment
|
||||
|
||||
def test_sensitive_outputs_show_published_to_ssm(self):
|
||||
@@ -175,12 +175,12 @@ class TestFormatComment:
|
||||
def test_ssm_path_included_when_results_provided(self):
|
||||
outputs = {"bucket_name": "my-bucket", "secret_token": "s3cret"}
|
||||
ssm_results = {
|
||||
"bucket_name": "/acdl/dev/contract-001/bucket_name",
|
||||
"secret_token": "/acdl/dev/contract-001/secret_token",
|
||||
"bucket_name": "/nova/dev/contract-001/bucket_name",
|
||||
"secret_token": "/nova/dev/contract-001/secret_token",
|
||||
}
|
||||
comment = format_comment(outputs, "dev", "contract-001", ssm_results)
|
||||
assert "/acdl/dev/contract-001/bucket_name" in comment
|
||||
assert "/acdl/dev/contract-001/secret_token" in comment
|
||||
assert "/nova/dev/contract-001/bucket_name" in comment
|
||||
assert "/nova/dev/contract-001/secret_token" in comment
|
||||
|
||||
def test_dash_shown_when_ssm_results_provided_but_missing(self):
|
||||
outputs = {"bucket_name": "my-bucket"}
|
||||
@@ -193,12 +193,12 @@ class TestFormatComment:
|
||||
outputs = {"bucket_name": "my-bucket"}
|
||||
comment = format_comment(outputs, "dev", "contract-001", ssm_results=None)
|
||||
# No SSM column content when ssm_results is None
|
||||
assert "/acdl/" not in comment or "get-parameter" in comment # only footer
|
||||
assert "/nova/" not in comment or "get-parameter" in comment # only footer
|
||||
|
||||
def test_ssm_footer_contains_correct_path(self):
|
||||
outputs = {"bucket_name": "b"}
|
||||
comment = format_comment(outputs, "dev", "contract-001")
|
||||
assert "/acdl/dev/contract-001/<output_name>" in comment
|
||||
assert "/nova/dev/contract-001/<output_name>" in comment
|
||||
|
||||
def test_skips_none_and_empty_values(self):
|
||||
outputs = {"real": "val", "none_val": None, "empty": ""}
|
||||
@@ -355,7 +355,7 @@ class TestCli:
|
||||
output = captured.getvalue()
|
||||
assert "cli-bucket" in output
|
||||
assert "vpc-1" in output
|
||||
assert "### ACDL Deploy Outputs (dev)" in output
|
||||
assert "### Nova Deploy Outputs (dev)" in output
|
||||
finally:
|
||||
op.boto3 = saved_boto3
|
||||
sys.argv = old_argv
|
||||
@@ -411,8 +411,8 @@ class TestKmsFailLoud:
|
||||
with mock_aws():
|
||||
ssm = boto3.client("ssm", region_name="us-east-1")
|
||||
results = publish_to_ssm({"vpc_id": "vpc-1"}, "dev", "c-1")
|
||||
assert results["vpc_id"] == "/acdl/dev/c-1/vpc_id"
|
||||
param = ssm.get_parameter(Name="/acdl/dev/c-1/vpc_id", WithDecryption=True)
|
||||
assert results["vpc_id"] == "/nova/dev/c-1/vpc_id"
|
||||
param = ssm.get_parameter(Name="/nova/dev/c-1/vpc_id", WithDecryption=True)
|
||||
assert param["Parameter"]["Type"] == "SecureString"
|
||||
|
||||
def test_kms_set_takes_precedence_over_allow_default(self, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Unit tests for scripts/untag_acdl_keys.py key-list logic (REQ-162, P3).
|
||||
|
||||
Tests the pure ``acdl_keys_in()`` + ``keys_to_untag()`` functions (the AWS
|
||||
I/O glue is thin boto3 around them). The script does not need live AWS to
|
||||
be importable.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
from untag_acdl_keys import acdl_keys_in, keys_to_untag, DEFAULT_LEGACY_KEYS # noqa: E402
|
||||
|
||||
|
||||
class TestAcdlKeysIn:
|
||||
def test_empty(self):
|
||||
assert acdl_keys_in([]) == []
|
||||
|
||||
def test_no_acdl_keys(self):
|
||||
assert acdl_keys_in(["nova:owner", "nova:contract", "Name"]) == []
|
||||
|
||||
def test_all_acdl_keys(self):
|
||||
keys = ["acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center", "acdl:ref"]
|
||||
assert acdl_keys_in(keys) == list(keys)
|
||||
|
||||
def test_mixed_keys(self):
|
||||
keys = ["acdl:owner", "nova:owner", "Name", "acdl:cost-center"]
|
||||
assert acdl_keys_in(keys) == ["acdl:owner", "acdl:cost-center"]
|
||||
|
||||
def test_preserves_input_order(self):
|
||||
keys = ["acdl:ref", "nova:owner", "acdl:owner", "acdl:contract"]
|
||||
assert acdl_keys_in(keys) == ["acdl:ref", "acdl:owner", "acdl:contract"]
|
||||
|
||||
def test_custom_legacy_set(self):
|
||||
# Only removing acdl:owner + acdl:ref (subset)
|
||||
legacy = ("acdl:owner", "acdl:ref")
|
||||
keys = ["acdl:owner", "acdl:contract", "acdl:ref", "nova:owner"]
|
||||
assert acdl_keys_in(keys, legacy_keys=legacy) == ["acdl:owner", "acdl:ref"]
|
||||
|
||||
def test_default_legacy_keys_all_5(self):
|
||||
assert len(DEFAULT_LEGACY_KEYS) == 5
|
||||
assert "acdl:owner" in DEFAULT_LEGACY_KEYS
|
||||
assert "acdl:contract" in DEFAULT_LEGACY_KEYS
|
||||
assert "acdl:environment" in DEFAULT_LEGACY_KEYS
|
||||
assert "acdl:cost-center" in DEFAULT_LEGACY_KEYS
|
||||
assert "acdl:ref" in DEFAULT_LEGACY_KEYS
|
||||
|
||||
def test_duplicates_not_duplicated_in_output(self):
|
||||
# List comprehension preserves duplicates in input; the API dedups via set
|
||||
# but the function is a faithful list filter. Duplicates are unusual but
|
||||
# the function does not dedup (the UntagResources API tolerates the same
|
||||
# key once; real GetResources never returns duplicate keys).
|
||||
keys = ["acdl:owner", "acdl:owner"]
|
||||
assert acdl_keys_in(keys) == ["acdl:owner", "acdl:owner"]
|
||||
|
||||
|
||||
class TestKeysToUntag:
|
||||
def test_empty_tags(self):
|
||||
assert keys_to_untag({"ResourceARN": "arn:...", "Tags": []}) == []
|
||||
|
||||
def test_no_tags_key(self):
|
||||
assert keys_to_untag({"ResourceARN": "arn:..."}) == []
|
||||
|
||||
def test_with_acdl_keys(self):
|
||||
resource = {
|
||||
"ResourceARN": "arn:aws:s3:::my-bucket",
|
||||
"Tags": [
|
||||
{"Key": "acdl:owner", "Value": "acdl"},
|
||||
{"Key": "nova:owner", "Value": "acdl"},
|
||||
{"Key": "acdl:cost-center", "Value": "acdl-default"},
|
||||
{"Key": "Name", "Value": "my-bucket"},
|
||||
],
|
||||
}
|
||||
assert keys_to_untag(resource) == ["acdl:owner", "acdl:cost-center"]
|
||||
|
||||
def test_with_only_nova_keys(self):
|
||||
resource = {
|
||||
"ResourceARN": "arn:aws:s3:::my-bucket",
|
||||
"Tags": [
|
||||
{"Key": "nova:owner", "Value": "acdl"},
|
||||
{"Key": "nova:contract", "Value": "platform"},
|
||||
{"Key": "Name", "Value": "my-bucket"},
|
||||
],
|
||||
}
|
||||
assert keys_to_untag(resource) == []
|
||||
|
||||
def test_all_5_acdl_keys(self):
|
||||
resource = {
|
||||
"ResourceARN": "arn:aws:ecs:us-east-1:123:cluster/x",
|
||||
"Tags": [
|
||||
{"Key": "acdl:owner", "Value": "v"},
|
||||
{"Key": "acdl:contract", "Value": "v"},
|
||||
{"Key": "acdl:environment", "Value": "v"},
|
||||
{"Key": "acdl:cost-center", "Value": "v"},
|
||||
{"Key": "acdl:ref", "Value": "v"},
|
||||
],
|
||||
}
|
||||
result = keys_to_untag(resource)
|
||||
assert result == ["acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center", "acdl:ref"]
|
||||
|
||||
def test_custom_legacy_keys(self):
|
||||
resource = {
|
||||
"ResourceARN": "arn:...",
|
||||
"Tags": [
|
||||
{"Key": "acdl:owner", "Value": "v"},
|
||||
{"Key": "acdl:contract", "Value": "v"},
|
||||
],
|
||||
}
|
||||
# Only targeting acdl:owner
|
||||
assert keys_to_untag(resource, legacy_keys=("acdl:owner",)) == ["acdl:owner"]
|
||||
|
||||
def test_malformed_tag_entry_skipped(self):
|
||||
# A tag entry without a Key is skipped gracefully
|
||||
resource = {
|
||||
"ResourceARN": "arn:...",
|
||||
"Tags": [
|
||||
{"Value": "no-key"},
|
||||
{"Key": "acdl:owner", "Value": "v"},
|
||||
"not-a-dict",
|
||||
],
|
||||
}
|
||||
assert keys_to_untag(resource) == ["acdl:owner"]
|
||||
|
||||
|
||||
class TestRunDryRunNoClient:
|
||||
def test_dry_run_returns_empty_summary_without_client(self):
|
||||
from untag_acdl_keys import run
|
||||
summary = run(apply=False, client=None)
|
||||
assert summary["listed"] == 0
|
||||
assert summary["untagged"] == 0
|
||||
assert summary["keys_removed"] == 0
|
||||
assert summary["errors"] == 0
|
||||
|
||||
def test_dry_run_apply_false_no_mutation(self):
|
||||
# apply=False with a client still only lists (no untag)
|
||||
from untag_acdl_keys import run
|
||||
|
||||
class FakeClient:
|
||||
def get_paginator(self, name):
|
||||
class P:
|
||||
def paginate(self, **kw):
|
||||
return iter([{"ResourceMappingList": []}])
|
||||
return P()
|
||||
|
||||
summary = run(apply=False, client=FakeClient())
|
||||
assert summary["listed"] == 0
|
||||
assert summary["untagged"] == 0
|
||||
Reference in New Issue
Block a user