cfn-stack-updater/tests/test_updater.py
Vijaya Manne 632ac9e328 Initial commit: One-Click CloudFormation Stack Updater
- Python CLI tool for rolling updates of CL-AppPipe-* and CL-SvcPipe-* stacks
- Async update engine with configurable concurrency (asyncio.Semaphore)
- Exponential backoff retry for API throttling
- Dry-run mode for safe preview
- IAM permission pre-validation
- Comprehensive test suite (80 tests: 11 property-based + 69 unit)
- Full spec documentation (requirements, design, tasks)
2026-05-29 14:56:59 -04:00

697 lines
26 KiB
Python

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