Repository: cloud-custodian/cloud-custodian

Issue:
lambda dlq support



Discussion:
Per call, the idea here is to expose this config in the `LambdaFunction` class(es) in mu.py.
I'm reading this as extend [`LambdaManager._create_or_update`](https://github.com/capitalone/cloud-custodian/blob/8d1d593e7839452c955699f04eed04842988f50b/c7n/mu.py#L319-L357).
that sounds right, also while your there... we also want lambda tag support.
effectively execution-mode is going to grow some more lambda config pass through

Resolving patch:
diff --git a/c7n/mu.py b/c7n/mu.py
--- a/c7n/mu.py
+++ b/c7n/mu.py
@@ -307,19 +307,28 @@ def logs(self, func, start, end):
                 yield e
 
     @staticmethod
-    def delta_function(lambda_func, func, role):
-        conf = func.get_config()
-        # TODO feels a little wierd
-        conf['Role'] = role
-        for k in conf:
-            if conf[k] != lambda_func['Configuration'][k]:
+    def delta_function(old_config, new_config):
+        for k in new_config:
+            if k not in old_config or new_config[k] != old_config[k]:
                 return True
 
+    @staticmethod
+    def diff_tags(old_tags, new_tags):
+        add = {}
+        remove = set()
+        for k,v in new_tags.items():
+            if k not in old_tags or old_tags[k] != v:
+                add[k] = v
+        for k in old_tags:
+            if k not in new_tags:
+                remove.add(k)
+        return add, list(remove)
+
     def _create_or_update(self, func, role=None, s3_uri=None, qualifier=None):
         role = func.role or role
         assert role, "Lambda function role must be specified"
         archive = func.get_archive()
-        lfunc = self.get(func.name, qualifier)
+        existing = self.get(func.name, qualifier)
 
         if s3_uri:
             # TODO: support versioned buckets
@@ -329,9 +338,9 @@ def _create_or_update(self, func, role=None, s3_uri=None, qualifier=None):
             code_ref = {'ZipFile': archive.get_bytes()}
 
         changed = False
-        if lfunc:
-            result = lfunc['Configuration']
-            if archive.get_checksum() != lfunc['Configuration']['CodeSha256']:
+        if existing:
+            old_config = existing['Configuration']
+            if archive.get_checksum() != old_config['CodeSha256']:
                 log.debug("Updating function %s code", func.name)
                 params = dict(FunctionName=func.name, Publish=True)
                 params.update(code_ref)
@@ -339,13 +348,36 @@ def _create_or_update(self, func, role=None, s3_uri=None, qualifier=None):
                 changed = True
             # TODO/Consider also set publish above to false, and publish
             # after configuration change?
-            if self.delta_function(lfunc, func, role):
+
+            new_config = func.get_config()
+            new_config['Role'] = role
+            del new_config['Runtime']
+            new_tags = new_config.pop('Tags', {})
+
+            if self.delta_function(old_config, new_config):
                 log.debug("Updating function: %s config" % func.name)
-                params = func.get_config()
-                del params['Runtime']
-                params['Role'] = role
-                result = self.client.update_function_configuration(**params)
+                result = self.client.update_function_configuration(**new_config)
                 changed = True
+
+            # tag dance
+            base_arn = old_config['FunctionArn']
+            if base_arn.count(':') > 6:  # trim version/alias
+                base_arn = base_arn.rsplit(':', 1)[0]
+
+            old_tags = self.client.list_tags(Resource=base_arn)['Tags']
+            tags_to_add, tags_to_remove = self.diff_tags(old_tags, new_tags)
+
+            if tags_to_add:
+                log.debug("Adding/updating tags: %s config" % func.name)
+                self.client.tag_resource(
+                    Resource=base_arn, Tags=tags_to_add)
+            if tags_to_remove:
+                log.debug("Removing tags: %s config" % func.name)
+                self.client.untag_resource(
+                    Resource=base_arn, TagKeys=tags_to_remove)
+
+            if not changed:
+                result = old_config
         else:
             log.info('Publishing custodian policy lambda function %s', func.name)
             params = func.get_config()
@@ -457,6 +489,26 @@ def subnets(self):
     def security_groups(self):
         """ """
 
+    @abc.abstractproperty
+    def dead_letter_config(self):
+        """ """
+
+    @abc.abstractproperty
+    def environment(self):
+        """ """
+
+    @abc.abstractproperty
+    def kms_key_arn(self):
+        """ """
+
+    @abc.abstractproperty
+    def tracing_config(self):
+        """ """
+
+    @abc.abstractproperty
+    def tags(self):
+        """ """
+
     @abc.abstractmethod
     def get_events(self, session_factory):
         """event sources that should be bound to this lambda."""
@@ -473,7 +525,12 @@ def get_config(self):
             'Description': self.description,
             'Runtime': self.runtime,
             'Handler': self.handler,
-            'Timeout': self.timeout}
+            'Timeout': self.timeout,
+            'DeadLetterConfig': self.dead_letter_config,
+            'Environment': self.environment,
+            'KMSKeyArn': self.kms_key_arn,
+            'TracingConfig': self.tracing_config,
+            'Tags': self.tags}
         if self.subnets and self.security_groups:
             conf['VpcConfig'] = {
                 'SubnetIds': self.subnets,
@@ -530,6 +587,26 @@ def security_groups(self):
     def subnets(self):
         return self.func_data.get('subnets', None)
 
+    @property
+    def dead_letter_config(self):
+        return self.func_data.get('dead_letter_config', {})
+
+    @property
+    def environment(self):
+        return self.func_data.get('environment', {})
+
+    @property
+    def kms_key_arn(self):
+        return self.func_data.get('kms_key_arn', '')
+
+    @property
+    def tracing_config(self):
+        return self.func_data.get('tracing_config', {})
+
+    @property
+    def tags(self):
+        return self.func_data.get('tags', {})
+
     def get_events(self, session_factory):
         return self.func_data.get('events', ())
 
@@ -585,6 +662,26 @@ def security_groups(self):
     def subnets(self):
         return None
 
+    @property
+    def dead_letter_config(self):
+        return self.policy.data['mode'].get('dead_letter_config', {})
+
+    @property
+    def environment(self):
+        return self.policy.data['mode'].get('environment', {})
+
+    @property
+    def kms_key_arn(self):
+        return self.policy.data['mode'].get('kms_key_arn', '')
+
+    @property
+    def tracing_config(self):
+        return self.policy.data['mode'].get('tracing_config', {})
+
+    @property
+    def tags(self):
+        return self.policy.data['mode'].get('tags', {})
+
     def get_events(self, session_factory):
         events = []
         if self.policy.data['mode']['type'] == 'config-rule':


Test patch:
diff --git a/tests/placebo_data.zip b/tests/placebo_data.zip
Binary files a/tests/placebo_data.zip and b/tests/placebo_data.zip differ
diff --git a/tests/test_mu.py b/tests/test_mu.py
--- a/tests/test_mu.py
+++ b/tests/test_mu.py
@@ -36,7 +36,7 @@
 
 class PolicyLambdaProvision(BaseTest):
 
-    role = "arn:aws:iam::619193117841:role/lambda_basic_execution"
+    role = "arn:aws:iam::644160558196:role/custodian-mu"
 
     def assert_items(self, result, expected):
         for k, v in expected.items():
@@ -213,6 +213,7 @@ def test_cwe_trail(self):
         }, Config.empty())
         pl = PolicyLambda(p)
         mgr = LambdaManager(session_factory)
+        self.addCleanup(mgr.remove, pl)
         result = mgr.publish(pl, 'Dev', role=self.role)
 
         events = pl.get_events(session_factory)
@@ -232,7 +233,6 @@ def test_cwe_trail(self):
              'MemorySize': 512,
              'Runtime': RUNTIME,
              'Timeout': 60})
-        mgr.remove(pl)
 
     def test_mu_metrics(self):
         session_factory = self.replay_flight_data('test_mu_metrics')
@@ -266,22 +266,23 @@ def test_cwe_instance(self):
         }, Config.empty())
         pl = PolicyLambda(p)
         mgr = LambdaManager(session_factory)
+        self.addCleanup(mgr.remove, pl)
         result = mgr.publish(pl, 'Dev', role=self.role)
         self.assert_items(
             result,
             {'Description': 'cloud-custodian lambda policy',
-             'FunctionName': 'c7n-ec2-encrypted-vol',
-             'Handler': 'c7n_policy.run',
+             'FunctionName': 'custodian-ec2-encrypted-vol',
+             'Handler': 'custodian_policy.run',
              'MemorySize': 512,
              'Runtime': RUNTIME,
              'Timeout': 60})
 
         events = session_factory().client('events')
-        result = events.list_rules(NamePrefix="c7n-ec2-encrypted-vol")
+        result = events.list_rules(NamePrefix="custodian-ec2-encrypted-vol")
         self.assert_items(
             result['Rules'][0],
             {"State": "ENABLED",
-             "Name": "c7n-ec2-encrypted-vol"})
+             "Name": "custodian-ec2-encrypted-vol"})
 
         self.assertEqual(
             json.loads(result['Rules'][0]['EventPattern']),
@@ -289,7 +290,6 @@ def test_cwe_instance(self):
              "detail": {
                  "state": ["pending"]},
              "detail-type": ["EC2 Instance State-change Notification"]})
-        mgr.remove(pl)
 
     def test_cwe_asg_instance(self):
         session_factory = self.replay_flight_data('test_cwe_asg', zdata=True)
@@ -302,27 +302,27 @@ def test_cwe_asg_instance(self):
         }, Config.empty())
         pl = PolicyLambda(p)
         mgr = LambdaManager(session_factory)
+        self.addCleanup(mgr.remove, pl)
         result = mgr.publish(pl, 'Dev', role=self.role)
         self.assert_items(
             result,
-            {'FunctionName': 'c7n-asg-spin-detector',
-             'Handler': 'c7n_policy.run',
+            {'FunctionName': 'custodian-asg-spin-detector',
+             'Handler': 'custodian_policy.run',
              'MemorySize': 512,
              'Runtime': RUNTIME,
              'Timeout': 60})
 
         events = session_factory().client('events')
-        result = events.list_rules(NamePrefix="c7n-asg-spin-detector")
+        result = events.list_rules(NamePrefix="custodian-asg-spin-detector")
         self.assert_items(
             result['Rules'][0],
             {"State": "ENABLED",
-             "Name": "c7n-asg-spin-detector"})
+             "Name": "custodian-asg-spin-detector"})
 
         self.assertEqual(
             json.loads(result['Rules'][0]['EventPattern']),
             {"source": ["aws.autoscaling"],
              "detail-type": ["EC2 Instance Launch Unsuccessful"]})
-        mgr.remove(pl)
 
     def test_cwe_schedule(self):
         session_factory = self.replay_flight_data(
@@ -338,24 +338,134 @@ def test_cwe_schedule(self):
 
         pl = PolicyLambda(p)
         mgr = LambdaManager(session_factory)
+        self.addCleanup(mgr.remove, pl)
         result = mgr.publish(pl, 'Dev', role=self.role)
         self.assert_items(
             result,
-            {'FunctionName': 'c7n-periodic-ec2-checker',
-             'Handler': 'c7n_policy.run',
+            {'FunctionName': 'custodian-periodic-ec2-checker',
+             'Handler': 'custodian_policy.run',
              'MemorySize': 512,
              'Runtime': RUNTIME,
              'Timeout': 60})
 
         events = session_factory().client('events')
-        result = events.list_rules(NamePrefix="c7n-periodic-ec2-checker")
+        result = events.list_rules(NamePrefix="custodian-periodic-ec2-checker")
         self.assert_items(
             result['Rules'][0],
             {
                 "State": "ENABLED",
                 "ScheduleExpression": "rate(1 day)",
-                "Name": "c7n-periodic-ec2-checker"})
-        mgr.remove(pl)
+                "Name": "custodian-periodic-ec2-checker"})
+
+    key_arn = 'arn:aws:kms:us-west-2:644160558196:key/'\
+        '44d25a5c-7efa-44ed-8436-b9511ea921b3'
+    sns_arn = 'arn:aws:sns:us-west-2:644160558196:config-topic'
+
+    def create_a_lambda(self, flight, **extra):
+        session_factory = self.replay_flight_data(flight, zdata=True)
+        mode = {
+            'type': 'config-rule',
+            'role':'arn:aws:iam::644160558196:role/custodian-mu'}
+        mode.update(extra)
+        p = Policy({
+            'resource': 's3',
+            'name': 'hello-world',
+            'actions': ['no-op'],
+            'mode': mode,
+        }, Config.empty())
+        pl = PolicyLambda(p)
+        mgr = LambdaManager(session_factory)
+        self.addCleanup(mgr.remove, pl)
+        return mgr, mgr.publish(pl)
+
+    def create_a_lambda_with_lots_of_config(self, flight):
+        extra = {
+            'environment': {'Variables': {'FOO': 'bar'}},
+            'kms_key_arn': self.key_arn,
+            'dead_letter_config': {'TargetArn': self.sns_arn},
+            'tracing_config': {'Mode': 'Active'},
+            'tags': {'Foo': 'Bar'}}
+        return self.create_a_lambda(flight, **extra)
+
+    def update_a_lambda(self, mgr, **config):
+        mode = {
+            'type': 'config-rule',
+            'role':'arn:aws:iam::644160558196:role/custodian-mu'}
+        mode.update(config)
+        p = Policy({
+            'resource': 's3',
+            'name': 'hello-world',
+            'actions': ['no-op'],
+            'mode': mode
+        }, Config.empty())
+        pl = PolicyLambda(p)
+        return mgr.publish(pl)
+
+    def test_config_coverage_for_lambda_creation(self):
+        mgr, result = self.create_a_lambda_with_lots_of_config(
+            'test_config_coverage_for_lambda_creation')
+        self.assert_items(
+            result,
+            {'Description': 'cloud-custodian lambda policy',
+             'FunctionName': 'custodian-hello-world',
+             'Handler': 'custodian_policy.run',
+             'MemorySize': 512,
+             'Runtime': RUNTIME,
+             'Timeout': 60,
+             'DeadLetterConfig': {'TargetArn': self.sns_arn},
+             'Environment': {'Variables': {'FOO': 'bar'}},
+             'KMSKeyArn': self.key_arn,
+             'TracingConfig': {'Mode': 'Active'}})
+        tags = mgr.client.list_tags(Resource=result['FunctionArn'])['Tags']
+        self.assert_items(tags, {'Foo': 'Bar'})
+
+    def test_config_coverage_for_lambda_update_from_plain(self):
+        mgr, result = self.create_a_lambda(
+            'test_config_coverage_for_lambda_update_from_plain')
+        result = self.update_a_lambda(mgr, **{
+            'environment': {'Variables': {'FOO': 'bloo'}},
+            'kms_key_arn': self.key_arn,
+            'dead_letter_config': {'TargetArn': self.sns_arn},
+            'tracing_config': {'Mode': 'Active'},
+            'tags': {'Foo': 'Bloo'}})
+
+        self.assert_items(
+            result,
+            {'Description': 'cloud-custodian lambda policy',
+             'FunctionName': 'custodian-hello-world',
+             'Handler': 'custodian_policy.run',
+             'MemorySize': 512,
+             'Runtime': RUNTIME,
+             'Timeout': 60,
+             'DeadLetterConfig': {'TargetArn': self.sns_arn},
+             'Environment': {'Variables': {'FOO': 'bloo'}},
+             'TracingConfig': {'Mode': 'Active'}})
+        tags = mgr.client.list_tags(Resource=result['FunctionArn'])['Tags']
+        self.assert_items(tags, {'Foo': 'Bloo'})
+
+    def test_config_coverage_for_lambda_update_from_complex(self):
+        mgr, result = self.create_a_lambda_with_lots_of_config(
+            'test_config_coverage_for_lambda_update_from_complex')
+        result = self.update_a_lambda(mgr, **{
+            'environment': {'Variables': {'FOO': 'baz'}},
+            'kms_key_arn': '',
+            'dead_letter_config': {},
+            'tracing_config': {},
+            'tags': {'Foo': 'Baz', 'Bah': 'Bug'}})
+
+        self.assert_items(
+            result,
+            {'Description': 'cloud-custodian lambda policy',
+             'FunctionName': 'custodian-hello-world',
+             'Handler': 'custodian_policy.run',
+             'MemorySize': 512,
+             'Runtime': RUNTIME,
+             'Timeout': 60,
+             'DeadLetterConfig': {'TargetArn': self.sns_arn},
+             'Environment': {'Variables': {'FOO': 'baz'}},
+             'TracingConfig': {'Mode': 'Active'}})
+        tags = mgr.client.list_tags(Resource=result['FunctionArn'])['Tags']
+        self.assert_items(tags, {'Foo': 'Baz', 'Bah': 'Bug'})
 
 
 class PythonArchiveTest(unittest.TestCase):
@@ -546,3 +656,20 @@ def test_fails_if_py_not_available(self):
         py = self.py_with_pyc('foo.py')
         os.unlink(py)
         self.assertRaises(IOError, archive.add_py_file, py+'c')
+
+
+class DiffTags(unittest.TestCase):
+
+    def test_empty(self):
+        assert LambdaManager.diff_tags({}, {}) == ({}, [])
+
+    def test_removal(self):
+        assert LambdaManager.diff_tags({'Foo': 'Bar'}, {}) == ({}, ['Foo'])
+
+    def test_addition(self):
+        assert LambdaManager.diff_tags(
+            {}, {'Foo': 'Bar'}) == ({'Foo': 'Bar'}, [])
+
+    def test_update(self):
+        assert LambdaManager.diff_tags(
+            {'Foo': 'Bar'}, {'Foo': 'Baz'}) == ({'Foo': 'Baz'}, [])