Repository: aws-cloudformation/cfn-lint

Issue:
Check whether RecordSet name is valid
*cfn-lint version: 0.28.2*

*Description of issue.*

cfn-lint could check whether the name is valid for a given hosted zone. Specifically:

```
+    "FooBarDelegation": {
+      "Type": "AWS::Route53::RecordSet",
+      "Properties": {
+        "HostedZoneName": "bar.example.com.",
+        "Name": "foo",
+        "TTL": "86400",
+        "Type": "NS",
+        "ResourceRecords": [
...
+        ]
+      }
```

could be attempted, which results in a not very clear error:

```
RRSet with DNS name foo. is not permitted in zone bar.example.com.
```

The solution is to have name `foo.bar.example.com` instead.
This came up in a highly upvoted SO question as well: https://stackoverflow.com/questions/11420063/rrset-with-dns-name-foo-is-not-permitted-in-zone-bar

It would be great if (when the values are available), cfn-lint raised an error "Name has to end with HostedZoneName"


Discussion:
@viraptor few other things I'm curious on.
1.  Looks like `HostedZoneName` needs to end with a trailing `.`
2. Name can end or not with a trailing dot.  They are treated equally

Do I have that correct?

Resolving patch:
diff --git a/src/cfnlint/rules/resources/route53/RecordSetName.py b/src/cfnlint/rules/resources/route53/RecordSetName.py
new file mode 100644
--- /dev/null
+++ b/src/cfnlint/rules/resources/route53/RecordSetName.py
@@ -0,0 +1,59 @@
+"""
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: MIT-0
+"""
+import six
+from cfnlint.rules import CloudFormationLintRule
+from cfnlint.rules import RuleMatch
+
+
+class RecordSetName(CloudFormationLintRule):
+    """Check if a Route53 Resoruce Records Name is valid with a HostedZoneName"""
+    id = 'E3041'
+    shortdesc = 'RecordSet HostedZoneName is a superdomain of Name'
+    description = 'In a RecordSet, the HostedZoneName must be a superdomain of the Name being validated'
+    source_url = 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-recordset.html#cfn-route53-recordset-name'
+    tags = ['resource', 'properties', 'route53']
+
+    def __init__(self):
+        """ Init """
+        super(RecordSetName, self).__init__()
+        self.resource_property_types = ['AWS::Route53::RecordSet']
+
+    def match_resource_properties(self, properties, _, path, cfn):
+        """ Check Load Balancers """
+        matches = []
+
+        property_sets = cfn.get_object_without_conditions(properties, ['Name', 'HostedZoneName'])
+        for property_set in property_sets:
+            props = property_set.get('Object')
+            scenario = property_set.get('Scenario')
+            name = props.get('Name', None)
+            hz_name = props.get('HostedZoneName', None)
+            if isinstance(name, six.string_types) and isinstance(hz_name, six.string_types):
+                if hz_name[-1] != '.':
+                    message = 'HostedZoneName must end in a dot at {}'
+                    if scenario is None:
+                        matches.append(
+                            RuleMatch(path[:] + ['HostedZoneName'], message.format('/'.join(map(str, path)))))
+                    else:
+                        scenario_text = ' and '.join(
+                            ['when condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                        matches.append(
+                            RuleMatch(path[:] + ['HostedZoneName'], message.format('/'.join(map(str, path)) + ' ' + scenario_text)))
+                if hz_name[-1] == '.':
+                    hz_name = hz_name[:-1]
+                if name[-1] == '.':
+                    name = name[:-1]
+                if not name.endswith('.' + hz_name):
+                    message = 'Name must be a superdomain of HostedZoneName at {}'
+                    if scenario is None:
+                        matches.append(
+                            RuleMatch(path[:] + ['Name'], message.format('/'.join(map(str, path)))))
+                    else:
+                        scenario_text = ' and '.join(
+                            ['when condition "%s" is %s' % (k, v) for (k, v) in scenario.items()])
+                        matches.append(
+                            RuleMatch(path[:] + ['Name'], message.format('/'.join(map(str, path)) + ' ' + scenario_text)))
+
+        return matches


Test patch:
diff --git a/test/fixtures/templates/bad/resources/route53/recordset_name.yaml b/test/fixtures/templates/bad/resources/route53/recordset_name.yaml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/bad/resources/route53/recordset_name.yaml
@@ -0,0 +1,43 @@
+AWSTemplateFormatVersion: 2010-09-09
+Conditions:
+  isPrimaryRegion:
+    Fn::Equals:
+      - !Ref AWS::Region
+      - "us-east-1"
+Resources:
+  RecordSet1:
+    Type: AWS::Route53::RecordSet
+    Properties:
+      HostedZoneName: notexample.com
+      Name: test.example.com
+      ResourceRecords:
+        - 192.0.2.99
+      TTL: 900
+      Type: A
+  RecordSet2:
+    Type: AWS::Route53::RecordSet
+    Properties:
+      HostedZoneName: example.com.
+      Name: testexample.com
+      ResourceRecords:
+        - 192.0.2.99
+      TTL: 900
+      Type: A
+  RecordSet3:
+    Type: AWS::Route53::RecordSet
+    Properties:
+      HostedZoneName: !If [isPrimaryRegion, notexample.com, example.com.]
+      Name: test.example.com
+      ResourceRecords:
+        - 192.0.2.99
+      TTL: 900
+      Type: A
+  RecordSet4:
+    Type: AWS::Route53::RecordSet
+    Properties:
+      HostedZoneName: example.com.
+      Name: !If [isPrimaryRegion, testexample.com, test.example.com]
+      ResourceRecords:
+        - 192.0.2.99
+      TTL: 900
+      Type: A
diff --git a/test/fixtures/templates/good/resources/route53/recordset_name.yaml b/test/fixtures/templates/good/resources/route53/recordset_name.yaml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/good/resources/route53/recordset_name.yaml
@@ -0,0 +1,20 @@
+AWSTemplateFormatVersion: 2010-09-09
+Resources:
+  RecordSet1:
+    Type: AWS::Route53::RecordSet
+    Properties:
+      HostedZoneName: example.com.
+      Name: rs1.example.com
+      ResourceRecords:
+        - 192.0.2.99
+      TTL: 900
+      Type: A
+  RecordSet2:
+    Type: AWS::Route53::RecordSet
+    Properties:
+      HostedZoneName: example.com.
+      Name: rs2.example.com.
+      ResourceRecords:
+        - 192.0.2.99
+      TTL: 900
+      Type: A
diff --git a/test/fixtures/templates/good/transform/list_transform.yaml b/test/fixtures/templates/good/transform/list_transform.yaml
--- a/test/fixtures/templates/good/transform/list_transform.yaml
+++ b/test/fixtures/templates/good/transform/list_transform.yaml
@@ -1,5 +1,5 @@
 ---
-AWSTemplateFormatVersion: '2010-09-09'
+AWSTemplateFormatVersion: "2010-09-09"
 Transform:
   - AWS::Serverless-2016-10-31
 
@@ -15,7 +15,7 @@ Resources:
   SkillFunction:
     Type: AWS::Serverless::Function
     Properties:
-      CodeUri: '.'
+      CodeUri: "."
       Handler: main.handler
       Runtime: python3.7
       Timeout: 30
diff --git a/test/unit/rules/resources/route53/test_recordset_name.py b/test/unit/rules/resources/route53/test_recordset_name.py
new file mode 100644
--- /dev/null
+++ b/test/unit/rules/resources/route53/test_recordset_name.py
@@ -0,0 +1,27 @@
+"""
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: MIT-0
+"""
+from test.unit.rules import BaseRuleTestCase
+from cfnlint.rules.resources.route53.RecordSetName import RecordSetName  # pylint: disable=E0401
+
+
+class TestRoute53RecordSetName(BaseRuleTestCase):
+    """Test CloudFront Aliases Configuration"""
+
+    def setUp(self):
+        """Setup"""
+        super(TestRoute53RecordSetName, self).setUp()
+        self.collection.register(RecordSetName())
+        self.success_templates = [
+            'test/fixtures/templates/good/resources/route53/recordset_name.yaml'
+        ]
+
+    def test_file_positive(self):
+        """Test Positive"""
+        self.helper_file_positive()
+
+    def test_file_negative(self):
+        """Test failure"""
+        self.helper_file_negative(
+            'test/fixtures/templates/bad/resources/route53/recordset_name.yaml', 6)