Repository: getmoto/moto

Issue:
StepFunctions StateMachine cannot yet  be updated by Cloudformation
In my project, we use CDK to generate a large cloudformation template
We feed this cloudformation template into moto in our pytest suite so a number of things 'just work' like they would in the cloud.

I'm trying to upgrade to 1.3.15, and this setup broke.
When referencing a Statemachine in CDK, CDK emits cloudformation like this
`"Fn::Select": [6, {"Fn::Split": [":", {"Ref": "MyCoolStateMachine"}]}]}, "*"]]}}`

This ends up giving us this error

```
resource_json = {'Fn::Split': [':', {'Ref': 'MyCoolStateMachine'}]}, resources_map = <moto.cloudformation.parsing.ResourceMap object at 0x7fd2728eb220>

    def clean_json(resource_json, resources_map):
        """
        Cleanup the a resource dict. For now, this just means replacing any Ref node
        with the corresponding physical_resource_id.

        Eventually, this is where we would add things like function parsing (fn::)
        """
        if isinstance(resource_json, dict):
            if "Ref" in resource_json:
                # Parse resource reference
                resource = resources_map[resource_json["Ref"]]
                if hasattr(resource, "physical_resource_id"):
                    return resource.physical_resource_id
                else:
                    return resource

            if "Fn::FindInMap" in resource_json:
                map_name = resource_json["Fn::FindInMap"][0]
                map_path = resource_json["Fn::FindInMap"][1:]
                result = resources_map[map_name]
                for path in map_path:
                    if "Fn::Transform" in result:
                        result = resources_map[clean_json(path, resources_map)]
                    else:
                        result = result[clean_json(path, resources_map)]
                return result

            if "Fn::GetAtt" in resource_json:
                resource = resources_map.get(resource_json["Fn::GetAtt"][0])
                if resource is None:
                    return resource_json
                try:
                    return resource.get_cfn_attribute(resource_json["Fn::GetAtt"][1])
                except NotImplementedError as n:
                    logger.warning(str(n).format(resource_json["Fn::GetAtt"][0]))
                except UnformattedGetAttTemplateException:
                    raise ValidationError(
                        "Bad Request",
                        UnformattedGetAttTemplateException.description.format(
                            resource_json["Fn::GetAtt"][0], resource_json["Fn::GetAtt"][1]
                        ),
                    )

            if "Fn::If" in resource_json:
                condition_name, true_value, false_value = resource_json["Fn::If"]
                if resources_map.lazy_condition_map[condition_name]:
                    return clean_json(true_value, resources_map)
                else:
                    return clean_json(false_value, resources_map)

            if "Fn::Join" in resource_json:
                join_list = clean_json(resource_json["Fn::Join"][1], resources_map)
                return resource_json["Fn::Join"][0].join([str(x) for x in join_list]) 

            if "Fn::Split" in resource_json:
                to_split = clean_json(resource_json["Fn::Split"][1], resources_map)
>               return to_split.split(resource_json["Fn::Split"][0])

```

Looks like the resource Map value is `None`, if I had it guess it's because StateMachine doesn't implement CloudformationModel.


We are working around this problem with a simple monkey patch 
```

    from moto.cloudformation import parsing 
    cj = parsing.clean_json
    
    def new_cj(json, map):
        cleaned = cj(json, map)
        if cleaned is None:
            if json["Ref"].endswith("StateMachine"):
                return f'arn:aws:states:us-east-1:111122223333:stateMachine:{json["Ref"]}'
        return cleaned
    parsing.clean_json = new_cj
```

This is clearly not ideal, but allows us to upgrade. 


I was not able to find another issue for SFN support in Cloudformation




Discussion:
Similarly, when deleting the stack above we see

```
    def delete(self):
        remaining_resources = set(self.resources)
        tries = 1
        while remaining_resources and tries < 5:
            for resource in remaining_resources.copy():                parsed_resource = self._parsed_resources.get(resource)
                try:
                    if parsed_resource and hasattr(parsed_resource, "delete"):
                        parsed_resource.delete(self._region_name)
                    else:
                        if hasattr(parsed_resource, "physical_resource_id"):
                            resource_name = parsed_resource.physical_resource_id
                        else:
                            resource_name = None

                        resource_json = self._resource_json_map[
>                           parsed_resource.logical_resource_id
                        ]

```
Which is likely the Statemachine that has a `None` resource.
Thanks for raising this, @prestomation. 
In 1.3.14, there was no support for SFN at all, so I guess it just ignored that setup. We did introduce basic support for it in 1.3.15, but indeed no CloudFromation support yet. Marking it as an enhancement.

FYI: 1.3.15 has a bug surrounding dependency management, so you might be better off upgrading to 1.3.16 - literraly the only change in that version is a bugfix for 1.3.15.
CloudFormation creation and deletion of a StateMachine is now available as of moto >= 1.3.16.dev89.

Will keep this open as a placeholder to integrate CF updates.

Resolving patch:
diff --git a/moto/stepfunctions/models.py b/moto/stepfunctions/models.py
--- a/moto/stepfunctions/models.py
+++ b/moto/stepfunctions/models.py
@@ -16,7 +16,7 @@
     ResourceNotFound,
     StateMachineDoesNotExist,
 )
-from .utils import paginate
+from .utils import paginate, api_to_cfn_tags, cfn_to_api_tags
 
 
 class StateMachine(CloudFormationModel):
@@ -62,11 +62,39 @@ def remove_tags(self, tag_keys):
     def physical_resource_id(self):
         return self.arn
 
+    def get_cfn_properties(self, prop_overrides):
+        property_names = [
+            "DefinitionString",
+            "RoleArn",
+            "StateMachineName",
+        ]
+        properties = {}
+        for prop in property_names:
+            properties[prop] = prop_overrides.get(prop, self.get_cfn_attribute(prop))
+        # Special handling for Tags
+        overridden_keys = [tag["Key"] for tag in prop_overrides.get("Tags", [])]
+        original_tags_to_include = [
+            tag
+            for tag in self.get_cfn_attribute("Tags")
+            if tag["Key"] not in overridden_keys
+        ]
+        properties["Tags"] = original_tags_to_include + prop_overrides.get("Tags", [])
+        return properties
+
     def get_cfn_attribute(self, attribute_name):
         from moto.cloudformation.exceptions import UnformattedGetAttTemplateException
 
         if attribute_name == "Name":
             return self.name
+        elif attribute_name == "DefinitionString":
+            return self.definition
+        elif attribute_name == "RoleArn":
+            return self.roleArn
+        elif attribute_name == "StateMachineName":
+            return self.name
+        elif attribute_name == "Tags":
+            return api_to_cfn_tags(self.tags)
+
         raise UnformattedGetAttTemplateException()
 
     @staticmethod
@@ -85,18 +113,46 @@ def create_from_cloudformation_json(
         name = properties.get("StateMachineName", resource_name)
         definition = properties.get("DefinitionString", "")
         role_arn = properties.get("RoleArn", "")
-        tags = properties.get("Tags", [])
-        tags_xform = [{k.lower(): v for k, v in d.items()} for d in tags]
+        tags = cfn_to_api_tags(properties.get("Tags", []))
         sf_backend = stepfunction_backends[region_name]
-        return sf_backend.create_state_machine(
-            name, definition, role_arn, tags=tags_xform
-        )
+        return sf_backend.create_state_machine(name, definition, role_arn, tags=tags)
 
     @classmethod
     def delete_from_cloudformation_json(cls, resource_name, _, region_name):
         sf_backend = stepfunction_backends[region_name]
         sf_backend.delete_state_machine(resource_name)
 
+    @classmethod
+    def update_from_cloudformation_json(
+        cls, original_resource, new_resource_name, cloudformation_json, region_name
+    ):
+        properties = cloudformation_json.get("Properties", {})
+        name = properties.get("StateMachineName", original_resource.name)
+
+        if name != original_resource.name:
+            # Replacement
+            new_properties = original_resource.get_cfn_properties(properties)
+            cloudformation_json["Properties"] = new_properties
+            new_resource = cls.create_from_cloudformation_json(
+                name, cloudformation_json, region_name
+            )
+            cls.delete_from_cloudformation_json(
+                original_resource.arn, cloudformation_json, region_name
+            )
+            return new_resource
+
+        else:
+            # No Interruption
+            definition = properties.get("DefinitionString")
+            role_arn = properties.get("RoleArn")
+            tags = cfn_to_api_tags(properties.get("Tags", []))
+            sf_backend = stepfunction_backends[region_name]
+            state_machine = sf_backend.update_state_machine(
+                original_resource.arn, definition=definition, role_arn=role_arn,
+            )
+            state_machine.add_tags(tags)
+            return state_machine
+
 
 class Execution:
     def __init__(
diff --git a/moto/stepfunctions/utils.py b/moto/stepfunctions/utils.py
--- a/moto/stepfunctions/utils.py
+++ b/moto/stepfunctions/utils.py
@@ -136,3 +136,13 @@ def paginate(self, results):
             page_ending_result = results[index_end]
             next_token = self._build_next_token(page_ending_result)
         return results_page, next_token
+
+
+def cfn_to_api_tags(cfn_tags_entry):
+    api_tags = [{k.lower(): v for k, v in d.items()} for d in cfn_tags_entry]
+    return api_tags
+
+
+def api_to_cfn_tags(api_tags):
+    cfn_tags_entry = [{k.capitalize(): v for k, v in d.items()} for d in api_tags]
+    return cfn_tags_entry


Test patch:
diff --git a/tests/test_stepfunctions/test_stepfunctions.py b/tests/test_stepfunctions/test_stepfunctions.py
--- a/tests/test_stepfunctions/test_stepfunctions.py
+++ b/tests/test_stepfunctions/test_stepfunctions.py
@@ -867,6 +867,148 @@ def test_state_machine_cloudformation():
     ex.exception.response["Error"]["Message"].should.contain("Does Not Exist")
 
 
+@mock_stepfunctions
+@mock_cloudformation
+def test_state_machine_cloudformation_update_with_replacement():
+    sf = boto3.client("stepfunctions", region_name="us-east-1")
+    cf = boto3.resource("cloudformation", region_name="us-east-1")
+    definition = '{"StartAt": "HelloWorld", "States": {"HelloWorld": {"Type": "Task", "Resource": "arn:aws:lambda:us-east-1:111122223333;:function:HelloFunction", "End": true}}}'
+    role_arn = (
+        "arn:aws:iam::111122223333:role/service-role/StatesExecutionRole-us-east-1"
+    )
+    properties = {
+        "StateMachineName": "HelloWorld-StateMachine",
+        "DefinitionString": definition,
+        "RoleArn": role_arn,
+        "Tags": [
+            {"Key": "key1", "Value": "value1"},
+            {"Key": "key2", "Value": "value2"},
+        ],
+    }
+    template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Description": "An example template for a Step Functions state machine.",
+        "Resources": {
+            "MyStateMachine": {
+                "Type": "AWS::StepFunctions::StateMachine",
+                "Properties": {},
+            }
+        },
+        "Outputs": {
+            "StateMachineArn": {"Value": {"Ref": "MyStateMachine"}},
+            "StateMachineName": {"Value": {"Fn::GetAtt": ["MyStateMachine", "Name"]}},
+        },
+    }
+    template["Resources"]["MyStateMachine"]["Properties"] = properties
+    cf.create_stack(StackName="test_stack", TemplateBody=json.dumps(template))
+    outputs_list = cf.Stack("test_stack").outputs
+    output = {item["OutputKey"]: item["OutputValue"] for item in outputs_list}
+    state_machine = sf.describe_state_machine(stateMachineArn=output["StateMachineArn"])
+    original_machine_arn = state_machine["stateMachineArn"]
+    original_creation_date = state_machine["creationDate"]
+
+    # Update State Machine, with replacement.
+    updated_role = role_arn + "-updated"
+    updated_definition = definition.replace("HelloWorld", "HelloWorld2")
+    updated_properties = {
+        "StateMachineName": "New-StateMachine-Name",
+        "DefinitionString": updated_definition,
+        "RoleArn": updated_role,
+        "Tags": [
+            {"Key": "key3", "Value": "value3"},
+            {"Key": "key1", "Value": "updated_value"},
+        ],
+    }
+    template["Resources"]["MyStateMachine"]["Properties"] = updated_properties
+    cf.Stack("test_stack").update(TemplateBody=json.dumps(template))
+    outputs_list = cf.Stack("test_stack").outputs
+    output = {item["OutputKey"]: item["OutputValue"] for item in outputs_list}
+    state_machine = sf.describe_state_machine(stateMachineArn=output["StateMachineArn"])
+    state_machine["stateMachineArn"].should_not.equal(original_machine_arn)
+    state_machine["name"].should.equal("New-StateMachine-Name")
+    state_machine["creationDate"].should.be.greater_than(original_creation_date)
+    state_machine["roleArn"].should.equal(updated_role)
+    state_machine["definition"].should.equal(updated_definition)
+    tags = sf.list_tags_for_resource(resourceArn=output["StateMachineArn"]).get("tags")
+    tags.should.have.length_of(3)
+    for tag in tags:
+        if tag["key"] == "key1":
+            tag["value"].should.equal("updated_value")
+
+    with assert_raises(ClientError) as ex:
+        sf.describe_state_machine(stateMachineArn=original_machine_arn)
+    ex.exception.response["Error"]["Code"].should.equal("StateMachineDoesNotExist")
+    ex.exception.response["Error"]["Message"].should.contain(
+        "State Machine Does Not Exist"
+    )
+
+
+@mock_stepfunctions
+@mock_cloudformation
+def test_state_machine_cloudformation_update_with_no_interruption():
+    sf = boto3.client("stepfunctions", region_name="us-east-1")
+    cf = boto3.resource("cloudformation", region_name="us-east-1")
+    definition = '{"StartAt": "HelloWorld", "States": {"HelloWorld": {"Type": "Task", "Resource": "arn:aws:lambda:us-east-1:111122223333;:function:HelloFunction", "End": true}}}'
+    role_arn = (
+        "arn:aws:iam::111122223333:role/service-role/StatesExecutionRole-us-east-1"
+    )
+    properties = {
+        "StateMachineName": "HelloWorld-StateMachine",
+        "DefinitionString": definition,
+        "RoleArn": role_arn,
+        "Tags": [
+            {"Key": "key1", "Value": "value1"},
+            {"Key": "key2", "Value": "value2"},
+        ],
+    }
+    template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Description": "An example template for a Step Functions state machine.",
+        "Resources": {
+            "MyStateMachine": {
+                "Type": "AWS::StepFunctions::StateMachine",
+                "Properties": {},
+            }
+        },
+        "Outputs": {
+            "StateMachineArn": {"Value": {"Ref": "MyStateMachine"}},
+            "StateMachineName": {"Value": {"Fn::GetAtt": ["MyStateMachine", "Name"]}},
+        },
+    }
+    template["Resources"]["MyStateMachine"]["Properties"] = properties
+    cf.create_stack(StackName="test_stack", TemplateBody=json.dumps(template))
+    outputs_list = cf.Stack("test_stack").outputs
+    output = {item["OutputKey"]: item["OutputValue"] for item in outputs_list}
+    state_machine = sf.describe_state_machine(stateMachineArn=output["StateMachineArn"])
+    machine_arn = state_machine["stateMachineArn"]
+    creation_date = state_machine["creationDate"]
+
+    # Update State Machine in-place, no replacement.
+    updated_role = role_arn + "-updated"
+    updated_definition = definition.replace("HelloWorld", "HelloWorldUpdated")
+    updated_properties = {
+        "DefinitionString": updated_definition,
+        "RoleArn": updated_role,
+        "Tags": [
+            {"Key": "key3", "Value": "value3"},
+            {"Key": "key1", "Value": "updated_value"},
+        ],
+    }
+    template["Resources"]["MyStateMachine"]["Properties"] = updated_properties
+    cf.Stack("test_stack").update(TemplateBody=json.dumps(template))
+
+    state_machine = sf.describe_state_machine(stateMachineArn=machine_arn)
+    state_machine["name"].should.equal("HelloWorld-StateMachine")
+    state_machine["creationDate"].should.equal(creation_date)
+    state_machine["roleArn"].should.equal(updated_role)
+    state_machine["definition"].should.equal(updated_definition)
+    tags = sf.list_tags_for_resource(resourceArn=machine_arn).get("tags")
+    tags.should.have.length_of(3)
+    for tag in tags:
+        if tag["key"] == "key1":
+            tag["value"].should.equal("updated_value")
+
+
 def _get_account_id():
     global account_id
     if account_id: