From a3be970b62695f844a6349e759b6fbfa24401b26 Mon Sep 17 00:00:00 2001 From: Kevin DeJong Date: Mon, 15 Jul 2024 11:52:02 -0700 Subject: [PATCH] Move rule to E2532 to E3601 (#3502) * Start of state machine validation * Move rule to E2532 to E3601 * Make sure state machine functions are covered --- .../schemas/other/step_functions/__init__.py | 0 .../other/step_functions/statemachine.json | 850 ++++++++++++++++++ .../resources/stepfunctions/StateMachine.py | 202 ----- .../stepfunctions/StateMachineDefinition.py | 85 ++ .../stepfunctions/state_machine.yaml | 47 - .../stepfunctions/state_machine.yaml | 164 ---- .../stepfunctions/test_state_machine.py | 32 - .../test_state_machine_definition.py | 505 +++++++++++ 8 files changed, 1440 insertions(+), 445 deletions(-) create mode 100644 src/cfnlint/data/schemas/other/step_functions/__init__.py create mode 100644 src/cfnlint/data/schemas/other/step_functions/statemachine.json delete mode 100644 src/cfnlint/rules/resources/stepfunctions/StateMachine.py create mode 100644 src/cfnlint/rules/resources/stepfunctions/StateMachineDefinition.py delete mode 100644 test/fixtures/templates/bad/resources/stepfunctions/state_machine.yaml delete mode 100644 test/fixtures/templates/good/resources/stepfunctions/state_machine.yaml delete mode 100644 test/unit/rules/resources/stepfunctions/test_state_machine.py create mode 100644 test/unit/rules/resources/stepfunctions/test_state_machine_definition.py diff --git a/src/cfnlint/data/schemas/other/step_functions/__init__.py b/src/cfnlint/data/schemas/other/step_functions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/cfnlint/data/schemas/other/step_functions/statemachine.json b/src/cfnlint/data/schemas/other/step_functions/statemachine.json new file mode 100644 index 0000000000..827d968b0b --- /dev/null +++ b/src/cfnlint/data/schemas/other/step_functions/statemachine.json @@ -0,0 +1,850 @@ +{ + "additionalProperties": false, + "definitions": { + "choice": { + "additionalProperties": false, + "definitions": { + "Operator": { + "properties": { + "And": { + "items": { + "$ref": "#/definitions/choice/definitions/Operator" + }, + "type": "array" + }, + "BooleanEquals": { + "type": "boolean" + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + }, + "Not": { + "$ref": "#/definitions/choice/definitions/Operator" + }, + "NumericEquals": { + "type": "number" + }, + "NumericGreaterThan": { + "type": "number" + }, + "NumericGreaterThanEquals": { + "type": "number" + }, + "NumericLessThan": { + "type": "number" + }, + "NumericLessThanEquals": { + "type": "number" + }, + "Or": { + "items": { + "$ref": "#/definitions/choice/definitions/Operator" + }, + "type": "array" + }, + "StringEquals": { + "type": "string" + }, + "StringGreaterThan": { + "type": "string" + }, + "StringGreaterThanEquals": { + "type": "string" + }, + "StringLessThan": { + "type": "string" + }, + "StringLessThanEquals": { + "type": "string" + }, + "TimestampEquals": { + "type": "string" + }, + "TimestampGreaterThan": { + "type": "string" + }, + "TimestampGreaterThanEquals": { + "type": "string" + }, + "TimestampLessThan": { + "type": "string" + }, + "TimestampLessThanEquals": { + "type": "string" + }, + "Variable": { + "type": "string" + } + }, + "requiredXor": [ + "And", + "BooleanEquals", + "Not", + "NumericEquals", + "NumericGreaterThan", + "NumericGreaterThanEquals", + "NumericLessThan", + "NumericLessThanEquals", + "Or", + "StringEquals", + "StringGreaterThan", + "StringGreaterThanEquals", + "StringLessThan", + "StringLessThanEquals", + "TimestampEquals", + "TimestampGreaterThan", + "TimestampGreaterThanEquals", + "TimestampLessThan", + "TimestampLessThanEquals" + ], + "type": "object" + } + }, + "properties": { + "Choices": { + "items": { + "$ref": "#/definitions/choice/definitions/Operator" + }, + "type": "array" + }, + "Comment": { + "type": "string" + }, + "Default": { + "type": "string" + }, + "End": { + "enum": [ + true + ] + }, + "InputPath": { + "type": [ + "string", + "null" + ] + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + }, + "OutputPath": { + "type": [ + "string", + "null" + ] + }, + "Type": { + "enum": [ + "Choice" + ], + "type": "string" + } + }, + "required": [ + "Type", + "Choices" + ], + "type": "object" + }, + "fail": { + "additionalProperties": false, + "properties": { + "Cause": { + "type": "string" + }, + "Comment": { + "type": "string" + }, + "Error": { + "type": "string" + }, + "InputPath": { + "type": [ + "string", + "null" + ] + }, + "OutputPath": { + "type": [ + "string", + "null" + ] + }, + "Type": { + "enum": [ + "Fail" + ], + "type": "string" + } + }, + "required": [ + "Type" + ], + "type": "object" + }, + "map": { + "additionalProperties": false, + "properties": { + "Catch": { + "items": { + "properties": { + "ErrorEquals": { + "items": { + "types": "string" + }, + "type": "array" + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + } + }, + "required": [ + "ErrorEquals", + "Next" + ], + "types": "object" + }, + "type": "array" + }, + "Comment": { + "type": "string" + }, + "End": { + "enum": [ + true + ] + }, + "InputPath": { + "type": [ + "string", + "null" + ] + }, + "ItemsPath": { + "type": [ + "string", + "null" + ] + }, + "Iterator": { + "$ref": "#/" + }, + "MaxConcurrency": { + "minimum": 0, + "type": "number" + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + }, + "OutputPath": { + "type": [ + "string", + "null" + ] + }, + "Parameters": { + "type": "object" + }, + "ResultPath": { + "type": [ + "string", + "null" + ] + }, + "ResultSelector": { + "type": "object" + }, + "Retry": { + "items": { + "properties": { + "BackoffRate": { + "minimum": 0, + "type": "number" + }, + "ErrorEquals": { + "items": { + "types": "string" + }, + "type": "array" + }, + "IntervalSeconds": { + "minimum": 0, + "type": "number" + }, + "MaxAttempts": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "ErrorEquals" + ], + "types": "object" + }, + "type": "array" + }, + "Type": { + "enum": [ + "Map" + ], + "type": "string" + } + }, + "required": [ + "Type", + "Iterator" + ], + "requiredXor": [ + "Next", + "End" + ], + "type": "object" + }, + "parallel": { + "additionalProperties": false, + "properties": { + "Branches": { + "items": { + "$ref": "#/" + }, + "type": "array" + }, + "Catch": { + "items": { + "properties": { + "ErrorEquals": { + "items": { + "types": "string" + }, + "type": "array" + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + } + }, + "required": [ + "ErrorEquals", + "Next" + ], + "types": "object" + }, + "type": "array" + }, + "Comment": { + "type": "string" + }, + "End": { + "enum": [ + true + ] + }, + "InputPath": { + "type": [ + "string", + "null" + ] + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + }, + "OutputPath": { + "type": [ + "string", + "null" + ] + }, + "ResultPath": { + "type": [ + "string", + "null" + ] + }, + "ResultSelector": { + "type": "object" + }, + "Retry": { + "items": { + "properties": { + "BackoffRate": { + "minimum": 0, + "type": "number" + }, + "ErrorEquals": { + "items": { + "types": "string" + }, + "type": "array" + }, + "IntervalSeconds": { + "minimum": 0, + "type": "number" + }, + "MaxAttempts": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "ErrorEquals" + ], + "types": "object" + }, + "type": "array" + }, + "Type": { + "enum": [ + "Parallel" + ], + "type": "string" + } + }, + "required": [ + "Type", + "Branches" + ], + "requiredXor": [ + "Next", + "End" + ], + "type": "object" + }, + "pass": { + "additionalProperties": false, + "properties": { + "Comment": { + "type": "string" + }, + "End": { + "enum": [ + true + ] + }, + "InputPath": { + "type": [ + "string", + "null" + ] + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + }, + "OutputPath": { + "type": [ + "string", + "null" + ] + }, + "Parameters": { + "type": "object" + }, + "Result": {}, + "ResultPath": { + "type": "string" + }, + "Type": { + "enum": [ + "Pass" + ], + "type": "string" + } + }, + "required": [ + "Type" + ], + "requiredXor": [ + "Next", + "End" + ], + "type": "object" + }, + "state": { + "allOf": [ + { + "if": { + "properties": { + "Type": { + "const": "Choice" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/choice" + } + }, + { + "if": { + "properties": { + "Type": { + "const": "Fail" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/fail" + } + }, + { + "if": { + "properties": { + "Type": { + "const": "Parallel" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/parallel" + } + }, + { + "if": { + "properties": { + "Type": { + "const": "Pass" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/pass" + } + }, + { + "if": { + "properties": { + "Type": { + "const": "Succeed" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/succeed" + } + }, + { + "if": { + "properties": { + "Type": { + "const": "Task" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/task" + } + }, + { + "if": { + "properties": { + "Type": { + "const": "Wait" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/wait" + } + }, + { + "if": { + "properties": { + "Type": { + "const": "Map" + } + }, + "required": [ + "Type" + ] + }, + "then": { + "$ref": "#/definitions/map" + } + } + ], + "properties": { + "Comment": { + "type": "string" + }, + "Type": { + "enum": [ + "Pass", + "Task", + "Wait", + "Choice", + "Succeed", + "Fail", + "Parallel", + "Map" + ], + "type": "string" + } + }, + "required": [ + "Type" + ], + "type": "object" + }, + "succeed": { + "additionalProperties": false, + "properties": { + "Comment": { + "type": "string" + }, + "Type": { + "enum": [ + "Succeed" + ], + "type": "string" + } + }, + "required": [ + "Type" + ], + "type": "object" + }, + "task": { + "additionalProperties": false, + "properties": { + "Catch": { + "items": { + "properties": { + "ErrorEquals": { + "items": { + "types": "string" + }, + "type": "array" + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + } + }, + "required": [ + "ErrorEquals", + "Next" + ], + "types": "object" + }, + "type": "array" + }, + "Comment": { + "type": "string" + }, + "Credentials": { + "type": [ + "string", + "object" + ] + }, + "End": { + "enum": [ + true + ] + }, + "HeartbeatSeconds": { + "minimum": 1, + "type": "number" + }, + "InputPath": { + "type": [ + "string", + "null" + ] + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + }, + "OutputPath": { + "type": [ + "string", + "null" + ] + }, + "Parameters": { + "type": "object" + }, + "Resource": { + "pattern": "^arn:aws:([a-z]|-)+:([a-z]|[0-9]|-)*:[0-9]*:([a-z]|-)+:[a-zA-Z0-9-_.]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?$", + "type": [ + "string" + ] + }, + "ResultPath": { + "type": [ + "string", + "null" + ] + }, + "ResultSelector": { + "type": "object" + }, + "Retry": { + "items": { + "properties": { + "BackoffRate": { + "minimum": 0, + "type": "number" + }, + "ErrorEquals": { + "items": { + "types": "string" + }, + "type": "array" + }, + "IntervalSeconds": { + "minimum": 0, + "type": "number" + }, + "MaxAttempts": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "ErrorEquals" + ], + "types": "object" + }, + "type": "array" + }, + "TimeoutSeconds": { + "minimum": 1, + "type": "number" + }, + "Type": { + "enum": [ + "Task" + ], + "type": "string" + } + }, + "required": [ + "Type", + "Resource" + ], + "requiredXor": [ + "Next", + "End" + ], + "type": "object" + }, + "wait": { + "additionalProperties": false, + "properties": { + "Comment": { + "type": "string" + }, + "End": { + "enum": [ + true + ] + }, + "InputPath": { + "type": [ + "string", + "null" + ] + }, + "Next": { + "pattern": "^.{1,128}$", + "type": "string" + }, + "OutputPath": { + "type": [ + "string", + "null" + ] + }, + "Seconds": { + "minimum": 0, + "type": "number" + }, + "SecondsPath": { + "type": [ + "string", + "null" + ] + }, + "Timestamp": { + "type": "string" + }, + "TimestampPath": { + "type": [ + "string", + "null" + ] + }, + "Type": { + "enum": [ + "Wait" + ], + "type": "string" + } + }, + "required": [ + "Type" + ], + "requiredXor": [ + "Next", + "End" + ], + "type": "object" + } + }, + "properties": { + "Comment": { + "type": "string" + }, + "StartAt": { + "type": "string" + }, + "States": { + "additionalProperties": false, + "patternProperties": { + "^.{1,128}$": { + "$ref": "#/definitions/state" + } + }, + "type": "object" + }, + "TimeoutSeconds": { + "minimum": 0, + "type": "integer" + }, + "Version": { + "type": "string" + } + }, + "required": [ + "StartAt", + "States" + ], + "type": "object" +} diff --git a/src/cfnlint/rules/resources/stepfunctions/StateMachine.py b/src/cfnlint/rules/resources/stepfunctions/StateMachine.py deleted file mode 100644 index c8608e2d14..0000000000 --- a/src/cfnlint/rules/resources/stepfunctions/StateMachine.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -SPDX-License-Identifier: MIT-0 -""" - -import json - -from cfnlint.rules import CloudFormationLintRule, RuleMatch - - -class StateMachine(CloudFormationLintRule): - """Check State Ma chine Definition""" - - id = "E2532" - shortdesc = "Check State Machine Definition for proper syntax" - description = ( - "Check the State Machine String Definition to make sure its JSON. " - "Validate basic syntax of the file to determine validity." - ) - source_url = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-stepfunctions-statemachine.html" - tags = ["resources", "stepfunctions"] - - def __init__(self): - """Init""" - super().__init__() - self.resource_property_types.append("AWS::StepFunctions::StateMachine") - - def _check_state_json(self, def_json, state_name, path): - """Check State JSON Definition""" - matches = [] - - # https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-common-fields.html - common_state_keys = [ - "Next", - "End", - "Type", - "Comment", - "InputPath", - "OutputPath", - ] - common_state_required_keys = [ - "Type", - ] - state_key_types = { - "Pass": ["Result", "ResultPath", "Parameters"], - "Task": [ - "Credentials", - "Resource", - "Parameters", - "ResultPath", - "ResultSelector", - "Retry", - "Catch", - "TimeoutSeconds", - "TimeoutSecondsPath", - "Parameters", - "HeartbeatSeconds", - "HeartbeatSecondsPath", - ], - "Map": [ - "MaxConcurrency", - "Iterator", - "ItemsPath", - "ItemProcessor", - "ItemReader", - "ItemSelector", - "ResultPath", - "ResultSelector", - "Retry", - "Catch", - "Parameters", - "ToleratedFailurePercentage", - "ItemBatcher", - ], - "Choice": ["Choices", "Default"], - "Wait": ["Seconds", "Timestamp", "SecondsPath", "TimestampPath"], - "Succeed": [], - "Fail": ["Cause", "CausePath", "Error", "ErrorPath"], - "Parallel": [ - "Branches", - "ResultPath", - "ResultSelector", - "Parameters", - "Retry", - "Catch", - ], - } - state_required_types = { - "Pass": [], - "Task": ["Resource"], - "Choice": ["Choices"], - "Wait": [], - "Succeed": [], - "Fail": [], - "Parallel": ["Branches"], - } - - for req_key in common_state_required_keys: - if req_key not in def_json: - message = ( - f"State Machine Definition required key ({req_key}) for State" - f" ({state_name}) is missing" - ) - matches.append(RuleMatch(path, message)) - return matches - - state_type = def_json.get("Type") - - if state_type in state_key_types: - for state_key, _ in def_json.items(): - if state_key not in common_state_keys + state_key_types.get( - state_type, [] - ): - message = ( - f"State Machine Definition key ({state_key}) for State" - f" ({state_name}) of Type ({state_type}) is not valid" - ) - matches.append(RuleMatch(path, message)) - for req_key in common_state_required_keys + state_required_types.get( - state_type, [] - ): - if req_key not in def_json: - message = ( - f"State Machine Definition required key ({req_key}) for State" - f" ({state_name}) of Type ({state_type}) is missing" - ) - matches.append(RuleMatch(path, message)) - return matches - else: - message = f"State Machine Definition Type ({state_type}) is not valid" - matches.append(RuleMatch(path, message)) - - return matches - - def _check_definition_json(self, def_json, path): - """Check JSON Definition""" - matches = [] - - top_level_keys = ["Comment", "StartAt", "TimeoutSeconds", "Version", "States"] - top_level_required_keys = ["StartAt", "States"] - for top_key, _ in def_json.items(): - if top_key not in top_level_keys: - message = f"State Machine Definition key ({top_key}) is not valid" - matches.append(RuleMatch(path, message)) - - for req_key in top_level_required_keys: - if req_key not in def_json: - message = ( - f"State Machine Definition required key ({req_key}) is missing" - ) - matches.append(RuleMatch(path, message)) - - for state_name, state_value in def_json.get("States", {}).items(): - matches.extend(self._check_state_json(state_value, state_name, path)) - return matches - - def check_value(self, value, path, fail_on_loads=True): - """Check Definition Value""" - matches = [] - try: - def_json = json.loads(value) - # pylint: disable=W0703 - except Exception as err: - if fail_on_loads: - message = ( - "State Machine Definition needs to be formatted as JSON. Error" - f" {err}" - ) - matches.append(RuleMatch(path, message)) - return matches - - self.logger.debug("State Machine definition could not be parsed. Skipping") - return matches - - matches.extend(self._check_definition_json(def_json, path)) - return matches - - def check_sub(self, value, path): - """Check Sub Object""" - matches = [] - if isinstance(value, list): - matches.extend(self.check_value(value[0], path, False)) - elif isinstance(value, str): - matches.extend(self.check_value(value, path, False)) - - return matches - - def match_resource_properties(self, properties, _, path, cfn): - """Check CloudFormation Properties""" - matches = [] - - matches.extend( - cfn.check_value( - obj=properties, - key="DefinitionString", - path=path[:], - check_value=self.check_value, - check_sub=self.check_sub, - ) - ) - - return matches diff --git a/src/cfnlint/rules/resources/stepfunctions/StateMachineDefinition.py b/src/cfnlint/rules/resources/stepfunctions/StateMachineDefinition.py new file mode 100644 index 0000000000..7d7c3de89a --- /dev/null +++ b/src/cfnlint/rules/resources/stepfunctions/StateMachineDefinition.py @@ -0,0 +1,85 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import json +from typing import Any + +import cfnlint.data.schemas.other.resources +import cfnlint.data.schemas.other.step_functions +import cfnlint.helpers +from cfnlint.jsonschema import ValidationError, ValidationResult, Validator +from cfnlint.rules.jsonschema.CfnLintJsonSchema import CfnLintJsonSchema, SchemaDetails +from cfnlint.schema.resolver import RefResolver + + +class StateMachineDefinition(CfnLintJsonSchema): + id = "E3601" + shortdesc = "Validate the structure of a StateMachine definition" + description = ( + "Validate the Definition or DefinitionString inside a " + "AWS::StepFunctions::StateMachine resource" + ) + source_url = "https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-state-machine-structure.html" + tags = ["resources", "statemachine"] + + def __init__(self): + super().__init__( + keywords=[ + "Resources/AWS::StepFunctions::StateMachine/Properties/Definition", + "Resources/AWS::StepFunctions::StateMachine/Properties/DefinitionString", + ], + schema_details=SchemaDetails( + cfnlint.data.schemas.other.step_functions, "statemachine.json" + ), + all_matches=True, + ) + + store = { + "definition": self.schema, + } + + self.resolver = RefResolver.from_schema(self.schema, store=store) + + def _fix_message(self, err: ValidationError) -> ValidationError: + if len(err.path) > 1: + err.message = f"{err.message} at {'/'.join(err.path)!r}" + for i, c_err in enumerate(err.context): + err.context[i] = self._fix_message(c_err) + return err + + def validate( + self, validator: Validator, keywords: Any, instance: Any, schema: dict[str, Any] + ) -> ValidationResult: + # First time child rules are configured against the rule + # so we can run this now + add_path_to_message = False + if validator.is_type(instance, "string"): + try: + step_validator = validator.evolve( + context=validator.context.evolve( + functions=[], + ), + resolver=self.resolver, + schema=self.schema, + ) + instance = json.loads(instance) + add_path_to_message = True + except json.JSONDecodeError: + return + else: + step_validator = validator.evolve( + resolver=self.resolver, + schema=self.schema, + ) + + for err in step_validator.iter_errors(instance): + if add_path_to_message: + err = self._fix_message(err) + if not err.validator.startswith("fn_") and err.validator not in ["cfnLint"]: + err.rule = self + + yield self._clean_error(err) diff --git a/test/fixtures/templates/bad/resources/stepfunctions/state_machine.yaml b/test/fixtures/templates/bad/resources/stepfunctions/state_machine.yaml deleted file mode 100644 index 889ed6a9f8..0000000000 --- a/test/fixtures/templates/bad/resources/stepfunctions/state_machine.yaml +++ /dev/null @@ -1,47 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Description: An example template for a Step Functions state machine. -Resources: - MyStateMachine: - Type: AWS::StepFunctions::StateMachine - Properties: - StateMachineName: HelloWorld-StateMachine - # Alert on Bad JSON - DefinitionString: |- - { - "StartAt": "HelloWorld", - "States": { - "HelloWorld": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:111122223333:function:HelloFunction", - "End": true - - } - } - RoleArn: arn:aws:iam::111122223333:role/service-role/StatesExecutionRole-us-east-1 - MyStateMachine2: - Type: AWS::StepFunctions::StateMachine - Properties: - StateMachineName: HelloWorld-StateMachine - # Missing StartsAt - # ByeWorld is missing Resource - # HelloWorld is missing Type - # Type doesn't exist - DefinitionString: - Fn::Sub: |- - { - "States": { - "HelloWorld": { - "Resource": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:HelloFunction", - "Next": "GoodDay" - }, - "GoodDay": { - "Type": "DNE", - "Next": "ByeWorld" - }, - "ByeWorld": { - "Type": "Task", - "End": true - } - } - } - RoleArn: arn:aws:iam::111122223333:role/service-role/StatesExecutionRole-us-east-1 diff --git a/test/fixtures/templates/good/resources/stepfunctions/state_machine.yaml b/test/fixtures/templates/good/resources/stepfunctions/state_machine.yaml deleted file mode 100644 index 7c53a4ddef..0000000000 --- a/test/fixtures/templates/good/resources/stepfunctions/state_machine.yaml +++ /dev/null @@ -1,164 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Description: An example template for a Step Functions state machine. -Parameters: - timeout: - Type: Number -Resources: - MyStateMachine: - Type: AWS::StepFunctions::StateMachine - Properties: - StateMachineName: HelloWorld-StateMachine - DefinitionString: |- - { - "StartAt": "HelloWorld", - "States": { - "HelloWorld": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:111122223333:function:HelloFunction", - "Next": "CreatePublishedRequest" - }, - "CreatePublishedRequest": { - "Type": "Task", - "Resource": "{$createPublishedRequest}", - "ResultPath":"$.publishedRequest", - "OutputPath":"$.publishedRequest", - "Next": "PutRequest" - }, - "PutRequest": { - "Type": "Task", - "Resource": "{$updateKey}", - "ResultPath":"$.response", - "End": true - } - } - } - RoleArn: arn:aws:iam::111122223333:role/service-role/StatesExecutionRole-us-east-1 - # doesn't fail on sub that can't be parsed - MyStateMachine2: - Type: AWS::StepFunctions::StateMachine - Properties: - StateMachineName: HelloWorld-StateMachine - DefinitionString: - Fn::Sub: - - | - { - "StartAt": "HelloWorld", - "States": { - "HelloWorld": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:111122223333:function:HelloFunction", - "Next": "CreatePublishedRequest" - }, - "CreatePublishedRequest": { - "Type": "Task", - "Resource": "{$createPublishedRequest}", - "ResultPath":"$.publishedRequest", - "OutputPath":"$.publishedRequest", - "Next": "Read Next Message from DynamoDB" - }, - "Read Next Message from DynamoDB": { - "Type": "Task", - "Resource": "arn:aws:states:::dynamodb:getItem", - "Parameters": { - "TableName": "sqsconnector-DDBTable-1CAFOJWP8QD6I", - "Key": { - "MessageId": {"S.$": "$.List[0]"} - } - }, - "ResultPath": "$.DynamoDB", - "Next": "CreatePublishedRequest" - }, - "Sleep": { - "Type": "Wait", - "Seconds": ${TestParam}, - "Next": "Stop" - }, - "PutRequest": { - "Type": "Task", - "Resource": "{$updateKey}", - "ResultPath":"$.response", - "End": true - } - } - } - - TestParam: !Ref timeout - RoleArn: arn:aws:iam::111122223333:role/service-role/StatesExecutionRole-us-east-1 - MyStateMachineNewTypes: - Type: AWS::StepFunctions::StateMachine - Properties: - StateMachineName: HelloWorld-StateMachine - DefinitionString: |- - { - "StartAt": "ValidatePayment", - "States": { - "ValidatePayment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-west-2:123456789012:function:validatePayment", - "Next": "CheckPayment" - }, - "CheckPayment": { - "Type": "Choice", - "Choices": [ - { - "Not": { - "Variable": "$.payment", - "StringEquals": "Ok" - }, - "Next": "PaymentFailed" - } - ], - "Default": "ProcessAllItems" - }, - "PaymentFailed": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-west-2:123456789012:function:paymentFailed", - "End": true - }, - "ProcessAllItems": { - "Type": "Map", - "InputPath": "$.detail", - "ItemsPath": "$.items", - "MaxConcurrency": 3, - "Iterator": { - "StartAt": "CheckAvailability", - "States": { - "CheckAvailability": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-west-2:123456789012:function:checkAvailability", - "Retry": [ - { - "ErrorEquals": [ - "TimeOut" - ], - "IntervalSeconds": 1, - "BackoffRate": 2, - "MaxAttempts": 3 - } - ], - "Next": "PrepareForDelivery" - }, - "PrepareForDelivery": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-west-2:123456789012:function:prepareForDelivery", - "Next": "StartDelivery" - }, - "StartDelivery": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-west-2:123456789012:function:startDelivery", - "End": true - } - } - }, - "ResultPath": "$.detail.processedItems", - "Next": "SendOrderSummary" - }, - "SendOrderSummary": { - "Type": "Task", - "InputPath": "$.detail.processedItems", - "Resource": "arn:aws:lambda:us-west-2:123456789012:function:sendOrderSummary", - "ResultPath": "$.detail.summary", - "End": true - } - } - } - RoleArn: arn:aws:iam::111122223333:role/service-role/StatesExecutionRole-us-east-1 diff --git a/test/unit/rules/resources/stepfunctions/test_state_machine.py b/test/unit/rules/resources/stepfunctions/test_state_machine.py deleted file mode 100644 index 0c9db8dd24..0000000000 --- a/test/unit/rules/resources/stepfunctions/test_state_machine.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -SPDX-License-Identifier: MIT-0 -""" - -from test.unit.rules import BaseRuleTestCase - -from cfnlint.rules.resources.stepfunctions.StateMachine import ( - StateMachine, # pylint: disable=E0401 -) - - -class TestStateMachine(BaseRuleTestCase): - """Test StateMachine for Step Functions""" - - def setUp(self): - """Setup""" - super(TestStateMachine, self).setUp() - self.collection.register(StateMachine()) - self.success_templates = [ - "test/fixtures/templates/good/resources/stepfunctions/state_machine.yaml" - ] - - def test_file_positive(self): - """Test Positive""" - self.helper_file_positive() - - def test_file_negative_alias(self): - """Test failure""" - self.helper_file_negative( - "test/fixtures/templates/bad/resources/stepfunctions/state_machine.yaml", 5 - ) diff --git a/test/unit/rules/resources/stepfunctions/test_state_machine_definition.py b/test/unit/rules/resources/stepfunctions/test_state_machine_definition.py new file mode 100644 index 0000000000..00fdcf4ba6 --- /dev/null +++ b/test/unit/rules/resources/stepfunctions/test_state_machine_definition.py @@ -0,0 +1,505 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from collections import deque + +import pytest + +from cfnlint.jsonschema.validators import ValidationError +from cfnlint.rules.resources.stepfunctions.StateMachineDefinition import ( + StateMachineDefinition, +) + + +@pytest.fixture(scope="module") +def rule(): + rule = StateMachineDefinition() + yield rule + + +# These state machines for testing have long lines +# with lots of indentation +# ruff: noqa: E501 + + +@pytest.mark.parametrize( + "name,instance,expected", + [ + ( + "Valid string json", + { + "Comment": ( + "An example of the Amazon States " + "Language for notification on an " + "AWS Batch job completion" + ), + "StartAt": "Submit Batch Job", + "TimeoutSeconds": 3600, + "States": { + "Submit Batch Job": { + "Type": "Task", + "Resource": "arn:aws:states:::batch:submitJob.sync", + "Parameters": { + "JobName": "BatchJobNotification", + "JobQueue": "arn:aws:batch:us-east-1:123456789012:job-queue/BatchJobQueue-7049d367474b4dd", + "JobDefinition": "arn:aws:batch:us-east-1:123456789012:job-definition/BatchJobDefinition-74d55ec34c4643c:1", + }, + "Next": "Notify Success", + "Catch": [ + {"ErrorEquals": ["States.ALL"], "Next": "Notify Failure"} + ], + }, + "Notify Success": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "Message": "Batch job submitted through Step Functions succeeded", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:batchjobnotificatiointemplate-SNSTopic-1J757CVBQ2KHM", + }, + "End": True, + }, + "Notify Failure": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "Message": "Batch job submitted through Step Functions failed", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:batchjobnotificatiointemplate-SNSTopic-1J757CVBQ2KHM", + }, + "End": True, + }, + }, + }, + [], + ), + ( + "Fan out example", + { + "Comment": "An example of the Amazon States Language for fanning out AWS Batch job", + "StartAt": "Generate batch job input", + "TimeoutSeconds": 3600, + "States": { + "Generate batch job input": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "OutputPath": "$.Payload", + "Parameters": { + "FunctionName": "" + }, + "Next": "Fan out batch jobs", + }, + "Fan out batch jobs": { + "Comment": "Start multiple executions of batch job depending on pre-processed data", + "Type": "Map", + "End": True, + "ItemsPath": "$", + "Parameters": {"BatchNumber.$": "$$.Map.Item.Value"}, + "Iterator": { + "StartAt": "Submit Batch Job", + "States": { + "Submit Batch Job": { + "Type": "Task", + "Resource": "arn:aws:states:::batch:submitJob.sync", + "Parameters": { + "JobName": "BatchJobFanOut", + "JobQueue": "", + "JobDefinition": "", + }, + "End": True, + } + }, + }, + }, + }, + }, + [], + ), + ( + "Error handling", + """ + { + "Comment": "An example of the Amazon States Language for notification on an AWS Batch job completion", + "StartAt": "Submit Batch Job", + "TimeoutSeconds": 3600, + "States": { + "Submit Batch Job": { + "Type": "Task", + "Resource": "arn:aws:states:::batch:submitJob.sync", + "Parameters": { + "JobName": "BatchJobNotification", + "JobQueue": "arn:aws:batch:us-west-2:123456789012:job-queue/BatchJobQueue-123456789abcdef", + "JobDefinition": "arn:aws:batch:us-west-2:123456789012:job-definition/BatchJobDefinition-123456789abcdef:1", + }, + "Next": "Notify Success", + "Retry": [ + { + "ErrorEquals": ["States.ALL"], + "IntervalSeconds": 30, + "MaxAttempts": 2, + "BackoffRate": 1.5, + } + ], + "Catch": [ + {"ErrorEquals": ["States.ALL"], "Next": "Notify Failure"} + ], + }, + "Notify Success": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "Message": "Batch job submitted through Step Functions succeeded", + "TopicArn": "arn:aws:sns:us-west-2:123456789012:StepFunctionsSample-BatchJobManagement12345678-9abc-def0-1234-567890abcdef-SNSTopic-A2B3C4D5E6F7G", + }, + "End": True, + }, + "Notify Failure": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "Message": "Batch job submitted through Step Functions failed", + "TopicArn": "arn:aws:sns:us-west-2:123456789012:StepFunctionsSample-BatchJobManagement12345678-9abc-def0-1234-567890abcdef-SNSTopic-A2B3C4D5E6F7G", + }, + "End": True, + }, + }, + } + """, + [], + ), + ( + "Transfer data records", + { + "Comment": "An example of the Amazon States Language for reading messages from a DynamoDB table and sending them to SQS", + "StartAt": "Seed the DynamoDB Table", + "TimeoutSeconds": 3600, + "States": { + "Seed the DynamoDB Table": { + "Type": "Task", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:sqsconnector-SeedingFunction-T3U43VYDU5OQ", + "ResultPath": "$.List", + "Next": "For Loop Condition", + }, + "For Loop Condition": { + "Type": "Choice", + "Choices": [ + { + "Not": { + "Variable": "$.List[0]", + "StringEquals": "DONE", + }, + "Next": "Read Next Message from DynamoDB", + } + ], + "Default": "Succeed", + }, + "Read Next Message from DynamoDB": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:getItem", + "Parameters": { + "TableName": "sqsconnector-DDBTable-1CAFOJWP8QD6I", + "Key": {"MessageId": {"S.$": "$.List[0]"}}, + }, + "ResultPath": "$.DynamoDB", + "Next": "Send Message to SQS", + }, + "Send Message to SQS": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage", + "Parameters": { + "MessageBody.$": "$.DynamoDB.Item.Message.S", + "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123456789012/sqsconnector-SQSQueue-QVGQBW134PWK", + }, + "ResultPath": "$.SQS", + "Next": "Pop Element from List", + }, + "Pop Element from List": { + "Type": "Pass", + "Parameters": {"List.$": "$.List[1:]"}, + "Next": "For Loop Condition", + }, + "Succeed": {"Type": "Succeed"}, + }, + }, + [], + ), + ( + "A map", + { + "Comment": "An example of the Amazon States Language for reading messages from an SQS queue and iteratively processing each message.", + "StartAt": "Read messages from SQS Queue", + "States": { + "Read messages from SQS Queue": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "OutputPath": "$.Payload", + "Parameters": { + "FunctionName": "MapSampleProj-ReadFromSQSQueueLambda-1MY3M63RMJVA9" + }, + "Next": "Are there messages to process?", + }, + "Are there messages to process?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$", + "StringEquals": "No messages", + "Next": "Finish", + } + ], + "Default": "Process messages", + }, + "Process messages": { + "Type": "Map", + "Next": "Finish", + "ItemsPath": "$", + "Parameters": { + "MessageNumber.$": "$$.Map.Item.Index", + "MessageDetails.$": "$$.Map.Item.Value", + }, + "Iterator": { + "StartAt": "Write message to DynamoDB", + "States": { + "Write message to DynamoDB": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:putItem", + "ResultPath": None, + "Parameters": { + "TableName": "MapSampleProj-DDBTable-YJDJ1MKIN6C5", + "ReturnConsumedCapacity": "TOTAL", + "Item": { + "MessageId": { + "S.$": "$.MessageDetails.MessageId" + }, + "Body": {"S.$": "$.MessageDetails.Body"}, + }, + }, + "Next": "Remove message from SQS queue", + }, + "Remove message from SQS queue": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "InputPath": "$.MessageDetails", + "ResultPath": None, + "Parameters": { + "FunctionName": "MapSampleProj-DeleteFromSQSQueueLambda-198J2839ZO5K2", + "Payload": { + "ReceiptHandle.$": "$.ReceiptHandle" + }, + }, + "Next": "Publish message to SNS topic", + }, + "Publish message to SNS topic": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "InputPath": "$.MessageDetails", + "Parameters": { + "Subject": "Message from Step Functions!", + "Message.$": "$.Body", + "TopicArn": "arn:aws:sns:us-east-1:012345678910:MapSampleProj-SNSTopic-1CQO4HQ3IR1KN", + }, + "End": True, + }, + }, + }, + }, + "Finish": {"Type": "Succeed"}, + }, + }, + [], + ), + ( + "Test functions", + { + "StartAt": "Task1", + "States": { + "Task1": { + "Type": "Task", + "Credentials": { + "Data": "input data", + "Algorithm": "SHA-1", + }, + "Resource": "arn:aws:states:::batch:submitJob.sync", + "End": True, + }, + }, + }, + [], + ), + ( + "Choices", + { + "StartAt": "ChoiceStateX", + "States": { + "ChoiceStateX": { + "Type": "Choice", + "Choices": [ + { + "Not": { + "Variable": "$.type", + "StringEquals": "Private", + }, + "Next": "Public", + }, + { + "Variable": "$.value", + "NumericEquals": 0, + "Next": "ValueIsZero", + }, + { + "And": [ + { + "Variable": "$.value", + "NumericGreaterThanEquals": 20, + }, + {"Variable": "$.value", "NumericLessThan": 30}, + ], + "Next": "ValueInTwenties", + }, + { + "Variable": "$.value", + "NumericLessThan": 0, + "Next": "ValueIsNegative", + }, + ], + "Default": "DefaultState", + }, + "Public": { + "Type": "Task", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:Foo", + "Next": "NextState", + }, + "ValueIsZero": { + "Type": "Task", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:Zero", + "Next": "NextState", + }, + "ValueInTwenties": { + "Type": "Task", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:Bar", + "Next": "NextState", + }, + "ValueIsNegative": { + "Type": "Succeed", + }, + "DefaultState": {"Type": "Fail", "Cause": "No Matches!"}, + }, + }, + [], + ), + ( + "Invalid configuration", + { + "StartAt": "SampleChoice", + "States": { + "NoType": {}, + "SampleChoice": { + "Default": "__SucceedEntryPoint__", + "Type": "Choice", + }, + "__SucceedEntryPoint__": {"Type": "Pass"}, + }, + }, + [ + ValidationError( + "'Type' is a required property", + rule=StateMachineDefinition(), + validator="required", + schema_path=deque( + [ + "properties", + "States", + "patternProperties", + "^.{1,128}$", + "required", + ] + ), + path=deque(["States", "NoType"]), + ), + ValidationError( + "'Choices' is a required property", + rule=StateMachineDefinition(), + validator="required", + schema_path=deque( + [ + "properties", + "States", + "patternProperties", + "^.{1,128}$", + "allOf", + 0, + "then", + "required", + ] + ), + path=deque(["States", "SampleChoice"]), + ), + ValidationError( + "Only one of ['Next', 'End'] is a required property", + rule=StateMachineDefinition(), + validator="requiredXor", + schema_path=deque( + [ + "properties", + "States", + "patternProperties", + "^.{1,128}$", + "allOf", + 3, + "then", + "requiredXor", + ] + ), + path=deque(["States", "__SucceedEntryPoint__"]), + ), + ], + ), + ( + "Invalid string definition", + """ + { + "States": { + "NoType": {} + } + } + """, + [ + ValidationError( + "'Type' is a required property at 'States/NoType'", + rule=StateMachineDefinition(), + validator="required", + schema_path=deque( + [ + "properties", + "States", + "patternProperties", + "^.{1,128}$", + "required", + ] + ), + path=deque(["States", "NoType"]), + ), + ValidationError( + "'StartAt' is a required property", + rule=StateMachineDefinition(), + validator="required", + schema_path=deque( + [ + "required", + ] + ), + path=deque([]), + ), + ], + ), + ], +) +def test_validate( + name, + instance, + expected, + rule, + validator, +): + errs = list(rule.validate(validator, {}, instance, {})) + assert errs == expected, f"{name!r} test failed with {errs!r}"