Repository: cloud-custodian/cloud-custodian

Issue:
Add a `aws.workspaces-bundle` resource
### Describe the feature

There are resources for WorkSpaces and related directories/images/web settings, but nothing to [show WorkSpaces bundles](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/workspaces/client/describe_workspace_bundles.html#WorkSpaces.Client.describe_workspace_bundles).

### Extra information or context

It would probably also be useful to filter workspaces by their associated bundles.


Discussion:

Resolving patch:
diff --git a/c7n/resources/resource_map.py b/c7n/resources/resource_map.py
--- a/c7n/resources/resource_map.py
+++ b/c7n/resources/resource_map.py
@@ -274,6 +274,7 @@
   "aws.waf-regional": "c7n.resources.waf.RegionalWAF",
   "aws.wafv2": "c7n.resources.waf.WAFV2",
   "aws.workspaces": "c7n.resources.workspaces.Workspace",
+  "aws.workspaces-bundle": "c7n.resources.workspaces.WorkspacesBundle",
   "aws.workspaces-directory": "c7n.resources.workspaces.WorkspaceDirectory",
   "aws.workspaces-image": "c7n.resources.workspaces.WorkspaceImage",
   "aws.workspaces-web": "c7n.resources.workspaces.WorkspacesWeb",
diff --git a/c7n/resources/workspaces.py b/c7n/resources/workspaces.py
--- a/c7n/resources/workspaces.py
+++ b/c7n/resources/workspaces.py
@@ -569,3 +569,42 @@ def disassociate_settings(self, client, resource, setting_arn_key, disassociate_
 
     def delete_portal(self, client, resource):
         client.delete_portal(portalArn=resource['portalArn'])
+
+
+@resources.register('workspaces-bundle')
+class WorkspacesBundle(QueryResourceManager):
+
+    class resource_type(TypeInfo):
+        service = 'workspaces'
+        enum_spec = ('describe_workspace_bundles', 'Bundles', None)
+        arn_type = 'workspacebundle'
+        name = id = 'BundleId'
+        universal_taggable = True
+
+
+@WorkspacesBundle.action_registry.register('delete')
+class DeleteWorkspaceBundle(BaseAction):
+    """
+    Deletes a WorkSpaces Bundle
+
+    :example:
+
+    .. code-block:: yaml
+
+        policies:
+          - name: delete-workspaces-bundle
+            resource: aws.workspaces-bundle
+            actions:
+              - delete
+    """
+
+    schema = type_schema('delete')
+    permissions = ('workspaces:DeleteWorkspaceBundle',)
+
+    def process(self, bundles):
+        client = local_session(self.manager.session_factory).client('workspaces')
+        for bundle in bundles:
+            try:
+                client.delete_workspace_bundle(BundleId=bundle['BundleId'])
+            except client.exceptions.ResourceNotFoundException:
+                self.log.warning("Bundle not found: %s" % bundle['BundleId'])


Test patch:
diff --git a/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DeleteWorkspaceBundle_1.json b/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DeleteWorkspaceBundle_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DeleteWorkspaceBundle_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DescribeWorkspaceBundles_1.json b/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DescribeWorkspaceBundles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DescribeWorkspaceBundles_1.json
@@ -0,0 +1,46 @@
+{
+    "status_code": 200,
+    "data": {
+        "Bundles": [
+            {
+                "BundleId": "wsb-g0znf3451",
+                "Name": "test",
+                "Owner": "644160558196",
+                "Description": "test",
+                "ImageId": "wsi-f19s7dtsh",
+                "RootStorage": {
+                    "Capacity": "80"
+                },
+                "UserStorage": {
+                    "Capacity": "10"
+                },
+                "ComputeType": {
+                    "Name": "VALUE"
+                },
+                "LastUpdatedTime": {
+                    "__class__": "datetime",
+                    "year": 2024,
+                    "month": 3,
+                    "day": 23,
+                    "hour": 14,
+                    "minute": 1,
+                    "second": 43,
+                    "microsecond": 963000
+                },
+                "CreationTime": {
+                    "__class__": "datetime",
+                    "year": 2024,
+                    "month": 3,
+                    "day": 23,
+                    "hour": 13,
+                    "minute": 58,
+                    "second": 2,
+                    "microsecond": 280000
+                },
+                "State": "AVAILABLE",
+                "BundleType": "REGULAR"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DescribeWorkspaceBundles_2.json b/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DescribeWorkspaceBundles_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_delete/workspaces.DescribeWorkspaceBundles_2.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "Bundles": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_tag/tagging.TagResources_1.json b/tests/data/placebo/test_workspaces_bundle_tag/tagging.TagResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_tag/tagging.TagResources_1.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "FailedResourcesMap": {},
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_tag/workspaces.DescribeTags_1.json b/tests/data/placebo/test_workspaces_bundle_tag/workspaces.DescribeTags_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_tag/workspaces.DescribeTags_1.json
@@ -0,0 +1,12 @@
+{
+    "status_code": 200,
+    "data": {
+        "TagList": [
+            {
+                "Key": "test",
+                "Value": "testval"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_tag/workspaces.DescribeWorkspaceBundles_1.json b/tests/data/placebo/test_workspaces_bundle_tag/workspaces.DescribeWorkspaceBundles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_tag/workspaces.DescribeWorkspaceBundles_1.json
@@ -0,0 +1,46 @@
+{
+    "status_code": 200,
+    "data": {
+        "Bundles": [
+            {
+                "BundleId": "wsb-g0znf3451",
+                "Name": "test",
+                "Owner": "644160558196",
+                "Description": "test",
+                "ImageId": "wsi-f19s7dtsh",
+                "RootStorage": {
+                    "Capacity": "80"
+                },
+                "UserStorage": {
+                    "Capacity": "10"
+                },
+                "ComputeType": {
+                    "Name": "VALUE"
+                },
+                "LastUpdatedTime": {
+                    "__class__": "datetime",
+                    "year": 2024,
+                    "month": 3,
+                    "day": 23,
+                    "hour": 14,
+                    "minute": 1,
+                    "second": 43,
+                    "microsecond": 963000
+                },
+                "CreationTime": {
+                    "__class__": "datetime",
+                    "year": 2024,
+                    "month": 3,
+                    "day": 23,
+                    "hour": 13,
+                    "minute": 58,
+                    "second": 2,
+                    "microsecond": 280000
+                },
+                "State": "AVAILABLE",
+                "BundleType": "REGULAR"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_untag/tagging.UntagResources_1.json b/tests/data/placebo/test_workspaces_bundle_untag/tagging.UntagResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_untag/tagging.UntagResources_1.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "FailedResourcesMap": {},
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_untag/workspaces.DescribeTags_1.json b/tests/data/placebo/test_workspaces_bundle_untag/workspaces.DescribeTags_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_untag/workspaces.DescribeTags_1.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "TagList": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_bundle_untag/workspaces.DescribeWorkspaceBundles_1.json b/tests/data/placebo/test_workspaces_bundle_untag/workspaces.DescribeWorkspaceBundles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_bundle_untag/workspaces.DescribeWorkspaceBundles_1.json
@@ -0,0 +1,46 @@
+{
+    "status_code": 200,
+    "data": {
+        "Bundles": [
+            {
+                "BundleId": "wsb-g0znf3451",
+                "Name": "test",
+                "Owner": "644160558196",
+                "Description": "test",
+                "ImageId": "wsi-f19s7dtsh",
+                "RootStorage": {
+                    "Capacity": "80"
+                },
+                "UserStorage": {
+                    "Capacity": "10"
+                },
+                "ComputeType": {
+                    "Name": "VALUE"
+                },
+                "LastUpdatedTime": {
+                    "__class__": "datetime",
+                    "year": 2024,
+                    "month": 3,
+                    "day": 23,
+                    "hour": 14,
+                    "minute": 1,
+                    "second": 43,
+                    "microsecond": 963000
+                },
+                "CreationTime": {
+                    "__class__": "datetime",
+                    "year": 2024,
+                    "month": 3,
+                    "day": 23,
+                    "hour": 13,
+                    "minute": 58,
+                    "second": 2,
+                    "microsecond": 280000
+                },
+                "State": "AVAILABLE",
+                "BundleType": "REGULAR"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py
--- a/tests/test_workspaces.py
+++ b/tests/test_workspaces.py
@@ -456,3 +456,63 @@ def test_workspaces_web_delete(self):
             time.sleep(5)
         portals = client.list_portals()['portals']
         self.assertEqual(len(portals), 0)
+
+
+class TestWorkspacesBundleDelete(BaseTest):
+
+    def test_workspaces_bundle_tag(self):
+        session_factory = self.replay_flight_data("test_workspaces_bundle_tag")
+        client = session_factory().client("workspaces")
+
+        p = self.load_policy({
+            'name': 'workspaces-bundle-tag',
+            'resource': 'workspaces-bundle',
+            'filters': [{'Name': 'test'}],
+            'actions': [{
+                'type': 'tag',
+                'tags': {'test': 'testval'}
+            }]
+        }, session_factory=session_factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+
+        response = client.describe_tags(ResourceId=resources[0]['BundleId'])
+        self.assertIn({'Key': 'test', 'Value': 'testval'}, response.get('TagList', []))
+
+    def test_workspaces_bundle_untag(self):
+        session_factory = self.replay_flight_data("test_workspaces_bundle_untag")
+        client = session_factory().client("workspaces")
+
+        p = self.load_policy({
+            'name': 'workspaces-bundle-untag',
+            'resource': 'workspaces-bundle',
+            'filters': [{'Name': 'test'}],
+            'actions': [{
+                'type': 'remove-tag',
+                'tags': ['test']
+            }]
+        }, session_factory=session_factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+
+        response = client.describe_tags(ResourceId=resources[0]['BundleId'])
+        self.assertNotIn({'Key': 'test', 'Value': 'testval'}, response.get('TagList', []))
+
+    def test_workspaces_bundle_delete(self):
+        session_factory = self.replay_flight_data("test_workspaces_bundle_delete")
+        client = session_factory().client("workspaces")
+
+        p = self.load_policy({
+            'name': 'workspaces-bundle-delete',
+            'resource': 'aws.workspaces-bundle',
+            'filters': [{'Name': 'test'}],
+            'actions': [{'type': 'delete'}]
+        }, session_factory=session_factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        self.assertEqual(resources[0]['Name'], 'test')
+        if self.recording:
+            time.sleep(5)
+
+        response = client.describe_workspace_bundles()['Bundles']
+        self.assertFalse(any(b['Name'] == 'test' for b in response))