8000 CFn v2: Implement stack deletion by simonrw · Pull Request #12576 · localstack/localstack · GitHub
[go: up one dir, main page]

Skip to content

CFn v2: Implement stack deletion #12576

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntity
node_resource = self._get_node_resource_for(
resource_name=depends_on_resource_logical_id, node_template=self._node_template
)
self.visit_node_resource(node_resource)
self.visit(node_resource)

return array_identifiers_delta

Expand Down Expand Up @@ -257,6 +257,7 @@ def _execute_resource_action(
resource_provider = resource_provider_executor.try_load_resource_provider(resource_type)

extra_resource_properties = {}
event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
if resource_provider is not None:
# TODO: stack events
try:
Expand All @@ -271,11 +272,15 @@ def _execute_resource_action(
exc_info=LOG.isEnabledFor(logging.DEBUG),
)
stack = self._change_set.stack
stack_status = stack.status
if stack_status == StackStatus.CREATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
elif stack_status == StackStatus.UPDATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
match stack.status:
case StackStatus.CREATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
case StackStatus.UPDATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
case StackStatus.DELETE_IN_PROGRESS:
stack.set_stack_status(StackStatus.DELETE_FAILED, reason=reason)
case _:
raise NotImplementedError(f"Unexpected stack status: {stack.status}")
# update resource status
stack.set_resource_status(
logical_resource_id=logical_resource_id,
Expand All @@ -288,8 +293,6 @@ def _execute_resource_action(
resource_status_reason=reason,
)
return
else:
event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})

self.resources.setdefault(logical_resource_id, {"Properties": {}})
match event.status:
Expand Down Expand Up @@ -341,13 +344,15 @@ def _execute_resource_action(
)
# TODO: duplication
stack = self._change_set.stack
stack_status = stack.status
if stack_status == StackStatus.CREATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
elif stack_status == StackStatus.UPDATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
else:
raise NotImplementedError(f"Unhandled stack status: '{stack.status}'")
match stack.status:
case StackStatus.CREATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.CREATE_FAILED, reason=reason)
case StackStatus.UPDATE_IN_PROGRESS:
stack.set_stack_status(StackStatus.UPDATE_FAILED, reason=reason)
case StackStatus.DELETE_IN_PROGRESS:
stack.set_stack_status(StackStatus.DELETE_FAILED, reason=reason)
case _:
raise NotImplementedError(f"Unhandled stack status: '{stack.status}'")
stack.set_resource_status(
logical_resource_id=logical_resource_id,
# TODO
Expand All @@ -358,8 +363,8 @@ def _execute_resource_action(
else ResourceStatus.UPDATE_FAILED,
resource_status_reason=reason,
)
case any:
raise NotImplementedError(f"Event status '{any}' not handled")
case other:
raise NotImplementedError(f"Event status '{other}' not handled")

def create_resource_provider_payload(
self,
Expand Down Expand Up @@ -387,7 +392,9 @@ def create_resource_provider_payload(
previous_resource_properties = before_properties_value or {}
case ChangeAction.Remove:
resource_properties = before_properties_value or {}
previous_resource_properties = None
# previous_resource_properties = None
# HACK: our providers use a mix of `desired_state` and `previous_state` so ensure the payload is present for both
previous_resource_properties = resource_properties
case _:
raise NotImplementedError(f"Action '{action}' not handled")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Stack:
status_reason: StackStatusReason | None
stack_id: str
creation_time: datetime
deletion_time: datetime | None

# state after deploy
resolved_parameters: dict[str, str]
Expand All @@ -67,6 +68,7 @@ def __init__(
self.status_reason = None
self.change_set_ids = change_set_ids or []
self.creation_time = datetime.now(tz=timezone.utc)
self.deletion_time = None

self.stack_name = request_payload["StackName"]
self.change_set_name = request_payload.get("ChangeSetName")
Expand Down Expand Up @@ -118,6 +120,7 @@ def describe_details(self) -> ApiStack:
result = {
"ChangeSetId": self.change_set_id,
"CreationTime": self.creation_time,
"DeletionTime": self.deletion_time,
"StackId": self.stack_id,
"StackName": self.stack_name,
"StackStatus": self.status,
Expand Down
52 changes: 44 additions & 8 deletions localstack-core/localstack/services/cloudformation/v2/provider.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
import logging
from datetime import datetime, timezone
from typing import Any

from localstack.aws.api import RequestContext, handler
Expand Down Expand Up @@ -101,7 +102,7 @@ def find_change_set_v2(
# TODO: check for active stacks
if (
stack_candidate.stack_name == stack_name
and stack.status != StackStatus.DELETE_COMPLETE
and stack_candidate.status != StackStatus.DELETE_COMPLETE
):
stack = stack_candidate
break
Expand Down Expand Up @@ -175,10 +176,10 @@ def create_change_set(
# on a CREATE an empty Stack should be generated if we didn't find an active one
if not active_stack_candidates and change_set_type == ChangeSetType.CREATE:
stack = Stack(
context.account_id,
context.region,
request,
structured_template,
account_id=context.account_id,
region_name=context.region,
request_payload=request,
template=structured_template,
template_body=template_body,
)
state.stacks_v2[stack.stack_id] = stack
Expand Down Expand Up @@ -240,7 +241,7 @@ def create_change_set(
after_template = structured_template

# create change set for the stack and apply changes
change_set = ChangeSet(stack, request)
change_set = ChangeSet(stack, request, template=after_template)

# only set parameters for the changeset, then switch to stack on execute_change_set
change_set.populate_update_graph(
Expand Down Expand Up @@ -309,6 +310,9 @@ def _run(*args):
change_set.stack.resolved_resources = result.resources
change_set.stack.resolved_parameters = result.parameters
change_set.stack.resolved_outputs = result.outputs
# if the deployment succeeded, update the stack's template representation to that
# which was just deployed
change_set.stack.template = change_set.template
except Exception as e:
LOG.error(
"Execute change set failed: %s", e, exc_info=LOG.isEnabledFor(logging.WARNING)
Expand Down Expand Up @@ -458,5 +462,37 @@ def delete_stack(
# aws will silently ignore invalid stack names - we should do the same
return

# TODO: actually delete
stack.set_stack_status(StackStatus.DELETE_COMPLETE)
# shortcut for stacks which have no deployed resources i.e. where a change set was
# created, but never executed
if stack.status == StackStatus.REVIEW_IN_PROGRESS and not stack.resolved_resources:
stack.set_stack_status(StackStatus.DELETE_COMPLETE)
stack.deletion_time = datetime.now(tz=timezone.utc)
return

# create a dummy change set
change_set = ChangeSet(stack, {"ChangeSetName": f"delete-stack_{stack.stack_name}"}) # noqa
change_set.populate_update_graph(
before_template=stack.template,
after_template=None,
before_parameters=stack.resolved_parameters,
after_parameters=None,
)

change_set_executor = ChangeSetModelExecutor(change_set)

def _run(*args):
try:
stack.set_stack_status(StackStatus.DELETE_IN_PROGRESS)
change_set_executor.execute()
stack.set_stack_status(StackStatus.DELETE_COMPLETE)
stack.deletion_time = datetime.now(tz=timezone.utc)
except Exception as e:
LOG.warning(
"Failed to delete stack '%s': %s",
stack.stack_name,
e,
exc_info=LOG.isEnabledFor(logging.DEBUG),
)
stack.set_stack_status(StackStatus.DELETE_FAILED)

start_worker_thread(_run)
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def delete(
client.describe_stream(StreamARN=model["Arn"])
10000 return ProgressEvent(
status=OperationStatus.IN_PROGRESS,
resource_model={},
resource_model=model,
)
except client.exceptions.ResourceNotFoundException:
return ProgressEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ def test_simple_update_two_resources(

res.destroy()

@pytest.mark.skip(reason="CFNV2:Destroy")
@markers.aws.validated
# TODO: the error response is incorrect, however the test is otherwise validated and raises
# an error because the SSM parameter has been deleted (removed from the stack).
Expand Down Expand Up @@ -576,7 +575,7 @@ def test_delete_change_set_exception(snapshot, aws_client):
snapshot.match("e2", e2.value.response)


@pytest.mark.skip("CFNV2:Destroy")
@pytest.mark.skip("CFNV2:Other")
@markers.aws.validated
def test_create_delete_create(aws_client, cleanups, deploy_cfn_template):
"""test the re-use of a changeset name with a re-used stack name"""
Expand Down Expand Up @@ -858,7 +857,7 @@ def _check_changeset_success():
snapshot.match("error_execute_failed", e.value)


@pytest.mark.skip(reason="CFNV2:Destroy")
@pytest.mark.skip(reason="CFNV2:Other delete change set not implemented yet")
@markers.aws.validated
def test_deleted_changeset(snapshot, cleanups, aws_client):
"""simple case verifying that proper exception is thrown when trying to get a deleted changeset"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,5 +498,20 @@
}
}
}
},
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_deleting_resource": {
"recorded-date": "02-06-2025, 10:29:41",
"recorded-content": {
"get-parameter-error": {
"Error": {
"Code": "ParameterNotFound",
"Message": ""
},
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@
"last_validated_date": "2025-04-01T16:40:03+00:00"
},
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_deleting_resource": {
"last_validated_date": "2025-04-15T15:07:18+00:00"
"last_validated_date": "2025-06-02T10:29:46+00:00",
"durations_in_seconds": {
"setup": 1.06,
"call": 20.61,
"teardown": 4.46,
"total": 26.13
}
},
"tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_simple_update_two_resources": {
"last_validated_date": "2025-04-02T10:05:26+00:00"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@


class TestStacksApi:
@pytest.mark.skip(reason="CFNV2:Destroy")
@pytest.mark.skip(reason="CFNV2:Other")
@markers.snapshot.skip_snapshot_verify(
paths=["$..ChangeSetId", "$..EnableTerminationProtection"]
)
Expand Down Expand Up @@ -445,7 +445,7 @@ def _assert_stack_process_finished():
]
assert len(updated_resources) == length_expected

@pytest.mark.skip(reason="CFNV2:Destroy")
@pytest.mark.skip(reason="CFNV2:Other")
@markers.aws.only_localstack
def test_create_stack_with_custom_id(
self, aws_client, cleanups, account_id, region_name, set_resource_custom_id
Expand Down Expand Up @@ -870,7 +870,7 @@ def test_describe_stack_events_errors(aws_client, snapshot):
TEMPLATE_ORDER_CASES = list(permutations(["A", "B", "C"]))


@pytest.mark.skip(reason="CFNV2:Destroy")
@pytest.mark.skip(reason="CFNV2:Other stack events")
@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_get_template_summary(deploy_cfn_template, snapshot, aws_client):
snapshot.match("template-summary", res)


@pytest.mark.skip(reason="CFNV2:Other, CFNV2:Destroy")
@pytest.mark.skip(reason="CFNV2:Other")
@markers.aws.validated
@pytest.mark.parametrize("url_style", ["s3_url", "http_path", "http_host", "http_invalid"])
def test_create_stack_from_s3_template_url(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,13 @@ def alarm_action_name_transformer(key: str, val: str):
response = aws_client.cloudwatch.describe_alarms(AlarmNames=[metric_alarm_name])
snapshot.match("metric_alarm", response["MetricAlarms"])

# CFNV2:Destroy does not destroy resources.
# stack.destroy()
# response = aws_client.cloudwatch.describe_alarms(
# AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"]
# )
# assert not response["CompositeAlarms"]
# response = aws_client.cloudwatch.describe_alarms(AlarmNames=[metric_alarm_name])
# assert not response["MetricAlarms"]
stack.destroy()
response = aws_client.cloudwatch.describe_alarms(
AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"]
)
assert not response["CompositeAlarms"]
response = aws_client.cloudwatch.describe_alarms(AlarmNames=[metric_alarm_name])
assert not response["MetricAlarms"]


@markers.aws.validated
Expand All @@ -114,7 +113,6 @@ def test_alarm_ext_statistic(aws_client, deploy_cfn_template, snapshot):
response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name])
snapshot.match("simple_alarm", response["MetricAlarms"])

# CFNV2:Destroy does not destroy resources.
# stack.destroy()
# response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name])
# assert not response["MetricAlarms"]
stack.destroy()
response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name])
assert not response["MetricAlarms"]
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,9 @@ def test_deploy_stack_with_dynamodb_table(deploy_cfn_template, aws_client, regio
rs = aws_client.dynamodb.list_tables()
assert ddb_table_name in rs["TableNames"]

# CFNV2:Destroy does not destroy resources.
# stack.destroy()
# rs = aws_client.dynamodb.list_tables()
# assert ddb_table_name not in rs["TableNames"]
stack.destroy()
rs = aws_client.dynamodb.list_tables()
assert ddb_table_name not in rs["TableNames"]


@markers.aws.validated
Expand Down Expand Up @@ -141,14 +140,13 @@ def test_global_table(deploy_cfn_template, snapshot, aws_client):
response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"])
snapshot.match("table_description", response)

# CFNV2:Destroy does not destroy resources.
# stack.destroy()
stack.destroy()

# with pytest.raises(Exception) as ex:
# aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"])
with pytest.raises(Exception) as ex:
aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"])

# error_code = ex.value.response["Error"]["Code"]
# assert "ResourceNotFoundException" == error_code
error_code = ex.value.response["Error"]["Code"]
assert "ResourceNotFoundException" == error_code


@pytest.mark.skip(reason="CFNV2:Other")
Expand Down
Loading
Loading
0