Repository: cloud-custodian/cloud-custodian

Issue:
tag-trim action for RDS instances
It would be great to have a `tag-trim` action for RDS instances.



Discussion:

Resolving patch:
diff --git a/c7n/resources/rds.py b/c7n/resources/rds.py
--- a/c7n/resources/rds.py
+++ b/c7n/resources/rds.py
@@ -59,6 +59,7 @@
 
 
 """
+import functools
 import logging
 import re
 
@@ -70,7 +71,8 @@
 from c7n.manager import resources
 from c7n.query import QueryResourceManager
 from c7n import tags
-from c7n.utils import local_session, type_schema, get_account_id, chunks
+from c7n.utils import (
+    local_session, type_schema, get_account_id, chunks, generate_arn)
 
 from skew.resources.aws import rds
 
@@ -94,7 +96,7 @@ class resource_type(rds.DBInstance.Meta):
 
     filter_registry = filters
     action_registry = actions
-    _arn_generator = _account_id = None
+    _generate_arn = _account_id = None
 
     def __init__(self, data, options):
         super(RDS, self).__init__(data, options)
@@ -107,33 +109,35 @@ def account_id(self):
         return self._account_id
 
     @property
-    def arn_generator(self):
-        if self._arn_generator is None:
-            self._arn_generator = DBInstanceARNGenerator(
-                self.config.region,
-                self.account_id)
-        return self._arn_generator
+    def generate_arn(self):
+        if self._generate_arn is None:
+            self._generate_arn = functools.partial(
+                generate_arn, 'rds', region=self.config.region,
+                account_id=self.account_id, resource_type='db', separator=':')
+        return self._generate_arn
 
     def augment(self, resources):
         filter(None, _rds_tags(
             self.get_model(),
             resources, self.session_factory, self.executor_factory,
-            self.arn_generator))
+            self.generate_arn))
         return resources
 
 
 def _rds_tags(
-        model, dbs, session_factory, executor_factory, arn_generator):
+        model, dbs, session_factory, executor_factory, generate_arn):
     """Augment rds instances with their respective tags."""
 
     def process_tags(db):
         client = local_session(session_factory).client('rds')
-        arn = arn_generator.generate(db[model.id])
+        arn = generate_arn(db[model.id])
+        tag_list = None
         try:
             tag_list = client.list_tags_for_resource(ResourceName=arn)['TagList']
         except ClientError as e:
-            if e.response['Error']['Code'] == "DBInstanceNotFound":
-                return None
+            if e.response['Error']['Code'] not in ['DBInstanceNotFound']:
+                log.warning("Exception getting rds tags  \n %s" % (e))
+            return None
         db['Tags'] = tag_list or []
         return db
 
@@ -188,7 +192,7 @@ def process(self, resources):
     def process_resource_set(self, resources, tags):
         client = local_session(self.manager.session_factory).client('rds')
         for r in resources:
-            arn = self.manager.arn_generator.generate(r['DBInstanceIdentifier'])
+            arn = self.manager.generate_arn(r['DBInstanceIdentifier'])
             client.add_tags_to_resource(ResourceName=arn, Tags=tags)
 
 
@@ -224,7 +228,7 @@ def process_resource_set(self, resources, tags):
         client = local_session(
             self.manager.session_factory).client('rds')
         for r in resources:
-            arn = self.manager.arn_generator.generate(r['DBInstanceIdentifier'])
+            arn = self.manager.generate_arn(r['DBInstanceIdentifier'])
             client.add_tags_to_resource(ResourceName=arn, Tags=tags)
 
 
@@ -239,11 +243,21 @@ def process_resource_set(self, resources, tag_keys):
         client = local_session(
             self.manager.session_factory).client('rds')
         for r in resources:
-            arn = self.manager.arn_generator.generate(r['DBInstanceIdentifier'])
+            arn = self.manager.generate_arn(r['DBInstanceIdentifier'])
             client.remove_tags_from_resource(
                 ResourceName=arn, TagKeys=tag_keys)
 
 
+@actions.register('tag-trim')
+class TagTrim(tags.TagTrim):
+
+    def process_tag_removal(self, resource, candidates):
+        client = local_session(
+            self.manager.session_factory).client('rds')
+        arn = self.manager.generate_arn(resource['DBInstanceIdentifier'])
+        client.remove_tags_from_resource(ResourceName=arn, TagKeys=candidates)
+
+
 @actions.register('delete')
 class Delete(BaseAction):
 
@@ -443,30 +457,3 @@ def process_snapshot_set(self, snapshots_set):
                     DBSnapshotIdentifier=s['DBSnapshotIdentifier'])
             except ClientError as e:
                 raise
-
-
-class ARNGenerator(object):
-    """Base class for RDS ARN generators.
-    """
-
-    def __init__(self, region, account_id, resource_type):
-        self._region = region
-        self._account_id = account_id
-        self._resource_type = resource_type
-
-    def generate(self, name):
-        """Generates an Amazon Resource Name for the specified resource.
-
-        See http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_Tagging.html
-        """
-        arn = 'arn:aws:rds:%s:%s:%s:%s' % (
-            self._region, self._account_id, self._resource_type, name)
-        return arn
-
-
-class DBInstanceARNGenerator(ARNGenerator):
-    """RDS DB instance ARN generator.
-    """
-
-    def __init__(self, region, account_id):
-        super(DBInstanceARNGenerator, self).__init__(region, account_id, 'db')
diff --git a/c7n/utils.py b/c7n/utils.py
--- a/c7n/utils.py
+++ b/c7n/utils.py
@@ -214,3 +214,18 @@ def parse_s3(s3_path):
     else:
         key_prefix = s3_path[s3_path.find('/', 5):]
     return s3_path, bucket, key_prefix
+
+
+def generate_arn(
+        service, resource, partition='aws',
+        region=None, account_id=None, resource_type=None, separator='/'):
+    """Generate an Amazon Resource Name.
+    See http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html.
+    """
+    arn = 'arn:%s:%s:%s:%s:' % (
+        partition, service, region if region else '', account_id if account_id else '')
+    if resource_type:
+        arn = arn + '%s%s%s' % (resource_type, separator, resource)
+    else:
+        arn = arn + resource
+    return arn


Test patch:
diff --git a/tests/data/placebo/test_rds_tag_trim/iam.ListRoles_1.json b/tests/data/placebo/test_rds_tag_trim/iam.ListRoles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_rds_tag_trim/iam.ListRoles_1.json
@@ -0,0 +1,30 @@
+{
+    "status_code": 200, 
+    "data": {
+        "Marker": "AAKVUG6oouqUWrxlFukIbjuEpYbnFs07xCfDiKrqFwQXJ5FtAS+E41rwcx8anK0PS96pdOV2t4dX2nmpIR0+E7T7", 
+        "IsTruncated": true, 
+        "Roles": [
+            {
+                "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22ec2.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", 
+                "RoleId": "AROAIBRFOWJZSGZJEZVAK", 
+                "CreateDate": {
+                    "hour": 10, 
+                    "__class__": "datetime", 
+                    "month": 11, 
+                    "second": 56, 
+                    "microsecond": 0, 
+                    "year": 2015, 
+                    "day": 24, 
+                    "minute": 55
+                }, 
+                "RoleName": "BaseIAMRole", 
+                "Path": "/", 
+                "Arn": "arn:aws:iam::619193117841:role/BaseIAMRole"
+            }
+        ], 
+        "ResponseMetadata": {
+            "HTTPStatusCode": 200, 
+            "RequestId": "d4b5d85e-2023-11e6-964c-7b76c530a910"
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_rds_tag_trim/rds.DescribeDBInstances_1.json b/tests/data/placebo/test_rds_tag_trim/rds.DescribeDBInstances_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_rds_tag_trim/rds.DescribeDBInstances_1.json
@@ -0,0 +1,111 @@
+{
+    "status_code": 200, 
+    "data": {
+        "DBInstances": [
+            {
+                "PubliclyAccessible": false, 
+                "MasterUsername": "custodian", 
+                "MonitoringInterval": 0, 
+                "LicenseModel": "postgresql-license", 
+                "VpcSecurityGroups": [
+                    {
+                        "Status": "active", 
+                        "VpcSecurityGroupId": "sg-0a08e365"
+                    }
+                ], 
+                "InstanceCreateTime": {
+                    "hour": 13, 
+                    "__class__": "datetime", 
+                    "month": 5, 
+                    "second": 22, 
+                    "microsecond": 993000, 
+                    "year": 2016, 
+                    "day": 22, 
+                    "minute": 43
+                }, 
+                "CopyTagsToSnapshot": true, 
+                "OptionGroupMemberships": [
+                    {
+                        "Status": "in-sync", 
+                        "OptionGroupName": "default:postgres-9-4"
+                    }
+                ], 
+                "PendingModifiedValues": {}, 
+                "Engine": "postgres", 
+                "MultiAZ": false, 
+                "LatestRestorableTime": {
+                    "hour": 13, 
+                    "__class__": "datetime", 
+                    "month": 5, 
+                    "second": 33, 
+                    "microsecond": 0, 
+                    "year": 2016, 
+                    "day": 22, 
+                    "minute": 44
+                }, 
+                "DBSecurityGroups": [], 
+                "DBParameterGroups": [
+                    {
+                        "DBParameterGroupName": "default.postgres9.4", 
+                        "ParameterApplyStatus": "in-sync"
+                    }
+                ], 
+                "AutoMinorVersionUpgrade": true, 
+                "PreferredBackupWindow": "08:35-09:05", 
+                "DBSubnetGroup": {
+                    "Subnets": [
+                        {
+                            "SubnetStatus": "Active", 
+                            "SubnetIdentifier": "subnet-389e3d53", 
+                            "SubnetAvailabilityZone": {
+                                "Name": "us-west-2c"
+                            }
+                        }, 
+                        {
+                            "SubnetStatus": "Active", 
+                            "SubnetIdentifier": "subnet-3e9e3d55", 
+                            "SubnetAvailabilityZone": {
+                                "Name": "us-west-2a"
+                            }
+                        }, 
+                        {
+                            "SubnetStatus": "Active", 
+                            "SubnetIdentifier": "subnet-3f9e3d54", 
+                            "SubnetAvailabilityZone": {
+                                "Name": "us-west-2b"
+                            }
+                        }
+                    ], 
+                    "DBSubnetGroupName": "default", 
+                    "VpcId": "vpc-399e3d52", 
+                    "DBSubnetGroupDescription": "default", 
+                    "SubnetGroupStatus": "Complete"
+                }, 
+                "ReadReplicaDBInstanceIdentifiers": [], 
+                "AllocatedStorage": 5, 
+                "BackupRetentionPeriod": 7, 
+                "PreferredMaintenanceWindow": "thu:10:25-thu:10:55", 
+                "Endpoint": {
+                    "Port": 5432, 
+                    "Address": "custodiantest.cj9uic5xuiu0.us-west-2.rds.amazonaws.com"
+                }, 
+                "DBInstanceStatus": "available", 
+                "EngineVersion": "9.4.7", 
+                "AvailabilityZone": "us-west-2b", 
+                "DomainMemberships": [], 
+                "StorageType": "gp2", 
+                "DbiResourceId": "db-V57KXLXSG4MTCQYEUXJF3D4LLE", 
+                "CACertificateIdentifier": "rds-ca-2015", 
+                "KmsKeyId": "arn:aws:kms:us-west-2:619193117841:key/43811d5f-db1f-448d-b61d-04c5bf49b9d8", 
+                "StorageEncrypted": true, 
+                "DBInstanceClass": "db.m3.medium", 
+                "DbInstancePort": 0, 
+                "DBInstanceIdentifier": "custodiantest"
+            }
+        ], 
+        "ResponseMetadata": {
+            "HTTPStatusCode": 200, 
+            "RequestId": "d46cc0b1-2023-11e6-9541-0dacbe99b868"
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_rds_tag_trim/rds.ListTagsForResource_1.json b/tests/data/placebo/test_rds_tag_trim/rds.ListTagsForResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_rds_tag_trim/rds.ListTagsForResource_1.json
@@ -0,0 +1,19 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "HTTPStatusCode": 200, 
+            "RequestId": "d4ec0352-2023-11e6-bcfb-97d0579f549a"
+        }, 
+        "TagList": [
+            {
+                "Value": "postgres", 
+                "Key": "Platform"
+            }, 
+            {
+                "Value": "other", 
+                "Key": "workload-type"
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_rds_tag_trim/rds.RemoveTagsFromResource_1.json b/tests/data/placebo/test_rds_tag_trim/rds.RemoveTagsFromResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_rds_tag_trim/rds.RemoveTagsFromResource_1.json
@@ -0,0 +1,9 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "HTTPStatusCode": 200, 
+            "RequestId": "8facab58-2a8a-11e6-bccf-cb3bd81739e8"
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/test_rds.py b/tests/test_rds.py
--- a/tests/test_rds.py
+++ b/tests/test_rds.py
@@ -42,6 +42,19 @@ def test_rds_tags(self):
         resources = p.run()
         self.assertEqual(len(resources), 1)
 
+    def test_rds_tag_trim(self):
+        session_factory = self.replay_flight_data('test_rds_tag_trim')
+        p = self.load_policy({
+            'name': 'rds-tags',
+            'resource': 'rds',
+            'filters': [
+                {'tag:Platform': 'postgres'}],
+            'actions': [
+                {'type': 'tag-trim', 'preserve': ['Name', 'Owner']}]},
+            session_factory=session_factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+
     def test_rds_tag_and_remove(self):
         self.patch(rds.RDS, 'executor_factory', MainThreadExecutor)
         session_factory = self.replay_flight_data('test_rds_tag_and_remove')
@@ -58,7 +71,7 @@ def test_rds_tag_and_remove(self):
         resources = p.run()
         self.assertEqual(len(resources), 1)
 
-        arn = p.resource_manager.arn_generator.generate(
+        arn = p.resource_manager.generate_arn(
             resources[0]['DBInstanceIdentifier'])
 
         tags = client.list_tags_for_resource(ResourceName=arn)
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -29,7 +29,7 @@ def test_chunks(self):
         self.assertEqual(
             list(utils.chunks(range(60), size=50)),
             [range(50), range(50, 60, 1)])
-        
+
     def test_type_schema(self):
         self.assertEqual(
             utils.type_schema('tester'),
@@ -38,4 +38,23 @@ def test_type_schema(self):
              'required': ['type'],
              'properties': {
                  'type': {'enum': ['tester']}}})
-        
+
+    def test_generate_arn(self):
+        self.assertEqual(
+            utils.generate_arn('s3', 'my_bucket'),
+            'arn:aws:s3:::my_bucket')
+        self.assertEqual(
+            utils.generate_arn('cloudformation',
+                'MyProductionStack/abc9dbf0-43c2-11e3-a6e8-50fa526be49c',
+                region='us-east-1',
+                account_id='123456789012',
+                resource_type='stack'),
+            'arn:aws:cloudformation:us-east-1:123456789012:stack/MyProductionStack/abc9dbf0-43c2-11e3-a6e8-50fa526be49c')
+        self.assertEqual(
+            utils.generate_arn('rds',
+                'mysql-option-group1',
+                region='us-east-1',
+                account_id='123456789012',
+                resource_type='og',
+                separator=':'),
+            'arn:aws:rds:us-east-1:123456789012:og:mysql-option-group1')