Repository: localstack/localstack

Issue:
bug: State Machine references don't get resolved properly
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

Lambda refs get lost

### Expected Behavior

Lambda refs work in state machines

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

    docker run localstack/localstack

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)

    awslocal s3 mb s3://mybucket


### Environment

```markdown
- OS: 
- LocalStack:
```


### Anything else?

This is based on a conversation I had with @dominikschubert 


Discussion:
@dominikschubert Any updates on status for this issue?

Resolving patch:
diff --git a/localstack/services/cloudformation/models/stepfunctions.py b/localstack/services/cloudformation/models/stepfunctions.py
--- a/localstack/services/cloudformation/models/stepfunctions.py
+++ b/localstack/services/cloudformation/models/stepfunctions.py
@@ -1,3 +1,6 @@
+import re
+from typing import Dict
+
 from localstack.services.cloudformation.deployment_utils import PLACEHOLDER_RESOURCE_NAME
 from localstack.services.cloudformation.service_models import GenericBaseModel
 from localstack.utils.aws import aws_stack
@@ -65,19 +68,43 @@ def update_resource(self, new_resource, stack_name, resources):
         }
         return client.update_state_machine(**kwargs)
 
-    @staticmethod
-    def get_deploy_templates():
+    @classmethod
+    def get_deploy_templates(cls):
+        def _create_params(params, **kwargs):
+            def _get_definition(params):
+                definition_str = params.get("DefinitionString")
+                substitutions = params.get("DefinitionSubstitutions")
+                if substitutions is not None:
+                    definition_str = _apply_substitutions(definition_str, substitutions)
+                return definition_str
+
+            return {
+                "name": params.get("StateMachineName", PLACEHOLDER_RESOURCE_NAME),
+                "definition": _get_definition(params),
+                "roleArn": params.get("RoleArn"),
+                "type": params.get("StateMachineTyp", None),
+            }
+
         return {
             "create": {
                 "function": "create_state_machine",
-                "parameters": {
-                    "name": ["StateMachineName", PLACEHOLDER_RESOURCE_NAME],
-                    "definition": "DefinitionString",
-                    "roleArn": "RoleArn",
-                },
+                "parameters": _create_params,
             },
             "delete": {
                 "function": "delete_state_machine",
                 "parameters": {"stateMachineArn": "PhysicalResourceId"},
             },
         }
+
+
+def _apply_substitutions(definition: str, substitutions: Dict[str, str]) -> str:
+    substitution_regex = re.compile("\\${[a-zA-Z0-9_]+}")  # might be a bit too strict in some cases
+    tokens = substitution_regex.findall(definition)
+    result = definition
+    for token in tokens:
+        raw_token = token[2:-1]  # strip ${ and }
+        if raw_token not in substitutions.keys():
+            raise
+        result = result.replace(token, substitutions[raw_token])
+
+    return result
diff --git a/localstack/utils/generic/wait_utils.py b/localstack/utils/generic/wait_utils.py
--- a/localstack/utils/generic/wait_utils.py
+++ b/localstack/utils/generic/wait_utils.py
@@ -24,5 +24,5 @@ def wait_until(
         if strategy == "linear":
             next_wait = (wait / _retries) * (_retries + 1)
         elif strategy == "exponential":
-            next_wait = wait ** 2
+            next_wait = wait * 2
         wait_until(fn, next_wait, max_retries, strategy, _retries + 1, _max_wait)


Test patch:
diff --git a/tests/integration/cloudformation/test_cloudformation_stepfunctions.py b/tests/integration/cloudformation/test_cloudformation_stepfunctions.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/cloudformation/test_cloudformation_stepfunctions.py
@@ -0,0 +1,69 @@
+import os
+
+from localstack.utils.common import short_uid
+from localstack.utils.generic.wait_utils import wait_until
+from tests.integration.cloudformation.test_cloudformation_changesets import load_template_raw
+
+THIS_FOLDER = os.path.dirname(os.path.realpath(__file__))
+
+
+def test_statemachine_definitionsubstitution(
+    cfn_client,
+    lambda_client,
+    cleanup_stacks,
+    cleanup_changesets,
+    is_change_set_created_and_available,
+    is_stack_created,
+    stepfunctions_client,
+    s3_client,
+):
+    stack_name = f"stack-{short_uid()}"
+    change_set_name = f"change-set-{short_uid()}"
+
+    response = cfn_client.create_change_set(
+        StackName=stack_name,
+        ChangeSetName=change_set_name,
+        TemplateBody=load_template_raw("stepfunctions_statemachine_substitutions.yaml"),
+        ChangeSetType="CREATE",
+        Capabilities=["CAPABILITY_IAM"],
+    )
+    change_set_id = response["Id"]
+    stack_id = response["StackId"]
+
+    try:
+        wait_until(is_change_set_created_and_available(change_set_id))
+        cfn_client.execute_change_set(ChangeSetName=change_set_id)
+
+        wait_until(is_stack_created(stack_id))
+
+        stack_result = cfn_client.describe_stacks(StackName=stack_id)
+        assert stack_result["Stacks"][0]["StackStatus"] == "CREATE_COMPLETE"
+
+        outputs = stack_result["Stacks"][0]["Outputs"]
+        assert len(outputs) == 1
+        statemachine_arn = outputs[0]["OutputValue"]
+
+        # execute statemachine
+        ex_result = stepfunctions_client.start_execution(stateMachineArn=statemachine_arn)
+
+        def _is_executed():
+            return (
+                stepfunctions_client.describe_execution(executionArn=ex_result["executionArn"])[
+                    "status"
+                ]
+                != "RUNNING"
+            )
+
+        wait_until(_is_executed)
+        execution_desc = stepfunctions_client.describe_execution(
+            executionArn=ex_result["executionArn"]
+        )
+        assert execution_desc["status"] == "SUCCEEDED"
+        # sync execution is currently not supported since botocore adds a "sync-" prefix
+        # ex_result = stepfunctions_client.start_sync_execution(stateMachineArn=statemachine_arn)
+
+        assert "hello from statemachine" in execution_desc["output"]
+
+    finally:
+        cleanup_changesets([change_set_id])
+        cleanup_stacks([stack_id])
diff --git a/tests/integration/fixtures.py b/tests/integration/fixtures.py
--- a/tests/integration/fixtures.py
+++ b/tests/integration/fixtures.py
@@ -23,6 +23,7 @@
     from mypy_boto3_sns import SNSClient
     from mypy_boto3_sqs import SQSClient
     from mypy_boto3_ssm import SSMClient
+    from mypy_boto3_stepfunctions import SFNClient
 
 LOG = logging.getLogger(__name__)
 
@@ -101,6 +102,11 @@ def secretsmanager_client() -> "SecretsManagerClient":
     return _client("secretsmanager")
 
 
+@pytest.fixture(scope="class")
+def stepfunctions_client() -> "SFNClient":
+    return _client("stepfunctions")
+
+
 @pytest.fixture
 def dynamodb_create_table(dynamodb_client):
     tables = list()
diff --git a/tests/integration/templates/stepfunctions_statemachine_substitutions.yaml b/tests/integration/templates/stepfunctions_statemachine_substitutions.yaml
new file mode 100644
--- /dev/null
+++ b/tests/integration/templates/stepfunctions_statemachine_substitutions.yaml
@@ -0,0 +1,86 @@
+Resources:
+  mystaterole904FED2D:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Statement:
+          - Action: sts:AssumeRole
+            Effect: Allow
+            Principal:
+              AWS: "*"
+        Version: "2012-10-17"
+  mystateroleDefaultPolicy4DD169B3:
+    Type: AWS::IAM::Policy
+    Properties:
+      PolicyDocument:
+        Statement:
+          - Action: lambda:InvokeFunction
+            Effect: Allow
+            Resource:
+              Fn::GetAtt:
+                - startFnB78B119D
+                - Arn
+        Version: "2012-10-17"
+      PolicyName: mystateroleDefaultPolicy4DD169B3
+      Roles:
+        - Ref: mystaterole904FED2D
+  startFnServiceRoleB86C6980:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Statement:
+          - Action: sts:AssumeRole
+            Effect: Allow
+            Principal:
+              Service: lambda.amazonaws.com
+        Version: "2012-10-17"
+      ManagedPolicyArns:
+        - Fn::Join:
+            - ""
+            - - "arn:"
+              - Ref: AWS::Partition
+              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+  startFnB78B119D:
+    Type: AWS::Lambda::Function
+    Properties:
+      Code:
+        ZipFile: "
+
+          \                exports.handler = async function(event) {
+
+          \                    console.log(JSON.stringify({event}));
+
+          \                    return JSON.stringify({message: \"hello from statemachine lambda\"});
+
+          \                }
+
+          \            "
+      Role:
+        Fn::GetAtt:
+          - startFnServiceRoleB86C6980
+          - Arn
+      Handler: index.handler
+      Runtime: nodejs12.x
+    DependsOn:
+      - startFnServiceRoleB86C6980
+  stm:
+    Type: AWS::StepFunctions::StateMachine
+    Properties:
+      StateMachineType: EXPRESS
+      RoleArn:
+        Fn::GetAtt:
+          - mystaterole904FED2D
+          - Arn
+      DefinitionString: '{"StartAt": "HelloWorld", "States": {"HelloWorld": {"Type": "Task", "Resource": "${StartFn}", "End": true}}}'
+      DefinitionSubstitutions:
+        StartFn:
+          Fn::GetAtt:
+            - startFnB78B119D
+            - Arn
+
+Outputs:
+  StateMachineArnOutput:
+    Value:
+      Fn::GetAtt:
+        - stm
+        - Arn
diff --git a/tests/unit/test_cloudformation.py b/tests/unit/test_cloudformation.py
--- a/tests/unit/test_cloudformation.py
+++ b/tests/unit/test_cloudformation.py
@@ -1,49 +1,54 @@
 import re
-import unittest
 
+from localstack.services.cloudformation.models.stepfunctions import _apply_substitutions
 from localstack.utils.cloudformation import template_deployer, template_preparer
 
 
-class CloudFormationTest(unittest.TestCase):
-    def test_resolve_references(self):
-        ref = {
-            "Fn::Join": [
-                "",
-                [
-                    "arn:",
-                    {"Ref": "AWS::Partition"},
-                    ":apigateway:",
-                    {"Ref": "AWS::Region"},
-                    ":lambda:path/2015-03-31/functions/",
-                    "test:lambda:arn",
-                    "/invocations",
-                ],
-            ]
-        }
-        stack_name = "test"
-        resources = {}
-        result = template_deployer.resolve_refs_recursively(stack_name, ref, resources)
-        pattern = (
-            r"arn:aws:apigateway:.*:lambda:path/2015-03-31/functions/test:lambda:arn/invocations"
-        )
-        self.assertTrue(re.match(pattern, result))
-
-    def test_is_local_service_url(self):
-        local_urls = [
-            "http://localhost",
-            "https://localhost",
-            "http://localhost:4566",
-            "https://localhost:4566",
-            "http://localhost.localstack.cloud:4566",
-            "https://s3.localhost.localstack.cloud",
-            "http://mybucket.s3.localhost.localstack.cloud:4566",
-            "https://mybucket.s3.localhost",
+def test_resolve_references():
+    ref = {
+        "Fn::Join": [
+            "",
+            [
+                "arn:",
+                {"Ref": "AWS::Partition"},
+                ":apigateway:",
+                {"Ref": "AWS::Region"},
+                ":lambda:path/2015-03-31/functions/",
+                "test:lambda:arn",
+                "/invocations",
+            ],
         ]
-        remote_urls = [
-            "https://mybucket.s3.amazonaws.com",
-            "http://mybucket.s3.us-east-1.amazonaws.com",
-        ]
-        for url in local_urls:
-            self.assertTrue(template_preparer.is_local_service_url(url))
-        for url in remote_urls:
-            self.assertFalse(template_preparer.is_local_service_url(url))
+    }
+    stack_name = "test"
+    resources = {}
+    result = template_deployer.resolve_refs_recursively(stack_name, ref, resources)
+    pattern = r"arn:aws:apigateway:.*:lambda:path/2015-03-31/functions/test:lambda:arn/invocations"
+    assert re.match(pattern, result)
+
+
+def test_is_local_service_url():
+    local_urls = [
+        "http://localhost",
+        "https://localhost",
+        "http://localhost:4566",
+        "https://localhost:4566",
+        "http://localhost.localstack.cloud:4566",
+        "https://s3.localhost.localstack.cloud",
+        "http://mybucket.s3.localhost.localstack.cloud:4566",
+        "https://mybucket.s3.localhost",
+    ]
+    remote_urls = [
+        "https://mybucket.s3.amazonaws.com",
+        "http://mybucket.s3.us-east-1.amazonaws.com",
+    ]
+    for url in local_urls:
+        assert template_preparer.is_local_service_url(url)
+    for url in remote_urls:
+        assert not template_preparer.is_local_service_url(url)
+
+
+def test_apply_substitutions():
+    blubstr = "something ${foo} and ${test} + ${foo}"
+    subs = {"foo": "bar", "test": "resolved"}
+
+    assert _apply_substitutions(blubstr, subs) == "something bar and resolved + bar"