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)
This commit is contained in:
Vijaya Manne 2026-05-29 14:56:59 -04:00
commit 632ac9e328
24 changed files with 3363 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
dist/
build/
.eggs/
# Hypothesis
.hypothesis/
# Pytest
.pytest_cache/
# IDE
.vscode/
.idea/
# Environment
.env
.venv/
venv/

View file

@ -0,0 +1 @@
{"specId": "399202b9-ef41-47e2-9231-0b9fc55e433b", "workflowType": "requirements-first", "specType": "feature"}

View file

@ -0,0 +1,388 @@
# Design Document: One-Click CloudFormation Stack Updater
## Overview
The One-Click CFN Stack Updater is a CLI tool (Python) that automates the rolling update of all `CL-AppPipe-*` CloudFormation stacks in an audit account. It discovers stacks dynamically by prefix, validates IAM permissions, and updates each stack using its existing parameters so the only change is the refreshed nested template URL resolving to the latest version. The tool supports concurrency control, dry-run mode, exponential backoff on throttling, and produces a structured summary report.
The tool is implemented as a single Python package using `boto3` for AWS interactions. Python is chosen because it is the standard language for AWS automation tooling, `boto3` provides first-class CloudFormation support, and the operator audience is already familiar with Python-based AWS scripts.
## Architecture
The system follows a pipeline architecture with four sequential phases:
```mermaid
flowchart LR
A[Permission\nValidation] --> B[Stack\nDiscovery]
B --> C[Stack\nUpdate Engine]
C --> D[Report\nGenerator]
```
1. **Permission Validation** — Verifies the executing role has the required IAM permissions before any work begins.
2. **Stack Discovery** — Lists all CloudFormation stacks matching the `CL-AppPipe-` prefix and filters to updatable states.
3. **Stack Update Engine** — Updates stacks concurrently (bounded by `Concurrency_Limit`) with retry logic for throttling errors.
4. **Report Generator** — Aggregates results and produces the final summary.
### Concurrency Model
The update engine uses a semaphore-based concurrency model with `asyncio` to run up to `Concurrency_Limit` stack updates in parallel. Each update is an independent coroutine that:
1. Fetches current stack parameters
2. Calls `UpdateStack` with existing parameters and the nested template URL
3. Polls `DescribeStacks` until the update completes or fails
4. Records the result
```mermaid
flowchart TD
S[Semaphore: Concurrency_Limit] --> U1[Update Stack 1]
S --> U2[Update Stack 2]
S --> U3[Update Stack N]
U1 --> R[Result Collector]
U2 --> R
U3 --> R
R --> Report[Summary Report]
```
## Components and Interfaces
### CLI Entry Point (`cli.py`)
Parses command-line arguments and orchestrates the pipeline.
```python
def main(
prefix: str = "CL-AppPipe-",
concurrency: int = 5,
dry_run: bool = False,
region: str | None = None,
) -> int:
"""
Entry point. Returns 0 on full success, 1 if any stack failed.
"""
```
**Arguments:**
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--prefix` | `str` | `CL-AppPipe-` | Stack name prefix to match |
| `--concurrency` | `int` | `5` | Max parallel updates |
| `--dry-run` | `bool` | `False` | Preview mode, no updates |
| `--region` | `str` | SDK default | AWS region override |
### Permission Validator (`permissions.py`)
```python
def validate_permissions(cfn_client) -> list[str]:
"""
Checks required IAM permissions by performing dry-run API calls.
Returns a list of missing permission names. Empty list means all OK.
"""
```
Validates by attempting:
- `cloudformation:ListStacks` — calls `list_stacks` with a narrow filter
- `cloudformation:DescribeStacks` — calls `describe_stacks` with a non-existent stack name (expects specific error)
- `cloudformation:UpdateStack` — validated implicitly during updates; pre-check uses IAM policy simulation via `iam:SimulatePrincipalPolicy` if available, otherwise deferred
### Stack Discovery (`discovery.py`)
```python
@dataclass
class DiscoveredStack:
name: str
status: str
updatable: bool
def discover_stacks(cfn_client, prefix: str) -> list[DiscoveredStack]:
"""
Lists all stacks matching the prefix. Paginates through all results.
Marks each stack as updatable or not based on its status.
"""
```
**Non-updatable statuses:**
- `ROLLBACK_COMPLETE`
- `ROLLBACK_IN_PROGRESS`
- `UPDATE_ROLLBACK_IN_PROGRESS`
- `UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS`
- `DELETE_IN_PROGRESS`
- `DELETE_COMPLETE`
### Stack Updater (`updater.py`)
```python
@dataclass
class StackUpdateResult:
stack_name: str
status: Literal["succeeded", "failed", "skipped", "no-update-needed"]
error: str | None = None
duration_seconds: float = 0.0
async def update_stack(
cfn_client,
stack_name: str,
template_url: str,
max_retries: int = 3,
) -> StackUpdateResult:
"""
Updates a single stack. Handles 'No updates' response, throttling retries,
and non-updatable state detection.
"""
async def update_all_stacks(
cfn_client,
stacks: list[DiscoveredStack],
template_url: str,
concurrency: int = 5,
max_retries: int = 3,
) -> list[StackUpdateResult]:
"""
Updates all stacks with bounded concurrency using asyncio.Semaphore.
"""
```
**Retry logic:**
- Triggered on `Throttling` or `RequestLimitExceeded` error codes
- Exponential backoff: `base_delay * 2^attempt` (base_delay = 1s)
- Maximum 3 retries per stack
### Report Generator (`report.py`)
```python
@dataclass
class UpdateRunReport:
start_time: datetime
end_time: datetime
total_found: int
succeeded: int
failed: int
skipped: int
no_update_needed: int
results: list[StackUpdateResult]
def generate_report(
results: list[StackUpdateResult],
total_found: int,
start_time: datetime,
end_time: datetime,
) -> UpdateRunReport:
"""
Aggregates results into a summary report.
"""
def format_report(report: UpdateRunReport) -> str:
"""
Formats the report as a human-readable string for console output.
"""
```
## Data Models
### DiscoveredStack
| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | CloudFormation stack name |
| `status` | `str` | Current stack status (e.g., `CREATE_COMPLETE`) |
| `updatable` | `bool` | Whether the stack is in an updatable state |
### StackUpdateResult
| Field | Type | Description |
|-------|------|-------------|
| `stack_name` | `str` | Name of the stack |
| `status` | `Literal["succeeded", "failed", "skipped", "no-update-needed"]` | Outcome of the update attempt |
| `error` | `str \| None` | Error message if failed |
| `duration_seconds` | `float` | Time taken for this stack's update |
### UpdateRunReport
| Field | Type | Description |
|-------|------|-------------|
| `start_time` | `datetime` | When the Update_Run started |
| `end_time` | `datetime` | When the Update_Run ended |
| `total_found` | `int` | Total Target_Stacks discovered |
| `succeeded` | `int` | Count of successfully updated stacks |
| `failed` | `int` | Count of failed stacks |
| `skipped` | `int` | Count of skipped (non-updatable) stacks |
| `no_update_needed` | `int` | Count of stacks with no changes |
| `results` | `list[StackUpdateResult]` | Per-stack results |
### Configuration Constants
```python
TEMPLATE_URL = "https://s3.amazonaws.com/solutions-reference/centralized-logging-with-opensearch/latest/AppLogS3Buffer.template"
DEFAULT_PREFIX = "CL-AppPipe-"
DEFAULT_CONCURRENCY = 5
MAX_RETRIES = 3
BASE_RETRY_DELAY = 1.0 # seconds
NON_UPDATABLE_STATUSES = frozenset({
"ROLLBACK_COMPLETE",
"ROLLBACK_IN_PROGRESS",
"UPDATE_ROLLBACK_IN_PROGRESS",
"UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS",
"DELETE_IN_PROGRESS",
"DELETE_COMPLETE",
})
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Discovery returns exactly prefix-matched stacks with correct count
*For any* list of CloudFormation stacks with arbitrary names, the discovery function should return exactly those stacks whose names start with the configured prefix, and the reported count should equal the length of that filtered list.
**Validates: Requirements 1.1, 1.3**
### Property 2: All updatable stacks are attempted
*For any* set of discovered stacks marked as updatable, the update engine should produce exactly one update result per updatable stack — no stack is silently dropped and no stack is attempted twice.
**Validates: Requirements 2.2**
### Property 3: Concurrency limit invariant
*For any* positive concurrency limit and any list of stacks, at no point during an Update_Run should the number of concurrently in-progress stack updates exceed the specified concurrency limit.
**Validates: Requirements 2.3, 3.3**
### Property 4: Update call preserves existing parameters and uses correct template URL
*For any* stack with any set of existing parameters, the UpdateStack API call should include exactly those same parameter keys with `UsePreviousValue=True`, and the `TemplateURL` argument should equal the configured `Nested_Template_URL`.
**Validates: Requirements 3.1, 3.2**
### Property 5: "No updates" response maps to no-update-needed status
*For any* stack where CloudFormation returns a "No updates are to be performed" error, the resulting `StackUpdateResult` should have status `no-update-needed` (not `failed`).
**Validates: Requirements 3.4**
### Property 6: Non-updatable stacks are skipped
*For any* stack whose CloudFormation status is in the set of non-updatable statuses (e.g., `ROLLBACK_COMPLETE`, `DELETE_IN_PROGRESS`), the result should have status `skipped` and no UpdateStack API call should be made for that stack.
**Validates: Requirements 4.2**
### Property 7: Fault isolation — failures do not block remaining stacks
*For any* list of N updatable stacks where K of them fail (including after retry exhaustion), the update engine should still produce results for all N stacks, and the number of attempted updates should equal N.
**Validates: Requirements 4.1, 4.4**
### Property 8: Throttling triggers exponential backoff retries
*For any* stack that receives throttling errors, the system should retry up to `MAX_RETRIES` times, and the delay between the i-th and (i+1)-th attempt should be at least `BASE_RETRY_DELAY * 2^i` seconds.
**Validates: Requirements 4.3**
### Property 9: Report aggregation and exit code correctness
*For any* list of `StackUpdateResult` values, the generated report's `succeeded`, `failed`, `skipped`, and `no_update_needed` counts should equal the actual counts of each status in the input list, and the exit code should be non-zero if and only if `failed > 0`.
**Validates: Requirements 5.2, 5.3**
### Property 10: Dry-run performs no updates and lists all discovered stacks
*For any* set of discovered stacks, when dry-run mode is enabled, zero UpdateStack API calls should be made, and the output should contain the name and current status of every discovered stack.
**Validates: Requirements 6.2, 6.3**
### Property 11: Permission validation correctness
*For any* subset of required permissions that are missing, the permission validator should return exactly those missing permissions, and when any permissions are missing, the Update_Run should terminate without making any UpdateStack API calls.
**Validates: Requirements 7.1, 7.2**
## Error Handling
### Error Categories and Responses
| Error | Source | Response |
|-------|--------|----------|
| Missing IAM permissions | Permission validation phase | Report missing permissions, exit with non-zero code, no updates attempted |
| No stacks found | Discovery phase | Log warning, exit with code 0 (not an error) |
| Stack in non-updatable state | Update phase | Skip stack, log warning, record as `skipped` |
| "No updates to be performed" | CloudFormation UpdateStack API | Treat as success, record as `no-update-needed` |
| Throttling / RequestLimitExceeded | CloudFormation API | Retry with exponential backoff (max 3 retries) |
| Throttling after max retries | CloudFormation API | Mark stack as `failed`, continue with remaining stacks |
| UpdateStack failure (other) | CloudFormation API | Log error details, mark as `failed`, continue with remaining stacks |
| Boto3 connection error | Network / SDK | Mark stack as `failed`, log error, continue |
| Invalid CLI arguments | Argument parsing | Print usage, exit with non-zero code |
### Retry Strategy
```python
async def retry_with_backoff(func, max_retries=3, base_delay=1.0):
for attempt in range(max_retries + 1):
try:
return await func()
except ClientError as e:
code = e.response["Error"]["Code"]
if code in ("Throttling", "RequestLimitExceeded") and attempt < max_retries:
delay = base_delay * (2 ** attempt)
await asyncio.sleep(delay)
else:
raise
```
### Exit Codes
| Code | Meaning |
|------|---------|
| `0` | All stacks updated successfully (or no stacks found, or dry-run) |
| `1` | One or more stacks failed to update |
| `2` | Permission validation failed |
## Testing Strategy
### Testing Framework
- **Unit tests**: `pytest`
- **Property-based tests**: `hypothesis` (Python's standard PBT library)
- **Mocking**: `unittest.mock` and `botocore.stub.Stubber` for AWS API mocking
### Property-Based Tests
Each correctness property from the design maps to a single property-based test. All property tests run a minimum of 100 iterations using Hypothesis settings.
| Property | Test Description | Key Generators |
|----------|-----------------|----------------|
| P1 | Discovery prefix filtering | Random stack name lists (some with prefix, some without) |
| P2 | All updatable stacks attempted | Random lists of DiscoveredStack with mixed updatable flags |
| P3 | Concurrency limit invariant | Random concurrency values (120), random stack counts (150) |
| P4 | Parameter preservation and template URL | Random parameter key-value dicts |
| P5 | "No updates" status mapping | Random stacks with mocked "no updates" responses |
| P6 | Non-updatable stack skipping | Random stacks with statuses drawn from updatable and non-updatable sets |
| P7 | Fault isolation | Random stack lists with random failure injection |
| P8 | Exponential backoff retries | Random retry counts (03), verify delay sequence |
| P9 | Report aggregation | Random lists of StackUpdateResult with random statuses |
| P10 | Dry-run no-op | Random discovered stacks, verify zero update calls |
| P11 | Permission validation | Random subsets of required permissions marked as missing |
Each test must be tagged with a comment:
```python
# Feature: one-click-cfn-stack-updater, Property 9: Report aggregation and exit code correctness
```
### Unit Tests
Unit tests complement property tests by covering:
- **Specific examples**: Known stack names, known parameter sets, expected API responses
- **Edge cases**: Empty stack list (Req 1.4), concurrency of 1, all stacks failing, all stacks already up-to-date
- **Integration points**: CLI argument parsing, boto3 Stubber-based API interaction tests
- **Error conditions**: Malformed API responses, unexpected exception types
### Test Organization
```
tests/
├── test_discovery.py # P1, P6 property tests + unit tests
├── test_updater.py # P2, P3, P4, P5, P7, P8 property tests + unit tests
├── test_report.py # P9 property tests + unit tests
├── test_dry_run.py # P10 property tests + unit tests
├── test_permissions.py # P11 property tests + unit tests
└── test_cli.py # CLI argument parsing unit tests
```

View file

@ -0,0 +1,92 @@
# Requirements Document
## Introduction
This feature provides a one-click mechanism to update 22+ CloudFormation stacks in an audit account. All stacks follow the `CL-AppPipe-*` naming convention (e.g., `CL-AppPipe-9894aa72`, `CL-AppPipe-ca6dca90`) and use the same nested template URL (`https://s3.amazonaws.com/solutions-reference/centralized-logging-with-opensearch/latest/AppLogS3Buffer.template`) from the Centralized Logging with OpenSearch solution (SO8025-s3b). The number of stacks grows over time as new pipelines are added. When a new version of that template becomes available, the operator needs a single action to trigger a rolling update across every stack without manually updating each one.
## Glossary
- **Stack_Updater**: The system that orchestrates the discovery and update of all target CloudFormation stacks in the audit account.
- **Target_Stack**: A CloudFormation stack whose name starts with the `CL-AppPipe-` prefix, belonging to the Centralized Logging with OpenSearch solution for log ingestion pipelines.
- **Stack_Name_Prefix**: The string `CL-AppPipe-` used to identify Target_Stacks by their CloudFormation stack name.
- **Nested_Template_URL**: The S3 URL (`https://s3.amazonaws.com/solutions-reference/centralized-logging-with-opensearch/latest/AppLogS3Buffer.template`) that always resolves to the latest version of the pipeline template.
- **Solution_ID**: The identifier `SO8025-s3b` for the Centralized Logging with OpenSearch AWS Solution that provisions the Target_Stacks.
- **Update_Run**: A single execution of the Stack_Updater that attempts to update all Target_Stacks.
- **Operator**: The person who triggers and monitors the update process.
- **Concurrency_Limit**: The maximum number of CloudFormation stack updates that the Stack_Updater executes in parallel during an Update_Run.
## Requirements
### Requirement 1: Discover Target Stacks
**User Story:** As an Operator, I want the system to automatically discover all stacks matching the pipeline naming convention, so that I do not have to maintain a manual list and newly added stacks are picked up automatically.
#### Acceptance Criteria
1. WHEN an Update_Run is triggered, THE Stack_Updater SHALL discover Target_Stacks by listing all CloudFormation stacks whose name starts with the Stack_Name_Prefix `CL-AppPipe-`.
2. THE Stack_Updater SHALL perform discovery dynamically on each Update_Run so that stacks added after the previous run are included automatically.
3. WHEN discovery completes, THE Stack_Updater SHALL report the total number of Target_Stacks found before proceeding with updates.
4. IF no Target_Stacks are found, THEN THE Stack_Updater SHALL log a warning message and terminate the Update_Run without error.
### Requirement 2: One-Click Trigger
**User Story:** As an Operator, I want to trigger the update of all Target_Stacks with a single action, so that I do not have to update each stack individually.
#### Acceptance Criteria
1. THE Stack_Updater SHALL expose a single invocation endpoint that starts an Update_Run.
2. WHEN the Operator invokes the endpoint, THE Stack_Updater SHALL begin an Update_Run that updates all discovered Target_Stacks.
3. THE Stack_Updater SHALL accept an optional Concurrency_Limit parameter that controls how many stacks are updated in parallel.
4. IF no Concurrency_Limit is provided, THEN THE Stack_Updater SHALL default to updating 5 stacks in parallel.
### Requirement 3: Stack Update Execution
**User Story:** As an Operator, I want each stack to be updated using its existing parameters, so that the only change is the refreshed nested template.
#### Acceptance Criteria
1. WHEN updating a Target_Stack, THE Stack_Updater SHALL call the CloudFormation UpdateStack API using the existing parameter values of the Target_Stack.
2. WHEN updating a Target_Stack, THE Stack_Updater SHALL use the current Nested_Template_URL so that CloudFormation resolves the latest template version.
3. WHILE an Update_Run is in progress, THE Stack_Updater SHALL respect the Concurrency_Limit and wait for an in-progress update to complete before starting the next one.
4. IF CloudFormation returns a "No updates are to be performed" response for a Target_Stack, THEN THE Stack_Updater SHALL treat that Target_Stack as successfully updated and continue.
### Requirement 4: Error Handling and Resilience
**User Story:** As an Operator, I want the update process to continue even if individual stacks fail, so that one failure does not block the remaining stacks.
#### Acceptance Criteria
1. IF a Target_Stack update fails, THEN THE Stack_Updater SHALL log the stack name and error details and continue updating the remaining Target_Stacks.
2. IF a Target_Stack is in a non-updatable state (e.g., ROLLBACK_COMPLETE, UPDATE_ROLLBACK_IN_PROGRESS), THEN THE Stack_Updater SHALL skip that Target_Stack and log a warning.
3. IF the CloudFormation API returns a throttling error, THEN THE Stack_Updater SHALL retry the request using exponential backoff with a maximum of 3 retries.
4. IF all retries for a Target_Stack are exhausted, THEN THE Stack_Updater SHALL mark that Target_Stack as failed and continue with the remaining stacks.
### Requirement 5: Progress Reporting and Summary
**User Story:** As an Operator, I want to see the progress and final results of an Update_Run, so that I know which stacks succeeded and which failed.
#### Acceptance Criteria
1. WHILE an Update_Run is in progress, THE Stack_Updater SHALL log the status of each Target_Stack update as it completes (succeeded, failed, skipped, no-update-needed).
2. WHEN an Update_Run completes, THE Stack_Updater SHALL produce a summary report containing: total stacks found, stacks updated successfully, stacks skipped, stacks failed, and stacks with no update needed.
3. WHEN an Update_Run completes, THE Stack_Updater SHALL return a non-zero exit code if any Target_Stack update failed.
4. THE Stack_Updater SHALL log the start time and end time of each Update_Run.
### Requirement 6: Dry Run Mode
**User Story:** As an Operator, I want to preview which stacks would be updated without actually performing updates, so that I can verify the scope before committing.
#### Acceptance Criteria
1. THE Stack_Updater SHALL accept a dry-run flag as an input parameter.
2. WHEN the dry-run flag is set, THE Stack_Updater SHALL discover and list all Target_Stacks without performing any updates.
3. WHEN the dry-run flag is set, THE Stack_Updater SHALL output the name and current status of each Target_Stack that would be updated.
### Requirement 7: IAM Permission Validation
**User Story:** As an Operator, I want the system to verify it has the required permissions before starting updates, so that I catch permission issues early.
#### Acceptance Criteria
1. WHEN an Update_Run is triggered, THE Stack_Updater SHALL verify that the executing role has `cloudformation:DescribeStacks`, `cloudformation:UpdateStack`, and `cloudformation:ListStacks` permissions before proceeding.
2. IF the executing role lacks required permissions, THEN THE Stack_Updater SHALL report the missing permissions and terminate the Update_Run without attempting any updates.

View file

@ -0,0 +1,183 @@
# Implementation Plan: One-Click CloudFormation Stack Updater
## Overview
Implement a Python CLI tool that discovers and updates all `CL-AppPipe-*` CloudFormation stacks in an audit account. The implementation follows the pipeline architecture: Permission Validation → Stack Discovery → Stack Update Engine → Report Generator. Each task builds incrementally, wiring components together at the end.
## Tasks
- [x] 1. Set up project structure, configuration constants, and data models
- Create the package directory structure: `cfn_updater/` with `__init__.py`, `cli.py`, `permissions.py`, `discovery.py`, `updater.py`, `report.py`, `config.py`
- Create `tests/` directory with `__init__.py`, `conftest.py`
- Implement `config.py` with all configuration constants (`TEMPLATE_URL`, `DEFAULT_PREFIX`, `DEFAULT_CONCURRENCY`, `MAX_RETRIES`, `BASE_RETRY_DELAY`, `NON_UPDATABLE_STATUSES`)
- Implement data model dataclasses: `DiscoveredStack`, `StackUpdateResult`, `UpdateRunReport`
- Add `pyproject.toml` or `requirements.txt` with dependencies: `boto3`, `pytest`, `hypothesis`, `botocore`
- _Requirements: 1.1, 2.3, 2.4, 3.1, 5.2_
- [ ] 2. Implement Permission Validator
- [x] 2.1 Implement `validate_permissions()` in `permissions.py`
- Check `cloudformation:ListStacks`, `cloudformation:DescribeStacks`, and `cloudformation:UpdateStack` permissions via dry-run API calls
- Return a list of missing permission names; empty list means all OK
- _Requirements: 7.1, 7.2_
- [x] 2.2 Write property test for permission validation (Property 11)
- **Property 11: Permission validation correctness**
- For any subset of required permissions marked as missing, the validator returns exactly those missing permissions
- When any permissions are missing, no UpdateStack calls are made
- **Validates: Requirements 7.1, 7.2**
- [x] 2.3 Write unit tests for permission validation
- Test all permissions present (happy path)
- Test single missing permission
- Test all permissions missing
- Test boto3 error handling during validation
- _Requirements: 7.1, 7.2_
- [ ] 3. Implement Stack Discovery
- [x] 3.1 Implement `discover_stacks()` in `discovery.py`
- List all CloudFormation stacks using paginated `list_stacks` API calls
- Filter stacks by `Stack_Name_Prefix` (`CL-AppPipe-`)
- Mark each stack as updatable or not based on `NON_UPDATABLE_STATUSES`
- Return list of `DiscoveredStack` objects
- _Requirements: 1.1, 1.2, 1.3, 4.2_
- [x] 3.2 Write property test for discovery prefix filtering (Property 1)
- **Property 1: Discovery returns exactly prefix-matched stacks with correct count**
- For any list of stack names, discovery returns exactly those starting with the prefix
- Reported count equals the length of the filtered list
- **Validates: Requirements 1.1, 1.3**
- [x] 3.3 Write property test for non-updatable stack classification (Property 6)
- **Property 6: Non-updatable stacks are skipped**
- For any stack with a status in `NON_UPDATABLE_STATUSES`, `updatable` is `False`
- For any stack with a status not in `NON_UPDATABLE_STATUSES`, `updatable` is `True`
- **Validates: Requirements 4.2**
- [x] 3.4 Write unit tests for stack discovery
- Test empty stack list (Req 1.4)
- Test pagination across multiple pages
- Test mixed updatable and non-updatable stacks
- _Requirements: 1.1, 1.2, 1.3, 1.4, 4.2_
- [x] 4. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [ ] 5. Implement Stack Update Engine
- [x] 5.1 Implement `update_stack()` coroutine in `updater.py`
- Fetch current stack parameters via `describe_stacks`
- Call `UpdateStack` with `UsePreviousValue=True` for all existing parameters and the configured `TEMPLATE_URL`
- Handle "No updates are to be performed" response as `no-update-needed`
- Implement exponential backoff retry for throttling errors (`Throttling`, `RequestLimitExceeded`)
- Record duration and return `StackUpdateResult`
- _Requirements: 3.1, 3.2, 3.4, 4.1, 4.3, 4.4_
- [x] 5.2 Implement `update_all_stacks()` coroutine in `updater.py`
- Use `asyncio.Semaphore` to bound concurrent updates to `Concurrency_Limit`
- Skip non-updatable stacks (record as `skipped`)
- Collect results for all stacks, including failures
- _Requirements: 2.2, 2.3, 3.3, 4.1, 4.2_
- [x] 5.3 Write property test for parameter preservation (Property 4)
- **Property 4: Update call preserves existing parameters and uses correct template URL**
- For any stack with any set of parameters, UpdateStack includes all parameter keys with `UsePreviousValue=True`
- `TemplateURL` equals the configured `TEMPLATE_URL`
- **Validates: Requirements 3.1, 3.2**
- [x] 5.4 Write property test for "no updates" handling (Property 5)
- **Property 5: "No updates" response maps to no-update-needed status**
- For any stack returning "No updates are to be performed", result status is `no-update-needed`
- **Validates: Requirements 3.4**
- [x] 5.5 Write property test for all updatable stacks attempted (Property 2)
- **Property 2: All updatable stacks are attempted**
- For any set of discovered stacks, exactly one result per updatable stack; no drops, no duplicates
- **Validates: Requirements 2.2**
- [x] 5.6 Write property test for concurrency limit invariant (Property 3)
- **Property 3: Concurrency limit invariant**
- For any positive concurrency limit and stack list, concurrent in-progress updates never exceed the limit
- **Validates: Requirements 2.3, 3.3**
- [x] 5.7 Write property test for fault isolation (Property 7)
- **Property 7: Fault isolation — failures do not block remaining stacks**
- For any N updatable stacks where K fail, results are produced for all N stacks
- **Validates: Requirements 4.1, 4.4**
- [x] 5.8 Write property test for exponential backoff (Property 8)
- **Property 8: Throttling triggers exponential backoff retries**
- For any stack receiving throttling errors, retries up to `MAX_RETRIES` times
- Delay between attempt i and i+1 is at least `BASE_RETRY_DELAY * 2^i` seconds
- **Validates: Requirements 4.3**
- [x] 5.9 Write unit tests for stack updater
- Test successful update flow
- Test "no updates" response handling
- Test throttling with retry and eventual success
- Test throttling with retry exhaustion
- Test non-updatable stack skipping
- Test concurrent updates with Stubber
- _Requirements: 3.1, 3.2, 3.4, 4.1, 4.2, 4.3, 4.4_
- [x] 6. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [ ] 7. Implement Report Generator
- [x] 7.1 Implement `generate_report()` and `format_report()` in `report.py`
- Aggregate `StackUpdateResult` list into `UpdateRunReport` with correct counts
- Format report as human-readable console output with start/end times, per-stack results, and summary totals
- _Requirements: 5.1, 5.2, 5.4_
- [x] 7.2 Write property test for report aggregation (Property 9)
- **Property 9: Report aggregation and exit code correctness**
- For any list of `StackUpdateResult`, report counts match actual counts per status
- Exit code is non-zero if and only if `failed > 0`
- **Validates: Requirements 5.2, 5.3**
- [x] 7.3 Write unit tests for report generator
- Test all-success scenario
- Test mixed results scenario
- Test empty results list
- Test format output contains expected fields
- _Requirements: 5.1, 5.2, 5.3, 5.4_
- [ ] 8. Implement Dry-Run Mode
- [x] 8.1 Add dry-run logic to the pipeline
- When `--dry-run` is set, discover stacks and output name + status for each, but make zero UpdateStack calls
- _Requirements: 6.1, 6.2, 6.3_
- [x] 8.2 Write property test for dry-run (Property 10)
- **Property 10: Dry-run performs no updates and lists all discovered stacks**
- For any set of discovered stacks with dry-run enabled, zero UpdateStack calls are made
- Output contains name and status of every discovered stack
- **Validates: Requirements 6.2, 6.3**
- [x] 8.3 Write unit tests for dry-run mode
- Test dry-run outputs all stack names and statuses
- Test dry-run makes no API update calls
- _Requirements: 6.1, 6.2, 6.3_
- [ ] 9. Implement CLI Entry Point and Wire Components Together
- [x] 9.1 Implement `main()` in `cli.py` with argument parsing
- Parse `--prefix`, `--concurrency`, `--dry-run`, `--region` flags using `argparse`
- Orchestrate the full pipeline: validate permissions → discover stacks → update (or dry-run) → generate report
- Return exit code 0 (success/no stacks/dry-run), 1 (any failure), or 2 (permission failure)
- Add `if __name__ == "__main__"` block
- _Requirements: 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 5.1, 5.2, 5.3, 5.4, 6.1, 7.1, 7.2_
- [x] 9.2 Write unit tests for CLI argument parsing and pipeline orchestration
- Test default argument values
- Test custom argument values
- Test invalid arguments produce non-zero exit
- Test end-to-end pipeline with mocked components
- _Requirements: 2.1, 2.3, 2.4_
- [x] 10. Final checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Property tests use `hypothesis` with a minimum of 100 iterations per property
- AWS API calls are mocked using `botocore.stub.Stubber` in all tests
- Checkpoints ensure incremental validation between major phases

185
README.md Normal file
View file

@ -0,0 +1,185 @@
# One-Click CloudFormation Stack Updater
A Python CLI tool that discovers and updates all `CL-AppPipe-*` and `CL-SvcPipe-*` CloudFormation stacks in an AWS audit account. When a new version of the Centralized Logging with OpenSearch nested templates becomes available, this tool triggers a rolling update across every matching stack with a single command.
## Supported Stack Types
| Prefix | Solution ID | Template |
|--------|-------------|----------|
| `CL-AppPipe-*` | SO8025-s3b | `AppLogS3Buffer.template` |
| `CL-SvcPipe-*` | SO8025-s3 | `S3AccessLog.template` |
Each prefix is automatically mapped to its correct template URL. Running without `--prefix` updates both types.
## What It Does
1. Validates IAM permissions before starting
2. Discovers all stacks matching the `CL-AppPipe-` prefix
3. Updates each stack using its existing parameters (only the template URL is refreshed)
4. Runs updates concurrently with configurable parallelism
5. Produces a summary report showing succeeded, failed, skipped, and no-update-needed counts
## Prerequisites
- Python 3.10+
- AWS CLI configured with a profile that has access to the audit account
- Required IAM permissions:
- `cloudformation:ListStacks`
- `cloudformation:DescribeStacks`
- `cloudformation:UpdateStack`
## Installation
```bash
# Install dependencies
pip install boto3 botocore
# For development (tests)
pip install pytest hypothesis
```
## Usage
### Dry Run (preview which stacks would be updated)
```bash
# Preview all stack types (CL-AppPipe-* and CL-SvcPipe-*)
py -m cfn_updater.cli --profile audit --dry-run
# Preview only CL-AppPipe-* stacks
py -m cfn_updater.cli --profile audit --prefix "CL-AppPipe-" --dry-run
# Preview only CL-SvcPipe-* stacks
py -m cfn_updater.cli --profile audit --prefix "CL-SvcPipe-" --dry-run
```
### Run the Update
```bash
# Update all stack types
py -m cfn_updater.cli --profile audit
# Update only CL-AppPipe-* stacks
py -m cfn_updater.cli --profile audit --prefix "CL-AppPipe-"
# Update only CL-SvcPipe-* stacks
py -m cfn_updater.cli --profile audit --prefix "CL-SvcPipe-"
```
### CLI Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--profile` | string | None | AWS profile name (e.g. `audit`) |
| `--region` | string | SDK default | AWS region override |
| `--prefix` | string | all configured | Stack name prefix to match (omit to update all types) |
| `--concurrency` | int | `5` | Max parallel stack updates |
| `--dry-run` | flag | `False` | Preview mode — lists stacks without updating |
### Examples
```bash
# Dry run all stack types with audit profile
py -m cfn_updater.cli --profile audit --dry-run
# Update only CL-SvcPipe-* stacks, 3 at a time
py -m cfn_updater.cli --profile audit --prefix "CL-SvcPipe-" --concurrency 3
# Update all stacks in a specific region
py -m cfn_updater.cli --profile audit --region us-east-1
# Update only CL-AppPipe-* stacks
py -m cfn_updater.cli --profile audit --prefix "CL-AppPipe-"
```
## Exit Codes
| Code | Meaning |
|------|---------|
| `0` | All stacks updated successfully, no stacks found, or dry-run |
| `1` | One or more stacks failed to update |
| `2` | Permission validation failed |
## Configuration
Default values are in `cfn_updater/config.py`:
| Constant | Value | Description |
|----------|-------|-------------|
| `TEMPLATE_URL` | `https://s3.amazonaws.com/.../AppLogS3Buffer.template` | Default template URL (CL-AppPipe-*) |
| `STACK_PROFILES` | `{"CL-AppPipe-": "...AppLogS3Buffer.template", "CL-SvcPipe-": "...S3AccessLog.template"}` | Prefix-to-template mapping |
| `DEFAULT_PREFIX` | `CL-AppPipe-` | Legacy default prefix |
| `DEFAULT_CONCURRENCY` | `5` | Max parallel updates |
| `MAX_RETRIES` | `3` | Retry attempts on throttling |
| `BASE_RETRY_DELAY` | `1.0` seconds | Base delay for exponential backoff |
## Error Handling
- **Throttling**: Automatically retries with exponential backoff (1s, 2s, 4s) up to 3 times
- **Non-updatable stacks**: Stacks in states like `ROLLBACK_COMPLETE` or `DELETE_IN_PROGRESS` are skipped
- **"No updates needed"**: Treated as success when the stack is already on the latest template
- **Individual failures**: One stack failing does not block the rest — all stacks are attempted
## Project Structure
```
cfn_updater/
├── __init__.py # Package exports
├── cli.py # CLI entry point and pipeline orchestration
├── config.py # Configuration constants
├── discovery.py # Stack discovery (prefix filtering, pagination)
├── models.py # Data models (DiscoveredStack, StackUpdateResult, UpdateRunReport)
├── permissions.py # IAM permission validation
├── report.py # Report generation and formatting
└── updater.py # Stack update engine (async, concurrency, retry)
tests/
├── test_cli.py # CLI argument parsing and pipeline tests
├── test_discovery.py # Stack discovery property + unit tests
├── test_dry_run.py # Dry-run property + unit tests
├── test_permissions.py # Permission validation property + unit tests
├── test_report.py # Report aggregation property + unit tests
└── test_updater.py # Update engine property + unit tests
```
## Running Tests
```bash
# Run all tests
py -m pytest tests/ -v
# Run a specific test file
py -m pytest tests/test_updater.py -v
# Run with short output
py -m pytest tests/
```
The test suite includes 80 tests: 11 property-based tests (using Hypothesis) and 69 unit tests. All AWS API calls are mocked — no real AWS credentials needed for testing.
## Sample Output
```
Discovered 22 stack(s).
============================================================
CloudFormation Stack Update Report
============================================================
Start Time : 2026-04-02T02:20:41.247416+00:00
End Time : 2026-04-02T02:20:54.620248+00:00
Total Found: 22
Per-Stack Results:
------------------------------------------------------------
CL-AppPipe-9894aa72: succeeded (0.4s)
CL-AppPipe-ca6dca90: succeeded (0.6s)
CL-AppPipe-8521cc5e: no-update-needed (0.5s)
...
Summary:
------------------------------------------------------------
Succeeded : 20
Failed : 0
Skipped : 1
No Update Needed: 1
============================================================
```

5
cfn_updater/__init__.py Normal file
View file

@ -0,0 +1,5 @@
"""One-Click CloudFormation Stack Updater."""
from cfn_updater.models import DiscoveredStack, StackUpdateResult, UpdateRunReport
__all__ = ["DiscoveredStack", "StackUpdateResult", "UpdateRunReport"]

131
cfn_updater/cli.py Normal file
View file

@ -0,0 +1,131 @@
"""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())

23
cfn_updater/config.py Normal file
View file

@ -0,0 +1,23 @@
"""Configuration constants for the CloudFormation Stack Updater."""
# Mapping of stack name prefix → template URL.
# Each prefix uses a different nested template from the Centralized Logging solution.
STACK_PROFILES: dict[str, str] = {
"CL-AppPipe-": "https://s3.amazonaws.com/solutions-reference/centralized-logging-with-opensearch/latest/AppLogS3Buffer.template",
"CL-SvcPipe-": "https://s3.amazonaws.com/solutions-reference/centralized-logging-with-opensearch/latest/S3AccessLog.template",
}
# Kept for backward compatibility and single-prefix CLI usage
TEMPLATE_URL = STACK_PROFILES["CL-AppPipe-"]
DEFAULT_PREFIX = "CL-AppPipe-"
DEFAULT_CONCURRENCY = 5
MAX_RETRIES = 3
BASE_RETRY_DELAY = 1.0 # seconds
NON_UPDATABLE_STATUSES = frozenset({
"ROLLBACK_COMPLETE",
"ROLLBACK_IN_PROGRESS",
"UPDATE_ROLLBACK_IN_PROGRESS",
"UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS",
"DELETE_IN_PROGRESS",
"DELETE_COMPLETE",
})

65
cfn_updater/discovery.py Normal file
View file

@ -0,0 +1,65 @@
"""Stack discovery for the CloudFormation Stack Updater."""
from __future__ import annotations
from cfn_updater.config import NON_UPDATABLE_STATUSES
from cfn_updater.models import DiscoveredStack
# Statuses to exclude from list_stacks (DELETE_COMPLETE stacks are hidden by default
# but we explicitly exclude them to be safe).
_ACTIVE_STACK_STATUSES = [
"CREATE_IN_PROGRESS",
"CREATE_FAILED",
"CREATE_COMPLETE",
"ROLLBACK_IN_PROGRESS",
"ROLLBACK_FAILED",
"ROLLBACK_COMPLETE",
"DELETE_IN_PROGRESS",
"DELETE_FAILED",
"UPDATE_IN_PROGRESS",
"UPDATE_COMPLETE_CLEANUP_IN_PROGRESS",
"UPDATE_COMPLETE",
"UPDATE_FAILED",
"UPDATE_ROLLBACK_IN_PROGRESS",
"UPDATE_ROLLBACK_FAILED",
"UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS",
"UPDATE_ROLLBACK_COMPLETE",
"REVIEW_IN_PROGRESS",
"IMPORT_IN_PROGRESS",
"IMPORT_COMPLETE",
"IMPORT_ROLLBACK_IN_PROGRESS",
"IMPORT_ROLLBACK_FAILED",
"IMPORT_ROLLBACK_COMPLETE",
]
def discover_stacks(cfn_client, prefix: str) -> list[DiscoveredStack]:
"""List all stacks matching *prefix*. Paginates through all results.
Marks each stack as updatable or not based on its status against
``NON_UPDATABLE_STATUSES``.
"""
stacks: list[DiscoveredStack] = []
next_token: str | None = None
while True:
kwargs: dict = {"StackStatusFilter": _ACTIVE_STACK_STATUSES}
if next_token is not None:
kwargs["NextToken"] = next_token
response = cfn_client.list_stacks(**kwargs)
for summary in response.get("StackSummaries", []):
name = summary["StackName"]
if not name.startswith(prefix):
continue
status = summary["StackStatus"]
updatable = status not in NON_UPDATABLE_STATUSES
stacks.append(DiscoveredStack(name=name, status=status, updatable=updatable))
next_token = response.get("NextToken")
if not next_token:
break
return stacks

40
cfn_updater/models.py Normal file
View file

@ -0,0 +1,40 @@
"""Data models for the CloudFormation Stack Updater."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
@dataclass
class DiscoveredStack:
"""Represents a CloudFormation stack found during discovery."""
name: str
status: str
updatable: bool
@dataclass
class StackUpdateResult:
"""Result of a single stack update attempt."""
stack_name: str
status: Literal["succeeded", "failed", "skipped", "no-update-needed"]
error: str | None = None
duration_seconds: float = 0.0
@dataclass
class UpdateRunReport:
"""Summary report for an entire update run."""
start_time: datetime
end_time: datetime
total_found: int
succeeded: int
failed: int
skipped: int
no_update_needed: int
results: list[StackUpdateResult] = field(default_factory=list)

116
cfn_updater/permissions.py Normal file
View file

@ -0,0 +1,116 @@
"""Permission validation for the CloudFormation Stack Updater."""
from __future__ import annotations
from botocore.exceptions import ClientError
# Permissions we need to validate before proceeding with an update run.
REQUIRED_PERMISSIONS = [
"cloudformation:ListStacks",
"cloudformation:DescribeStacks",
"cloudformation:UpdateStack",
]
# Error codes that indicate missing IAM permissions.
_ACCESS_DENIED_CODES = frozenset({
"AccessDenied",
"AccessDeniedException",
"UnauthorizedAccess",
"AuthorizationError",
})
def validate_permissions(
cfn_client,
sts_client=None,
iam_client=None,
) -> list[str]:
"""Check required IAM permissions by performing dry-run API calls.
Args:
cfn_client: A boto3 CloudFormation client.
sts_client: Optional boto3 STS client (used for UpdateStack simulation).
iam_client: Optional boto3 IAM client (used for UpdateStack simulation).
Returns:
A list of missing permission names. An empty list means all
permissions are present.
"""
missing: list[str] = []
# --- cloudformation:ListStacks ---
if not _check_list_stacks(cfn_client):
missing.append("cloudformation:ListStacks")
# --- cloudformation:DescribeStacks ---
if not _check_describe_stacks(cfn_client):
missing.append("cloudformation:DescribeStacks")
# --- cloudformation:UpdateStack ---
if not _check_update_stack(sts_client, iam_client):
missing.append("cloudformation:UpdateStack")
return missing
def _check_list_stacks(cfn_client) -> bool:
"""Return True if the caller has cloudformation:ListStacks."""
try:
cfn_client.list_stacks(StackStatusFilter=["CREATE_COMPLETE"])
return True
except ClientError as exc:
if exc.response["Error"]["Code"] in _ACCESS_DENIED_CODES:
return False
raise
def _check_describe_stacks(cfn_client) -> bool:
"""Return True if the caller has cloudformation:DescribeStacks."""
try:
cfn_client.describe_stacks(
StackName="cfn-updater-permission-check-nonexistent",
)
return True
except ClientError as exc:
code = exc.response["Error"]["Code"]
if code in _ACCESS_DENIED_CODES:
return False
if code == "ValidationError":
# "Stack … does not exist" — permission is present, stack just
# doesn't exist. This is the expected happy-path response.
return True
raise
def _check_update_stack(sts_client, iam_client) -> bool:
"""Best-effort check for cloudformation:UpdateStack via IAM simulation.
Uses ``iam:SimulatePrincipalPolicy`` to verify the permission. If the
STS or IAM clients are not provided, or if the simulation call itself
is denied, the check is deferred (returns True) the real UpdateStack
call will surface the error later.
"""
if sts_client is None or iam_client is None:
# Cannot simulate without both clients — defer the check.
return True
try:
caller_arn = sts_client.get_caller_identity()["Arn"]
result = iam_client.simulate_principal_policy(
PolicySourceArn=caller_arn,
ActionNames=["cloudformation:UpdateStack"],
)
for eval_result in result.get("EvaluationResults", []):
if eval_result.get("EvalDecision") != "allowed":
return False
return True
except ClientError as exc:
if exc.response["Error"]["Code"] in _ACCESS_DENIED_CODES:
return False
# If simulation itself fails for another reason (e.g. missing
# iam:SimulatePrincipalPolicy), defer — the real call will fail later.
return True
except Exception:
# Any unexpected error — defer.
return True

67
cfn_updater/report.py Normal file
View file

@ -0,0 +1,67 @@
"""Report generator for the CloudFormation Stack Updater."""
from __future__ import annotations
from datetime import datetime
from cfn_updater.models import StackUpdateResult, UpdateRunReport
def generate_report(
results: list[StackUpdateResult],
total_found: int,
start_time: datetime,
end_time: datetime,
) -> UpdateRunReport:
"""Aggregate results into a summary report."""
succeeded = sum(1 for r in results if r.status == "succeeded")
failed = sum(1 for r in results if r.status == "failed")
skipped = sum(1 for r in results if r.status == "skipped")
no_update_needed = sum(1 for r in results if r.status == "no-update-needed")
return UpdateRunReport(
start_time=start_time,
end_time=end_time,
total_found=total_found,
succeeded=succeeded,
failed=failed,
skipped=skipped,
no_update_needed=no_update_needed,
results=list(results),
)
def format_report(report: UpdateRunReport) -> str:
"""Format the report as a human-readable string for console output."""
lines: list[str] = []
lines.append("=" * 60)
lines.append("CloudFormation Stack Update Report")
lines.append("=" * 60)
lines.append(f"Start Time : {report.start_time.isoformat()}")
lines.append(f"End Time : {report.end_time.isoformat()}")
lines.append(f"Total Found: {report.total_found}")
lines.append("")
lines.append("Per-Stack Results:")
lines.append("-" * 60)
for r in report.results:
line = f" {r.stack_name}: {r.status} ({r.duration_seconds:.1f}s)"
if r.error:
line += f" - {r.error}"
lines.append(line)
lines.append("")
lines.append("Summary:")
lines.append("-" * 60)
lines.append(f" Succeeded : {report.succeeded}")
lines.append(f" Failed : {report.failed}")
lines.append(f" Skipped : {report.skipped}")
lines.append(f" No Update Needed: {report.no_update_needed}")
lines.append("=" * 60)
return "\n".join(lines)
def get_exit_code(report: UpdateRunReport) -> int:
"""Return 0 if no failures, 1 if any stack failed."""
return 1 if report.failed > 0 else 0

170
cfn_updater/updater.py Normal file
View file

@ -0,0 +1,170 @@
"""Stack update engine for the CloudFormation Stack Updater."""
from __future__ import annotations
import asyncio
import time
from botocore.exceptions import ClientError
from cfn_updater.config import BASE_RETRY_DELAY, DEFAULT_CONCURRENCY, MAX_RETRIES, TEMPLATE_URL
from cfn_updater.models import DiscoveredStack, StackUpdateResult
async def update_stack(
cfn_client,
stack_name: str,
template_url: str,
max_retries: int = MAX_RETRIES,
) -> StackUpdateResult:
"""
Updates a single CloudFormation stack.
Fetches current parameters, calls UpdateStack with UsePreviousValue=True
for all existing parameters and the configured template URL.
Handles 'No updates are to be performed' as no-update-needed.
Retries on throttling errors with exponential backoff.
"""
start = time.monotonic()
try:
# Fetch current stack parameters
describe_resp = cfn_client.describe_stacks(StackName=stack_name)
stacks = describe_resp.get("Stacks", [])
if not stacks:
duration = time.monotonic() - start
return StackUpdateResult(
stack_name=stack_name,
status="failed",
error=f"Stack '{stack_name}' not found",
duration_seconds=duration,
)
existing_params = stacks[0].get("Parameters", [])
# Build parameter list with UsePreviousValue=True
params = [
{"ParameterKey": p["ParameterKey"], "UsePreviousValue": True}
for p in existing_params
]
# Attempt UpdateStack with retry logic for throttling
for attempt in range(max_retries + 1):
try:
cfn_client.update_stack(
StackName=stack_name,
TemplateURL=template_url,
Parameters=params,
UsePreviousTemplate=False,
Capabilities=["CAPABILITY_NAMED_IAM"],
)
# Update initiated successfully
duration = time.monotonic() - start
return StackUpdateResult(
stack_name=stack_name,
status="succeeded",
duration_seconds=duration,
)
except ClientError as e:
error_code = e.response["Error"]["Code"]
error_message = e.response["Error"].get("Message", str(e))
# Handle "No updates are to be performed"
if "No updates are to be performed" in error_message:
duration = time.monotonic() - start
return StackUpdateResult(
stack_name=stack_name,
status="no-update-needed",
duration_seconds=duration,
)
# Handle throttling with exponential backoff
if error_code in ("Throttling", "RequestLimitExceeded") and attempt < max_retries:
delay = BASE_RETRY_DELAY * (2 ** attempt)
await asyncio.sleep(delay)
continue
# Non-retryable error or retries exhausted
duration = time.monotonic() - start
return StackUpdateResult(
stack_name=stack_name,
status="failed",
error=error_message,
duration_seconds=duration,
)
except ClientError as e:
# Error from describe_stacks
duration = time.monotonic() - start
error_message = e.response["Error"].get("Message", str(e))
return StackUpdateResult(
stack_name=stack_name,
status="failed",
error=error_message,
duration_seconds=duration,
)
except Exception as e:
# Catch-all for unexpected errors (network issues, etc.)
duration = time.monotonic() - start
return StackUpdateResult(
stack_name=stack_name,
status="failed",
error=str(e),
duration_seconds=duration,
)
# Should not reach here, but safety net if max_retries loop completes
# without returning (all attempts were throttled)
duration = time.monotonic() - start
return StackUpdateResult(
stack_name=stack_name,
status="failed",
error="Max retries exhausted due to throttling",
duration_seconds=duration,
)
async def update_all_stacks(
cfn_client,
stacks: list[DiscoveredStack],
template_url: str,
concurrency: int = DEFAULT_CONCURRENCY,
max_retries: int = MAX_RETRIES,
) -> list[StackUpdateResult]:
"""
Updates all stacks with bounded concurrency using asyncio.Semaphore.
Non-updatable stacks are immediately recorded as skipped.
Updatable stacks are updated concurrently, bounded by the semaphore.
Results are returned in the same order as the input stacks list.
"""
sem = asyncio.Semaphore(concurrency)
async def _update_with_semaphore(stack_name: str) -> StackUpdateResult:
async with sem:
return await update_stack(cfn_client, stack_name, template_url, max_retries)
# Build a list of coroutines/futures in input order
coros: list = []
for stack in stacks:
if not stack.updatable:
# Wrap skipped results as a coroutine so gather() can handle them uniformly
async def _skipped(name: str = stack.name) -> StackUpdateResult:
return StackUpdateResult(stack_name=name, status="skipped")
coros.append(_skipped())
else:
coros.append(_update_with_semaphore(stack.name))
results: list[StackUpdateResult] = await asyncio.gather(*coros)
return list(results)
def format_dry_run_output(stacks: list[DiscoveredStack]) -> str:
"""Format discovered stacks for dry-run output. No updates are performed."""
if not stacks:
return "Dry-run: No stacks found."
lines = [f"Dry-run: {len(stacks)} stack(s) discovered:\n"]
for stack in stacks:
lines.append(f" {stack.name}{stack.status}")
return "\n".join(lines)

22
pyproject.toml Normal file
View file

@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "cfn-updater"
version = "0.1.0"
description = "One-Click CloudFormation Stack Updater"
requires-python = ">=3.10"
dependencies = [
"boto3",
"botocore",
]
[project.optional-dependencies]
dev = [
"pytest",
"hypothesis",
]
[tool.pytest.ini_options]
testpaths = ["tests"]

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
boto3
botocore
pytest
hypothesis

1
tests/__init__.py Normal file
View file

@ -0,0 +1 @@
# Tests package

1
tests/conftest.py Normal file
View file

@ -0,0 +1 @@
"""Shared test fixtures for the CloudFormation Stack Updater tests."""

217
tests/test_cli.py Normal file
View file

@ -0,0 +1,217 @@
"""Unit tests for CLI argument parsing and pipeline orchestration."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from cfn_updater.cli import main, parse_args
from cfn_updater.config import DEFAULT_CONCURRENCY, DEFAULT_PREFIX
from cfn_updater.models import DiscoveredStack, StackUpdateResult
# ---------------------------------------------------------------------------
# Argument parsing tests
# ---------------------------------------------------------------------------
class TestParseArgs:
"""Tests for parse_args() default and custom values."""
def test_default_values(self):
args = parse_args([])
assert args.prefix is None # None means "all configured prefixes"
assert args.concurrency == DEFAULT_CONCURRENCY
assert args.dry_run is False
assert args.region is None
assert args.profile is None
def test_custom_prefix(self):
args = parse_args(["--prefix", "MyStack-"])
assert args.prefix == "MyStack-"
def test_custom_concurrency(self):
args = parse_args(["--concurrency", "10"])
assert args.concurrency == 10
def test_dry_run_flag(self):
args = parse_args(["--dry-run"])
assert args.dry_run is True
def test_region_flag(self):
args = parse_args(["--region", "eu-west-1"])
assert args.region == "eu-west-1"
def test_profile_flag(self):
args = parse_args(["--profile", "audit"])
assert args.profile == "audit"
def test_all_custom_values(self):
args = parse_args([
"--prefix", "Test-",
"--concurrency", "3",
"--dry-run",
"--region", "us-east-1",
"--profile", "prod",
])
assert args.prefix == "Test-"
assert args.concurrency == 3
assert args.dry_run is True
assert args.region == "us-east-1"
assert args.profile == "prod"
# ---------------------------------------------------------------------------
# Pipeline orchestration tests
# ---------------------------------------------------------------------------
_PATCH_BASE = "cfn_updater.cli"
def _make_stacks(count: int, updatable: bool = True) -> list[DiscoveredStack]:
return [
DiscoveredStack(name=f"CL-AppPipe-{i}", status="CREATE_COMPLETE", updatable=updatable)
for i in range(count)
]
def _make_results(stacks: list[DiscoveredStack], status: str = "succeeded") -> list[StackUpdateResult]:
return [
StackUpdateResult(stack_name=s.name, status=status, duration_seconds=0.5)
for s in stacks
]
class TestMainPipeline:
"""Tests for the main() pipeline orchestration."""
@patch(f"{_PATCH_BASE}.boto3.Session")
@patch(f"{_PATCH_BASE}.validate_permissions", return_value=[])
@patch(f"{_PATCH_BASE}.discover_stacks")
@patch(f"{_PATCH_BASE}.asyncio.run")
@patch(f"{_PATCH_BASE}.generate_report")
@patch(f"{_PATCH_BASE}.format_report", return_value="report output")
@patch(f"{_PATCH_BASE}.get_exit_code", return_value=0)
def test_successful_end_to_end(
self, mock_exit, mock_fmt, mock_gen, mock_arun, mock_disc, mock_perm, mock_session
):
stacks = _make_stacks(3)
results = _make_results(stacks)
mock_disc.return_value = stacks
mock_arun.return_value = results
mock_gen.return_value = MagicMock()
exit_code = main(["--prefix", "CL-AppPipe-"])
assert exit_code == 0
mock_perm.assert_called_once()
mock_disc.assert_called_once()
mock_arun.assert_called_once()
mock_gen.assert_called_once()
mock_exit.assert_called_once()
@patch(f"{_PATCH_BASE}.boto3.Session")
@patch(f"{_PATCH_BASE}.validate_permissions", return_value=["cloudformation:UpdateStack"])
def test_permission_failure_returns_exit_code_2(self, mock_perm, mock_session, capsys):
exit_code = main([])
assert exit_code == 2
captured = capsys.readouterr()
assert "Missing required permissions" in captured.out
assert "cloudformation:UpdateStack" in captured.out
@patch(f"{_PATCH_BASE}.boto3.Session")
@patch(f"{_PATCH_BASE}.validate_permissions", return_value=[])
@patch(f"{_PATCH_BASE}.discover_stacks", return_value=[])
def test_no_stacks_found_returns_exit_code_0(self, mock_disc, mock_perm, mock_session, capsys):
exit_code = main([])
assert exit_code == 0
captured = capsys.readouterr()
assert "No stacks found" in captured.out
@patch(f"{_PATCH_BASE}.boto3.Session")
@patch(f"{_PATCH_BASE}.validate_permissions", return_value=[])
@patch(f"{_PATCH_BASE}.discover_stacks")
@patch(f"{_PATCH_BASE}.format_dry_run_output", return_value="Dry-run: 2 stack(s) discovered:")
def test_dry_run_returns_exit_code_0(self, mock_dry, mock_disc, mock_perm, mock_session, capsys):
stacks = _make_stacks(2)
mock_disc.return_value = stacks
exit_code = main(["--prefix", "CL-AppPipe-", "--dry-run"])
assert exit_code == 0
mock_dry.assert_called_once_with(stacks)
captured = capsys.readouterr()
assert "Dry-run" in captured.out
@patch(f"{_PATCH_BASE}.boto3.Session")
@patch(f"{_PATCH_BASE}.validate_permissions", return_value=[])
@patch(f"{_PATCH_BASE}.discover_stacks")
@patch(f"{_PATCH_BASE}.asyncio.run")
@patch(f"{_PATCH_BASE}.generate_report")
@patch(f"{_PATCH_BASE}.format_report", return_value="report")
@patch(f"{_PATCH_BASE}.get_exit_code", return_value=1)
def test_failed_stacks_return_exit_code_1(
self, mock_exit, mock_fmt, mock_gen, mock_arun, mock_disc, mock_perm, mock_session
):
stacks = _make_stacks(2)
results = _make_results(stacks, status="failed")
mock_disc.return_value = stacks
mock_arun.return_value = results
mock_gen.return_value = MagicMock()
exit_code = main(["--prefix", "CL-AppPipe-"])
assert exit_code == 1
@patch(f"{_PATCH_BASE}.boto3.Session")
@patch(f"{_PATCH_BASE}.validate_permissions", return_value=[])
@patch(f"{_PATCH_BASE}.discover_stacks")
@patch(f"{_PATCH_BASE}.format_dry_run_output", return_value="dry-run output")
def test_dry_run_does_not_call_update(self, mock_dry, mock_disc, mock_perm, mock_session):
"""Dry-run should never invoke update_all_stacks."""
mock_disc.return_value = _make_stacks(1)
with patch(f"{_PATCH_BASE}.asyncio.run") as mock_arun:
exit_code = main(["--dry-run"])
assert exit_code == 0
mock_arun.assert_not_called()
@patch(f"{_PATCH_BASE}.boto3.Session")
def test_profile_passed_to_session(self, mock_session_cls):
"""--profile should be forwarded to boto3.Session."""
mock_session = MagicMock()
mock_session.client.return_value = MagicMock()
mock_session_cls.return_value = mock_session
with patch(f"{_PATCH_BASE}.validate_permissions", return_value=["cloudformation:ListStacks"]):
main(["--profile", "audit"])
mock_session_cls.assert_called_once_with(profile_name="audit")
@patch(f"{_PATCH_BASE}.boto3.Session")
def test_region_passed_to_session(self, mock_session_cls):
"""--region should be forwarded to boto3.Session."""
mock_session = MagicMock()
mock_session.client.return_value = MagicMock()
mock_session_cls.return_value = mock_session
with patch(f"{_PATCH_BASE}.validate_permissions", return_value=["cloudformation:ListStacks"]):
main(["--region", "ap-southeast-1"])
mock_session_cls.assert_called_once_with(region_name="ap-southeast-1")
@patch(f"{_PATCH_BASE}.boto3.Session")
@patch(f"{_PATCH_BASE}.validate_permissions", return_value=[])
@patch(f"{_PATCH_BASE}.discover_stacks")
def test_discovered_count_printed(self, mock_disc, mock_perm, mock_session, capsys):
mock_disc.return_value = _make_stacks(5)
with patch(f"{_PATCH_BASE}.format_dry_run_output", return_value="dry"):
main(["--dry-run"])
captured = capsys.readouterr()
assert "Discovered 5 stack(s)" in captured.out

325
tests/test_discovery.py Normal file
View file

@ -0,0 +1,325 @@
# Feature: one-click-cfn-stack-updater, Property 1: Discovery returns exactly prefix-matched stacks with correct count
"""Property-based tests for stack discovery prefix filtering.
**Validates: Requirements 1.1, 1.3**
Property 1: For any list of CloudFormation stacks with arbitrary names,
the discovery function returns exactly those stacks whose names start with
the configured prefix, and the reported count equals the length of that
filtered list.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from hypothesis import given, settings, strategies as st
from cfn_updater.discovery import discover_stacks
PREFIX = "CL-AppPipe-"
# Strategy: generate a suffix that doesn't accidentally start with the prefix
_safe_suffix = st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "Pd"), whitelist_characters="-_"),
min_size=1,
max_size=20,
)
# Stack names that DO match the prefix
_prefixed_name = _safe_suffix.map(lambda s: PREFIX + s)
# Stack names that do NOT match the prefix — use a different leading string
_non_prefixed_name = _safe_suffix.map(lambda s: "OTHER-" + s).filter(
lambda n: not n.startswith(PREFIX)
)
# A mixed list of stack names (some prefixed, some not)
_stack_names = st.lists(
st.one_of(_prefixed_name, _non_prefixed_name),
min_size=0,
max_size=30,
)
def _mock_cfn_client(stack_names: list[str]) -> MagicMock:
"""Build a mock cfn_client whose list_stacks returns the given names."""
client = MagicMock()
summaries = [
{"StackName": name, "StackStatus": "CREATE_COMPLETE"}
for name in stack_names
]
client.list_stacks.return_value = {
"StackSummaries": summaries,
# No NextToken → single page
}
return client
@settings(max_examples=100)
@given(names=_stack_names)
def test_discovery_returns_exactly_prefix_matched_stacks(
names: list[str],
) -> None:
"""Property 1: Discovery returns exactly prefix-matched stacks with correct count.
**Validates: Requirements 1.1, 1.3**
"""
cfn = _mock_cfn_client(names)
result = discover_stacks(cfn, PREFIX)
expected_names = [n for n in names if n.startswith(PREFIX)]
# The returned stacks are exactly those whose names start with the prefix
assert [s.name for s in result] == expected_names
# The count equals the number of prefix-matched stacks
assert len(result) == len(expected_names)
# Feature: one-click-cfn-stack-updater, Property 6: Non-updatable stacks are skipped
"""Property-based test for non-updatable stack classification.
**Validates: Requirements 4.2**
Property 6: For any stack with a status in NON_UPDATABLE_STATUSES, updatable
is False. For any stack with a status not in NON_UPDATABLE_STATUSES, updatable
is True.
"""
from cfn_updater.config import NON_UPDATABLE_STATUSES
# Statuses that appear in _ACTIVE_STACK_STATUSES (excludes DELETE_COMPLETE)
_UPDATABLE_STATUSES = [
"CREATE_IN_PROGRESS",
"CREATE_FAILED",
"CREATE_COMPLETE",
"ROLLBACK_FAILED",
"DELETE_FAILED",
"UPDATE_IN_PROGRESS",
"UPDATE_COMPLETE_CLEANUP_IN_PROGRESS",
"UPDATE_COMPLETE",
"UPDATE_FAILED",
"UPDATE_ROLLBACK_FAILED",
"UPDATE_ROLLBACK_COMPLETE",
"REVIEW_IN_PROGRESS",
"IMPORT_IN_PROGRESS",
"IMPORT_COMPLETE",
"IMPORT_ROLLBACK_IN_PROGRESS",
"IMPORT_ROLLBACK_FAILED",
"IMPORT_ROLLBACK_COMPLETE",
]
# Non-updatable statuses that are in _ACTIVE_STACK_STATUSES (excludes DELETE_COMPLETE)
_NON_UPDATABLE_ACTIVE_STATUSES = [
"ROLLBACK_COMPLETE",
"ROLLBACK_IN_PROGRESS",
"UPDATE_ROLLBACK_IN_PROGRESS",
"UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS",
"DELETE_IN_PROGRESS",
]
_ALL_ACTIVE_STATUSES = _UPDATABLE_STATUSES + _NON_UPDATABLE_ACTIVE_STATUSES
# Strategy: generate a list of (suffix, status) pairs for stacks with the prefix
_stack_entry = st.tuples(
_safe_suffix,
st.sampled_from(_ALL_ACTIVE_STATUSES),
)
_stack_entries = st.lists(_stack_entry, min_size=1, max_size=30)
def _mock_cfn_client_with_statuses(entries: list[tuple[str, str]]) -> MagicMock:
"""Build a mock cfn_client returning stacks with given (suffix, status) pairs."""
client = MagicMock()
summaries = [
{"StackName": PREFIX + suffix, "StackStatus": status}
for suffix, status in entries
]
client.list_stacks.return_value = {"StackSummaries": summaries}
return client
@settings(max_examples=100)
@given(entries=_stack_entries)
def test_non_updatable_stacks_are_classified_correctly(
entries: list[tuple[str, str]],
) -> None:
"""Property 6: Non-updatable stacks are skipped.
**Validates: Requirements 4.2**
"""
cfn = _mock_cfn_client_with_statuses(entries)
result = discover_stacks(cfn, PREFIX)
assert len(result) == len(entries)
for stack, (_, status) in zip(result, entries):
if status in NON_UPDATABLE_STATUSES:
assert stack.updatable is False, (
f"Stack with status {status!r} should NOT be updatable"
)
else:
assert stack.updatable is True, (
f"Stack with status {status!r} should be updatable"
)
# ---------------------------------------------------------------------------
# Unit tests for stack discovery
# Requirements: 1.1, 1.2, 1.3, 1.4, 4.2
# ---------------------------------------------------------------------------
class TestDiscoverStacksEmpty:
"""Req 1.4: If no Target_Stacks are found, return an empty list."""
def test_empty_stack_list_returns_empty(self) -> None:
"""No stacks at all → empty result."""
client = MagicMock()
client.list_stacks.return_value = {"StackSummaries": []}
result = discover_stacks(client, PREFIX)
assert result == []
def test_no_matching_prefix_returns_empty(self) -> None:
"""Stacks exist but none match the prefix → empty result."""
client = MagicMock()
client.list_stacks.return_value = {
"StackSummaries": [
{"StackName": "OTHER-stack-1", "StackStatus": "CREATE_COMPLETE"},
{"StackName": "my-app-stack", "StackStatus": "UPDATE_COMPLETE"},
]
}
result = discover_stacks(client, PREFIX)
assert result == []
class TestDiscoverStacksPagination:
"""Pagination: discover_stacks must follow NextToken across pages."""
def test_two_pages_are_merged(self) -> None:
"""Two pages of results are combined into a single list."""
client = MagicMock()
client.list_stacks.side_effect = [
{
"StackSummaries": [
{"StackName": "CL-AppPipe-aaa", "StackStatus": "CREATE_COMPLETE"},
],
"NextToken": "page2",
},
{
"StackSummaries": [
{"StackName": "CL-AppPipe-bbb", "StackStatus": "UPDATE_COMPLETE"},
],
# No NextToken → last page
},
]
result = discover_stacks(client, PREFIX)
assert len(result) == 2
assert result[0].name == "CL-AppPipe-aaa"
assert result[1].name == "CL-AppPipe-bbb"
# Verify the second call included the NextToken
calls = client.list_stacks.call_args_list
assert len(calls) == 2
assert "NextToken" not in calls[0].kwargs
assert calls[1].kwargs["NextToken"] == "page2"
def test_three_pages_with_mixed_stacks(self) -> None:
"""Three pages, some stacks match prefix and some don't."""
client = MagicMock()
client.list_stacks.side_effect = [
{
"StackSummaries": [
{"StackName": "CL-AppPipe-p1", "StackStatus": "CREATE_COMPLETE"},
{"StackName": "OTHER-stack", "StackStatus": "CREATE_COMPLETE"},
],
"NextToken": "tok2",
},
{
"StackSummaries": [
{"StackName": "CL-AppPipe-p2", "StackStatus": "UPDATE_COMPLETE"},
],
"NextToken": "tok3",
},
{
"StackSummaries": [
{"StackName": "CL-AppPipe-p3", "StackStatus": "CREATE_COMPLETE"},
],
},
]
result = discover_stacks(client, PREFIX)
assert [s.name for s in result] == [
"CL-AppPipe-p1",
"CL-AppPipe-p2",
"CL-AppPipe-p3",
]
assert client.list_stacks.call_count == 3
class TestDiscoverStacksMixedUpdatability:
"""Req 4.2: Non-updatable stacks are marked updatable=False."""
def test_mixed_updatable_and_non_updatable(self) -> None:
"""Stacks with various statuses are classified correctly."""
client = MagicMock()
client.list_stacks.return_value = {
"StackSummaries": [
{"StackName": "CL-AppPipe-ok1", "StackStatus": "CREATE_COMPLETE"},
{"StackName": "CL-AppPipe-rb", "StackStatus": "ROLLBACK_COMPLETE"},
{"StackName": "CL-AppPipe-ok2", "StackStatus": "UPDATE_COMPLETE"},
{"StackName": "CL-AppPipe-del", "StackStatus": "DELETE_IN_PROGRESS"},
{"StackName": "CL-AppPipe-ok3", "StackStatus": "IMPORT_COMPLETE"},
]
}
result = discover_stacks(client, PREFIX)
assert len(result) == 5
# updatable stacks
assert result[0].updatable is True # CREATE_COMPLETE
assert result[2].updatable is True # UPDATE_COMPLETE
assert result[4].updatable is True # IMPORT_COMPLETE
# non-updatable stacks
assert result[1].updatable is False # ROLLBACK_COMPLETE
assert result[3].updatable is False # DELETE_IN_PROGRESS
def test_all_non_updatable(self) -> None:
"""Every discovered stack is non-updatable."""
client = MagicMock()
client.list_stacks.return_value = {
"StackSummaries": [
{"StackName": "CL-AppPipe-a", "StackStatus": "ROLLBACK_COMPLETE"},
{"StackName": "CL-AppPipe-b", "StackStatus": "DELETE_IN_PROGRESS"},
{"StackName": "CL-AppPipe-c", "StackStatus": "ROLLBACK_IN_PROGRESS"},
]
}
result = discover_stacks(client, PREFIX)
assert all(s.updatable is False for s in result)
def test_all_updatable(self) -> None:
"""Every discovered stack is updatable."""
client = MagicMock()
client.list_stacks.return_value = {
"StackSummaries": [
{"StackName": "CL-AppPipe-x", "StackStatus": "CREATE_COMPLETE"},
{"StackName": "CL-AppPipe-y", "StackStatus": "UPDATE_COMPLETE"},
]
}
result = discover_stacks(client, PREFIX)
assert all(s.updatable is True for s in result)

128
tests/test_dry_run.py Normal file
View file

@ -0,0 +1,128 @@
"""Property and unit tests for dry-run mode.
Requirements: 6.1, 6.2, 6.3
"""
from __future__ import annotations
import string
from hypothesis import given, settings, strategies as st
from cfn_updater.models import DiscoveredStack
from cfn_updater.updater import format_dry_run_output
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
_stack_name = st.text(
alphabet=string.ascii_letters + string.digits + "-",
min_size=1,
max_size=40,
).map(lambda s: f"CL-AppPipe-{s}")
_stack_status = st.sampled_from([
"CREATE_COMPLETE",
"UPDATE_COMPLETE",
"ROLLBACK_COMPLETE",
"DELETE_IN_PROGRESS",
"UPDATE_ROLLBACK_COMPLETE",
"CREATE_IN_PROGRESS",
])
_discovered_stack = st.builds(
DiscoveredStack,
name=_stack_name,
status=_stack_status,
updatable=st.booleans(),
)
# ---------------------------------------------------------------------------
# Property-Based Test — Property 10
# ---------------------------------------------------------------------------
# Feature: one-click-cfn-stack-updater, Property 10: Dry-run performs no updates and lists all discovered stacks
class TestPropertyDryRunNoUpdates:
"""
**Validates: Requirements 6.2, 6.3**
For any set of discovered stacks with dry-run enabled, zero UpdateStack
calls are made and the output contains the name and status of every
discovered stack.
"""
@settings(max_examples=100)
@given(stacks=st.lists(_discovered_stack, min_size=0, max_size=30))
def test_dry_run_lists_all_stacks_without_updates(self, stacks: list[DiscoveredStack]) -> None:
output = format_dry_run_output(stacks)
# The function is pure formatting — it never receives a CFN client,
# so zero UpdateStack calls are made by construction.
# Every stack's name and status must appear in the output.
for stack in stacks:
assert stack.name in output, f"Missing stack name: {stack.name}"
assert stack.status in output, f"Missing stack status: {stack.status}"
# ---------------------------------------------------------------------------
# Unit Tests
# ---------------------------------------------------------------------------
class TestDryRunOutputFormat:
"""Req 6.2, 6.3: Dry-run outputs all stack names and statuses."""
def test_single_stack(self) -> None:
stacks = [
DiscoveredStack(name="CL-AppPipe-abc123", status="CREATE_COMPLETE", updatable=True),
]
output = format_dry_run_output(stacks)
assert "CL-AppPipe-abc123" in output
assert "CREATE_COMPLETE" in output
def test_multiple_stacks(self) -> None:
stacks = [
DiscoveredStack(name="CL-AppPipe-aaa", status="CREATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-bbb", status="UPDATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-ccc", status="ROLLBACK_COMPLETE", updatable=False),
]
output = format_dry_run_output(stacks)
for stack in stacks:
assert stack.name in output
assert stack.status in output
def test_empty_stack_list(self) -> None:
output = format_dry_run_output([])
assert "No stacks found" in output
def test_output_contains_count(self) -> None:
stacks = [
DiscoveredStack(name=f"CL-AppPipe-s{i}", status="CREATE_COMPLETE", updatable=True)
for i in range(5)
]
output = format_dry_run_output(stacks)
assert "5" in output
class TestDryRunNoApiCalls:
"""Req 6.1, 6.2: Dry-run makes no update API calls.
format_dry_run_output is a pure function that takes a list of
DiscoveredStack and returns a string. It has no CFN client parameter,
so it is impossible for it to make any API calls by design.
"""
def test_function_signature_has_no_client_parameter(self) -> None:
import inspect
sig = inspect.signature(format_dry_run_output)
param_names = list(sig.parameters.keys())
# Only parameter is 'stacks'
assert param_names == ["stacks"]

266
tests/test_permissions.py Normal file
View file

@ -0,0 +1,266 @@
# Feature: one-click-cfn-stack-updater, Property 11: Permission validation correctness
"""Property-based tests for permission validation correctness.
**Validates: Requirements 7.1, 7.2**
Property 11: For any subset of required permissions marked as missing,
the validator returns exactly those missing permissions. When any
permissions are missing, no UpdateStack calls are made.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from botocore.exceptions import ClientError
from hypothesis import given, settings, strategies as st
from cfn_updater.permissions import REQUIRED_PERMISSIONS, validate_permissions
# The three permissions under test.
_ALL_PERMS = frozenset(REQUIRED_PERMISSIONS)
def _access_denied_error(operation: str = "Unknown") -> ClientError:
"""Build a botocore ClientError that looks like an AccessDenied response."""
return ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access Denied"}},
operation,
)
def _build_mocks(missing: frozenset[str]):
"""Return (cfn_client, sts_client, iam_client) mocks configured so that
exactly the permissions in *missing* appear to be absent.
"""
cfn = MagicMock()
sts = MagicMock()
iam = MagicMock()
# --- ListStacks ---
if "cloudformation:ListStacks" in missing:
cfn.list_stacks.side_effect = _access_denied_error("ListStacks")
else:
cfn.list_stacks.return_value = {"StackStatusFilter": []}
# --- DescribeStacks ---
if "cloudformation:DescribeStacks" in missing:
cfn.describe_stacks.side_effect = _access_denied_error("DescribeStacks")
else:
# Permission present — the stack doesn't exist, so CFN returns
# a ValidationError which the code treats as "permission OK".
cfn.describe_stacks.side_effect = ClientError(
{
"Error": {
"Code": "ValidationError",
"Message": "Stack does not exist",
}
},
"DescribeStacks",
)
# --- UpdateStack (via IAM simulation) ---
if "cloudformation:UpdateStack" in missing:
sts.get_caller_identity.return_value = {
"Arn": "arn:aws:iam::123456789012:user/test"
}
iam.simulate_principal_policy.return_value = {
"EvaluationResults": [
{
"EvalActionName": "cloudformation:UpdateStack",
"EvalDecision": "implicitDeny",
}
]
}
else:
sts.get_caller_identity.return_value = {
"Arn": "arn:aws:iam::123456789012:user/test"
}
iam.simulate_principal_policy.return_value = {
"EvaluationResults": [
{
"EvalActionName": "cloudformation:UpdateStack",
"EvalDecision": "allowed",
}
]
}
return cfn, sts, iam
# ---------------------------------------------------------------------------
# Strategy: draw any subset of the three required permissions as "missing".
# ---------------------------------------------------------------------------
missing_perms_strategy = st.frozensets(
st.sampled_from(sorted(REQUIRED_PERMISSIONS)),
min_size=0,
max_size=len(REQUIRED_PERMISSIONS),
)
@settings(max_examples=100)
@given(missing=missing_perms_strategy)
def test_permission_validation_returns_exactly_missing_perms(
missing: frozenset[str],
) -> None:
"""Property 11: validate_permissions returns exactly the missing subset.
**Validates: Requirements 7.1, 7.2**
"""
cfn, sts, iam = _build_mocks(missing)
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert set(result) == set(missing), (
f"Expected missing={sorted(missing)}, got {sorted(result)}"
)
# ---------------------------------------------------------------------------
# Unit tests for permission validation
# Validates: Requirements 7.1, 7.2
# ---------------------------------------------------------------------------
import pytest
class TestValidatePermissionsHappyPath:
"""All permissions present — validator returns an empty list."""
def test_all_permissions_present(self):
cfn, sts, iam = _build_mocks(missing=frozenset())
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert result == []
def test_all_present_calls_list_stacks(self):
cfn, sts, iam = _build_mocks(missing=frozenset())
validate_permissions(cfn, sts_client=sts, iam_client=iam)
cfn.list_stacks.assert_called_once()
def test_all_present_calls_describe_stacks(self):
cfn, sts, iam = _build_mocks(missing=frozenset())
validate_permissions(cfn, sts_client=sts, iam_client=iam)
cfn.describe_stacks.assert_called_once()
class TestValidatePermissionsSingleMissing:
"""Exactly one permission missing at a time."""
def test_missing_list_stacks(self):
cfn, sts, iam = _build_mocks(
missing=frozenset({"cloudformation:ListStacks"})
)
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert result == ["cloudformation:ListStacks"]
def test_missing_describe_stacks(self):
cfn, sts, iam = _build_mocks(
missing=frozenset({"cloudformation:DescribeStacks"})
)
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert result == ["cloudformation:DescribeStacks"]
def test_missing_update_stack(self):
cfn, sts, iam = _build_mocks(
missing=frozenset({"cloudformation:UpdateStack"})
)
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert result == ["cloudformation:UpdateStack"]
class TestValidatePermissionsAllMissing:
"""All three permissions missing."""
def test_all_permissions_missing(self):
cfn, sts, iam = _build_mocks(missing=frozenset(REQUIRED_PERMISSIONS))
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert set(result) == set(REQUIRED_PERMISSIONS)
assert len(result) == 3
class TestValidatePermissionsBoto3Errors:
"""boto3 / botocore error handling during validation."""
def test_unexpected_client_error_propagates_from_list_stacks(self):
"""A non-access-denied ClientError should bubble up, not be swallowed."""
cfn, sts, iam = _build_mocks(missing=frozenset())
cfn.list_stacks.side_effect = ClientError(
{"Error": {"Code": "InternalError", "Message": "Something broke"}},
"ListStacks",
)
with pytest.raises(ClientError) as exc_info:
validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert exc_info.value.response["Error"]["Code"] == "InternalError"
def test_unexpected_client_error_propagates_from_describe_stacks(self):
cfn, sts, iam = _build_mocks(missing=frozenset())
cfn.describe_stacks.side_effect = ClientError(
{"Error": {"Code": "InternalError", "Message": "Something broke"}},
"DescribeStacks",
)
with pytest.raises(ClientError) as exc_info:
validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert exc_info.value.response["Error"]["Code"] == "InternalError"
def test_no_sts_client_defers_update_check(self):
"""Without sts_client, UpdateStack check is deferred (returns True)."""
cfn, _, iam = _build_mocks(missing=frozenset())
result = validate_permissions(cfn, sts_client=None, iam_client=iam)
assert "cloudformation:UpdateStack" not in result
def test_no_iam_client_defers_update_check(self):
"""Without iam_client, UpdateStack check is deferred (returns True)."""
cfn, sts, _ = _build_mocks(missing=frozenset())
result = validate_permissions(cfn, sts_client=sts, iam_client=None)
assert "cloudformation:UpdateStack" not in result
def test_simulate_policy_access_denied_reports_missing(self):
"""If simulate_principal_policy itself is denied, UpdateStack is flagged."""
cfn, sts, iam = _build_mocks(missing=frozenset())
sts.get_caller_identity.return_value = {
"Arn": "arn:aws:iam::123456789012:user/test"
}
iam.simulate_principal_policy.side_effect = _access_denied_error(
"SimulatePrincipalPolicy"
)
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert "cloudformation:UpdateStack" in result
def test_simulate_policy_unexpected_error_defers(self):
"""Non-access-denied ClientError from simulation defers the check."""
cfn, sts, iam = _build_mocks(missing=frozenset())
sts.get_caller_identity.return_value = {
"Arn": "arn:aws:iam::123456789012:user/test"
}
iam.simulate_principal_policy.side_effect = ClientError(
{"Error": {"Code": "ServiceException", "Message": "Oops"}},
"SimulatePrincipalPolicy",
)
result = validate_permissions(cfn, sts_client=sts, iam_client=iam)
assert "cloudformation:UpdateStack" not in result

212
tests/test_report.py Normal file
View file

@ -0,0 +1,212 @@
"""Tests for the report generator module.
Property-based test: Property 9 (report aggregation and exit code correctness)
Unit tests: Requirements 5.1, 5.2, 5.3, 5.4
"""
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
from hypothesis import given, settings, strategies as st
from cfn_updater.models import StackUpdateResult, UpdateRunReport
from cfn_updater.report import format_report, generate_report, get_exit_code
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
_statuses = st.sampled_from(["succeeded", "failed", "skipped", "no-update-needed"])
_stack_update_result = st.builds(
StackUpdateResult,
stack_name=st.text(min_size=1, max_size=30).map(lambda s: f"CL-AppPipe-{s}"),
status=_statuses,
error=st.none(),
duration_seconds=st.floats(min_value=0.0, max_value=300.0, allow_nan=False),
)
_results_list = st.lists(_stack_update_result, min_size=0, max_size=50)
# ---------------------------------------------------------------------------
# Property-Based Test
# ---------------------------------------------------------------------------
# Feature: one-click-cfn-stack-updater, Property 9: Report aggregation and exit code correctness
class TestPropertyReportAggregation:
"""
**Validates: Requirements 5.2, 5.3**
For any list of StackUpdateResult, the generated report's succeeded,
failed, skipped, and no_update_needed counts equal the actual counts
of each status in the input list, and the exit code is non-zero if
and only if failed > 0.
"""
@settings(max_examples=100)
@given(results=_results_list)
def test_report_counts_match_actual_and_exit_code_correct(
self, results: list[StackUpdateResult]
) -> None:
start = datetime(2024, 1, 1)
end = datetime(2024, 1, 1, 1, 0, 0)
total_found = len(results) + 5 # arbitrary; independent of counts
report = generate_report(results, total_found, start, end)
# Counts must match actual occurrences
expected_succeeded = sum(1 for r in results if r.status == "succeeded")
expected_failed = sum(1 for r in results if r.status == "failed")
expected_skipped = sum(1 for r in results if r.status == "skipped")
expected_no_update = sum(1 for r in results if r.status == "no-update-needed")
assert report.succeeded == expected_succeeded
assert report.failed == expected_failed
assert report.skipped == expected_skipped
assert report.no_update_needed == expected_no_update
# Exit code: non-zero iff failed > 0
exit_code = get_exit_code(report)
if expected_failed > 0:
assert exit_code != 0
else:
assert exit_code == 0
# ---------------------------------------------------------------------------
# Unit Tests
# ---------------------------------------------------------------------------
_START = datetime(2024, 6, 15, 10, 0, 0)
_END = datetime(2024, 6, 15, 10, 5, 30)
class TestGenerateReportAllSuccess:
"""Req 5.2: All stacks succeed."""
def test_all_success_counts(self) -> None:
results = [
StackUpdateResult(stack_name="CL-AppPipe-a", status="succeeded", duration_seconds=1.0),
StackUpdateResult(stack_name="CL-AppPipe-b", status="succeeded", duration_seconds=2.0),
StackUpdateResult(stack_name="CL-AppPipe-c", status="succeeded", duration_seconds=0.5),
]
report = generate_report(results, total_found=3, start_time=_START, end_time=_END)
assert report.succeeded == 3
assert report.failed == 0
assert report.skipped == 0
assert report.no_update_needed == 0
assert report.total_found == 3
assert len(report.results) == 3
def test_all_success_exit_code_zero(self) -> None:
results = [
StackUpdateResult(stack_name="CL-AppPipe-x", status="succeeded"),
]
report = generate_report(results, total_found=1, start_time=_START, end_time=_END)
assert get_exit_code(report) == 0
class TestGenerateReportMixed:
"""Req 5.2, 5.3: Mixed results produce correct counts and non-zero exit."""
def test_mixed_results_counts(self) -> None:
results = [
StackUpdateResult(stack_name="CL-AppPipe-ok", status="succeeded", duration_seconds=1.0),
StackUpdateResult(stack_name="CL-AppPipe-fail", status="failed", error="boom", duration_seconds=2.0),
StackUpdateResult(stack_name="CL-AppPipe-skip", status="skipped", duration_seconds=0.0),
StackUpdateResult(stack_name="CL-AppPipe-noop", status="no-update-needed", duration_seconds=0.3),
StackUpdateResult(stack_name="CL-AppPipe-ok2", status="succeeded", duration_seconds=1.5),
]
report = generate_report(results, total_found=10, start_time=_START, end_time=_END)
assert report.succeeded == 2
assert report.failed == 1
assert report.skipped == 1
assert report.no_update_needed == 1
assert report.total_found == 10
def test_mixed_results_exit_code_nonzero(self) -> None:
results = [
StackUpdateResult(stack_name="CL-AppPipe-ok", status="succeeded"),
StackUpdateResult(stack_name="CL-AppPipe-fail", status="failed", error="err"),
]
report = generate_report(results, total_found=2, start_time=_START, end_time=_END)
assert get_exit_code(report) == 1
class TestGenerateReportEmpty:
"""Req 5.2: Empty results list."""
def test_empty_results_all_counts_zero(self) -> None:
report = generate_report([], total_found=0, start_time=_START, end_time=_END)
assert report.succeeded == 0
assert report.failed == 0
assert report.skipped == 0
assert report.no_update_needed == 0
assert report.total_found == 0
assert report.results == []
def test_empty_results_exit_code_zero(self) -> None:
report = generate_report([], total_found=0, start_time=_START, end_time=_END)
assert get_exit_code(report) == 0
class TestFormatReport:
"""Req 5.1, 5.4: Formatted output contains expected fields."""
def test_format_contains_times(self) -> None:
report = generate_report([], total_found=0, start_time=_START, end_time=_END)
output = format_report(report)
assert _START.isoformat() in output
assert _END.isoformat() in output
def test_format_contains_stack_names_and_statuses(self) -> None:
results = [
StackUpdateResult(stack_name="CL-AppPipe-alpha", status="succeeded", duration_seconds=1.2),
StackUpdateResult(stack_name="CL-AppPipe-beta", status="failed", error="timeout", duration_seconds=5.0),
]
report = generate_report(results, total_found=2, start_time=_START, end_time=_END)
output = format_report(report)
assert "CL-AppPipe-alpha" in output
assert "succeeded" in output
assert "CL-AppPipe-beta" in output
assert "failed" in output
assert "timeout" in output
def test_format_contains_summary_counts(self) -> None:
results = [
StackUpdateResult(stack_name="CL-AppPipe-a", status="succeeded"),
StackUpdateResult(stack_name="CL-AppPipe-b", status="skipped"),
StackUpdateResult(stack_name="CL-AppPipe-c", status="no-update-needed"),
]
report = generate_report(results, total_found=5, start_time=_START, end_time=_END)
output = format_report(report)
assert "Total Found: 5" in output
assert "Succeeded : 1" in output
assert "Failed : 0" in output
assert "Skipped : 1" in output
assert "No Update Needed: 1" in output
def test_format_contains_duration(self) -> None:
results = [
StackUpdateResult(stack_name="CL-AppPipe-d", status="succeeded", duration_seconds=3.7),
]
report = generate_report(results, total_found=1, start_time=_START, end_time=_END)
output = format_report(report)
assert "3.7s" in output

697
tests/test_updater.py Normal file
View file

@ -0,0 +1,697 @@
"""Unit tests for the update_stack() coroutine.
Requirements: 3.1, 3.2, 3.4, 4.1, 4.3, 4.4
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from botocore.exceptions import ClientError
from cfn_updater.config import BASE_RETRY_DELAY, TEMPLATE_URL
from cfn_updater.updater import update_stack
def _make_client_error(code: str, message: str = "error") -> ClientError:
"""Helper to create a botocore ClientError."""
return ClientError(
{"Error": {"Code": code, "Message": message}},
"UpdateStack",
)
def _mock_cfn_client(
parameters: list[dict] | None = None,
update_side_effect=None,
describe_side_effect=None,
) -> MagicMock:
"""Build a mock CFN client with configurable describe/update behavior."""
client = MagicMock()
if describe_side_effect is not None:
client.describe_stacks.side_effect = describe_side_effect
else:
params = parameters or []
client.describe_stacks.return_value = {
"Stacks": [
{
"StackName": "CL-AppPipe-test",
"Parameters": params,
}
]
}
if update_side_effect is not None:
client.update_stack.side_effect = update_side_effect
else:
client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/test"}
return client
class TestUpdateStackSuccess:
"""Req 3.1, 3.2: Successful update with parameter preservation."""
def test_successful_update_returns_succeeded(self) -> None:
params = [
{"ParameterKey": "Param1", "ParameterValue": "val1"},
{"ParameterKey": "Param2", "ParameterValue": "val2"},
]
client = _mock_cfn_client(parameters=params)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
assert result.stack_name == "CL-AppPipe-test"
assert result.status == "succeeded"
assert result.error is None
assert result.duration_seconds > 0
def test_update_passes_use_previous_value_for_all_params(self) -> None:
params = [
{"ParameterKey": "KeyA", "ParameterValue": "a"},
{"ParameterKey": "KeyB", "ParameterValue": "b"},
{"ParameterKey": "KeyC", "ParameterValue": "c"},
]
client = _mock_cfn_client(parameters=params)
asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
call_kwargs = client.update_stack.call_args.kwargs
assert call_kwargs["TemplateURL"] == TEMPLATE_URL
assert call_kwargs["Parameters"] == [
{"ParameterKey": "KeyA", "UsePreviousValue": True},
{"ParameterKey": "KeyB", "UsePreviousValue": True},
{"ParameterKey": "KeyC", "UsePreviousValue": True},
]
def test_update_with_no_existing_params(self) -> None:
client = _mock_cfn_client(parameters=[])
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
assert result.status == "succeeded"
call_kwargs = client.update_stack.call_args.kwargs
assert call_kwargs["Parameters"] == []
class TestUpdateStackNoUpdates:
"""Req 3.4: 'No updates are to be performed' → no-update-needed."""
def test_no_updates_response_returns_no_update_needed(self) -> None:
client = _mock_cfn_client(
update_side_effect=_make_client_error(
"ValidationError",
"No updates are to be performed",
)
)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
assert result.status == "no-update-needed"
assert result.error is None
class TestUpdateStackThrottling:
"""Req 4.3: Throttling triggers exponential backoff retries."""
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_throttling_retries_then_succeeds(self, mock_sleep) -> None:
"""Two throttling errors then success on third attempt."""
client = _mock_cfn_client(
update_side_effect=[
_make_client_error("Throttling", "Rate exceeded"),
_make_client_error("Throttling", "Rate exceeded"),
{"StackId": "arn:aws:cfn:us-east-1:123:stack/test"},
]
)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL, max_retries=3))
assert result.status == "succeeded"
assert client.update_stack.call_count == 3
# Verify exponential backoff delays
assert mock_sleep.call_count == 2
assert mock_sleep.call_args_list[0].args[0] == BASE_RETRY_DELAY * (2 ** 0) # 1s
assert mock_sleep.call_args_list[1].args[0] == BASE_RETRY_DELAY * (2 ** 1) # 2s
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_request_limit_exceeded_retries(self, mock_sleep) -> None:
"""RequestLimitExceeded also triggers retry."""
client = _mock_cfn_client(
update_side_effect=[
_make_client_error("RequestLimitExceeded", "Too many requests"),
{"StackId": "arn:aws:cfn:us-east-1:123:stack/test"},
]
)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL, max_retries=3))
assert result.status == "succeeded"
assert client.update_stack.call_count == 2
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_throttling_exhausts_retries_returns_failed(self, mock_sleep) -> None:
"""Req 4.4: All retries exhausted → failed."""
client = _mock_cfn_client(
update_side_effect=[
_make_client_error("Throttling", "Rate exceeded"),
_make_client_error("Throttling", "Rate exceeded"),
_make_client_error("Throttling", "Rate exceeded"),
_make_client_error("Throttling", "Rate exceeded"),
]
)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL, max_retries=3))
assert result.status == "failed"
assert "Rate exceeded" in result.error
assert client.update_stack.call_count == 4 # initial + 3 retries
class TestUpdateStackErrors:
"""Req 4.1: Failures are recorded, not raised."""
def test_non_retryable_client_error_returns_failed(self) -> None:
client = _mock_cfn_client(
update_side_effect=_make_client_error(
"ValidationError",
"Stack is in UPDATE_IN_PROGRESS state and can not be updated",
)
)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
assert result.status == "failed"
assert "UPDATE_IN_PROGRESS" in result.error
def test_describe_stacks_error_returns_failed(self) -> None:
client = _mock_cfn_client(
describe_side_effect=_make_client_error(
"ValidationError",
"Stack with id CL-AppPipe-gone does not exist",
)
)
result = asyncio.run(update_stack(client, "CL-AppPipe-gone", TEMPLATE_URL))
assert result.status == "failed"
assert "does not exist" in result.error
def test_unexpected_exception_returns_failed(self) -> None:
client = _mock_cfn_client(
describe_side_effect=ConnectionError("Network unreachable")
)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
assert result.status == "failed"
assert "Network unreachable" in result.error
def test_empty_stacks_response_returns_failed(self) -> None:
"""describe_stacks returns empty Stacks list."""
client = MagicMock()
client.describe_stacks.return_value = {"Stacks": []}
result = asyncio.run(update_stack(client, "CL-AppPipe-ghost", TEMPLATE_URL))
assert result.status == "failed"
assert "not found" in result.error
class TestUpdateStackDuration:
"""Duration is always recorded."""
def test_duration_is_positive_on_success(self) -> None:
client = _mock_cfn_client()
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
assert result.duration_seconds >= 0
def test_duration_is_positive_on_failure(self) -> None:
client = _mock_cfn_client(
update_side_effect=_make_client_error("InternalError", "boom")
)
result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL))
assert result.duration_seconds >= 0
# ---------------------------------------------------------------------------
# Property-Based Tests (hypothesis)
# ---------------------------------------------------------------------------
import string
from hypothesis import given, settings, strategies as st
from cfn_updater.config import MAX_RETRIES, TEMPLATE_URL as CFG_TEMPLATE_URL
from cfn_updater.models import DiscoveredStack
from cfn_updater.updater import update_all_stacks
# Reusable strategies -------------------------------------------------------
# Parameter keys: non-empty ASCII identifiers (CloudFormation keys are strings)
_param_key = st.text(
alphabet=string.ascii_letters + string.digits,
min_size=1,
max_size=30,
)
# A list of unique parameter keys (1-20 keys)
_param_keys = st.lists(_param_key, min_size=1, max_size=20, unique=True)
# Stack name strategy
_stack_name = st.text(
alphabet=string.ascii_letters + string.digits + "-",
min_size=1,
max_size=40,
).map(lambda s: f"CL-AppPipe-{s}")
# Feature: one-click-cfn-stack-updater, Property 4: Update call preserves existing parameters and uses correct template URL
class TestPropertyParameterPreservation:
"""
**Validates: Requirements 3.1, 3.2**
For any stack with any set of parameters, UpdateStack includes all
parameter keys with UsePreviousValue=True and TemplateURL equals the
configured TEMPLATE_URL.
"""
@settings(max_examples=100)
@given(param_keys=_param_keys)
def test_update_preserves_all_parameters_and_template_url(self, param_keys: list[str]) -> None:
# Build mock describe_stacks response with the generated parameter keys
params = [{"ParameterKey": k, "ParameterValue": "v"} for k in param_keys]
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "CL-AppPipe-test", "Parameters": params}]
}
client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/test"}
result = asyncio.run(update_stack(client, "CL-AppPipe-test", CFG_TEMPLATE_URL))
assert result.status == "succeeded"
call_kwargs = client.update_stack.call_args.kwargs
# TemplateURL must equal the configured constant
assert call_kwargs["TemplateURL"] == CFG_TEMPLATE_URL
# Every generated key must appear with UsePreviousValue=True
sent_params = call_kwargs["Parameters"]
sent_keys = {p["ParameterKey"] for p in sent_params}
assert sent_keys == set(param_keys)
for p in sent_params:
assert p["UsePreviousValue"] is True
# Feature: one-click-cfn-stack-updater, Property 5: "No updates" response maps to no-update-needed status
class TestPropertyNoUpdatesHandling:
"""
**Validates: Requirements 3.4**
For any stack returning "No updates are to be performed", result status
is no-update-needed.
"""
@settings(max_examples=100)
@given(stack_name=_stack_name)
def test_no_updates_response_maps_to_no_update_needed(self, stack_name: str) -> None:
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": stack_name, "Parameters": []}]
}
client.update_stack.side_effect = _make_client_error(
"ValidationError", "No updates are to be performed"
)
result = asyncio.run(update_stack(client, stack_name, CFG_TEMPLATE_URL))
assert result.stack_name == stack_name
assert result.status == "no-update-needed"
assert result.error is None
# Feature: one-click-cfn-stack-updater, Property 2: All updatable stacks are attempted
class TestPropertyAllUpdatableStacksAttempted:
"""
**Validates: Requirements 2.2**
For any set of discovered stacks, exactly one result per updatable stack;
no drops, no duplicates.
"""
@settings(max_examples=100)
@given(
stacks=st.lists(
st.tuples(
_stack_name,
st.booleans(), # updatable flag
),
min_size=1,
max_size=20,
unique_by=lambda t: t[0],
)
)
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_one_result_per_stack_no_drops_no_duplicates(
self, mock_sleep, stacks: list[tuple[str, bool]]
) -> None:
discovered = [
DiscoveredStack(name=name, status="CREATE_COMPLETE" if updatable else "DELETE_COMPLETE", updatable=updatable)
for name, updatable in stacks
]
client = MagicMock()
# describe_stacks returns empty params for any stack
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "x", "Parameters": []}]
}
client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"}
results = asyncio.run(update_all_stacks(client, discovered, CFG_TEMPLATE_URL, concurrency=5))
# Exactly one result per input stack
assert len(results) == len(discovered)
# Result names match input names (same order)
result_names = [r.stack_name for r in results]
input_names = [s.name for s in discovered]
assert result_names == input_names
# Updatable stacks should not be skipped
for stack, result in zip(discovered, results):
if stack.updatable:
assert result.status != "skipped"
else:
assert result.status == "skipped"
# Feature: one-click-cfn-stack-updater, Property 3: Concurrency limit invariant
class TestPropertyConcurrencyLimitInvariant:
"""
**Validates: Requirements 2.3, 3.3**
For any positive concurrency limit and stack list, concurrent in-progress
updates never exceed the limit.
"""
@settings(max_examples=100)
@given(
concurrency=st.integers(min_value=1, max_value=10),
num_stacks=st.integers(min_value=1, max_value=30),
)
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_concurrent_updates_never_exceed_limit(
self, mock_sleep, concurrency: int, num_stacks: int
) -> None:
discovered = [
DiscoveredStack(name=f"CL-AppPipe-s{i}", status="CREATE_COMPLETE", updatable=True)
for i in range(num_stacks)
]
# Use threading primitives since the synchronous mock side_effect
# runs on the event loop thread but we need atomic counter updates.
import threading
peak_concurrent = 0
current_concurrent = 0
counter_lock = threading.Lock()
event_for_yield: list[asyncio.Event] = []
original_update_return = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"}
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "x", "Parameters": []}]
}
def _tracking_update(**kwargs):
"""Synchronous side_effect that tracks concurrency via describe_stacks calls."""
nonlocal peak_concurrent, current_concurrent
with counter_lock:
current_concurrent += 1
if current_concurrent > peak_concurrent:
peak_concurrent = current_concurrent
result = original_update_return
with counter_lock:
current_concurrent -= 1
return result
client.update_stack.side_effect = _tracking_update
results = asyncio.run(
update_all_stacks(client, discovered, CFG_TEMPLATE_URL, concurrency=concurrency)
)
assert len(results) == num_stacks
assert peak_concurrent <= concurrency
# Feature: one-click-cfn-stack-updater, Property 7: Fault isolation — failures do not block remaining stacks
class TestPropertyFaultIsolation:
"""
**Validates: Requirements 4.1, 4.4**
For any N updatable stacks where K fail, results are produced for all N
stacks.
"""
@settings(max_examples=100)
@given(
stacks_and_failures=st.lists(
st.tuples(
_stack_name,
st.booleans(), # True = will fail
),
min_size=1,
max_size=20,
unique_by=lambda t: t[0],
)
)
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_all_stacks_get_results_even_when_some_fail(
self, mock_sleep, stacks_and_failures: list[tuple[str, bool]]
) -> None:
discovered = [
DiscoveredStack(name=name, status="CREATE_COMPLETE", updatable=True)
for name, _ in stacks_and_failures
]
# Build a set of stack names that should fail
failing_names = {name for name, should_fail in stacks_and_failures if should_fail}
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "x", "Parameters": []}]
}
def _update_side_effect(**kwargs):
stack_name = kwargs.get("StackName", "")
if stack_name in failing_names:
raise _make_client_error("InternalError", f"Simulated failure for {stack_name}")
return {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"}
client.update_stack.side_effect = _update_side_effect
results = asyncio.run(update_all_stacks(client, discovered, CFG_TEMPLATE_URL, concurrency=5))
# Every input stack must have a result
assert len(results) == len(discovered)
result_names = {r.stack_name for r in results}
input_names = {s.name for s in discovered}
assert result_names == input_names
# Feature: one-click-cfn-stack-updater, Property 8: Throttling triggers exponential backoff retries
class TestPropertyExponentialBackoff:
"""
**Validates: Requirements 4.3**
For any stack receiving throttling errors, retries up to MAX_RETRIES times.
Delay between attempt i and i+1 is at least BASE_RETRY_DELAY * 2^i seconds.
"""
@settings(max_examples=100)
@given(
num_throttles=st.integers(min_value=1, max_value=MAX_RETRIES),
)
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_throttling_retries_with_correct_backoff_delays(
self, mock_sleep, num_throttles: int
) -> None:
# Build side effects: num_throttles throttling errors then success
side_effects: list = [
_make_client_error("Throttling", "Rate exceeded")
for _ in range(num_throttles)
]
side_effects.append({"StackId": "arn:aws:cfn:us-east-1:123:stack/test"})
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "CL-AppPipe-test", "Parameters": []}]
}
client.update_stack.side_effect = side_effects
result = asyncio.run(
update_stack(client, "CL-AppPipe-test", CFG_TEMPLATE_URL, max_retries=MAX_RETRIES)
)
assert result.status == "succeeded"
# update_stack called num_throttles + 1 times (retries + final success)
assert client.update_stack.call_count == num_throttles + 1
# asyncio.sleep called once per throttle
assert mock_sleep.call_count == num_throttles
# Verify exponential backoff: delay_i >= BASE_RETRY_DELAY * 2^i
for i, call in enumerate(mock_sleep.call_args_list):
expected_delay = BASE_RETRY_DELAY * (2 ** i)
actual_delay = call.args[0]
assert actual_delay >= expected_delay, (
f"Attempt {i}: expected delay >= {expected_delay}, got {actual_delay}"
)
# ---------------------------------------------------------------------------
# Unit Tests for update_all_stacks()
# Requirements: 3.1, 3.2, 3.4, 4.1, 4.2, 4.3, 4.4
# ---------------------------------------------------------------------------
class TestUpdateAllStacksSuccess:
"""Req 2.2, 3.1: All updatable stacks are updated successfully."""
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_all_updatable_stacks_succeed(self, mock_sleep) -> None:
stacks = [
DiscoveredStack(name="CL-AppPipe-aaa", status="CREATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-bbb", status="UPDATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-ccc", status="CREATE_COMPLETE", updatable=True),
]
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "x", "Parameters": [{"ParameterKey": "P1", "ParameterValue": "v1"}]}]
}
client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"}
results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=5))
assert len(results) == 3
assert all(r.status == "succeeded" for r in results)
assert [r.stack_name for r in results] == ["CL-AppPipe-aaa", "CL-AppPipe-bbb", "CL-AppPipe-ccc"]
assert client.update_stack.call_count == 3
class TestUpdateAllStacksMixed:
"""Req 4.2: Non-updatable stacks are skipped, updatable stacks are updated."""
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_non_updatable_stacks_skipped(self, mock_sleep) -> None:
stacks = [
DiscoveredStack(name="CL-AppPipe-ok1", status="CREATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-rb", status="ROLLBACK_COMPLETE", updatable=False),
DiscoveredStack(name="CL-AppPipe-ok2", status="UPDATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-del", status="DELETE_COMPLETE", updatable=False),
]
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "x", "Parameters": []}]
}
client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"}
results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=5))
assert len(results) == 4
assert results[0].status == "succeeded"
assert results[0].stack_name == "CL-AppPipe-ok1"
assert results[1].status == "skipped"
assert results[1].stack_name == "CL-AppPipe-rb"
assert results[2].status == "succeeded"
assert results[2].stack_name == "CL-AppPipe-ok2"
assert results[3].status == "skipped"
assert results[3].stack_name == "CL-AppPipe-del"
# Only updatable stacks trigger update_stack calls
assert client.update_stack.call_count == 2
class TestUpdateAllStacksFaultIsolation:
"""Req 4.1, 4.4: Failures in some stacks don't block others."""
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_some_stacks_fail_others_succeed(self, mock_sleep) -> None:
stacks = [
DiscoveredStack(name="CL-AppPipe-good1", status="CREATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-bad", status="CREATE_COMPLETE", updatable=True),
DiscoveredStack(name="CL-AppPipe-good2", status="CREATE_COMPLETE", updatable=True),
]
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "x", "Parameters": []}]
}
def _update_side_effect(**kwargs):
if kwargs.get("StackName") == "CL-AppPipe-bad":
raise _make_client_error("ValidationError", "Stack is in UPDATE_IN_PROGRESS state")
return {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"}
client.update_stack.side_effect = _update_side_effect
results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=5))
assert len(results) == 3
assert results[0].status == "succeeded"
assert results[1].status == "failed"
assert "UPDATE_IN_PROGRESS" in results[1].error
assert results[2].status == "succeeded"
class TestUpdateAllStacksSequential:
"""Req 2.3: Concurrency=1 means sequential execution."""
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_concurrency_one_runs_sequentially(self, mock_sleep) -> None:
stacks = [
DiscoveredStack(name=f"CL-AppPipe-s{i}", status="CREATE_COMPLETE", updatable=True)
for i in range(4)
]
call_order: list[str] = []
client = MagicMock()
client.describe_stacks.return_value = {
"Stacks": [{"StackName": "x", "Parameters": []}]
}
def _tracking_update(**kwargs):
call_order.append(kwargs["StackName"])
return {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"}
client.update_stack.side_effect = _tracking_update
results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=1))
assert len(results) == 4
assert all(r.status == "succeeded" for r in results)
# All stacks were called
assert len(call_order) == 4
class TestUpdateAllStacksEmpty:
"""Req 1.4: Empty stack list produces no results and no errors."""
@patch("cfn_updater.updater.asyncio.sleep", return_value=None)
def test_empty_stack_list(self, mock_sleep) -> None:
client = MagicMock()
results = asyncio.run(update_all_stacks(client, [], TEMPLATE_URL, concurrency=5))
assert results == []
client.describe_stacks.assert_not_called()
client.update_stack.assert_not_called()