Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit d32e212

Browse files
authored
CFn: implement DeletionPolicy and UpdateReplacePolicy (#13535)
1 parent cf9a6fc commit d32e212

File tree

11 files changed

+570
-52
lines changed

11 files changed

+570
-52
lines changed

localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,26 +1046,20 @@ def _visit_type(self, scope: Scope, before_type: Any, after_type: Any) -> Termin
10461046

10471047
def _visit_deletion_policy(
10481048
self, scope: Scope, before_deletion_policy: Any, after_deletion_policy: Any
1049-
) -> TerminalValue:
1049+
) -> ChangeSetEntity:
10501050
value = self._visit_value(
10511051
scope=scope, before_value=before_deletion_policy, after_value=after_deletion_policy
10521052
)
1053-
if not isinstance(value, TerminalValue):
1054-
# TODO: decide where template schema validation should occur.
1055-
raise RuntimeError()
10561053
return value
10571054

10581055
def _visit_update_replace_policy(
10591056
self, scope: Scope, before_update_replace_policy: Any, after_deletion_policy: Any
1060-
) -> TerminalValue:
1057+
) -> ChangeSetEntity:
10611058
value = self._visit_value(
10621059
scope=scope,
10631060
before_value=before_update_replace_policy,
10641061
after_value=after_deletion_policy,
10651062
)
1066-
if not isinstance(value, TerminalValue):
1067-
# TODO: decide where template schema validation should occur.
1068-
raise RuntimeError()
10691063
return value
10701064

10711065
def _visit_resource(

localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
_AWS_URL_SUFFIX,
2929
MOCKED_REFERENCE,
3030
ChangeSetModelPreproc,
31+
DeletionPolicy,
3132
PreprocEntityDelta,
3233
PreprocOutput,
3334
PreprocProperties,
3435
PreprocResource,
36+
UpdateReplacePolicy,
3537
)
3638
from localstack.services.cloudformation.engine.v2.unsupported_resource import (
3739
should_ignore_unsupported_resource_type,
@@ -165,19 +167,25 @@ def _process_event(
165167
resource_type: str,
166168
special_action: str = None,
167169
reason: str = None,
170+
custom_status: ResourceStatus | str | None = None,
168171
):
169172
status_from_action = special_action or EventOperationFromAction[action.value]
173+
174+
status: ResourceStatus
170175
if event_status == OperationStatus.SUCCESS:
171-
status = f"{status_from_action}_COMPLETE"
176+
status = ResourceStatus(f"{status_from_action}_COMPLETE")
172177
else:
173-
status = f"{status_from_action}_{event_status.name}"
178+
status = ResourceStatus(f"{status_from_action}_{event_status.name}")
179+
180+
if custom_status:
181+
status = ResourceStatus(custom_status)
174182

175183
physical_resource_id = self._get_physical_id(logical_resource_id, False)
176184
self._change_set.stack.set_resource_status(
177185
logical_resource_id=logical_resource_id,
178186
physical_resource_id=physical_resource_id,
179187
resource_type=resource_type,
180-
status=ResourceStatus(status),
188+
status=status,
181189
resource_status_reason=reason,
182190
)
183191

@@ -335,27 +343,37 @@ def _execute_resource_change(
335343
)
336344

337345
def cleanup():
338-
self._process_event(
339-
action=ChangeAction.Remove,
340-
logical_resource_id=name,
341-
event_status=OperationStatus.IN_PROGRESS,
342-
resource_type=before.resource_type,
343-
)
344-
event = self._execute_resource_action(
345-
action=ChangeAction.Remove,
346-
logical_resource_id=name,
347-
resource_type=before.resource_type,
348-
before_properties=before_properties,
349-
after_properties=None,
350-
part_of_replacement=True,
351-
)
352-
self._process_event(
353-
action=ChangeAction.Remove,
354-
logical_resource_id=name,
355-
event_status=event.status,
356-
resource_type=before.resource_type,
357-
reason=event.message,
358-
)
346+
# TODO: handle other update replace policy values
347+
if after.update_replace_policy != UpdateReplacePolicy.Retain:
348+
self._process_event(
349+
action=ChangeAction.Remove,
350+
logical_resource_id=name,
351+
event_status=OperationStatus.IN_PROGRESS,
352+
resource_type=before.resource_type,
353+
)
354+
event = self._execute_resource_action(
355+
action=ChangeAction.Remove,
356+
logical_resource_id=name,
357+
resource_type=before.resource_type,
358+
before_properties=before_properties,
359+
after_properties=None,
360+
part_of_replacement=True,
361+
)
362+
self._process_event(
363+
action=ChangeAction.Remove,
364+
logical_resource_id=name,
365+
event_status=event.status,
366+
resource_type=before.resource_type,
367+
reason=event.message,
368+
)
369+
else:
370+
self._process_event(
371+
action=ChangeAction.Remove,
372+
logical_resource_id=name,
373+
event_status=OperationStatus.SUCCESS,
374+
resource_type=before.resource_type,
375+
custom_status=ResourceStatus.DELETE_SKIPPED,
376+
)
359377

360378
self._defer_action(f"cleanup-from-replacement-{name}", cleanup)
361379
else:
@@ -419,26 +437,37 @@ def perform_deletion():
419437
before_properties = self._merge_before_properties(name, before)
420438

421439
def perform_deletion():
422-
self._process_event(
423-
action=ChangeAction.Remove,
424-
logical_resource_id=name,
425-
resource_type=before.resource_type,
426-
event_status=OperationStatus.IN_PROGRESS,
427-
)
428-
event = self._execute_resource_action(
429-
action=ChangeAction.Remove,
430-
logical_resource_id=name,
431-
resource_type=before.resource_type,
432-
before_properties=before_properties,
433-
after_properties=None,
434-
)
435-
self._process_event(
436-
action=ChangeAction.Remove,
437-
logical_resource_id=name,
438-
event_status=event.status,
439-
resource_type=before.resource_type,
440-
reason=event.message,
441-
)
440+
# TODO: other deletion policies
441+
if before.deletion_policy != DeletionPolicy.Retain:
442+
self._process_event(
443+
action=ChangeAction.Remove,
444+
logical_resource_id=name,
445+
resource_type=before.resource_type,
446+
event_status=OperationStatus.IN_PROGRESS,
447+
)
448+
event = self._execute_resource_action(
449+
action=ChangeAction.Remove,
450+
logical_resource_id=name,
451+
resource_type=before.resource_type,
452+
before_properties=before_properties,
453+
after_properties=None,
454+
)
455+
456+
self._process_event(
457+
action=ChangeAction.Remove,
458+
logical_resource_id=name,
459+
event_status=event.status,
460+
resource_type=before.resource_type,
461+
reason=event.message,
462+
)
463+
else:
464+
self._process_event(
465+
action=ChangeAction.Remove,
466+
logical_resource_id=name,
467+
event_status=OperationStatus.SUCCESS,
468+
resource_type=before.resource_type,
469+
custom_status=ResourceStatus.DELETE_SKIPPED,
470+
)
442471

443472
self._defer_action(f"remove-{name}", perform_deletion)
444473
elif not is_nothing(after):

localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import copy
55
import re
66
from collections.abc import Callable
7+
from enum import StrEnum
78
from typing import Any, Final
89

910
from botocore.exceptions import ClientError
@@ -114,6 +115,19 @@ def __eq__(self, other):
114115
return self.properties == other.properties
115116

116117

118+
class DeletionPolicy(StrEnum):
119+
Retain = "Retain"
120+
Delete = "Delete"
121+
RetainExceptOnCreate = "RetainExceptOnCreate"
122+
Snapshot = "Snapshot"
123+
124+
125+
class UpdateReplacePolicy(StrEnum):
126+
Delete = "Delete"
127+
Retain = "Retain"
128+
Snapshot = "Snapshot"
129+
130+
117131
class PreprocResource:
118132
logical_id: str
119133
physical_resource_id: str | None
@@ -123,6 +137,9 @@ class PreprocResource:
123137
depends_on: list[str] | None
124138
requires_replacement: bool
125139
status: ResourceStatus | None
140+
# TODO: typing
141+
deletion_policy: DeletionPolicy | None
142+
update_replace_policy: UpdateReplacePolicy | None
126143

127144
def __init__(
128145
self,
@@ -134,6 +151,8 @@ def __init__(
134151
depends_on: list[str] | None,
135152
requires_replacement: bool,
136153
status: ResourceStatus | None = None,
154+
deletion_policy: str | None = None,
155+
update_replace_policy: str | None = None,
137156
):
138157
self.logical_id = logical_id
139158
self.physical_resource_id = physical_resource_id
@@ -143,6 +162,8 @@ def __init__(
143162
self.depends_on = depends_on
144163
self.requires_replacement = requires_replacement
145164
self.status = status
165+
self.deletion_policy = deletion_policy
166+
self.update_replace_policy = update_replace_policy
146167

147168
@staticmethod
148169
def _compare_conditions(c1: bool, c2: bool):
@@ -1276,6 +1297,20 @@ def visit_node_resource(
12761297
else:
12771298
properties_delta = PreprocEntityDelta(before=Nothing, after=Nothing)
12781299

1300+
deletion_policy_before = Nothing
1301+
deletion_policy_after = Nothing
1302+
if not is_nothing(node_resource.deletion_policy):
1303+
deletion_policy_delta = self.visit(node_resource.deletion_policy)
1304+
deletion_policy_before = deletion_policy_delta.before
1305+
deletion_policy_after = deletion_policy_delta.after
1306+
1307+
update_replace_policy_before = Nothing
1308+
update_replace_policy_after = Nothing
1309+
if not is_nothing(node_resource.update_replace_policy):
1310+
update_replace_policy_delta = self.visit(node_resource.update_replace_policy)
1311+
update_replace_policy_before = update_replace_policy_delta.before
1312+
update_replace_policy_after = update_replace_policy_delta.after
1313+
12791314
before = Nothing
12801315
after = Nothing
12811316
if should_process_before:
@@ -1291,6 +1326,8 @@ def visit_node_resource(
12911326
properties=properties_delta.before,
12921327
depends_on=depends_on_before,
12931328
requires_replacement=False,
1329+
deletion_policy=deletion_policy_before,
1330+
update_replace_policy=update_replace_policy_before,
12941331
)
12951332
if should_process_after:
12961333
logical_resource_id = node_resource.name
@@ -1308,6 +1345,8 @@ def visit_node_resource(
13081345
properties=properties_delta.after,
13091346
depends_on=depends_on_after,
13101347
requires_replacement=node_resource.requires_replacement,
1348+
deletion_policy=deletion_policy_after,
1349+
update_replace_policy=update_replace_policy_after,
13111350
)
13121351
return PreprocEntityDelta(before=before, after=after)
13131352

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import os
2+
3+
import pytest
4+
from botocore.exceptions import ClientError
5+
from tests.aws.services.cloudformation.conftest import skip_if_legacy_engine
6+
7+
from localstack.testing.pytest import markers
8+
from localstack.utils.strings import short_uid
9+
10+
11+
@markers.snapshot.skip_snapshot_verify(
12+
paths=[
13+
# our message is different. The AWS message does not seem to include the parameter
14+
# name but ours does
15+
"$..message",
16+
]
17+
)
18+
@markers.aws.validated
19+
def test_deletion_policy_with_deletion(aws_client, deploy_cfn_template, snapshot):
20+
template_path = os.path.join(
21+
os.path.dirname(__file__),
22+
"../../../templates/deletion_policy.yaml",
23+
)
24+
parameter_value = short_uid()
25+
stack = deploy_cfn_template(
26+
template_path=template_path,
27+
parameters={
28+
"EnvType": "dev",
29+
"ParameterValue": parameter_value,
30+
},
31+
)
32+
33+
parameter_name = stack.outputs["ParameterName"]
34+
value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"]
35+
assert value == parameter_value
36+
37+
stack.destroy()
38+
39+
with pytest.raises(ClientError) as exc_info:
40+
aws_client.ssm.get_parameter(Name=parameter_name)
41+
42+
snapshot.match("error", {"message": str(exc_info.value)})
43+
44+
45+
@markers.aws.validated
46+
@markers.snapshot.skip_snapshot_verify(
47+
paths=[
48+
"$..PhysicalResourceId",
49+
]
50+
)
51+
@skip_if_legacy_engine()
52+
def test_deletion_policy_with_retain(
53+
aws_client, deploy_cfn_template, capture_per_resource_events, snapshot, cleanups
54+
):
55+
snapshot.add_transformer(snapshot.transform.cloudformation_api())
56+
template_path = os.path.join(
57+
os.path.dirname(__file__),
58+
"../../../templates/deletion_policy.yaml",
59+
)
60+
parameter_value = short_uid()
61+
stack = deploy_cfn_template(
62+
template_path=template_path,
63+
parameters={
64+
"EnvType": "prod",
65+
"ParameterValue": parameter_value,
66+
},
67+
)
68+
69+
snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "<stack-name>"))
70+
71+
parameter_name = stack.outputs["ParameterName"]
72+
73+
# make sure we clean up the parameter
74+
cleanups.append(lambda: aws_client.ssm.delete_parameter(Name=parameter_name))
75+
76+
value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"]
77+
assert value == parameter_value
78+
79+
stack.destroy()
80+
81+
value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"]
82+
assert value == parameter_value
83+
84+
events = capture_per_resource_events(stack.stack_id)
85+
snapshot.match("per-resource-events", events)

0 commit comments

Comments
 (0)