From 2f70373c2e9d91405082ddbd12eb61fb43914b20 Mon Sep 17 00:00:00 2001 From: Brian Pandola Date: Sun, 8 Nov 2020 04:44:23 -0800 Subject: [PATCH] Add CloudFormation Update support for `AWS::StepFunctions::StateMachine` (#3440) Closes #3402 --- moto/stepfunctions/models.py | 68 ++++++++- moto/stepfunctions/utils.py | 10 ++ .../test_stepfunctions/test_stepfunctions.py | 142 ++++++++++++++++++ 3 files changed, 214 insertions(+), 6 deletions(-) diff --git a/moto/stepfunctions/models.py b/moto/stepfunctions/models.py index 86c76c98a..125e5d807 100644 --- a/moto/stepfunctions/models.py +++ b/moto/stepfunctions/models.py @@ -16,7 +16,7 @@ from .exceptions import ( 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 @@ class StateMachine(CloudFormationModel): 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 @@ class StateMachine(CloudFormationModel): 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 index cf6b58c8a..130ffe792 100644 --- a/moto/stepfunctions/utils.py +++ b/moto/stepfunctions/utils.py @@ -136,3 +136,13 @@ class Paginator(object): 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 diff --git a/tests/test_stepfunctions/test_stepfunctions.py b/tests/test_stepfunctions/test_stepfunctions.py index 0bea43084..dd11e7961 100644 --- 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: