Cfn: Fix bug preventing reuse of stack names
diff --git a/localstack/services/cloudformation/stores.py b/localstack/services/cloudformation/stores.py
index 48cf5260217e0..8020d328c5561 100644
--- a/localstack/services/cloudformation/stores.py
+++ b/localstack/services/cloudformation/stores.py
@@ -54,6 +54,7 @@ def get_cloudformation_store(account_id: str, region_name: str) -> CloudFormatio
 
 # TODO: rework / fix usage of this
 def find_stack(account_id: str, region_name: str, stack_name: str) -> Stack | None:
+    # Warning: This function may not return the correct stack if multiple stacks with same name exist.
     state = get_cloudformation_store(account_id, region_name)
     return (
         [s for s in state.stacks.values() if stack_name in [s.stack_name, s.stack_id]] or [None]
@@ -63,16 +64,13 @@ def find_stack(account_id: str, region_name: str, stack_name: str) -> Stack | No
 def find_change_set(
     account_id: str, region_name: str, cs_name: str, stack_name: Optional[str] = None
 ) -> Optional[StackChangeSet]:
-    state = get_cloudformation_store(account_id, region_name)
-    stack = find_stack(account_id, region_name, stack_name)
-    stacks = [stack] if stack else state.stacks.values()
-    result = [
-        cs
-        for s in stacks
-        for cs in s.change_sets
-        if cs_name in [cs.change_set_id, cs.change_set_name]
-    ]
-    return (result or [None])[0]
+    store = get_cloudformation_store(account_id, region_name)
+    for stack in store.stacks.values():
+        if stack_name in (stack.stack_name, stack.stack_id, None):
+            for change_set in stack.change_sets:
+                if cs_name in (change_set.change_set_id, change_set.change_set_name):
+                    return change_set
+    return None
 
 
 def exports_map(account_id: str, region_name: str):
diff --git a/tests/aws/services/cloudformation/api/test_changesets.py b/tests/aws/services/cloudformation/api/test_changesets.py
index ebf3012c0d711..79a323bd6f10b 100644
--- a/tests/aws/services/cloudformation/api/test_changesets.py
+++ b/tests/aws/services/cloudformation/api/test_changesets.py
@@ -995,3 +995,39 @@ def test_name_conflicts(aws_client, snapshot, cleanups):
         ChangeSetName=second_initial_changeset_id
     )
     snapshot.match("second_initial_changeset_id_desc", second_initial_changeset_id_desc)
+
+
+@markers.aws.validated
+def test_describe_change_set_with_similarly_named_stacks(deploy_cfn_template, aws_client):
+    stack_name = f"stack-{short_uid()}"
+    change_set_name = f"change-set-{short_uid()}"
+
+    # create a changeset
+    template_path = os.path.join(os.path.dirname(__file__), "../../../templates/ec2_keypair.yml")
+    template_body = load_template_raw(template_path)
+    aws_client.cloudformation.create_change_set(
+        StackName=stack_name,
+        ChangeSetName=change_set_name,
+        TemplateBody=template_body,
+        ChangeSetType="CREATE",
+    )
+
+    # delete the stack
+    aws_client.cloudformation.delete_stack(StackName=stack_name)
+    aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name)
+
+    # create a new changeset with the same name
+    response = aws_client.cloudformation.create_change_set(
+        StackName=stack_name,
+        ChangeSetName=change_set_name,
+        TemplateBody=template_body,
+        ChangeSetType="CREATE",
+    )
+
+    # ensure that the correct changeset is returned when requested by stack name
+    assert (
+        aws_client.cloudformation.describe_change_set(
+            ChangeSetName=response["Id"], StackName=stack_name
+        )["ChangeSetId"]
+        == response["Id"]
+    )
diff --git a/tests/aws/services/cloudformation/api/test_changesets.validation.json b/tests/aws/services/cloudformation/api/test_changesets.validation.json
index db8510fd9decd..70b374a8181f3 100644
--- a/tests/aws/services/cloudformation/api/test_changesets.validation.json
+++ b/tests/aws/services/cloudformation/api/test_changesets.validation.json
@@ -17,6 +17,9 @@
   "tests/aws/services/cloudformation/api/test_changesets.py::test_describe_change_set_nonexisting": {
     "last_validated_date": "2022-08-11T11:22:01+00:00"
   },
+  "tests/aws/services/cloudformation/api/test_changesets.py::test_describe_change_set_with_similarly_named_stacks": {
+    "last_validated_date": "2024-03-06T13:56:47+00:00"
+  },
   "tests/aws/services/cloudformation/api/test_changesets.py::test_empty_changeset": {
     "last_validated_date": "2022-08-10T08:52:55+00:00"
   },

## Summary

This PR fixes a bug that prevented stack names from being reused.

```
$ awslocal cloudformation deploy --stack-name bar --template-file ./localstack/tests/aws/templates/ec2_keypair.yml

Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - bar

$ awslocal cloudformation delete-stack --stack-name bar

$ awslocal cloudformation deploy --stack-name bar --template-file ./localstack/tests/aws/templates/ec2_keypair.yml

Waiting for changeset to be created..

'Status'
```

It turns out that the function `find_stack()` is not guaranteed to return the correct stack if multiple stacks exist with the same name.

This caused the deployment to fail because `awscli cfn deploy` calls the following API operations in succession. The second API operation (which depended on `find_stack()`) failed.

```
AWS cloudformation.CreateChangeSet => 200
AWS cloudformation.DescribeChangeSet => 404 (ChangeSetNotFound)
```

## Changes

- Rework the `find_change_set()` function to remove dependency on `find_stack()`.

## Tests

Adds an AWS validated test.

## Related

https://github.com/localstack/localstack/issues/9911

https://github.com/localstack/localstack/issues/10327