From 51c3edf458127c5ec34b505bf4ed3dbbc04c83ee Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Thu, 30 Jul 2026 01:38:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(P3):=20Nova=20rebrand=20=E2=80=94=20SSM=20?= =?UTF-8?q?path=20+=20tag=20keys=20(REQ-161/162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--- --- .ciagent/REGRESSION_REPORT.json | 66 ++--- .ciagent/REGRESSION_REPORT.md | 54 ++-- adapters/terraform/policy/checkov_adapter.py | 12 +- .../policy/custom_rules/nova_tagging.py | 29 +- core/output_publisher.py | 12 +- docs/NOVA_MIGRATION.md | 18 +- modules/l1/s3/terraform/locals.tf | 4 +- schemas/tagging-standard.json | 22 +- scripts/migrate_ssm_paths.py | 247 ++++++++++++++++++ scripts/untag_acdl_keys.py | 195 ++++++++++++++ terraform/ci-vpc/main.tf | 8 +- terraform/platform/README.md | 2 +- .../platform/consumer_invoke_policy.json | 2 +- terraform/platform/main.tf | 80 +++--- tests/fixtures/kyverno_policyreport.json | 2 +- tests/test_migrate_ssm_paths.py | 77 ++++++ tests/test_output_publisher.py | 32 +-- tests/test_untag_acdl_keys.py | 150 +++++++++++ 18 files changed, 848 insertions(+), 164 deletions(-) create mode 100644 scripts/migrate_ssm_paths.py create mode 100644 scripts/untag_acdl_keys.py create mode 100644 tests/test_migrate_ssm_paths.py create mode 100644 tests/test_untag_acdl_keys.py diff --git a/.ciagent/REGRESSION_REPORT.json b/.ciagent/REGRESSION_REPORT.json index e70f59d..7271fbc 100644 --- a/.ciagent/REGRESSION_REPORT.json +++ b/.ciagent/REGRESSION_REPORT.json @@ -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 } ] } \ No newline at end of file diff --git a/.ciagent/REGRESSION_REPORT.md b/.ciagent/REGRESSION_REPORT.md index 7081f2e..d1c2068 100644 --- a/.ciagent/REGRESSION_REPORT.md +++ b/.ciagent/REGRESSION_REPORT.md @@ -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 | diff --git a/adapters/terraform/policy/checkov_adapter.py b/adapters/terraform/policy/checkov_adapter.py index b39982c..ac01a03 100644 --- a/adapters/terraform/policy/checkov_adapter.py +++ b/adapters/terraform/policy/checkov_adapter.py @@ -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"), } diff --git a/adapters/terraform/policy/custom_rules/nova_tagging.py b/adapters/terraform/policy/custom_rules/nova_tagging.py index 04cbb37..03e4ac5 100644 --- a/adapters/terraform/policy/custom_rules/nova_tagging.py +++ b/adapters/terraform/policy/custom_rules/nova_tagging.py @@ -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): diff --git a/core/output_publisher.py b/core/output_publisher.py index 9faa6fe..bee3420 100644 --- a/core/output_publisher.py +++ b/core/output_publisher.py @@ -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//... +The namespace is /nova/{environment}/{contractId}/{output_name} so consumers +can query their own outputs via aws ssm get-parameter --name /nova/dev//... +(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 + "/` (KMS-encrypted SecureString).") + lines.append("> Sensitive outputs are available via `aws ssm get-parameter --name /nova/" + environment + "/" + contract_id + "/` (KMS-encrypted SecureString).") return "\n".join(lines) diff --git a/docs/NOVA_MIGRATION.md b/docs/NOVA_MIGRATION.md index 9f8dd4f..ef9c586 100644 --- a/docs/NOVA_MIGRATION.md +++ b/docs/NOVA_MIGRATION.md @@ -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** diff --git a/modules/l1/s3/terraform/locals.tf b/modules/l1/s3/terraform/locals.tf index 12e626e..efce04e 100644 --- a/modules/l1/s3/terraform/locals.tf +++ b/modules/l1/s3/terraform/locals.tf @@ -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 ) diff --git a/schemas/tagging-standard.json b/schemas/tagging-standard.json index 3c6a9da..cc01119 100644 --- a/schemas/tagging-standard.json +++ b/schemas/tagging-standard.json @@ -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" } } } diff --git a/scripts/migrate_ssm_paths.py b/scripts/migrate_ssm_paths.py new file mode 100644 index 0000000..8e999df --- /dev/null +++ b/scripts/migrate_ssm_paths.py @@ -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()) \ No newline at end of file diff --git a/scripts/untag_acdl_keys.py b/scripts/untag_acdl_keys.py new file mode 100644 index 0000000..3f4dfce --- /dev/null +++ b/scripts/untag_acdl_keys.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Remove legacy `acdl:*` tag keys from all tagged AWS resources (REQ-162, P3). + +The Nova rebrand (v1.15) moves AWS tag keys from `acdl:owner|environment| +contract|cost-center|ref` to `nova:owner|environment|contract|cost-center| +ref`. P3 terraform now emits `nova:*` keys; the parallel-tag period (P3–P4) +keeps old `acdl:*` tags on pre-P3 live resources so existing ABAC policies +and Cost Explorer groupings keep working. Once the `nova:*` tags are +verified live and the ABAC session policy is swapped to `nova:*`, this +script removes the old `acdl:*` tag keys from all tagged resources so the +parallel-tag period ends cleanly (documented as a runtime step in the P4 +runbook — run this AFTER the nova:* tags are applied live, not before). + +Design: + - **Dry-run by default.** Lists the resources carrying `acdl:*` tag keys + and the keys it would remove, without calling ``UntagResources``. Pass + ``--apply`` to execute. + - **Idempotent.** Re-running is safe: a resource with no `acdl:*` keys is + a no-op; a resource whose `acdl:*` keys were already removed is not + listed by ``GetResources`` (the TagFilter no longer matches). + - **Key-list logic is unit-tested** (see ``tests/test_untag_acdl_keys.py``); + the AWS I/O is thin boto3 glue around ``acdl_keys_in()`` + + ``keys_to_untag()``. + +Usage: + python3 scripts/untag_acdl_keys.py # dry-run (all acdl:* keys) + python3 scripts/untag_acdl_keys.py --apply # execute + python3 scripts/untag_acdl_keys.py --region us-east-1 --apply + python3 scripts/untag_acdl_keys.py --key acdl:owner --key acdl:ref --apply + +This script does NOT need live AWS to be importable; the boto3 client is +constructed lazily inside ``run()`` so the module can be imported + the +key-list logic unit-tested without credentials. +""" + +from __future__ import annotations + +import argparse +import sys +from typing import Iterable, Optional + +try: + import boto3 +except ImportError: # pragma: no cover - boto3 is a test dep + boto3 = None # type: ignore + + +# The full legacy tag-key set (mirrors nova_tagging.py LEGACY_TAGS + acdl:ref). +DEFAULT_LEGACY_KEYS = ( + "acdl:owner", + "acdl:contract", + "acdl:environment", + "acdl:cost-center", + "acdl:ref", +) + + +# --------------------------------------------------------------------------- +# Key-list logic (pure, unit-tested) +# --------------------------------------------------------------------------- + +def acdl_keys_in(tag_keys: Iterable[str], legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[str]: + """Return the subset of ``tag_keys`` that are legacy ``acdl:*`` keys. + + Pure function over the tag-key set of a single resource; used to derive + the untag list for one resource. Order is preserved (input order). + + Examples: + >>> acdl_keys_in(["acdl:owner", "nova:owner", "Name", "acdl:cost-center"]) + ['acdl:owner', 'acdl:cost-center'] + >>> acdl_keys_in(["nova:owner", "nova:contract", "Name"]) + [] + >>> acdl_keys_in([]) + [] + """ + legacy_set = set(legacy_keys) + return [k for k in tag_keys if k in legacy_set] + + +def keys_to_untag(resource: dict, legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[str]: + """Extract the acdl:* keys to untag from a resourcegroupstaggingapi resource blob. + + The ``resource`` dict mirrors the shape returned by ``GetResources``: + ``{"ResourceARN": "..., "Tags": [{"Key": "...", "Value": "..."}, ...]}``. + Returns the list of legacy acdl:* keys present on that resource. + """ + tags = resource.get("Tags", []) or [] + tag_keys = [t.get("Key") for t in tags if isinstance(t, dict) and t.get("Key")] + return acdl_keys_in(tag_keys, legacy_keys=legacy_keys) + + +def list_tagged_resources(client, legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS) -> list[dict]: + """List all resources carrying any legacy ``acdl:*`` tag key. + + Uses ``resourcegroupstaggingapi:GetResources`` with a TagFilter per legacy + key (the API filters are OR'd across the TagFilter list). Pagination + handled via the built-in paginator. Returns a list of resource blobs + ``{"ResourceARN": ..., "Tags": [...]}``. + """ + tag_filters = [{"Key": k} for k in legacy_keys] + resources: list[dict] = [] + paginator = client.get_paginator("get_resources") + for page in paginator.paginate(TagFilters=tag_filters): + for r in page.get("ResourceMappingList", []): + resources.append(r) + return resources + + +def untag_one(client, resource_arn: str, keys: list[str]) -> None: + """Remove the given tag keys from a single resource.""" + client.untag_resources(ResourceARNList=[resource_arn], TagKeys=keys) + + +def run( + legacy_keys: Iterable[str] = DEFAULT_LEGACY_KEYS, + region: Optional[str] = None, + apply: bool = False, + client=None, +) -> dict: + """Run the untag pass. Returns a summary dict. + + When ``apply`` is False (default, dry-run), no AWS mutations happen — + the function lists resources carrying acdl:* keys and reports the keys + it would remove. When ``apply`` is True, it calls ``UntagResources`` per + resource. + + A pre-built boto3 resourcegroupstaggingapi ``client`` may be injected + for testing. + """ + if apply and client is None: + if boto3 is None: + raise RuntimeError("boto3 is required for --apply (live AWS)") + client = boto3.client("resourcegroupstaggingapi", region_name=region) if region else boto3.client("resourcegroupstaggingapi") + if client is None and apply: + raise RuntimeError("boto3 resourcegroupstaggingapi client required for --apply") + + summary = {"listed": 0, "untagged": 0, "keys_removed": 0, "errors": 0, "plan": []} + legacy_list = list(legacy_keys) + + resources = list_tagged_resources(client, legacy_keys=legacy_list) if apply else _dry_run_list(legacy_list, client) + summary["listed"] = len(resources) + + for r in resources: + arn = r.get("ResourceARN", "") + keys = keys_to_untag(r, legacy_keys=legacy_list) + if not keys: + continue + if not apply: + summary["plan"].append({"arn": arn, "keys": keys, "action": "untag"}) + continue + try: + untag_one(client, arn, keys) + summary["untagged"] += 1 + summary["keys_removed"] += len(keys) + except Exception as e: # pragma: no cover - AWS error path + summary["errors"] += 1 + summary["plan"].append({"arn": arn, "keys": keys, "action": f"error: {e}"}) + return summary + + +def _dry_run_list(legacy_keys: list[str], client) -> list[dict]: + """In dry-run, list resources if a client is available; else return [].""" + if client is None: + return [] + return list_tagged_resources(client, legacy_keys=legacy_keys) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Remove legacy acdl:* tag keys from all tagged AWS resources (REQ-162, P3)." + ) + parser.add_argument("--key", action="append", default=None, + help="Legacy acdl:* key to remove (repeatable; default: all 5 acdl:* keys)") + parser.add_argument("--region", default=None, help="AWS region (default: boto3 default)") + parser.add_argument("--apply", action="store_true", help="Execute the untag pass (default: dry-run)") + args = parser.parse_args(argv) + + legacy_keys = tuple(args.key) if args.key else DEFAULT_LEGACY_KEYS + mode = "APPLY" if args.apply else "DRY-RUN" + print(f"[untag_acdl_keys] {mode}: removing keys {list(legacy_keys)} (region={args.region or 'default'})") + summary = run( + legacy_keys=legacy_keys, + region=args.region, + apply=args.apply, + ) + print(f"[untag_acdl_keys] listed={summary['listed']} untagged={summary['untagged']} " + f"keys_removed={summary['keys_removed']} errors={summary['errors']}") + if not args.apply and summary["listed"] == 0: + print("[untag_acdl_keys] (dry-run with no live AWS client: 0 resources listed; " + "key-list logic is unit-tested in tests/test_untag_acdl_keys.py)") + return 0 if summary["errors"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/terraform/ci-vpc/main.tf b/terraform/ci-vpc/main.tf index d8975f5..4d6ebdd 100644 --- a/terraform/ci-vpc/main.tf +++ b/terraform/ci-vpc/main.tf @@ -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" } } diff --git a/terraform/platform/README.md b/terraform/platform/README.md index f49dd22..1a554d4 100644 --- a/terraform/platform/README.md +++ b/terraform/platform/README.md @@ -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 diff --git a/terraform/platform/consumer_invoke_policy.json b/terraform/platform/consumer_invoke_policy.json index 5ba4afe..c3e84fb 100644 --- a/terraform/platform/consumer_invoke_policy.json +++ b/terraform/platform/consumer_invoke_policy.json @@ -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}" } } } diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf index e807a4b..50c5668 100644 --- a/terraform/platform/main.tf +++ b/terraform/platform/main.tf @@ -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" } } diff --git a/tests/fixtures/kyverno_policyreport.json b/tests/fixtures/kyverno_policyreport.json index 0d6b413..5f628b0 100644 --- a/tests/fixtures/kyverno_policyreport.json +++ b/tests/fixtures/kyverno_policyreport.json @@ -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", diff --git a/tests/test_migrate_ssm_paths.py b/tests/test_migrate_ssm_paths.py new file mode 100644 index 0000000..c81f5f3 --- /dev/null +++ b/tests/test_migrate_ssm_paths.py @@ -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" \ No newline at end of file diff --git a/tests/test_output_publisher.py b/tests/test_output_publisher.py index 78e177e..847811e 100644 --- a/tests/test_output_publisher.py +++ b/tests/test_output_publisher.py @@ -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/" in comment + assert "/nova/dev/contract-001/" 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): diff --git a/tests/test_untag_acdl_keys.py b/tests/test_untag_acdl_keys.py new file mode 100644 index 0000000..093d543 --- /dev/null +++ b/tests/test_untag_acdl_keys.py @@ -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 \ No newline at end of file