Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
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 @@ -106,14 +106,15 @@ def execute(self) -> ChangeSetModelExecutorResult:
except Exception as e:
failure_message = str(e)

is_deletion = self._change_set.stack.status == StackStatus.DELETE_IN_PROGRESS
if self._deferred_actions:
if failure_message:
# TODO: differentiate between update and create
self._change_set.stack.set_stack_status(StackStatus.ROLLBACK_IN_PROGRESS)
else:
if not is_deletion:
# TODO: correct status
# TODO: differentiate between update and create
self._change_set.stack.set_stack_status(
StackStatus.UPDATE_COMPLETE_CLEANUP_IN_PROGRESS
StackStatus.ROLLBACK_IN_PROGRESS
if failure_message
else StackStatus.UPDATE_COMPLETE_CLEANUP_IN_PROGRESS
)

# perform all deferred actions such as deletions. These must happen in reverse from their
Expand All @@ -123,7 +124,7 @@ def execute(self) -> ChangeSetModelExecutorResult:
LOG.debug("executing deferred action: '%s'", deferred.name)
deferred.action()

if failure_message:
if failure_message and not is_deletion:
# TODO: differentiate between update and create
self._change_set.stack.set_stack_status(StackStatus.ROLLBACK_COMPLETE)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,14 @@ def create_change_set(
f"Stack [{stack_name}] already exists and cannot be created again with the changeSet [{change_set_name}]."
)

if change_set_type == ChangeSetType.UPDATE and (
stack.status == StackStatus.DELETE_COMPLETE
or stack.status == StackStatus.DELETE_IN_PROGRESS
):
raise ValidationError(
f"Stack:{stack.stack_id} is in {stack.status} state and can not be updated."
)

before_parameters: dict[str, Parameter] | None = None
match change_set_type:
case ChangeSetType.UPDATE:
Expand Down Expand Up @@ -1534,6 +1542,14 @@ def update_stack(
raise RuntimeError("Multiple stacks matched, update matching logic")
stack = active_stack_candidates[0]

if (
stack.status == StackStatus.DELETE_COMPLETE
or stack.status == StackStatus.DELETE_IN_PROGRESS
):
raise ValidationError(
f"Stack:{stack.stack_id} is in {stack.status} state and can not be updated."
)

# TODO: proper status modeling
before_parameters = stack.resolved_parameters
# TODO: reconsider the way parameters are modelled in the update graph process.
Expand Down Expand Up @@ -1681,6 +1697,7 @@ def _run(*args):
stack.set_stack_status(StackStatus.DELETE_FAILED)

start_worker_thread(_run)
return ExecuteChangeSetOutput()

@handler("ListExports")
def list_exports(
Expand Down
37 changes: 37 additions & 0 deletions tests/aws/services/cloudformation/api/test_changesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1487,3 +1487,40 @@ def test_update_change_set_with_aws_novalue_repro(aws_client, cleanups):
{"ParameterKey": "FallbackBucketName", "ParameterValue": fallback_bucket},
],
)


@markers.aws.validated
@skip_if_legacy_engine
def test_changeset_for_deleted_stack(aws_client, deploy_cfn_template, snapshot):
parameter_resource_body = {
"Type": "AWS::SSM::Parameter",
"Properties": {"Type": "String", "Value": "Test"},
}
template = json.dumps(
{"Resources": {f"Parameter{i}": parameter_resource_body for i in range(5)}}
)

stack = deploy_cfn_template(template=template)
aws_client.cloudformation.delete_stack(StackName=stack.stack_id)

with pytest.raises(ClientError) as in_progress_ex:
aws_client.cloudformation.create_change_set(
StackName=stack.stack_id,
ChangeSetName="test",
TemplateBody=template,
ChangeSetType="UPDATE",
)

aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack.stack_id)

with pytest.raises(ClientError) as complete_ex:
aws_client.cloudformation.create_change_set(
StackName=stack.stack_id,
ChangeSetName="test",
TemplateBody=template,
ChangeSetType="UPDATE",
)

snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "<stack-id>"))
snapshot.match("ErrorForInProgress", in_progress_ex.value.response)
snapshot.match("ErrorForComplete", complete_ex.value.response)
Original file line number Diff line number Diff line change
Expand Up @@ -655,5 +655,59 @@
}
}
}
},
"tests/aws/services/cloudformation/api/test_changesets.py::test_create_changeset_for_deleted_stack": {
"recorded-date": "10-10-2025, 18:02:05",
"recorded-content": {
"ErrorForCreate": {
"Error": {
"Code": "ValidationError",
"Message": "Stack [<stack-id>] already exists and cannot be created again with the changeSet [test].",
"Type": "Sender"
},
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
},
"ErrorForUpdate": {
"Error": {
"Code": "ValidationError",
"Message": "Stack:<stack-id> is in DELETE_COMPLETE state and can not be updated.",
"Type": "Sender"
},
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
},
"tests/aws/services/cloudformation/api/test_changesets.py::test_changeset_for_deleted_stack": {
"recorded-date": "10-10-2025, 18:49:37",
"recorded-content": {
"ErrorForInProgress": {
"Error": {
"Code": "ValidationError",
"Message": "Stack:<stack-id> is in DELETE_IN_PROGRESS state and can not be updated.",
"Type": "Sender"
},
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
},
"ErrorForComplete": {
"Error": {
"Code": "ValidationError",
"Message": "Stack:<stack-id> is in DELETE_COMPLETE state and can not be updated.",
"Type": "Sender"
},
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@
"tests/aws/services/cloudformation/api/test_changesets.py::TestUpdates::test_simple_update_two_resources": {
"last_validated_date": "2025-04-02T10:05:26+00:00"
},
"tests/aws/services/cloudformation/api/test_changesets.py::test_changeset_for_deleted_stack": {
"last_validated_date": "2025-10-10T19:59:52+00:00",
"durations_in_seconds": {
"setup": 0.25,
"call": 15.15,
"teardown": 0.12,
"total": 15.52
}
},
"tests/aws/services/cloudformation/api/test_changesets.py::test_create_and_then_update_refreshes_template_metadata": {
"last_validated_date": "2025-08-20T22:17:01+00:00",
"durations_in_seconds": {
Expand Down Expand Up @@ -77,6 +86,15 @@
"tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_update_without_parameters": {
"last_validated_date": "2022-05-31T07:32:02+00:00"
},
"tests/aws/services/cloudformation/api/test_changesets.py::test_create_changeset_for_deleted_stack": {
"last_validated_date": "2025-10-10T18:02:05+00:00",
"durations_in_seconds": {
"setup": 0.27,
"call": 16.63,
"teardown": 0.13,
"total": 17.03
}
},
"tests/aws/services/cloudformation/api/test_changesets.py::test_create_changeset_with_stack_id": {
"last_validated_date": "2023-11-28T06:48:23+00:00"
},
Expand Down
30 changes: 30 additions & 0 deletions tests/aws/services/cloudformation/api/test_update_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import botocore.errorfactory
import botocore.exceptions
import pytest
from tests.aws.services.cloudformation.conftest import skip_if_legacy_engine

from localstack.testing.pytest import markers
from localstack.utils.files import load_file
Expand Down Expand Up @@ -459,3 +460,32 @@ def test_diff_after_update(deploy_cfn_template, aws_client, snapshot):

describe_stack_response = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)
assert describe_stack_response["Stacks"][0]["StackStatus"] == "UPDATE_COMPLETE"


@markers.aws.validated
@skip_if_legacy_engine
def test_update_deleted_stack(aws_client, deploy_cfn_template, snapshot):
template = json.dumps(
{
"Resources": {
"Parameter": {
"Type": "AWS::SSM::Parameter",
"Properties": {"Type": "String", "Value": "Test"},
}
}
}
)

stack = deploy_cfn_template(template=template)
stack.destroy()

with pytest.raises(botocore.exceptions.ClientError) as ex:
aws_client.cloudformation.create_change_set(
StackName=stack.stack_id,
ChangeSetName="test",
TemplateBody=template,
ChangeSetType="UPDATE",
)

snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "<stack-id>"))
snapshot.match("Error", ex.value.response)
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,21 @@
}
}
}
},
"tests/aws/services/cloudformation/api/test_update_stack.py::test_update_deleted_stack": {
"recorded-date": "10-10-2025, 21:42:05",
"recorded-content": {
"Error": {
"Error": {
"Code": "ValidationError",
"Message": "Stack:<stack-id> is in DELETE_COMPLETE state and can not be updated.",
"Type": "Sender"
},
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
"tests/aws/services/cloudformation/api/test_update_stack.py::test_no_template_error": {
"last_validated_date": "2022-11-21T07:57:45+00:00"
},
"tests/aws/services/cloudformation/api/test_update_stack.py::test_update_deleted_stack": {
"last_validated_date": "2025-10-10T21:42:05+00:00",
"durations_in_seconds": {
"setup": 0.25,
"call": 18.99,
"teardown": 0.12,
"total": 19.36
}
},
"tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_invalid_rollback_configuration_errors": {
"last_validated_date": "2022-11-21T14:36:32+00:00"
},
Expand Down
Loading