Repository: aws-cloudformation/cfn-lint

Issue:
CodepipelineStageActions InputArtifacts & OutputArtifacts validation does not work with Intrinsic Functions
**Versions**
*cfn-lint version: 0.54.2*
*python version: 3.6*

**Description**
My work is using cfn-lint for validating their CloudFormation template files. These template files include the creation of complex CodePipelines with multiple stages. Each stages' `OutputArtifacts` and `InputArtifacts` also use `Fn::If` conditions which specify what artifacts are available depending, in my work's case, the programming language that is being used for the project the CodePipeline is for. 

cfn-lint does not parse these `Fn::If` statements and thus fails to validate the `InputArtifacts` and `OutputArtifacts` correctly. It prints out this error:
```
E2541 Every input artifact for an action must match the output artifact of an action earlier in the pipeline. (cfn_templates)
cfn/dist/resource.yaml:1132:15

E2541 Every input artifact for an action must match the output artifact of an action earlier in the pipeline. (deploy_templates)
cfn/dist/resource.yaml:1239:19
```
Eventhough the CloudFormation template works.

Looking at the code here, the parsing seems pretty rudimentary, with no checks or consideration for AWS Intrinsic functions: https://github.com/aws-cloudformation/cfn-lint/blob/34affe23fde9526b50f0800f2fdbb83d6c6bab1b/src/cfnlint/rules/resources/codepipeline/CodepipelineStageActions.py#L223

A similar issue was reported with conditionals but it seems the changes is not something global that is being used in CodePipeline: https://github.com/aws-cloudformation/cfn-lint/issues/350

**Sample CloudFormation**
A sample piece of our CloudFormation `OutputArtifacts` of the template looks like this:
```yaml
OutputArtifacts:
    'Fn::If':
        - EnableProjectAssetsPublish
        - 'Fn::If':
            - EnableOpenapiSpecPublish
            - 'Fn::If':
                - ProjectPublishesCode
                - - Name: openapi_specs
                   - Name:
                        'Fn::If':
                            - AutoCondition0
                            - maven_jars
                            - 'Fn::If':
                                - AutoCondition1
                                - npm_packages
                                - 'Fn::If':
                                    - AutoCondition2
                                    - py_packages
                                    - Ref: 'AWS::NoValue'
                   - Name: cfn_templates
                  - Name: deploy_templates
                  - Name: project_assets
                - - Name: openapi_specs
                   - Name: cfn_templates
                   - Name: deploy_templates
                   - Name: project_assets
```

**Current Work Around**
Currently we disable rule `E2541` but this does defeat the purpose of a lot of our templates which contain complex pipelines

**AWS References**
- https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html
- https://docs.aws.amazon.com/codepipeline/latest/userguide/welcome-introducing-artifacts.html



Discussion:

Resolving patch:
diff --git a/src/cfnlint/rules/resources/codepipeline/CodepipelineStageActions.py b/src/cfnlint/rules/resources/codepipeline/CodepipelineStageActions.py
--- a/src/cfnlint/rules/resources/codepipeline/CodepipelineStageActions.py
+++ b/src/cfnlint/rules/resources/codepipeline/CodepipelineStageActions.py
@@ -126,7 +126,7 @@ class CodepipelineStageActions(CloudFormationLintRule):
         'OutputArtifacts': 'OutputArtifactRange',
     }
 
-    def check_artifact_counts(self, action, artifact_type, path):
+    def check_artifact_counts(self, action, artifact_type, path, scenario):
         """Check that artifact counts are within valid ranges."""
         matches = []
 
@@ -158,6 +158,10 @@ def check_artifact_counts(self, action, artifact_type, path):
                     a=min_,
                     b=max_
                 )
+                if scenario:
+                    scenario_text = ' and '.join(
+                        ['condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                    message = message + ' When ' + scenario_text
                 matches.append(RuleMatch(
                     path + [artifact_type],
                     message
@@ -173,6 +177,10 @@ def check_artifact_counts(self, action, artifact_type, path):
                     artifact_type=artifact_type,
                     a=constraints[constraint_key]
                 )
+                if scenario:
+                    scenario_text = ' and '.join(
+                        ['condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                    message = message + ' When ' + scenario_text
                 matches.append(RuleMatch(
                     path + [artifact_type],
                     message
@@ -180,7 +188,7 @@ def check_artifact_counts(self, action, artifact_type, path):
 
         return matches
 
-    def check_version(self, action, path):
+    def check_version(self, action, path, scenario):
         """Check that action type version is valid."""
         matches = []
 
@@ -194,33 +202,45 @@ def check_version(self, action, path):
         elif isinstance(version, (six.string_types)):
             if not LENGTH_MIN <= len(version) <= LENGTH_MAX:
                 message = 'Version string ({0}) must be between {1} and {2} characters in length.'
+                if scenario:
+                    scenario_text = ' and '.join(
+                        ['condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                    message = message + ' When ' + scenario_text
                 matches.append(RuleMatch(
                     path + ['ActionTypeId', 'Version'],
                     message.format(version, LENGTH_MIN, LENGTH_MAX)))
             elif not re.match(REGEX_VERSION_STRING, version):
                 message = 'Version string must match the pattern [0-9A-Za-z_-]+.'
+                if scenario:
+                    scenario_text = ' and '.join(
+                        ['condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                    message = message + ' When ' + scenario_text
                 matches.append(RuleMatch(
                     path + ['ActionTypeId', 'Version'],
                     message
                 ))
         return matches
 
-    def check_names_unique(self, action, path, action_names):
+    def check_names_unique(self, action, path, action_names, scenario):
         """Check that action names are unique."""
         matches = []
 
         action_name = action.get('Name')
         if isinstance(action_name, six.string_types):
             if action.get('Name') in action_names:
-                message = 'All action names within a stage must be unique. ({name})'.format(
+                message = 'All action names within a stage must be unique ({name}).'.format(
                     name=action.get('Name')
                 )
+                if scenario:
+                    scenario_text = ' and '.join(
+                        ['condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                    message = message + ' When ' + scenario_text
                 matches.append(RuleMatch(path + ['Name'], message))
             action_names.add(action.get('Name'))
 
         return matches
 
-    def check_artifact_names(self, action, path, artifact_names):
+    def check_artifact_names(self, action, path, artifact_names, scenario):
         """Check that output artifact names are unique and inputs are from previous stage outputs."""
         matches = []
 
@@ -230,9 +250,13 @@ def check_artifact_names(self, action, path, artifact_names):
                 artifact_name = input_artifact.get('Name')
                 if isinstance(artifact_name, six.string_types):
                     if not artifact_name in artifact_names:
-                        message = 'Every input artifact for an action must match the output artifact of an action earlier in the pipeline. ({name})'.format(
+                        message = 'Every input artifact for an action must match the output artifact of an action earlier in the pipeline ({name}).'.format(
                             name=artifact_name
                         )
+                        if scenario:
+                            scenario_text = ' and '.join(
+                                ['condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                            message = message + ' When ' + scenario_text
                         matches.append(RuleMatch(path + ['InputArtifacts', 'Name'], message))
 
         output_artifacts = action.get('OutputArtifacts')
@@ -254,41 +278,43 @@ def match(self, cfn):
         matches = []
 
         resources = cfn.get_resource_properties(['AWS::CodePipeline::Pipeline'])
+
         for resource in resources:
-            path = resource['Path']
-            properties = resource['Value']
-            artifact_names = set()
+            scenarios = cfn.get_object_without_nested_conditions(
+                resource['Value'], resource['Path'])
+            for scenario in scenarios:
+                conditions = scenario.get('Scenario')
+                path = resource['Path'] + ['Stages']
+                properties = scenario.get('Object')
+                artifact_names = set()
 
-            s_stages = properties.get_safe('Stages', path)
-            for s_stage_v, s_stage_p in s_stages:
-                if not isinstance(s_stage_v, list):
+                s_stages = properties.get('Stages')
+                if not isinstance(s_stages, list):
                     self.logger.debug(
                         'Stages not list. Should have been caught by generic linting.')
                     return matches
-
-                for l_i_stage, l_i_path in s_stage_v.items_safe(s_stage_p):
+                for s_stage_i, s_stage_v in enumerate(s_stages):
                     action_names = set()
-                    s_actions = l_i_stage.get_safe('Actions', l_i_path)
-                    for s_action_v, s_action_p in s_actions:
-                        if not isinstance(s_action_v, list):
-                            self.logger.debug(
-                                'Actions not list. Should have been caught by generic linting.')
-                            return matches
+                    s_actions = s_stage_v.get('Actions')
+                    if not isinstance(s_actions, list):
+                        self.logger.debug(
+                            'Actions not list. Should have been caught by generic linting.')
+                        return matches
 
-                        for l_i_a_action, l_i_a_path in s_action_v.items_safe(s_action_p):
-                            try:
-                                full_path = path + l_i_path + l_i_a_path
-                                matches.extend(self.check_names_unique(
-                                    l_i_a_action, full_path, action_names))
-                                matches.extend(self.check_version(l_i_a_action, full_path))
-                                matches.extend(self.check_artifact_counts(
-                                    l_i_a_action, 'InputArtifacts', full_path))
-                                matches.extend(self.check_artifact_counts(
-                                    l_i_a_action, 'OutputArtifacts', full_path))
-                                matches.extend(self.check_artifact_names(
-                                    l_i_a_action, full_path, artifact_names))
-                            except AttributeError as err:
-                                self.logger.debug('Got AttributeError. Should have been caught by generic linting. '
-                                                  'Ignoring the error here: %s', str(err))
+                    for s_action_i, s_action_v in enumerate(s_actions):
+                        try:
+                            full_path = path + [s_stage_i, 'Actions', s_action_i]
+                            matches.extend(self.check_names_unique(
+                                s_action_v, full_path, action_names, conditions))
+                            matches.extend(self.check_version(s_action_v, full_path, conditions))
+                            matches.extend(self.check_artifact_counts(
+                                s_action_v, 'InputArtifacts', full_path, conditions))
+                            matches.extend(self.check_artifact_counts(
+                                s_action_v, 'OutputArtifacts', full_path, conditions))
+                            matches.extend(self.check_artifact_names(
+                                s_action_v, full_path, artifact_names, conditions))
+                        except AttributeError as err:
+                            self.logger.debug('Got AttributeError. Should have been caught by generic linting. '
+                                            'Ignoring the error here: %s', str(err))
 
         return matches


Test patch: