Repository: aws-cloudformation/cfn-lint

Issue:
[New Rule] BackupPlan Lifecycle cold to delete minimum difference
*cfn-lint version:* 0.58.2

*Description of issue.*
In CloudFormation, when deploying a stack with an `AWS::Backup::BackupPlan` lifecycle rule to age off data after moving it to cold storage, the deployment will fail if the `DeleteAfterDays` value is less than 90 days apart from the `MoveToColdStorageAfterDays` period.

As an example:
<details>
  <summary>Example failing backup plan</summary>
  CloudFormation error:  

![image](https://user-images.githubusercontent.com/1835431/156852439-202a56a9-e9ca-4cb5-8cdb-3ed19e87d80b.png)
  
  Example resource:
  ```yaml
  BackupPlan:
    Type: AWS::Backup::BackupPlan
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      BackupPlan:
        BackupPlanName: "invalid-backup-plan"
        BackupPlanRule:
          - RuleName: "invalid-rule"
            TargetBackupVault: !Ref BackupVault
            ScheduleExpression: "cron(0 0 ? * MON *)"
            Lifecycle:
              MoveToColdStorageAfterDays: 7
              DeleteAfterDays: 30
  ```
</details>
Currently this is not picked up by `cfn-lint`.
<hr><br>
I've opened a PR at #2230 to add in a rule that I think would resolve it, but not entirely sure on the process of adding a brand new rule / service to `cfn-lint`, so please shout if there's anything missing or extra to do. Thanks!



Discussion:

Resolving patch:
diff --git a/src/cfnlint/rules/resources/backup/BackupPlanLifecycleRule.py b/src/cfnlint/rules/resources/backup/BackupPlanLifecycleRule.py
new file mode 100644
--- /dev/null
+++ b/src/cfnlint/rules/resources/backup/BackupPlanLifecycleRule.py
@@ -0,0 +1,31 @@
+"""
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: MIT-0
+"""
+from cfnlint.rules import CloudFormationLintRule
+from cfnlint.rules import RuleMatch
+
+
+class BackupPlanLifecycleRule(CloudFormationLintRule):
+    """Check Backup Plan rules with lifecycle has minimum period between cold and delete"""
+    id = 'E3504'
+    shortdesc = 'Check minimum 90 period is met between BackupPlan cold and delete'
+    description = 'Check that Backup plans with lifecycle rules have >= 90 days between cold and delete'
+    source_url = 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-backup-backupplan-lifecycleresourcetype.html'
+    tags = ['properties', 'backup', 'plan', 'lifecycle']
+
+    def match(self, cfn):
+        """Check cold storage and deletion lifecycle period differences"""
+        matches = []
+        results = cfn.get_resource_properties(['AWS::Backup::BackupPlan', 'BackupPlan', 'BackupPlanRule', 'Lifecycle'])
+
+        for result in results:
+            backup_rule = result['Value']
+            # if 'MoveToColdStorageAfterDays' in backup_rule and 'DeleteAfterDays' in backup_rule:
+            if isinstance(backup_rule.get('MoveToColdStorageAfterDays'), int) and isinstance(backup_rule.get('DeleteAfterDays'), int):
+                if backup_rule['DeleteAfterDays'] - backup_rule['MoveToColdStorageAfterDays'] < 90:
+                    message = 'DeleteAfterDays in {0} must be at least 90 days after MoveToColdStorageAfterDays'
+                    rule_match = RuleMatch(result['Path'], message.format('/'.join(map(str, result['Path']))))
+                    matches.append(rule_match)
+
+        return matches
diff --git a/src/cfnlint/rules/resources/backup/__init__.py b/src/cfnlint/rules/resources/backup/__init__.py
new file mode 100644
--- /dev/null
+++ b/src/cfnlint/rules/resources/backup/__init__.py
@@ -0,0 +1,4 @@
+"""
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: MIT-0
+"""


Test patch:
diff --git a/test/fixtures/templates/bad/resources/backup/test_backup_plan_lifecycle_rule.yml b/test/fixtures/templates/bad/resources/backup/test_backup_plan_lifecycle_rule.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/bad/resources/backup/test_backup_plan_lifecycle_rule.yml
@@ -0,0 +1,17 @@
+---
+Resources:
+
+  BackupPlan:
+    Type: AWS::Backup::BackupPlan
+    DeletionPolicy: Delete
+    UpdateReplacePolicy: Delete
+    Properties:
+      BackupPlan:
+        BackupPlanName: "test-backup-plan"
+        BackupPlanRule:
+          - RuleName: "test-backup-plan-rule"
+            TargetBackupVault: "test-backup-vault"
+            ScheduleExpression: "cron(0 0 ? * MON *)"
+            Lifecycle:
+              MoveToColdStorageAfterDays: 7
+              DeleteAfterDays: 30  # 30 - 7 is less than 90
diff --git a/test/fixtures/templates/good/resources/backup/test_backup_plan_lifecycle_rule.yml b/test/fixtures/templates/good/resources/backup/test_backup_plan_lifecycle_rule.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/good/resources/backup/test_backup_plan_lifecycle_rule.yml
@@ -0,0 +1,43 @@
+---
+Resources:
+
+  BackupPlanWithValidLifecycle:
+    Type: AWS::Backup::BackupPlan
+    DeletionPolicy: Delete
+    UpdateReplacePolicy: Delete
+    Properties:
+      BackupPlan:
+        BackupPlanName: "test-backup-plan-valid-lifecycle"
+        BackupPlanRule:
+          - RuleName: "test-backup-plan-rule-valid-lifecycle"
+            TargetBackupVault: "test-backup-vault"
+            ScheduleExpression: "cron(0 0 ? * MON *)"
+            Lifecycle:
+              MoveToColdStorageAfterDays: 7
+              DeleteAfterDays: 100  # 100 - 7 is greater than 90
+
+  BackupPlanWithIrrelevantLifecycle:
+    Type: AWS::Backup::BackupPlan
+    DeletionPolicy: Delete
+    UpdateReplacePolicy: Delete
+    Properties:
+      BackupPlan:
+        BackupPlanName: "test-backup-plan-irrelevant-lifecycle"
+        BackupPlanRule:
+          - RuleName: "test-backup-plan-rule-irrelevant-lifecycle"
+            TargetBackupVault: "test-backup-vault"
+            ScheduleExpression: "cron(0 0 ? * MON *)"
+            Lifecycle:
+              MoveToColdStorageAfterDays: 7
+
+  BackupPlanWithoutLifecycle:
+    Type: AWS::Backup::BackupPlan
+    DeletionPolicy: Delete
+    UpdateReplacePolicy: Delete
+    Properties:
+      BackupPlan:
+        BackupPlanName: "test-backup-plan-no-lifecycle"
+        BackupPlanRule:
+          - RuleName: "test-backup-plan-rule-no-lifecycle"
+            TargetBackupVault: "test-backup-vault"
+            ScheduleExpression: "cron(0 0 ? * MON *)"
\ No newline at end of file
diff --git a/test/unit/rules/resources/backup/__init__.py b/test/unit/rules/resources/backup/__init__.py
new file mode 100644
--- /dev/null
+++ b/test/unit/rules/resources/backup/__init__.py
@@ -0,0 +1,4 @@
+"""
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: MIT-0
+"""
diff --git a/test/unit/rules/resources/backup/test_backup_plan_lifecycle_rule.py b/test/unit/rules/resources/backup/test_backup_plan_lifecycle_rule.py
new file mode 100644
--- /dev/null
+++ b/test/unit/rules/resources/backup/test_backup_plan_lifecycle_rule.py
@@ -0,0 +1,26 @@
+"""
+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.backup.BackupPlanLifecycleRule import BackupPlanLifecycleRule  # pylint: disable=E0401
+
+
+class TestBackupPlanLifecycleRule(BaseRuleTestCase):
+    """Check Backup Plan rules with lifecycle has minimum 90 day period between cold and delete"""
+
+    def setUp(self):
+        """Setup"""
+        super(TestBackupPlanLifecycleRule, self).setUp()
+        self.collection.register(BackupPlanLifecycleRule())
+        self.success_templates = [
+            'test/fixtures/templates/good/resources/backup/test_backup_plan_lifecycle_rule.yml',
+        ]
+
+    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/backup/test_backup_plan_lifecycle_rule.yml', 1)