cfn-stack-updater/cfn_updater/cli.py
Vijaya Manne 632ac9e328 Initial commit: One-Click CloudFormation Stack Updater
- 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)
2026-05-29 14:56:59 -04:00

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())