Repository: aws-cloudformation/cfn-lint

Issue:
RecrodSet linting fails if inside Fn::If
*cfn-lint version: `cfn-lint 0.18.1`*

*Description of issue.*

CFN-lint errors if a RecordSet defined inside a RecordSetGroup is surrounded by an Fn::If block.

cfn-lint error: 
```
E3020 Property ResourceRecords missing at Resources/PublicDns/Properties/RecordSets/1
```

Example template (see non-prod.example.com entry):
```
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "dns-definitions",
  "Conditions": {
    "EnvIsNotProd": { "Fn::Not": [ { "Fn::Equals": [ "prod", "prod" ] } ] }
  },
  "Resources": {
    "PublicDns": {
      "Type": "AWS::Route53::RecordSetGroup",
      "Properties": {
        "HostedZoneId": "HZID",
        "RecordSets": [
        {
          "Name": "my-service-admin.example.com",
          "Type": "A",
          "AliasTarget": {
            "HostedZoneId": "HZID",
            "DNSName": "admin.example.com"
          }
        },
        {
          "Fn::If": [
            "EnvIsNotProd",
            {
              "Name": "non-prod.example.com",
              "Type": "CNAME",
              "TTL": "900",
              "ResourceRecords": [ "other-non-prod.example.com" ]
            },
            { "Ref": "AWS::NoValue" }
          ]
        }]
      }
    }
  }
}
```



Discussion:

Resolving patch:
diff --git a/src/cfnlint/rules/resources/route53/RecordSet.py b/src/cfnlint/rules/resources/route53/RecordSet.py
--- a/src/cfnlint/rules/resources/route53/RecordSet.py
+++ b/src/cfnlint/rules/resources/route53/RecordSet.py
@@ -32,184 +32,163 @@ class RecordSet(CloudFormationLintRule):
     REGEX_TXT = re.compile(r'^("[^"]{1,255}" *)*"[^"]{1,255}"$')
     REGEX_CNAME_VALIDATIONS = re.compile(r'^.*\.acm-validations\.aws\.?$')
 
-    def check_a_record(self, path, recordset):
-        """Check A record Configuration"""
+    def count_c_names(self, records, path, cfn):
+        """ Count C Names """
         matches = []
 
-        resource_records = recordset.get('ResourceRecords')
-        for index, record in enumerate(resource_records):
+        scenarios = cfn.get_object_without_nested_conditions(records, path)
+        for scenario in scenarios:
+            if len(scenario.get('Object')) > 1:
+                scenario = scenario.get('Scenario')
+                message = 'A CNAME recordset can only contain 1 value'
+                if scenario is None:
+                    message = 'A CNAME recordset can only contain 1 value'
+                    matches.append(
+                        RuleMatch(path, message.format('/'.join(map(str, message)))))
+                else:
+                    message = 'A CNAME recordset can only contain 1 value {0} at {1}'
+                    scenario_text = ' and '.join(['when condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                    matches.append(
+                        RuleMatch(path, message.format(scenario_text, '/'.join(map(str, path)))))
 
-            if not isinstance(record, dict):
-                tree = path[:] + ['ResourceRecords', index]
+        return matches
 
-                # Check if a valid IPv4 address is specified
-                if not re.match(REGEX_IPV4, record):
-                    message = 'A record ({}) is not a valid IPv4 address'
-                    matches.append(RuleMatch(tree, message.format(record)))
+    def check_a_record(self, value, path):
+        """Check A record Configuration"""
+        matches = []
+
+        # Check if a valid IPv4 address is specified
+        if not re.match(REGEX_IPV4, value):
+            message = 'A record ({}) is not a valid IPv4 address'
+            matches.append(RuleMatch(path, message.format(value)))
 
         return matches
 
-    def check_aaaa_record(self, path, recordset):
+    def check_aaaa_record(self, value, path):
         """Check AAAA record Configuration"""
         matches = []
 
-        resource_records = recordset.get('ResourceRecords')
-        for index, record in enumerate(resource_records):
-
-            if not isinstance(record, dict):
-                tree = path[:] + ['ResourceRecords', index]
-
-                # Check if a valid IPv4 address is specified
-                if not re.match(REGEX_IPV6, record):
-                    message = 'AAAA record ({}) is not a valid IPv6 address'
-                    matches.append(RuleMatch(tree, message.format(record)))
+        if not isinstance(value, dict):
+            # Check if a valid IPv4 address is specified
+            if not re.match(REGEX_IPV6, value):
+                message = 'AAAA record ({}) is not a valid IPv6 address'
+                matches.append(RuleMatch(path, message.format(value)))
 
         return matches
 
-    def check_caa_record(self, path, recordset):
+    def check_caa_record(self, value, path):
         """Check CAA record Configuration"""
         matches = []
 
-        resource_records = recordset.get('ResourceRecords')
-
-        for index, record in enumerate(resource_records):
-            tree = path[:] + ['ResourceRecords', index]
+        if not isinstance(value, dict):
+            # Split the record up to the mandatory settings (flags tag "value")
+            items = value.split(' ', 2)
+            # Check if the 3 settings are given.
+            if len(items) != 3:
+                message = 'CAA record must contain 3 settings (flags tag "value"), record contains {} settings.'
+                matches.append(RuleMatch(path, message.format(len(items))))
+            else:
+                # Check the flag value
+                if not items[0].isdigit():
+                    message = 'CAA record flag setting ({}) should be of type Integer.'
+                    matches.append(RuleMatch(path, message.format(items[0])))
+                else:
+                    if int(items[0]) not in [0, 128]:
+                        message = 'Invalid CAA record flag setting ({}) given, must be 0 or 128.'
+                        matches.append(RuleMatch(path, message.format(items[0])))
 
-            if not isinstance(record, dict):
-                # Split the record up to the mandatory settings (flags tag "value")
-                items = record.split(' ', 2)
+                # Check the tag value
+                if not re.match(REGEX_ALPHANUMERIC, items[1]):
+                    message = 'Invalid CAA record tag setting {}. Value has to be alphanumeric.'
+                    matches.append(RuleMatch(path, message.format(items[1])))
 
-                # Check if the 3 settings are given.
-                if len(items) != 3:
-                    message = 'CAA record must contain 3 settings (flags tag "value"), record contains {} settings.'
-                    matches.append(RuleMatch(tree, message.format(len(items))))
-                else:
-                    # Check the flag value
-                    if not items[0].isdigit():
-                        message = 'CAA record flag setting ({}) should be of type Integer.'
-                        matches.append(RuleMatch(tree, message.format(items[0])))
-                    else:
-                        if int(items[0]) not in [0, 128]:
-                            message = 'Invalid CAA record flag setting ({}) given, must be 0 or 128.'
-                            matches.append(RuleMatch(tree, message.format(items[0])))
-
-                    # Check the tag value
-                    if not re.match(REGEX_ALPHANUMERIC, items[1]):
-                        message = 'Invalid CAA record tag setting {}. Value has to be alphanumeric.'
-                        matches.append(RuleMatch(tree, message.format(items[0])))
-
-                    # Check the value
-                    if not items[2].startswith('"') or not items[2].endswith('"'):
-                        message = 'CAA record value setting has to be enclosed in double quotation marks (").'
-                        matches.append(RuleMatch(tree, message))
+                # Check the value
+                if not items[2].startswith('"') or not items[2].endswith('"'):
+                    message = 'CAA record value setting has to be enclosed in double quotation marks (").'
+                    matches.append(RuleMatch(path, message))
 
         return matches
 
-    def check_cname_record(self, path, recordset):
+    def check_cname_record(self, value, path):
         """Check CNAME record Configuration"""
         matches = []
 
-        resource_records = recordset.get('ResourceRecords')
-        if len(resource_records) > 1:
-            message = 'A CNAME recordset can only contain 1 value'
-            matches.append(RuleMatch(path + ['ResourceRecords'], message))
-        else:
-            for index, record in enumerate(resource_records):
-                if not isinstance(record, dict):
-                    tree = path[:] + ['ResourceRecords', index]
-                    if (not re.match(self.REGEX_DOMAINNAME, record) and
-                            not re.match(self.REGEX_CNAME_VALIDATIONS, record)):
-                        # ACM Route 53 validation uses invalid CNAMEs starting with `_`,
-                        # special-case them rather than complicate the regex.
-                        message = 'CNAME record ({}) does not contain a valid domain name'
-                        matches.append(RuleMatch(tree, message.format(record)))
+        if not isinstance(value, dict):
+            if (not re.match(self.REGEX_DOMAINNAME, value) and
+                    not re.match(self.REGEX_CNAME_VALIDATIONS, value)):
+                # ACM Route 53 validation uses invalid CNAMEs starting with `_`,
+                # special-case them rather than complicate the regex.
+                message = 'CNAME record ({}) does not contain a valid domain name'
+                matches.append(RuleMatch(path, message.format(value)))
 
         return matches
 
-    def check_mx_record(self, path, recordset):
+    def check_mx_record(self, value, path):
         """Check MX record Configuration"""
         matches = []
 
-        resource_records = recordset.get('ResourceRecords')
-
-        for index, record in enumerate(resource_records):
-            tree = path[:] + ['ResourceRecords', index]
-
-            if not isinstance(record, dict):
-                # Split the record up to the mandatory settings (priority domainname)
-                items = record.split(' ')
-
-                # Check if the 3 settings are given.
-                if len(items) != 2:
-                    message = 'MX record must contain 2 settings (priority domainname), record contains {} settings.'
-                    matches.append(RuleMatch(tree, message.format(len(items), record)))
+        if not isinstance(value, dict):
+            # Split the record up to the mandatory settings (priority domainname)
+            items = value.split(' ')
+
+            # Check if the 3 settings are given.
+            if len(items) != 2:
+                message = 'MX record must contain 2 settings (priority domainname), record contains {} settings.'
+                matches.append(RuleMatch(path, message.format(len(items), value)))
+            else:
+                # Check the priority value
+                if not items[0].isdigit():
+                    message = 'MX record priority setting ({}) should be of type Integer.'
+                    matches.append(RuleMatch(path, message.format(items[0], value)))
                 else:
-                    # Check the priority value
-                    if not items[0].isdigit():
-                        message = 'MX record priority setting ({}) should be of type Integer.'
-                        matches.append(RuleMatch(tree, message.format(items[0], record)))
-                    else:
-                        if not 0 <= int(items[0]) <= 65535:
-                            message = 'Invalid MX record priority setting ({}) given, must be between 0 and 65535.'
-                            matches.append(RuleMatch(tree, message.format(items[0], record)))
-
-                    # Check the domainname value
-                    if not re.match(self.REGEX_DOMAINNAME, items[1]):
-                        matches.append(RuleMatch(tree, message.format(items[1])))
+                    if not 0 <= int(items[0]) <= 65535:
+                        message = 'Invalid MX record priority setting ({}) given, must be between 0 and 65535.'
+                        matches.append(RuleMatch(path, message.format(items[0], value)))
+
+                # Check the domainname value
+                if not re.match(self.REGEX_DOMAINNAME, items[1]):
+                    matches.append(RuleMatch(path, message.format(items[1])))
 
         return matches
 
-    def check_ns_record(self, path, recordset):
+    def check_ns_record(self, value, path):
         """Check NS record Configuration"""
         matches = []
 
-        resource_records = recordset.get('ResourceRecords')
+        if not isinstance(value, dict):
+            if not re.match(self.REGEX_DOMAINNAME, value):
+                message = 'NS record ({}) does not contain a valid domain name'
+                matches.append(RuleMatch(path, message.format(value)))
 
-        for index, record in enumerate(resource_records):
-            if not isinstance(record, dict):
-                tree = path[:] + ['ResourceRecords', index]
-                if not re.match(self.REGEX_DOMAINNAME, record):
-                    message = 'NS record ({}) does not contain a valid domain name'
-                    matches.append(RuleMatch(tree, message.format(record)))
         return matches
 
-    def check_ptr_record(self, path, recordset):
+    def check_ptr_record(self, value, path):
         """Check PTR record Configuration"""
         matches = []
 
-        resource_records = recordset.get('ResourceRecords')
-
-        for index, record in enumerate(resource_records):
-            if not isinstance(record, dict):
-                tree = path[:] + ['ResourceRecords', index]
-                if not re.match(self.REGEX_DOMAINNAME, record):
-                    message = 'PTR record ({}) does not contain a valid domain name'
-                    matches.append(RuleMatch(tree, message.format(record)))
+        if not isinstance(value, dict):
+            if not re.match(self.REGEX_DOMAINNAME, value):
+                message = 'PTR record ({}) does not contain a valid domain name'
+                matches.append(RuleMatch(path, message.format(value)))
         return matches
 
-    def check_txt_record(self, path, recordset):
+    def check_txt_record(self, value, path):
         """Check TXT record Configuration"""
         matches = []
 
-        # Check quotation of the records
-        resource_records = recordset.get('ResourceRecords')
-
-        for index, record in enumerate(resource_records):
-            tree = path[:] + ['ResourceRecords', index]
-
-            if not isinstance(record, dict) and not re.match(self.REGEX_TXT, record):
-                message = 'TXT record is not structured as one or more items up to 255 characters ' \
-                          'enclosed in double quotation marks at {0}'
-                matches.append(RuleMatch(
-                    tree,
-                    (
-                        message.format('/'.join(map(str, tree)))
-                    ),
-                ))
+        if not isinstance(value, dict) and not re.match(self.REGEX_TXT, value):
+            message = 'TXT record is not structured as one or more items up to 255 characters ' \
+                      'enclosed in double quotation marks at {0}'
+            matches.append(RuleMatch(
+                path,
+                (
+                    message.format('/'.join(map(str, path)))
+                ),
+            ))
 
         return matches
 
-    def check_recordset(self, path, recordset):
+    def check_recordset(self, path, recordset, cfn):
         """Check record configuration"""
 
         matches = []
@@ -220,25 +199,69 @@ def check_recordset(self, path, recordset):
             if not recordset.get('AliasTarget'):
                 # If no Alias is specified, ResourceRecords has to be specified
                 if not recordset.get('ResourceRecords'):
-                    message = 'Property ResourceRecords missing at {}'
-                    matches.append(RuleMatch(path, message.format('/'.join(map(str, path)))))
+                    return matches
                 # Record type specific checks
-                elif recordset_type == 'A':
-                    matches.extend(self.check_a_record(path, recordset))
+                if recordset_type == 'A':
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_a_record,
+                        )
+                    )
                 elif recordset_type == 'AAAA':
-                    matches.extend(self.check_aaaa_record(path, recordset))
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_aaaa_record,
+                        )
+                    )
                 elif recordset_type == 'CAA':
-                    matches.extend(self.check_caa_record(path, recordset))
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_caa_record,
+                        )
+                    )
                 elif recordset_type == 'CNAME':
-                    matches.extend(self.check_cname_record(path, recordset))
+                    matches.extend(
+                        self.count_c_names(
+                            recordset.get('ResourceRecords'), path[:] + ['ResourceRecords'], cfn
+                        )
+                    )
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_cname_record,
+                        )
+                    )
                 elif recordset_type == 'MX':
-                    matches.extend(self.check_mx_record(path, recordset))
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_mx_record,
+                        )
+                    )
                 elif recordset_type == 'NS':
-                    matches.extend(self.check_ns_record(path, recordset))
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_ns_record,
+                        )
+                    )
                 elif recordset_type == 'PTR':
-                    matches.extend(self.check_ptr_record(path, recordset))
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_ptr_record,
+                        )
+                    )
                 elif recordset_type == 'TXT':
-                    matches.extend(self.check_txt_record(path, recordset))
+                    matches.extend(
+                        cfn.check_value(
+                            recordset, 'ResourceRecords', path[:],
+                            check_value=self.check_txt_record,
+                        )
+                    )
 
         return matches
 
@@ -255,7 +278,7 @@ def match(self, cfn):
             if isinstance(recordset, dict):
                 props = recordset.get('Properties')
                 if props:
-                    matches.extend(self.check_recordset(path, props))
+                    matches.extend(self.check_recordset(path, props, cfn))
 
         recordsetgroups = cfn.get_resource_properties(['AWS::Route53::RecordSetGroup', 'RecordSets'])
 
@@ -265,6 +288,6 @@ def match(self, cfn):
             if isinstance(value, list):
                 for index, recordset in enumerate(value):
                     tree = path[:] + [index]
-                    matches.extend(self.check_recordset(tree, recordset))
+                    matches.extend(self.check_recordset(tree, recordset, cfn))
 
         return matches


Test patch:
diff --git a/test/fixtures/templates/bad/route53.yaml b/test/fixtures/templates/bad/route53.yaml
--- a/test/fixtures/templates/bad/route53.yaml
+++ b/test/fixtures/templates/bad/route53.yaml
@@ -4,6 +4,8 @@ Parameters:
   DomainName:
     Type: String
     Default: "www.example.com"
+Conditions:
+  isPrimaryRegion: !Equals [!Ref 'AWS::Region', 'us-east-1']
 Resources:
   MyHostedZone:
     Type: "AWS::Route53::HostedZone"
@@ -63,7 +65,7 @@ Resources:
   MyCNAMERecordSet:
     Type: "AWS::Route53::RecordSet"
     Properties:
-      Comment: "Invalid CAA Record"
+      Comment: "Invalid CNAME Record"
       HostedZoneId: !Ref "MyHostedZone"
       Name: "cname.example.com"
       Type: "CNAME"
@@ -71,6 +73,20 @@ Resources:
       ResourceRecords: # Multiple records
         - "cname1.example.com"
         - "cname2.example.com"
+  MyCNAMERecordSetConditions:
+    Type: "AWS::Route53::RecordSet"
+    Properties:
+      Comment: "Invalid CNAME Record"
+      HostedZoneId: !Ref "MyHostedZone"
+      Name: "cname.example.com"
+      Type: "CNAME"
+      TTL: "300"
+      ResourceRecords: # Multiple records
+        Fn::If:
+        - isPrimaryRegion
+        - - "cname1.example.com"
+          - "cname2.example.com"
+        - - "cname1.example.com"
   MyMXRecordSet:
     Type: "AWS::Route53::RecordSet"
     Properties:
@@ -141,6 +157,7 @@ Resources:
           TTL: "300"
           ResourceRecords:
             - "1 issue \"amazon.com; example.com\"" # Invalid flag
+            - "A issue \"amazon.com; example.com\"" # Invalid flag
             - "0 special-value \"amazon.com\"" # Invalid tag (not numeric)
         - Name: "cname.example.com"
           Type: "CNAME"
@@ -163,3 +180,12 @@ Resources:
           TTL: "300"
           ResourceRecords:
             - "\"henk\""
+  PoorlyConfiguredRoute53:
+    Type: "AWS::Route53::RecordSetGroup"
+    Properties:
+      HostedZoneId: !Ref "MyHostedZone"
+      RecordSets:
+        - Name: "z.example.com"
+          Type: "TXT"
+          TTL: "300"
+          # Missing AliasTarget and ResourceRecords.  Don't fail
diff --git a/test/rules/resources/route53/test_recordsets.py b/test/rules/resources/route53/test_recordsets.py
--- a/test/rules/resources/route53/test_recordsets.py
+++ b/test/rules/resources/route53/test_recordsets.py
@@ -34,4 +34,4 @@ def test_file_positive(self):
 
     def test_file_negative(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/route53.yaml', 30)
+        self.helper_file_negative('test/fixtures/templates/bad/route53.yaml', 32)