257 lines
11 KiB
Python
257 lines
11 KiB
Python
#!/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 client.exceptions.ParameterNotFound:
|
|
pass # target doesn't exist yet → proceed to put
|
|
except Exception as e:
|
|
# P4 (REQ-168): narrow the broad swallow — only ParameterNotFound
|
|
# is an expected "proceed to put" condition. Any other AWS error
|
|
# (auth, throttling, service) must surface, not be swallowed.
|
|
import sys
|
|
sys.stderr.write(
|
|
f"migrate_ssm_paths: get_parameter({dest_name}) failed: "
|
|
f"{type(e).__name__}: {e}\n"
|
|
)
|
|
raise
|
|
|
|
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()) |