- Python CLI tool for rolling updates of CL-AppPipe-* and CL-SvcPipe-* stacks - Async update engine with configurable concurrency (asyncio.Semaphore) - Exponential backoff retry for API throttling - Dry-run mode for safe preview - IAM permission pre-validation - Comprehensive test suite (80 tests: 11 property-based + 69 unit) - Full spec documentation (requirements, design, tasks)
131 lines
3.9 KiB
Python
131 lines
3.9 KiB
Python
"""CLI entry point for the CloudFormation Stack Updater."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
import boto3
|
|
|
|
from cfn_updater.config import DEFAULT_CONCURRENCY, DEFAULT_PREFIX, STACK_PROFILES, TEMPLATE_URL
|
|
from cfn_updater.discovery import discover_stacks
|
|
from cfn_updater.permissions import validate_permissions
|
|
from cfn_updater.report import format_report, generate_report, get_exit_code
|
|
from cfn_updater.updater import format_dry_run_output, update_all_stacks
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
"""Parse command-line arguments."""
|
|
parser = argparse.ArgumentParser(
|
|
description="One-click updater for CL-AppPipe-* and CL-SvcPipe-* CloudFormation stacks.",
|
|
)
|
|
parser.add_argument(
|
|
"--prefix",
|
|
type=str,
|
|
default=None,
|
|
help=f"Stack name prefix to match (default: all configured prefixes)",
|
|
)
|
|
parser.add_argument(
|
|
"--concurrency",
|
|
type=int,
|
|
default=DEFAULT_CONCURRENCY,
|
|
help=f"Max parallel updates (default: {DEFAULT_CONCURRENCY})",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
default=False,
|
|
help="Preview mode — discover stacks without performing updates",
|
|
)
|
|
parser.add_argument(
|
|
"--region",
|
|
type=str,
|
|
default=None,
|
|
help="AWS region override (default: SDK default)",
|
|
)
|
|
parser.add_argument(
|
|
"--profile",
|
|
type=str,
|
|
default=None,
|
|
help="AWS profile name to use (e.g. 'audit')",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def _resolve_prefixes(prefix: str | None) -> dict[str, str]:
|
|
"""Return a prefix→template_url mapping based on CLI input.
|
|
|
|
If --prefix is given, look it up in STACK_PROFILES (fall back to the
|
|
default template URL for unknown prefixes). If omitted, return all
|
|
configured profiles.
|
|
"""
|
|
if prefix is not None:
|
|
url = STACK_PROFILES.get(prefix, TEMPLATE_URL)
|
|
return {prefix: url}
|
|
return dict(STACK_PROFILES)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Entry point. Returns 0 on full success, 1 if any stack failed, 2 on permission failure."""
|
|
args = parse_args(argv)
|
|
|
|
# Create boto3 session with optional profile and region
|
|
session_kwargs: dict = {}
|
|
if args.profile:
|
|
session_kwargs["profile_name"] = args.profile
|
|
if args.region:
|
|
session_kwargs["region_name"] = args.region
|
|
session = boto3.Session(**session_kwargs)
|
|
|
|
cfn_client = session.client("cloudformation")
|
|
|
|
# Validate permissions
|
|
missing = validate_permissions(cfn_client)
|
|
if missing:
|
|
print(f"Missing required permissions: {', '.join(missing)}")
|
|
return 2
|
|
|
|
# Resolve which prefixes to process
|
|
prefix_map = _resolve_prefixes(args.prefix)
|
|
|
|
any_failed = False
|
|
|
|
for prefix, template_url in prefix_map.items():
|
|
print(f"\n--- Processing prefix: {prefix} ---")
|
|
print(f" Template: {template_url}")
|
|
|
|
# Discover stacks for this prefix
|
|
stacks = discover_stacks(cfn_client, prefix)
|
|
print(f" Discovered {len(stacks)} stack(s).")
|
|
|
|
if not stacks:
|
|
print(" No stacks found matching the prefix. Skipping.")
|
|
continue
|
|
|
|
# Dry-run mode
|
|
if args.dry_run:
|
|
output = format_dry_run_output(stacks)
|
|
print(output)
|
|
continue
|
|
|
|
# Run updates
|
|
start_time = datetime.now(timezone.utc)
|
|
results = asyncio.run(
|
|
update_all_stacks(cfn_client, stacks, template_url, args.concurrency)
|
|
)
|
|
end_time = datetime.now(timezone.utc)
|
|
|
|
# Generate and print report
|
|
report = generate_report(results, len(stacks), start_time, end_time)
|
|
print(format_report(report))
|
|
|
|
if get_exit_code(report) != 0:
|
|
any_failed = True
|
|
|
|
return 1 if any_failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|