Repository: cloud-custodian/cloud-custodian

Issue:
Filter aws.ebs-snapshot with deleted volumes
When I delete instance or volume, snapshot still has Volume ID `vol-1234567890123456789`

I would like to be able to filter all aws.ebs-snapshot created for deleted volumes.



Discussion:
afaics we would need to implement a related filter for an ebs volume on the snapshot, and then you could do a not: filter block on it to find snapshots without an extant volume.
One more potential use case for this filter – it would allow clean-up of Lifecycle Manager created snapshots for terminated instances and deleted volumes, unfortunately DLM does not provide clean-up feature…

Resolving patch:
diff --git a/c7n/resources/ebs.py b/c7n/resources/ebs.py
--- a/c7n/resources/ebs.py
+++ b/c7n/resources/ebs.py
@@ -16,6 +16,7 @@
     CrossAccountAccessFilter, Filter, AgeFilter, ValueFilter,
     ANNOTATION_KEY)
 from c7n.filters.health import HealthEventFilter
+from c7n.filters.related import RelatedResourceFilter
 
 from c7n.manager import resources
 from c7n.resources.kms import ResourceKmsKeyAlias
@@ -360,6 +361,37 @@ def process(self, snapshots, event=None):
         return resources
 
 
+@Snapshot.filter_registry.register('volume')
+class SnapshotVolumeFilter(RelatedResourceFilter):
+    """
+    Filter EBS snapshots by their volume attributes.
+
+    .. code-block:: yaml
+
+        policies:
+          - name: snapshot-with-no-volume
+            description: Find any snapshots that do not have a corresponding volume.
+            resource: aws.ebs-snapshot
+            filters:
+              - type: volume
+                key: VolumeId
+                value: absent
+          - name: find-snapshots-from-volume
+            resource: aws.ebs-snapshot
+            filters:
+              - type: volume
+                key: VolumeId
+                value: vol-foobarbaz
+    """
+
+    RelatedResource = 'c7n.resources.ebs.EBS'
+    RelatedIdsExpression = 'VolumeId'
+    AnnotationKey = 'Volume'
+
+    schema = type_schema(
+        'volume', rinherit=ValueFilter.schema)
+
+
 @Snapshot.action_registry.register('delete')
 class SnapshotDelete(BaseAction):
     """Deletes EBS snapshots


Test patch:
diff --git a/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeSnapshots_1.json b/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeSnapshots_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeSnapshots_1.json
@@ -0,0 +1,48 @@
+{
+    "status_code": 200,
+    "data": {
+        "Snapshots": [
+            {
+                "Description": "",
+                "Encrypted": false,
+                "OwnerId": "644160558196",
+                "Progress": "100%",
+                "SnapshotId": "snap-02185b624983574f8",
+                "StartTime": {
+                    "__class__": "datetime",
+                    "year": 2021,
+                    "month": 3,
+                    "day": 16,
+                    "hour": 20,
+                    "minute": 24,
+                    "second": 10,
+                    "microsecond": 691000
+                },
+                "State": "completed",
+                "VolumeId": "vol-02dbf91b6667bcdad",
+                "VolumeSize": 1
+            },
+            {
+                "Description": "",
+                "Encrypted": false,
+                "OwnerId": "644160558196",
+                "Progress": "100%",
+                "SnapshotId": "snap-03ab300c6ce8d9e7e",
+                "StartTime": {
+                    "__class__": "datetime",
+                    "year": 2021,
+                    "month": 3,
+                    "day": 15,
+                    "hour": 23,
+                    "minute": 16,
+                    "second": 16,
+                    "microsecond": 135000
+                },
+                "State": "completed",
+                "VolumeId": "vol-0ec2ee2808bf1bb7d",
+                "VolumeSize": 8
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeVolumes_1.json b/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeVolumes_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeVolumes_1.json
@@ -0,0 +1,10 @@
+{
+    "status_code": 400,
+    "data": {
+        "Error": {
+            "Code": "InvalidVolume.NotFound",
+            "Message": "The volume 'vol-0ec2ee2808bf1bb7d' does not exist."
+        },
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeVolumes_2.json b/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeVolumes_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_ebs_volume_related_filter/ec2.DescribeVolumes_2.json
@@ -0,0 +1,29 @@
+{
+    "status_code": 200,
+    "data": {
+        "Volumes": [
+            {
+                "Attachments": [],
+                "AvailabilityZone": "us-east-1a",
+                "CreateTime": {
+                    "__class__": "datetime",
+                    "year": 2021,
+                    "month": 3,
+                    "day": 16,
+                    "hour": 20,
+                    "minute": 24,
+                    "second": 1,
+                    "microsecond": 646000
+                },
+                "Encrypted": false,
+                "Size": 1,
+                "SnapshotId": "",
+                "State": "available",
+                "VolumeId": "vol-02dbf91b6667bcdad",
+                "VolumeType": "standard",
+                "MultiAttachEnabled": false
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/test_ebs.py b/tests/test_ebs.py
--- a/tests/test_ebs.py
+++ b/tests/test_ebs.py
@@ -432,6 +432,27 @@ def test_matched(self):
         assert perms == [{"UserId": "112233445566"}]
 
 
+class SnapshotVolumeFilter(BaseTest):
+
+    def test_ebs_volume_filter(self):
+        factory = self.replay_flight_data("test_ebs_volume_related_filter")
+        p = self.load_policy(
+            {
+                "name": "ebs-snapshot-volume",
+                "resource": "aws.ebs-snapshot",
+                "filters": [
+                    {
+                        "type": "volume",
+                        "key": "AvailabilityZone",
+                        "value": "us-east-1a"
+                    }
+                ]
+            }, session_factory=factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+
+
 class AttachedInstanceTest(BaseTest):
 
     def test_ebs_instance_filter(self):