Repository: cloud-custodian/cloud-custodian

Issue:
log-group resource: add tag and untag actions
Desired for cost-allocation purposes


Discussion:

Resolving patch:
diff --git a/c7n/resources/cw.py b/c7n/resources/cw.py
--- a/c7n/resources/cw.py
+++ b/c7n/resources/cw.py
@@ -22,6 +22,7 @@
 from c7n.query import QueryResourceManager, ChildResourceManager
 from c7n.manager import resources
 from c7n.resolver import ValuesFrom
+from c7n.tags import universal_augment, register_universal_tags
 from c7n.utils import type_schema, local_session, chunks, get_retry
 
 
@@ -154,6 +155,16 @@ class resource_type(object):
         dimension = 'LogGroupName'
         date = 'creationTime'
 
+    augment = universal_augment
+
+    def get_arns(self, resources):
+        # log group arn in resource describe has ':*' suffix, not all
+        # apis can use that form, so normalize to standard arn.
+        return [r['arn'][:-2] for r in resources]
+
+
+register_universal_tags(LogGroup.filter_registry, LogGroup.action_registry)
+
 
 @LogGroup.action_registry.register('retention')
 class Retention(BaseAction):
diff --git a/c7n/tags.py b/c7n/tags.py
--- a/c7n/tags.py
+++ b/c7n/tags.py
@@ -28,6 +28,7 @@
 from dateutil.parser import parse
 
 import itertools
+import time
 
 from c7n.actions import BaseAction as Action, AutoTagUser
 from c7n.filters import Filter, OPERATORS, FilterValidationError
@@ -36,12 +37,6 @@
 
 DEFAULT_TAG = "maid_status"
 
-universal_tag_retry = utils.get_retry((
-    'Throttled',
-    'RequestLimitExceeded',
-    'Client.RequestLimitExceeded'
-))
-
 
 def register_ec2_tags(filters, actions):
     filters.register('marked-for-op', TagActionFilter)
@@ -818,19 +813,8 @@ def process_resource_set(self, resource_set, tags):
 
         arns = self.manager.get_arns(resource_set)
 
-        response = universal_tag_retry(
-            client.tag_resources,
-            ResourceARNList=arns,
-            Tags=tags)
-
-        for f in response.get('FailedResourcesMap', ()):
-            raise Exception("Resource:{} ".format(f) +
-                            "ErrorCode:{} ".format(
-                            response['FailedResourcesMap'][f]['ErrorCode']) +
-                            "StatusCode:{} ".format(
-                            response['FailedResourcesMap'][f]['StatusCode']) +
-                            "ErrorMessage:{}".format(
-                            response['FailedResourcesMap'][f]['ErrorMessage']))
+        return universal_retry(
+            client.tag_resources, ResourceARNList=arns, Tags=tags)
 
 
 class UniversalUntag(RemoveTag):
@@ -844,22 +828,9 @@ class UniversalUntag(RemoveTag):
     def process_resource_set(self, resource_set, tag_keys):
         client = utils.local_session(
             self.manager.session_factory).client('resourcegroupstaggingapi')
-
         arns = self.manager.get_arns(resource_set)
-
-        response = universal_tag_retry(
-            client.untag_resources,
-            ResourceARNList=arns,
-            TagKeys=tag_keys)
-
-        for f in response.get('FailedResourcesMap', ()):
-            raise Exception("Resource:{} ".format(f) +
-                            "ErrorCode:{} ".format(
-                            response['FailedResourcesMap'][f]['ErrorCode']) +
-                            "StatusCode:{} ".format(
-                            response['FailedResourcesMap'][f]['StatusCode']) +
-                            "ErrorMessage:{}".format(
-                            response['FailedResourcesMap'][f]['ErrorMessage']))
+        return universal_retry(
+            client.untag_resources, ResourceARNList=arns, TagKeys=tag_keys)
 
 
 class UniversalTagDelayedAction(TagDelayedAction):
@@ -923,17 +894,45 @@ def process_resource_set(self, resource_set, tags):
             self.manager.session_factory).client('resourcegroupstaggingapi')
 
         arns = self.manager.get_arns(resource_set)
+        return universal_retry(
+            client.tag_resources, ResourceARNList=arns, Tags=tags)
+
+
+def universal_retry(method, ResourceARNList, **kw):
+    """Retry support for resourcegroup tagging apis.
+
+    The resource group tagging api typically returns a 200 status code
+    with embedded resource specific errors. To enable resource specific
+    retry on throttles, we extract those, perform backoff w/ jitter and
+    continue. Other errors are immediately raised.
+
+    We do not aggregate unified resource responses across retries, only the
+    last successful response is returned for a subset of the resources if
+    a retry is performed.
+    """
+    max_attempts = 6
+
+    for idx, delay in enumerate(
+            utils.backoff_delays(1.5, 2 ** 8, jitter=True)):
+        response = method(ResourceARNList=ResourceARNList, **kw)
+        failures = response.get('FailedResourcesMap', {})
+        if not failures:
+            return response
+
+        errors = {}
+        throttles = set()
+
+        for f_arn in failures:
+            if failures[f_arn]['ErrorCode'] == 'ThrottlingException':
+                throttles.add(f_arn)
+            else:
+                errors[f_arn] = failures[f_arn]['ErrorCode']
+
+        if errors:
+            raise Exception("Resource Tag Errors %s" % (errors))
+
+        if idx == max_attempts - 1:
+            raise Exception("Resource Tag Throttled %s" % (", ".join(throttles)))
 
-        response = universal_tag_retry(
-            client.tag_resources,
-            ResourceARNList=arns,
-            Tags=tags)
-
-        for f in response.get('FailedResourcesMap', ()):
-            raise Exception("Resource:{} ".format(f) +
-                            "ErrorCode:{} ".format(
-                            response['FailedResourcesMap'][f]['ErrorCode']) +
-                            "StatusCode:{} ".format(
-                            response['FailedResourcesMap'][f]['StatusCode']) +
-                            "ErrorMessage:{}".format(
-                            response['FailedResourcesMap'][f]['ErrorMessage']))
+        time.sleep(delay)
+        ResourceARNList = list(throttles)
diff --git a/tools/c7n_azure/setup.py b/tools/c7n_azure/setup.py
--- a/tools/c7n_azure/setup.py
+++ b/tools/c7n_azure/setup.py
@@ -31,5 +31,5 @@
         "custodian.resources": [
             'azure = c7n_azure.entry:initialize_azure']
     },
-    install_requires=["azure", "c7n", "click", "azure-cli-core", "adal", "futures==3.1.1"],
+    install_requires=["azure", "c7n", "click", "azure-cli-core", "adal~=0.5.0", "futures==3.1.1"],
 )


Test patch:
diff --git a/tests/data/placebo/test_log_group_cross_account/tagging.GetResources_1.json b/tests/data/placebo/test_log_group_cross_account/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_log_group_cross_account/tagging.GetResources_1.json
@@ -0,0 +1,19 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+        ],
+        "ResponseMetadata": {
+            "RequestId": "a91843b0-2524-11e8-ae44-39655428e147",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "a91843b0-2524-11e8-ae44-39655428e147",
+                "content-type": "application/x-amz-json-1.1",
+                "content-length": "139",
+                "date": "Sun, 11 Mar 2018 12:06:41 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
diff --git a/tests/data/placebo/test_log_group_delete/tagging.GetResources_1.json b/tests/data/placebo/test_log_group_delete/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_log_group_delete/tagging.GetResources_1.json
@@ -0,0 +1,19 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+        ],
+        "ResponseMetadata": {
+            "RequestId": "a91843b0-2524-11e8-ae44-39655428e147",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "a91843b0-2524-11e8-ae44-39655428e147",
+                "content-type": "application/x-amz-json-1.1",
+                "content-length": "139",
+                "date": "Sun, 11 Mar 2018 12:06:41 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
diff --git a/tests/data/placebo/test_log_group_last_write/tagging.GetResources_1.json b/tests/data/placebo/test_log_group_last_write/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_log_group_last_write/tagging.GetResources_1.json
@@ -0,0 +1,19 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+        ],
+        "ResponseMetadata": {
+            "RequestId": "a91843b0-2524-11e8-ae44-39655428e147",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "a91843b0-2524-11e8-ae44-39655428e147",
+                "content-type": "application/x-amz-json-1.1",
+                "content-length": "139",
+                "date": "Sun, 11 Mar 2018 12:06:41 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
diff --git a/tests/data/placebo/test_log_group_retention/tagging.GetResources_1.json b/tests/data/placebo/test_log_group_retention/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_log_group_retention/tagging.GetResources_1.json
@@ -0,0 +1,19 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+        ],
+        "ResponseMetadata": {
+            "RequestId": "a91843b0-2524-11e8-ae44-39655428e147",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "a91843b0-2524-11e8-ae44-39655428e147",
+                "content-type": "application/x-amz-json-1.1",
+                "content-length": "139",
+                "date": "Sun, 11 Mar 2018 12:06:41 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
diff --git a/tests/data/placebo/test_route53_hostedzone_tag_exception/route53.ListHostedZones_1.json b/tests/data/placebo/test_route53_hostedzone_tag_exception/route53.ListHostedZones_1.json
deleted file mode 100644
--- a/tests/data/placebo/test_route53_hostedzone_tag_exception/route53.ListHostedZones_1.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
-    "status_code": 200, 
-    "data": {
-        "HostedZones": [
-            {
-                "ResourceRecordSetCount": 2, 
-                "CallerReference": "6DB68085-4D2A-E0D9-8F52-4778CD25D610", 
-                "Config": {
-                    "PrivateZone": false
-                }, 
-                "Id": "/hostedzone/Z10JIOLZIZS60E", 
-                "Name": "test.zone."
-            }
-        ], 
-        "IsTruncated": false, 
-        "ResponseMetadata": {
-            "RetryAttempts": 0, 
-            "HTTPStatusCode": 200, 
-            "RequestId": "a5aaff89-3847-11e7-8bf5-a3e1b53923d7", 
-            "HTTPHeaders": {
-                "x-amzn-requestid": "a5aaff89-3847-11e7-8bf5-a3e1b53923d7", 
-                "date": "Sun, 14 May 2017 01:50:02 GMT", 
-                "content-length": "463", 
-                "content-type": "text/xml"
-            }
-        }, 
-        "MaxItems": "100"
-    }
-}
\ No newline at end of file
diff --git a/tests/data/placebo/test_route53_hostedzone_tag_exception/route53.ListTagsForResources_1.json b/tests/data/placebo/test_route53_hostedzone_tag_exception/route53.ListTagsForResources_1.json
deleted file mode 100644
--- a/tests/data/placebo/test_route53_hostedzone_tag_exception/route53.ListTagsForResources_1.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
-    "status_code": 200, 
-    "data": {
-        "ResourceTagSets": [
-            {
-                "ResourceType": "hostedzone", 
-                "ResourceId": "Z10JIOLZIZS60E", 
-                "Tags": [
-                    {
-                        "Value": "xyz", 
-                        "Key": "abc"
-                    }
-                ]
-            }
-        ], 
-        "ResponseMetadata": {
-            "RetryAttempts": 0, 
-            "HTTPStatusCode": 200, 
-            "RequestId": "a5dee069-3847-11e7-989d-11dfb3cd683e", 
-            "HTTPHeaders": {
-                "x-amzn-requestid": "a5dee069-3847-11e7-989d-11dfb3cd683e", 
-                "date": "Sun, 14 May 2017 01:50:03 GMT", 
-                "content-length": "339", 
-                "content-type": "text/xml"
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/tests/data/placebo/test_route53_hostedzone_tag_exception/tagging.TagResources_1.json b/tests/data/placebo/test_route53_hostedzone_tag_exception/tagging.TagResources_1.json
deleted file mode 100644
--- a/tests/data/placebo/test_route53_hostedzone_tag_exception/tagging.TagResources_1.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
-    "status_code": 200, 
-    "data": {
-        "ResponseMetadata": {
-            "RetryAttempts": 0, 
-            "HTTPStatusCode": 200, 
-            "RequestId": "a61559fb-3847-11e7-8925-51975ea142dd", 
-            "HTTPHeaders": {
-                "x-amzn-requestid": "a61559fb-3847-11e7-8925-51975ea142dd", 
-                "date": "Sun, 14 May 2017 01:50:03 GMT", 
-                "content-length": "182", 
-                "content-type": "application/x-amz-json-1.1"
-            }
-        }, 
-        "FailedResourcesMap": {
-            "arn:aws:route53:::hostedzone/Z148QEXAMPLE8V": {
-                "ErrorCode": "NoSuchHostedZone", 
-                "ErrorMessage": "No hosted zone found with ID: Z148QEXAMPLE8V", 
-                "StatusCode": 404
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/tests/test_route53.py b/tests/test_route53.py
--- a/tests/test_route53.py
+++ b/tests/test_route53.py
@@ -74,41 +74,6 @@ def test_route53_hostedzone_tag(self):
         self.assertEqual(len(tags['ResourceTagSet']['Tags']), 1)
         self.assertTrue('abc' in tags['ResourceTagSet']['Tags'][0].values())
 
-    def test_route53_hostedzone_tag_exception(self):
-        output = self.capture_logging(level=logging.DEBUG)
-        # intentionally cause error to be thrown by sending wrong arn
-        def generate_arn(*args):
-            return 'arn:aws:route53:::hostedzone/Z148QEXAMPLE8V'
-        self.patch(HostedZone, 'generate_arn', generate_arn)
-
-        session_factory = self.replay_flight_data('test_route53_hostedzone_tag_exception')
-        p = self.load_policy({
-            'name': 'hostedzone-tag-records',
-            'resource': 'hostedzone',
-            'filters': [
-                {
-                    'type': 'value',
-                    'key': 'ResourceRecordSetCount',
-                    'value': 2,
-                    'op': 'gte'
-                }
-            ],
-            'actions': [
-                {
-                    'type': 'tag',
-                    'key': 'abc',
-                    'value': 'xyz'
-                }
-            ]},
-            session_factory=session_factory)
-        resources = p.run()
-        self.assertEqual(len(resources), 1)
-
-        self.assertTrue(("Resource:arn:aws:route53:::hostedzone/Z148QEXAMPLE8V "
-                         "ErrorCode:NoSuchHostedZone StatusCode:404 "
-                         "ErrorMessage:No hosted zone "
-                         "found with ID: Z148QEXAMPLE8V") in output.getvalue())
-
     def test_route53_hostedzone_untag(self):
         session_factory = self.replay_flight_data('test_route53_hostedzone_untag')
 
diff --git a/tests/test_tags.py b/tests/test_tags.py
new file mode 100644
--- /dev/null
+++ b/tests/test_tags.py
@@ -0,0 +1,52 @@
+# Copyright 2018 Capital One Services, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Most tags tests within their corresponding resource tags, we use this
+module to test some universal tagging infrastructure not directly exposed.
+"""
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import time
+from mock import MagicMock, call
+from c7n.tags import universal_retry
+from .common import BaseTest
+
+
+class UniversalTagRetry(BaseTest):
+
+    def test_retry_no_error(self):
+        mock = MagicMock()
+        mock.side_effect = [{'Result': 42}]
+        self.assertEqual(universal_retry(mock, []), {'Result': 42})
+        mock.assert_called_once()
+
+    def test_retry_failure_reduced_set(self):
+        sleep = MagicMock()
+        self.patch(time, 'sleep', sleep)
+        method = MagicMock()
+        method.side_effect = [
+            {'FailedResourcesMap': {'arn:abc': {'ErrorCode': 'ThrottlingException'}}},
+            {'Result': 32}]
+        self.assertEqual(universal_retry(method, ['arn:abc', 'arn:def']),
+                         {'Result': 32})
+        sleep.assert_called_once()
+        self.assertTrue(
+            method.call_args_list == [
+                call(ResourceARNList=['arn:abc', 'arn:def']),
+                call(ResourceARNList=['arn:abc'])])
+
+    def test_retry_pass_error(self):
+        method = MagicMock()
+        method.side_effect = [
+            {'FailedResourcesMap': {'arn:abc': {'ErrorCode': 'PermissionDenied'}}}]
+        self.assertRaises(Exception, universal_retry, method, ['arn:abc'])