- 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)
697 lines
26 KiB
Python
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()
|