Repository: aws-cloudformation/cfn-lint

Issue:
False errors given with property relationships when conditions are used
Right now we check for attributes that should or shouldn't be defined together.  These scenarios can get tricky with conditions and AWS::NoValue. 

- [x] Short term hotfix to not give errors when complex situations are found
- [x] Ability to analyze if a related resource is available when conditions are used
- [x] Pull out values of properties in an object when values include conditions
- [x] Related objects and their values when each object has a condition applied

Related issues:
- https://github.com/awslabs/aws-cfn-lint-atom/issues/8


Discussion:
Working to invalidate pull request #121.  When the condition numbers are high the scenarios get large and can cause linting to be slow.
Working to replace with #352 which is more light weight and less costly.  

As it exists #121 will build a list of scenarios for the conditions.   That logic may have to be moved to a new rule that checks if Ref/GetAtt/DependsOn resources exist when using resource level conditions. 

Conditions when used in place of a resource property have to be singular.
```
Properties:
   Fn::If: [myCondition, { "ImageId": "ami-123456"}, {"LaunchConfiguration": "myLaunch"}]
```
is valid but the following is invalid because you cannot put an Fn::If with other properties.   (Still double checking this)
```
Properties:
  LaunchConfiguration: myLaunch
  Fn::If: [myCondition, { "ImageId": "ami-123456"}, {"Ref": "AWS::NoValue"}]
```

This means our Inclusive and Exclusive checks will be easier to write. If someone puts a condition in the properties section there isn't a mixing of properties from inside and outside of the condition that will cause confusion.
starting with pull request #523 

Next will be to get property values when conditions are used for values of those properties.
Facing a similar issue with E2540 linting error.

Error :

>  E2540 At least one stage in pipeline must contain an action that is not a source action.

Template 


```
Pipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      RoleArn: !GetAtt CodePipelineServiceRole.Arn
      ArtifactStore:
        Location: !Ref PipelineBucket
        Type: S3
      Stages:
        - Name: Source
          Actions:
          - Name: Source
            ActionTypeId:
              Category: Source
              Owner: AWS
              Version: "1"
              Provider: S3
            Configuration:
              S3Bucket: !Ref ThisEnvBucket
              S3ObjectKey: !Sub ${ProjectName}.zip
            OutputArtifacts:
              - Name: SourceOutput
            RunOrder: 1
        - !If
          - NotCiEnv
          - Name: Deploy
            Actions:
            - Name: Deploy
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: '1'
                Provider: CodeBuild
              Configuration:
                ProjectName: !Ref SSMPushProjectUpperEnvs
              InputArtifacts:
                - Name: SourceOutput
              OutputArtifacts: []
              RunOrder: 2
          - !Ref 'AWS::NoValue'
        - !If
          - NeedApproval
          - Name: ApprovePromotion
            Actions:
            - Name: Approval
              ActionTypeId:
                Category: Approval
                Owner: AWS
                Version: '1'
                Provider: Manual
              InputArtifacts: []
              OutputArtifacts: []
              RunOrder: 3
          - !Ref 'AWS::NoValue'
        - !If
          - NextEnvExists
          - Name: Promote
            Actions:
            - Name: Promote
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: '1'
                Provider: CodeBuild
              Configuration:
                ProjectName: !Ref PromotionBuild
              InputArtifacts:
                - Name: SourceOutput
              OutputArtifacts: []
              RunOrder: 4
          - !Ref 'AWS::NoValue'
```



@ap-hyperbole been working on a bunch of logic to make this easier.  I think I can work on this after I get #746 done.  That one will basically strip out all the conditions from a nested object giving us the different scenarios to test.  

Resolving patch:
diff --git a/src/cfnlint/rules/resources/codepipeline/CodepipelineStages.py b/src/cfnlint/rules/resources/codepipeline/CodepipelineStages.py
--- a/src/cfnlint/rules/resources/codepipeline/CodepipelineStages.py
+++ b/src/cfnlint/rules/resources/codepipeline/CodepipelineStages.py
@@ -27,17 +27,25 @@ class CodepipelineStages(CloudFormationLintRule):
     source_url = 'https://docs.aws.amazon.com/codepipeline/latest/userguide/reference-pipeline-structure.html#pipeline-requirements'
     tags = ['properties', 'codepipeline']
 
-    def check_stage_count(self, stages, path):
+    def _format_error_message(self, message, scenario):
+        """Format error message with scenario text"""
+        if scenario:
+            scenario_text = ' When ' + ' and '.join(['condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+            return message + scenario_text
+
+        return message
+
+    def check_stage_count(self, stages, path, scenario):
         """Check that there is minimum 2 stages."""
         matches = []
 
         if len(stages) < 2:
-            message = 'A pipeline must contain at least two stages.'
-            matches.append(RuleMatch(path, message))
+            message = 'CodePipeline has {} stages. There must be at least two stages.'.format(len(stages))
+            matches.append(RuleMatch(path, self._format_error_message(message, scenario)))
 
         return matches
 
-    def check_first_stage(self, stages, path):
+    def check_first_stage(self, stages, path, scenario):
         """Validate the first stage of a pipeline has source actions."""
         matches = []
 
@@ -49,15 +57,15 @@ def check_first_stage(self, stages, path):
         first_stage = set([a.get('ActionTypeId').get('Category') for a in stages[0]['Actions']])
         if first_stage and 'Source' not in first_stage:
             message = 'The first stage of a pipeline must contain at least one source action.'
-            matches.append(RuleMatch(path + [0], message))
+            matches.append(RuleMatch(path + [0, 'Name'], self._format_error_message(message, scenario)))
 
         if len(first_stage) != 1:
             message = 'The first stage of a pipeline must contain only source actions.'
-            matches.append(RuleMatch(path + [0], message))
+            matches.append(RuleMatch(path + [0, 'Name'], self._format_error_message(message, scenario)))
 
         return matches
 
-    def check_source_actions(self, stages, path):
+    def check_source_actions(self, stages, path, scenario):
         """Validate the all of the stages."""
         matches = []
         categories = set()
@@ -72,15 +80,15 @@ def check_source_actions(self, stages, path):
                 categories.add(action_type_id.get('Category'))
                 if sidx > 0 and action_type_id.get('Category') == 'Source':
                     message = 'Only the first stage of a pipeline may contain source actions.'
-                    matches.append(RuleMatch(path + [sidx, 'Actions', aidx], message))
+                    matches.append(RuleMatch(path + [sidx, 'Actions', aidx], self._format_error_message(message, scenario)))
 
         if not (categories - set(['Source'])):
             message = 'At least one stage in pipeline must contain an action that is not a source action.'
-            matches.append(RuleMatch(path, message))
+            matches.append(RuleMatch(path, self._format_error_message(message, scenario)))
 
         return matches
 
-    def check_names_unique(self, value, path):
+    def check_names_unique(self, value, path, scenario):
         """Check that stage names are unique."""
         matches = []
         stage_names = set()
@@ -91,7 +99,7 @@ def check_names_unique(self, value, path):
                     message = 'All stage names within a pipeline must be unique. ({name})'.format(
                         name=stage_name,
                     )
-                    matches.append(RuleMatch(path + [sidx, 'Name'], message))
+                    matches.append(RuleMatch(path + [sidx, 'Name'], self._format_error_message(message, scenario)))
                 stage_names.add(stage_name)
             else:
                 self.logger.debug('Found non string for stage name: %s', stage_name)
@@ -103,28 +111,29 @@ def match(self, cfn):
 
         resources = cfn.get_resource_properties(['AWS::CodePipeline::Pipeline'])
         for resource in resources:
-            path = resource['Path']
+            path = resource['Path'] + ['Stages']
             properties = resource['Value']
 
-            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 = cfn.get_object_without_nested_conditions(properties.get('Stages'), path)
+            for s_stage in s_stages:
+                s_stage_obj = s_stage.get('Object')
+                s_scenario = s_stage.get('Scenario')
+                if not isinstance(s_stage_obj, list):
                     self.logger.debug('Stages not list. Should have been caught by generic linting.')
-                    return matches
+                    continue
 
                 try:
-                    stage_path = path + s_stage_p
                     matches.extend(
-                        self.check_stage_count(s_stage_v, stage_path)
+                        self.check_stage_count(s_stage_obj, path, s_scenario)
                     )
                     matches.extend(
-                        self.check_first_stage(s_stage_v, stage_path)
+                        self.check_first_stage(s_stage_obj, path, s_scenario)
                     )
                     matches.extend(
-                        self.check_source_actions(s_stage_v, stage_path)
+                        self.check_source_actions(s_stage_obj, path, s_scenario)
                     )
                     matches.extend(
-                        self.check_names_unique(s_stage_v, stage_path)
+                        self.check_names_unique(s_stage_obj, path, s_scenario)
                     )
                 except AttributeError as err:
                     self.logger.debug('Got AttributeError. Should have been caught by generic linting. '


Test patch:
diff --git a/test/fixtures/templates/bad/resources_codepipeline_action_artifact_counts.yaml b/test/fixtures/templates/bad/resources/codepipeline/action_artifact_counts.yaml
similarity index 100%
rename from test/fixtures/templates/bad/resources_codepipeline_action_artifact_counts.yaml
rename to test/fixtures/templates/bad/resources/codepipeline/action_artifact_counts.yaml
diff --git a/test/fixtures/templates/bad/resources_codepipeline_action_invalid_version.yaml b/test/fixtures/templates/bad/resources/codepipeline/action_invalid_version.yaml
similarity index 100%
rename from test/fixtures/templates/bad/resources_codepipeline_action_invalid_version.yaml
rename to test/fixtures/templates/bad/resources/codepipeline/action_invalid_version.yaml
diff --git a/test/fixtures/templates/bad/resources_codepipeline_action_non_unique.yaml b/test/fixtures/templates/bad/resources/codepipeline/action_non_unique.yaml
similarity index 100%
rename from test/fixtures/templates/bad/resources_codepipeline_action_non_unique.yaml
rename to test/fixtures/templates/bad/resources/codepipeline/action_non_unique.yaml
diff --git a/test/fixtures/templates/bad/resources_codepipeline_stages_no_source.yaml b/test/fixtures/templates/bad/resources/codepipeline/stages_no_source.yaml
similarity index 100%
rename from test/fixtures/templates/bad/resources_codepipeline_stages_no_source.yaml
rename to test/fixtures/templates/bad/resources/codepipeline/stages_no_source.yaml
diff --git a/test/fixtures/templates/bad/resources_codepipeline_stages_non_unique.yaml b/test/fixtures/templates/bad/resources/codepipeline/stages_non_unique.yaml
similarity index 100%
rename from test/fixtures/templates/bad/resources_codepipeline_stages_non_unique.yaml
rename to test/fixtures/templates/bad/resources/codepipeline/stages_non_unique.yaml
diff --git a/test/fixtures/templates/bad/resources_codepipeline_stages_one_stage.yaml b/test/fixtures/templates/bad/resources/codepipeline/stages_one_stage.yaml
similarity index 79%
rename from test/fixtures/templates/bad/resources_codepipeline_stages_one_stage.yaml
rename to test/fixtures/templates/bad/resources/codepipeline/stages_one_stage.yaml
--- a/test/fixtures/templates/bad/resources_codepipeline_stages_one_stage.yaml
+++ b/test/fixtures/templates/bad/resources/codepipeline/stages_one_stage.yaml
@@ -37,3 +37,13 @@ Resources:
                 ProjectName: cfn-python-lint
               InputArtifacts:
               - Name: MyApp
+  TestPipeline2:  # no Error when Stages is a dict
+    Type: AWS::CodePipeline::Pipeline
+    Properties:
+      Name: Test-pipeline
+      ArtifactStore:
+        Location: 'pipeline-bucket'
+        Type: S3
+      RoleArn: arn:aws:iam:::role/AWSCodePipelineRole
+      Stages:
+        Name: Source2
diff --git a/test/fixtures/templates/bad/resources/codepipeline/stages_only_source.yaml b/test/fixtures/templates/bad/resources/codepipeline/stages_only_source.yaml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/bad/resources/codepipeline/stages_only_source.yaml
@@ -0,0 +1,29 @@
+AWSTemplateFormatVersion: 2010-09-09
+Description: >
+  Bad CodePipeline Template
+Resources:
+  TestPipeline:
+    Type: AWS::CodePipeline::Pipeline
+    Properties:
+      Name: Test-pipeline
+      ArtifactStore:
+        Location: 'pipeline-bucket'
+        Type: S3
+      RoleArn: arn:aws:iam:::role/AWSCodePipelineRole
+      Stages:
+        - Name: Source
+          Actions:
+            - Name: Github
+              ActionTypeId:
+                Category: Source
+                Owner: ThirdParty
+                Provider: GitHub
+                Version: "1"
+              OutputArtifacts:
+                - Name: MyApp
+              Configuration:
+                Owner: aws-cloudformation
+                Repo: cfn-python-lint
+                PollForSourceChanges: true
+                Branch: master
+                OAuthToken: 'secret-token'
diff --git a/test/fixtures/templates/bad/resources_codepipeline_stages_second_stage.yaml b/test/fixtures/templates/bad/resources/codepipeline/stages_second_stage.yaml
similarity index 100%
rename from test/fixtures/templates/bad/resources_codepipeline_stages_second_stage.yaml
rename to test/fixtures/templates/bad/resources/codepipeline/stages_second_stage.yaml
diff --git a/test/rules/resources/codepipeline/test_stageactions.py b/test/rules/resources/codepipeline/test_stageactions.py
--- a/test/rules/resources/codepipeline/test_stageactions.py
+++ b/test/rules/resources/codepipeline/test_stageactions.py
@@ -34,12 +34,12 @@ def test_file_positive(self):
 
     def test_file_artifact_counts(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_codepipeline_action_artifact_counts.yaml', 4)
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/action_artifact_counts.yaml', 4)
 
     def test_file_invalid_version(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_codepipeline_action_invalid_version.yaml', 3)
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/action_invalid_version.yaml', 3)
 
     def test_file_non_unique(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_codepipeline_action_non_unique.yaml', 1)
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/action_non_unique.yaml', 1)
diff --git a/test/rules/resources/codepipeline/test_stages.py b/test/rules/resources/codepipeline/test_stages.py
--- a/test/rules/resources/codepipeline/test_stages.py
+++ b/test/rules/resources/codepipeline/test_stages.py
@@ -31,16 +31,27 @@ def test_file_positive(self):
 
     def test_file_negative_onestage(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_codepipeline_stages_one_stage.yaml', 2)
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/stages_one_stage.yaml', 2)
 
     def test_file_negative_no_source(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_codepipeline_stages_no_source.yaml', 1)
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/stages_no_source.yaml', 1)
 
     def test_file_negative_second_stage(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_codepipeline_stages_second_stage.yaml', 1)
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/stages_second_stage.yaml', 1)
 
     def test_file_negative_non_unique(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_codepipeline_stages_non_unique.yaml', 1)
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/stages_non_unique.yaml', 1)
+
+    def test_file_negative_only_source_types(self):
+        """Test failure"""
+        self.helper_file_negative('test/fixtures/templates/bad/resources/codepipeline/stages_only_source.yaml', 2)
+
+    def test_scenario_format(self):
+        """Test scenario formatting"""
+        rule = CodepipelineStages()
+
+        self.assertEqual(rule._format_error_message('Test.', {'Condition': True}), 'Test. When condition "Condition" is True')
+        self.assertEqual(rule._format_error_message('Test.', None), 'Test.')