Repository: aws-cloudformation/cfn-lint

Issue:
[Feature Request] Proper Linting of List-to-String Fn::Join Actions When Concat-Elements Include List-objects
**cfn-lint version: (`cfn-lint --version`)**

cfn-lint 0.10.2

**Description of issue:**

I make frequent use of nested-templates. Typically, a parent template will contain a call to a child-stack that creates one or more `SecurityGroup` objects and then passes those `SecurityGroup` objects to an EC2 or `AutoScalingGroup`/`LaunchConfiguration` child template as a comma-delimited string using a method similar to the following:

~~~json
"SecurityGroupIds": {
    "Fn::Join": [
        ",",
        [
            { "Fn::GetAtt": [ "SgRes", "Outputs.AppSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.NasSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.RdsSg" ] }
        ]
    ]
},
~~~

Recently, I received a request to add the ability to ensure a user-specifiable "management" `SecurityGroup` is applied to the resultant EC2s. So, I added a `Parameter` to my parent template similar to:

~~~json
"MgtSgs": {
    "AllowedPattern": "^$|^sg-[0-9a-z]{8}$|^sg-[0-9a-z]{17}$",
    "Default": "",
    "Description": "Security group(s) that need management access to the Gluster nodes' CLIs",
    "Type": "List<AWS::EC2::SecurityGroup::Id>"
},
~~~

I use the `List<AWS::EC2::SecurityGroup::Id>` type because my template users prefer to be able to select from a pull-down when using the template from the CFn web-console. Otherwise, I'd consider using a basic `String` type. When I first updated my `"SecurityGroupIds"` join-block, I'd tried:

~~~json
"SecurityGroupIds": {
    "Fn::Join": [
        ",",
        [
            { "Ref": "MgtSgs" }
            { "Fn::GetAtt": [ "SgRes", "Outputs.AppSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.NasSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.RdsSg" ] }
        ]
    ]
},
~~~

and

~~~json
"SecurityGroupIds": {
    "Fn::Join": [
        ",",
        [
            {
                "Fn::Join": [
                    ",",
                    [
                        { "Ref": "MgtSgs" }
                    ]
                ]
            },
            { "Fn::GetAtt": [ "SgRes", "Outputs.AppSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.NasSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.RdsSg" ] }
        ]
    ]
},
~~~

Both of which `cfn-lint` said were valid. However, when attempting to use either of the above to deploy a stack, the launches failed with:

~~~
An error occurred (ValidationError) when calling the CreateStack operation: Template error: 
   every Fn::Join object requires two parameters, (1) a string delimiter and (2) a list of 
   strings to be joined or a function that returns a list of strings (such as Fn::GetAZs) to be
   joined.
~~~

This error is obviously the correct result, and the fix (after much hair-pulling and knob-twizzling) was to change to doing:

~~~json
"SecurityGroupIds": {
    "Fn::Join": [
        ",",
        [
            {
                "Fn::Join": [
                    ",",
                    { "Ref": "MgtSgs" }
                ]
            },
            { "Fn::GetAtt": [ "SgRes", "Outputs.AppSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.NasSg" ] },
            { "Fn::GetAtt": [ "SgRes", "Outputs.RdsSg" ] }
        ]
    ]
},
~~~

It would be great if `cfn-lint` could have flagged the faulty methods I'd tried. I'd likely still have experienced errors, but would have had them at the edit-stage rather than the attempt-to-deploy stage (though a meaningful error at the edit-stage would have saved that).


Discussion:
Good find, and complete information. We should be able to get this fixed!
Might worth tackling this one with #390 .  Similar issues when using a List Parameter inside a Sub.  

Resolving patch:
diff --git a/src/cfnlint/rules/functions/Join.py b/src/cfnlint/rules/functions/Join.py
--- a/src/cfnlint/rules/functions/Join.py
+++ b/src/cfnlint/rules/functions/Join.py
@@ -17,6 +17,7 @@
 import six
 from cfnlint import CloudFormationLintRule
 from cfnlint import RuleMatch
+from cfnlint.helpers import RESOURCE_SPECS
 
 
 class Join(CloudFormationLintRule):
@@ -27,6 +28,132 @@ class Join(CloudFormationLintRule):
     source_url = 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-join.html'
     tags = ['functions', 'join']
 
+    def __init__(self):
+        """Initialize the rule"""
+        super(Join, self).__init__()
+        self.list_supported_functions = []
+        self.singular_supported_functions = []
+        for intrinsic_type, intrinsic_value in RESOURCE_SPECS.get('us-east-1').get('IntrinsicTypes').items():
+            if 'List' in intrinsic_value.get('ReturnTypes', []):
+                self.list_supported_functions.append(intrinsic_type)
+            if 'Singular' in intrinsic_value.get('ReturnTypes', []):
+                self.singular_supported_functions.append(intrinsic_type)
+
+    def _get_parameters(self, cfn):
+        """Get all Parameter Names"""
+        results = {}
+        parameters = cfn.template.get('Parameters', {})
+        if isinstance(parameters, dict):
+            for param_name, param_values in parameters.items():
+                # This rule isn't here to check the Types but we need
+                # something valid if it doesn't exist
+                results[param_name] = param_values.get('Type', 'String')
+
+        return results
+
+    def _normalize_getatt(self, getatt):
+        """ Normalize getatt into an array"""
+        if isinstance(getatt, six.string_types):
+            return getatt.split('.', 1)
+        return getatt
+
+    def _is_ref_a_list(self, parameter, template_parameters):
+        """ Is a Ref a list """
+        list_params = [
+            'AWS::NotificationARNs',
+        ]
+
+        odd_list_params = [
+            'CommaDelimitedList',
+            'AWS::SSM::Parameter::Value<CommaDelimitedList>',
+        ]
+
+        if parameter in template_parameters:
+            if (
+                    template_parameters.get(parameter) in odd_list_params or
+                    template_parameters.get(parameter).startswith('AWS::SSM::Parameter::Value<List') or
+                    template_parameters.get(parameter).startswith('List')):
+                return True
+        if parameter in list_params:
+            return True
+        return False
+
+
+    def _is_getatt_a_list(self, parameter, get_atts):
+        """ Is a GetAtt a List """
+
+        for resource, attributes in get_atts.items():
+            for attribute_name, attribute_values in attributes.items():
+                if resource == parameter[0] and attribute_name in ['*', parameter[1]]:
+                    if attribute_values.get('Type') == 'List':
+                        return True
+
+        return False
+
+
+    def _match_string_objs(self, join_string_objs, cfn, path):
+        """ Check join list """
+
+        matches = []
+
+        template_parameters = self._get_parameters(cfn)
+        get_atts = cfn.get_valid_getatts()
+
+        if isinstance(join_string_objs, dict):
+            if len(join_string_objs) == 1:
+                for key, value in join_string_objs.items():
+                    if key not in self.list_supported_functions:
+                        message = 'Fn::Join unsupported function for {0}'
+                        matches.append(RuleMatch(
+                            path, message.format('/'.join(map(str, path)))))
+                    elif key in ['Ref']:
+                        if not self._is_ref_a_list(value, template_parameters):
+                            message = 'Fn::Join must use a list at {0}'
+                            matches.append(RuleMatch(
+                                path, message.format('/'.join(map(str, path)))))
+                    elif key in ['Fn::GetAtt']:
+                        if not self._is_getatt_a_list(self._normalize_getatt(value), get_atts):
+                            message = 'Fn::Join must use a list at {0}'
+                            matches.append(RuleMatch(
+                                path, message.format('/'.join(map(str, path)))))
+            else:
+                message = 'Join list of values should be singular for {0}'
+                matches.append(RuleMatch(
+                    path, message.format('/'.join(map(str, path)))))
+        elif not isinstance(join_string_objs, list):
+            message = 'Join list of values for {0}'
+            matches.append(RuleMatch(
+                path, message.format('/'.join(map(str, path)))))
+        else:
+            for string_obj in join_string_objs:
+                if isinstance(string_obj, dict):
+                    if len(string_obj) == 1:
+                        for key, value in string_obj.items():
+                            if key not in self.singular_supported_functions:
+                                message = 'Join unsupported function for {0}'
+                                matches.append(RuleMatch(
+                                    path, message.format('/'.join(map(str, path)))))
+                            elif key in ['Ref']:
+                                if self._is_ref_a_list(value, template_parameters):
+                                    message = 'Fn::Join must not be a list at {0}'
+                                    matches.append(RuleMatch(
+                                        path, message.format('/'.join(map(str, path)))))
+                            elif key in ['Fn::GetAtt']:
+                                if self._is_getatt_a_list(self._normalize_getatt(value), get_atts):
+                                    message = 'Fn::Join must not be a list at {0}'
+                                    matches.append(RuleMatch(
+                                        path, message.format('/'.join(map(str, path)))))
+                    else:
+                        message = 'Join list of values should be singular for {0}'
+                        matches.append(RuleMatch(
+                            path, message.format('/'.join(map(str, path)))))
+                elif not isinstance(string_obj, six.string_types):
+                    message = 'Join list of singular function or string for {0}'
+                    matches.append(RuleMatch(
+                        path, message.format('/'.join(map(str, path)))))
+
+        return matches
+
     def match(self, cfn):
         """Check CloudFormation Join"""
 
@@ -34,23 +161,9 @@ def match(self, cfn):
 
         join_objs = cfn.search_deep_keys('Fn::Join')
 
-        supported_functions = [
-            'Fn::Base64',
-            'Fn::FindInMap',
-            'Fn::GetAtt',
-            'Fn::GetAZs',
-            'Fn::ImportValue',
-            'Fn::If',
-            'Fn::Join',
-            'Fn::Select',
-            'Fn::Split',
-            'Fn::Sub',
-            'Ref'
-        ]
-
         for join_obj in join_objs:
             join_value_obj = join_obj[-1]
-            tree = join_obj[:-1]
+            path = join_obj[:-1]
             if isinstance(join_value_obj, list):
                 if len(join_value_obj) == 2:
                     join_string = join_value_obj[0]
@@ -58,50 +171,15 @@ def match(self, cfn):
                     if not isinstance(join_string, six.string_types):
                         message = 'Join string has to be of type string for {0}'
                         matches.append(RuleMatch(
-                            tree, message.format('/'.join(map(str, tree)))))
-                    if isinstance(join_string_objs, dict):
-                        if isinstance(join_string_objs, dict):
-                            if len(join_string_objs) == 1:
-                                for key, _ in join_string_objs.items():
-                                    if key not in supported_functions:
-                                        message = 'Join unsupported function for {0}'
-                                        matches.append(RuleMatch(
-                                            tree, message.format('/'.join(map(str, tree)))))
-                            else:
-                                message = 'Join list of values should be singular for {0}'
-                                matches.append(RuleMatch(
-                                    tree, message.format('/'.join(map(str, tree)))))
-                        elif not isinstance(join_string_objs, six.string_types):
-                            message = 'Join list of singular function or string for {0}'
-                            matches.append(RuleMatch(
-                                tree, message.format('/'.join(map(str, tree)))))
-                    elif not isinstance(join_string_objs, list):
-                        message = 'Join list of values for {0}'
-                        matches.append(RuleMatch(
-                            tree, message.format('/'.join(map(str, tree)))))
-                    else:
-                        for string_obj in join_string_objs:
-                            if isinstance(string_obj, dict):
-                                if len(string_obj) == 1:
-                                    for key, _ in string_obj.items():
-                                        if key not in supported_functions:
-                                            message = 'Join unsupported function for {0}'
-                                            matches.append(RuleMatch(
-                                                tree, message.format('/'.join(map(str, tree)))))
-                                else:
-                                    message = 'Join list of values should be singular for {0}'
-                                    matches.append(RuleMatch(
-                                        tree, message.format('/'.join(map(str, tree)))))
-                            elif not isinstance(string_obj, six.string_types):
-                                message = 'Join list of singular function or string for {0}'
-                                matches.append(RuleMatch(
-                                    tree, message.format('/'.join(map(str, tree)))))
+                            path, message.format('/'.join(map(str, path)))))
+                    matches.extend(self._match_string_objs(join_string_objs, cfn, path))
                 else:
                     message = 'Join should be an array of 2 for {0}'
                     matches.append(RuleMatch(
-                        tree, message.format('/'.join(map(str, tree)))))
+                        path, message.format('/'.join(map(str, path)))))
             else:
                 message = 'Join should be an array of 2 for {0}'
                 matches.append(RuleMatch(
-                    tree, message.format('/'.join(map(str, tree)))))
+                    path, message.format('/'.join(map(str, path)))))
+
         return matches


Test patch:
diff --git a/test/fixtures/templates/bad/functions/join.yaml b/test/fixtures/templates/bad/functions/join.yaml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/bad/functions/join.yaml
@@ -0,0 +1,76 @@
+---
+AWSTemplateFormatVersion: "2010-09-09"
+Description: >
+  Test Join Functions
+Parameters:
+  Partition:
+    Type: String
+  Partitions:
+    Type: List<String>
+Resources:
+  myInstance:
+    Type: AWS::EC2::Instance
+    Properties:
+      ImageId: ami-1234
+      UserData:
+        Fn::Base64:
+          Fn::Join:
+          - ''
+          - 'Test function'
+          - 'another item'
+  myInstance2:
+    Type: AWS::EC2::Instance
+    Properties:
+      ImageId: ami-1234
+      UserData:
+        Fn::Base64:
+          Fn::Join: !Ref myInstance
+  myVPC:
+    Type: AWS::EC2::VPC
+    Properties:
+      CidrBlock: 10.0.0.0/16
+      InstanceTenancy: dedicated
+  RolePolicies: 
+    Type: "AWS::IAM::ManagedPolicy"
+    Properties: 
+      ManagedPolicyName: "root"
+      PolicyDocument: 
+        Version: "2012-10-17"
+        Statement: 
+          - 
+            Effect: "Allow"
+            Action: "*"
+            Resource: 
+            - Fn::Join:
+                - ''
+                - !Sub "${AWS::AccountId}-test"  # Sub is singular and should be a list
+            - Fn::Join:
+                - ''
+                - - !GetAZs 'us-east-1'  # Get AZs returns a list and this should be singular
+            - Fn::Join:
+                - ''
+                - - !GetAtt myVPC.CidrBlockAssociations  # Is a list and should be singular
+            - Fn::Join:
+                - ''
+                - !GetAtt myVPC.CidrBlock  # Singular value and should be a list
+            - Fn::Join:
+                - ''
+                - - 'arn:'
+                  - !Ref Partitions  # Can't use a list here
+                  - ':s3:::elasticbeanstalk-*-'
+                  - !Ref 'AWS::AccountId'
+            - Fn::Join:
+              - ''
+              - Ref: Partition  # has to be a list here
+            - Fn::Join:
+              - ''
+              - Ref: Partition  # More than one item in the object
+                Fn::GetAtt: myVPC.CidrBlockAssociations
+            - Fn::Join:
+              - ''
+              - - Ref: Partition  # More than one item in the object
+                  Fn::GetAtt: myVPC.CidrBlockAssociations
+            - Fn::Join:
+              - ''
+              - String  # Can't be a string
+  
\ No newline at end of file
diff --git a/test/fixtures/templates/good/functions/join.yaml b/test/fixtures/templates/good/functions/join.yaml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/good/functions/join.yaml
@@ -0,0 +1,48 @@
+AWSTemplateFormatVersion: "2010-09-09"
+Parameters:
+  Partition:
+    Type: String
+  Partitions:
+    Type: List<String>
+Resources: 
+  myVPC:
+    Type: AWS::EC2::VPC
+    Properties:
+      CidrBlock: 10.0.0.0/16
+      InstanceTenancy: dedicated
+  RolePolicies: 
+    Type: "AWS::IAM::ManagedPolicy"
+    Properties: 
+      ManagedPolicyName: "root"
+      PolicyDocument: 
+        Version: "2012-10-17"
+        Statement: 
+          - 
+            Effect: "Allow"
+            Action: "*"
+            Resource: 
+            - Fn::Join:
+                - ''
+                - - !Sub "${AWS::AccountId}-test"   # Sub returns a string and shoule work here
+            - Fn::Join:
+                - ''
+                - !GetAZs 'us-east-1'  # a GetAZs returns a list which should work here
+            - Fn::Join:
+                - ''
+                - !GetAtt myVPC.CidrBlockAssociations  # a GetAtt to a list should work here
+            - Fn::Join:
+                - ''
+                - - !GetAtt myVPC.CidrBlock  # a GetAtt to a string should work here
+            - Fn::Join:
+                - ''
+                - - 'arn:'
+                  - !Ref Partition
+                  - ':s3:::elasticbeanstalk-*-'
+                  - !Ref 'AWS::AccountId'
+            - Fn::Join:
+              - ''
+              - Ref: Partitions
+            - Fn::Join:
+              - ''
+              - !Ref AWS::NotificationARNs
+
diff --git a/test/integration/test_patched_specs.py b/test/integration/test_patched_specs.py
--- a/test/integration/test_patched_specs.py
+++ b/test/integration/test_patched_specs.py
@@ -109,6 +109,15 @@ def test_sub_properties(self):
                     for p_name, p_values in v_value_properties.items():
                         self._test_sub_properties(resource_name, p_name, p_values)
 
+    def test_intrinsic_value_types(self):
+        """ Test Intrinsic Types """
+
+        for _, i_value in self.spec.get('IntrinsicTypes').items():
+            self.assertIn('Documentation', i_value)
+            self.assertIn('ReturnTypes', i_value)
+            for return_type in i_value.get('ReturnTypes'):
+                self.assertIn(return_type, ['Singular', 'List'])
+
     def test_property_value_types(self):
         """Test Property Value Types"""
         for v_name, v_values in self.spec.get('ValueTypes').items():
diff --git a/test/rules/functions/test_join.py b/test/rules/functions/test_join.py
--- a/test/rules/functions/test_join.py
+++ b/test/rules/functions/test_join.py
@@ -24,6 +24,25 @@ def setUp(self):
         """Setup"""
         super(TestRulesJoin, self).setUp()
         self.collection.register(Join())
+        self.success_templates = [
+            'test/fixtures/templates/good/functions/join.yaml',
+        ]
+
+    def test_functions(self):
+        """ Test some of the base functions """
+        rule = Join()
+
+        output = rule._normalize_getatt('Resource.Output')
+        self.assertEqual(output[0], 'Resource')
+        self.assertEqual(output[1], 'Output')
+
+        output = rule._normalize_getatt('Resource.Outputs.Output')
+        self.assertEqual(output[0], 'Resource')
+        self.assertEqual(output[1], 'Outputs.Output')
+
+        output = rule._normalize_getatt(['Resource', 'Output'])
+        self.assertEqual(output[0], 'Resource')
+        self.assertEqual(output[1], 'Output')
 
     def test_file_positive(self):
         """Test Positive"""
@@ -31,4 +50,4 @@ def test_file_positive(self):
 
     def test_file_negative(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/functions_join.yaml', 2)
+        self.helper_file_negative('test/fixtures/templates/bad/functions/join.yaml', 11)