Repository: getmoto/moto
Pull request: cloudformation:  fix  get_template_summary() related error

fixes #4424

Problem statement:
cloudformation: `get_template_summary()` fails for stacks created using `create_change_set()`
cloudformation call `get_template_summary()` fails for stacks created using `create_change_set()`.  Can be reproduced by
```py

    dummy = {
        "AWSTemplateFormatVersion": "2010-09-09",
        "Description": "Stack 3",
        "Resources": {
            "VPC": {"Properties": {"CidrBlock": "192.168.0.0/16"}, "Type": "AWS::EC2::VPC"}
        },
    }

    conn = boto3.client("cloudformation", region_name="us-east-1")
    conn.create_change_set(
        StackName="stack_from_changeset",
        TemplateBody=json.dumps(dummy),
        ChangeSetName="test_changeset",
        ChangeSetType="CREATE",
    )
    conn.execute_stack(tackName="stack_from_changeset")
    conn.get_template_summary(StackName="stack_from_changeset")
  ```

Throws error:
```
undefined]AttributeError: 'dict' object has no attribute 'read'

self = <yaml.loader.Loader object at 0x7f7c0fd44ee0>, size = 4096

    def update_raw(self, size=4096):
>       data = self.stream.read(size)
E       AttributeError: 'dict' object has no attribute 'read'

.venv/lib/python3.9/site-packages/yaml/reader.py:178: AttributeError
```

Edit:

Also found that when stack is in `REVIEW_IN_PROGRESS` status.  `get_template_summary()` should throw ValidationError `GetTemplateSummary cannot be called on REVIEW_IN_PROGRESS stacks.`.  This is not implemented yet.

Hints from issue discussion:

Commit message: fix(cloudformation): bugfix for #4424

Patch to review:
diff --git a/moto/cloudformation/exceptions.py b/moto/cloudformation/exceptions.py
index 10669ca56de9..83faec78e5c7 100644
--- a/moto/cloudformation/exceptions.py
+++ b/moto/cloudformation/exceptions.py
@@ -11,7 +11,7 @@ class UnformattedGetAttTemplateException(Exception):
 
 
 class ValidationError(BadRequest):
-    def __init__(self, name_or_id, message=None):
+    def __init__(self, name_or_id=None, message=None):
         if message is None:
             message = "Stack with id {0} does not exist".format(name_or_id)
 
diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py
index 76ef3de97bba..e10d97f931bb 100644
--- a/moto/cloudformation/models.py
+++ b/moto/cloudformation/models.py
@@ -745,24 +745,27 @@ def execute_change_set(self, change_set_name, stack_name=None):
         if change_set is None:
             raise ValidationError(stack_name)
 
+        stack = self.stacks[change_set.stack_id]
+        assert stack is change_set.stack
+
+        # TODO: handle execution errors and implement rollback
         if change_set.change_set_type == "CREATE":
-            change_set.stack._add_stack_event(
+            stack._add_stack_event(
                 "CREATE_IN_PROGRESS", resource_status_reason="User Initiated"
             )
             change_set.apply()
-            change_set.stack._add_stack_event("CREATE_COMPLETE")
+            stack._add_stack_event("CREATE_COMPLETE")
         else:
-            change_set.stack._add_stack_event("UPDATE_IN_PROGRESS")
+            stack._add_stack_event("UPDATE_IN_PROGRESS")
             change_set.apply()
-            change_set.stack._add_stack_event("UPDATE_COMPLETE")
+            stack._add_stack_event("UPDATE_COMPLETE")
 
         # set the execution status of the changeset
         change_set.execution_status = "EXECUTE_COMPLETE"
 
         # set the status of the stack
-        self.stacks[
-            change_set.stack_id
-        ].status = f"{change_set.change_set_type}_COMPLETE"
+        stack.status = f"{change_set.change_set_type}_COMPLETE"
+        stack.template = change_set.template
         return True
 
     def describe_stacks(self, name_or_stack_id):
diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py
index 9f711eedd11b..f9b7d16bfef4 100644
--- a/moto/cloudformation/responses.py
+++ b/moto/cloudformation/responses.py
@@ -324,7 +324,12 @@ def get_template_summary(self):
         stack_body = self._get_param("TemplateBody")
 
         if stack_name:
-            stack_body = self.cloudformation_backend.get_stack(stack_name).template
+            stack = self.cloudformation_backend.get_stack(stack_name)
+            if stack.status == "REVIEW_IN_PROGRESS":
+                raise ValidationError(
+                    message="GetTemplateSummary cannot be called on REVIEW_IN_PROGRESS stacks.",
+                )
+            stack_body = stack.template
         elif template_url:
             stack_body = self._get_stack_from_s3_url(template_url)
 
diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
index 248f7a3f9cbd..e30a573388c7 100644
--- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
+++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
@@ -862,37 +862,54 @@ def test_get_template_summary():
     s3 = boto3.client("s3", region_name="us-east-1")
     s3_conn = boto3.resource("s3", region_name="us-east-1")
 
+    # json template
     conn = boto3.client("cloudformation", region_name="us-east-1")
     result = conn.get_template_summary(TemplateBody=json.dumps(dummy_template3))
-
     result["ResourceTypes"].should.equal(["AWS::EC2::VPC"])
     result["Version"].should.equal("2010-09-09")
     result["Description"].should.equal("Stack 3")
 
+    # existing stack
     conn.create_stack(StackName="test_stack", TemplateBody=json.dumps(dummy_template3))
-
     result = conn.get_template_summary(StackName="test_stack")
+    result["ResourceTypes"].should.equal(["AWS::EC2::VPC"])
+    result["Version"].should.equal("2010-09-09")
+    result["Description"].should.equal("Stack 3")
 
+    # stack created from changeset
+    conn = boto3.client("cloudformation", region_name="us-east-1")
+    conn.create_change_set(
+        StackName="stack_from_changeset",
+        TemplateBody=json.dumps(dummy_template3),
+        ChangeSetName="test_changeset",
+        ChangeSetType="CREATE",
+    )
+    with pytest.raises(
+        ClientError,
+        match="GetTemplateSummary cannot be called on REVIEW_IN_PROGRESS stacks",
+    ):
+        conn.get_template_summary(StackName="stack_from_changeset")
+    conn.execute_change_set(ChangeSetName="test_changeset")
+    result = conn.get_template_summary(StackName="stack_from_changeset")
     result["ResourceTypes"].should.equal(["AWS::EC2::VPC"])
     result["Version"].should.equal("2010-09-09")
     result["Description"].should.equal("Stack 3")
 
+    # json template from s3
     s3_conn.create_bucket(Bucket="foobar")
     s3_conn.Object("foobar", "template-key").put(Body=json.dumps(dummy_template3))
-
     key_url = s3.generate_presigned_url(
         ClientMethod="get_object", Params={"Bucket": "foobar", "Key": "template-key"}
     )
-
     conn.create_stack(StackName="stack_from_url", TemplateURL=key_url)
     result = conn.get_template_summary(TemplateURL=key_url)
     result["ResourceTypes"].should.equal(["AWS::EC2::VPC"])
     result["Version"].should.equal("2010-09-09")
     result["Description"].should.equal("Stack 3")
 
+    # yaml template
     conn = boto3.client("cloudformation", region_name="us-east-1")
     result = conn.get_template_summary(TemplateBody=dummy_template_yaml)
-
     result["ResourceTypes"].should.equal(["AWS::EC2::Instance"])
     result["Version"].should.equal("2010-09-09")
     result["Description"].should.equal("Stack1 with yaml template")


Code review comments:
diff_hunk: @@ -745,24 +745,27 @@ def execute_change_set(self, change_set_name, stack_name=None):
         if change_set is None:
             raise ValidationError(stack_name)
 
+        stack = self.stacks[change_set.stack_id]
+        assert stack is change_set.stack
path: moto/cloudformation/models.py
text: @user1:
There shouldn't be any `assert`-statements in the source code. If there is a known scenario where this could happen, it should throw a custom exception

@author:
Didn't mean to leave that there. Thanks for catching it. 
BTW, i think we got plenty of assert statementes out there in the codebase other than for testing. Would be nice to get rid of them. may be one day, we 'll :-)

@user1:
Agreed. One day.. :)