Repository: cloud-custodian/cloud-custodian

Issue:
lambda policy updates not properly detecting no change
This is causing spurious updates to the lambdas, there should only be an attempt to update the lambda if the policy configuration changed or the custodian code was updated. This appears to be a regression. Noticed while working on #1953

(cloud-custodian) realms-slice:cloud-custodian kapil$ custodian run -c munet.yml -s out -v
2018-01-26 10:19:21,727: custodian.cli:DEBUG using default region:us-east-1 from boto
2018-01-26 10:19:22,659: custodian.commands:DEBUG Loaded file munet.yml. Contains 1 policies
2018-01-26 10:19:22,722: custodian.output:DEBUG Storing output with <DirectoryOutput to dir:out/munet>
2018-01-26 10:19:22,722: custodian.policy:INFO Provisioning policy lambda munet
2018-01-26 10:19:22,806: custodian.lambda:DEBUG Created custodian lambda archive size: 1.62mb
2018-01-26 10:19:23,649: custodian.lambda:DEBUG Updating function custodian-munet code
2018-01-26 10:19:29,867: custodian.lambda:DEBUG Updating function: custodian-munet config


(cloud-custodian) realms-slice:cloud-custodian kapil$ custodian run -c munet.yml -s out -v
2018-01-26 10:19:35,266: custodian.cli:DEBUG using default region:us-east-1 from boto
2018-01-26 10:19:36,359: custodian.commands:DEBUG Loaded file munet.yml. Contains 1 policies
2018-01-26 10:19:36,443: custodian.output:DEBUG Storing output with <DirectoryOutput to dir:out/munet>
2018-01-26 10:19:36,443: custodian.policy:INFO Provisioning policy lambda munet
2018-01-26 10:19:36,525: custodian.lambda:DEBUG Created custodian lambda archive size: 1.62mb
2018-01-26 10:19:37,337: custodian.lambda:DEBUG Updating function custodian-munet code
2018-01-26 10:19:44,403: custodian.lambda:DEBUG Updating function: custodian-munet config


Discussion:
something seems off on the checksum calc, the files on disk appear identical

(cloud-custodian) kapil$ md5 tmpjdzmzca1
MD5 (tmpjdzmzca1) = e4d1793fd2eb08d1fbe6e73be1b080b6
(cloud-custodian) kapil$ md5 tmprgpb8yqj
MD5 (tmprgpb8yqj) = e4d1793fd2eb08d1fbe6e73be1b080b6

Resolving patch:
diff --git a/c7n/mu.py b/c7n/mu.py
--- a/c7n/mu.py
+++ b/c7n/mu.py
@@ -229,8 +229,8 @@ def checksum(fh, hasher, blocksize=65536):
 def custodian_archive(packages=None):
     """Create a lambda code archive for running custodian.
 
-    Lambda archive currently always includes `c7n` and `pkg_resources`. Add additional
-    packages in the mode block
+    Lambda archive currently always includes `c7n` and
+    `pkg_resources`. Add additional packages in the mode block.
 
     Example policy that includes additional packages
 
@@ -243,13 +243,13 @@ def custodian_archive(packages=None):
             packages:
               - botocore
 
-    Kwargs:
-        packages (set): List of additional packages to include in the lambda archive.
+    packages: List of additional packages to include in the lambda archive.
+
     """
     modules = {'c7n', 'pkg_resources'}
     if packages:
         modules = filter(None, modules.union(packages))
-    return PythonPackageArchive(*modules)
+    return PythonPackageArchive(*sorted(modules))
 
 
 class LambdaManager(object):
@@ -357,9 +357,23 @@ def logs(self, func, start, end):
 
     @staticmethod
     def delta_function(old_config, new_config):
+        found = False
         for k in new_config:
-            if k not in old_config or new_config[k] != old_config[k]:
-                return True
+            # Vpc needs special handling as a dict with lists
+            if k == 'VpcConfig' and k in old_config and new_config[k]:
+                if set(old_config[k]['SubnetIds']) != set(
+                        new_config[k]['SubnetIds']):
+                    found = True
+                elif set(old_config[k]['SecurityGroupIds']) != set(
+                        new_config[k]['SecurityGroupIds']):
+                    found = True
+            elif k not in old_config:
+                if k in LAMBDA_EMPTY_VALUES and LAMBDA_EMPTY_VALUES[k] == new_config[k]:
+                    continue
+                found = True
+            elif new_config[k] != old_config[k]:
+                found = True
+        return found
 
     @staticmethod
     def diff_tags(old_tags, new_tags):
@@ -389,7 +403,8 @@ def _create_or_update(self, func, role=None, s3_uri=None, qualifier=None):
         changed = False
         if existing:
             old_config = existing['Configuration']
-            if archive.get_checksum() != old_config['CodeSha256']:
+            if archive.get_checksum() != old_config[
+                    'CodeSha256'].encode('ascii'):
                 log.debug("Updating function %s code", func.name)
                 params = dict(FunctionName=func.name, Publish=True)
                 params.update(code_ref)
@@ -574,11 +589,13 @@ def get_config(self):
             'Runtime': self.runtime,
             'Handler': self.handler,
             'Timeout': self.timeout,
-            'DeadLetterConfig': self.dead_letter_config,
+            'TracingConfig': self.tracing_config,
             'Environment': self.environment,
             'KMSKeyArn': self.kms_key_arn,
-            'TracingConfig': self.tracing_config,
+            'DeadLetterConfig': self.dead_letter_config,
+            'VpcConfig': LAMBDA_EMPTY_VALUES['VpcConfig'],
             'Tags': self.tags}
+
         if self.subnets and self.security_groups:
             conf['VpcConfig'] = {
                 'SubnetIds': self.subnets,
@@ -586,6 +603,15 @@ def get_config(self):
         return conf
 
 
+LAMBDA_EMPTY_VALUES = {
+    'Environment': {'Variables': {}},
+    'DeadLetterConfig': {'TargetArn': ''},
+    'TracingConfig': {'Mode': 'PassThrough'},
+    'VpcConfig': {'SubnetIds': [], 'SecurityGroupIds': []},
+    'KMSKeyArn': '',
+}
+
+
 class LambdaFunction(AbstractLambdaFunction):
 
     def __init__(self, func_data, archive):
@@ -637,11 +663,13 @@ def subnets(self):
 
     @property
     def dead_letter_config(self):
-        return self.func_data.get('dead_letter_config', {})
+        return self.func_data.get(
+            'dead_letter_config', LAMBDA_EMPTY_VALUES['DeadLetterConfig'])
 
     @property
     def environment(self):
-        return self.func_data.get('environment', {})
+        return self.func_data.get(
+            'environment', LAMBDA_EMPTY_VALUES['Environment'])
 
     @property
     def kms_key_arn(self):
@@ -649,7 +677,9 @@ def kms_key_arn(self):
 
     @property
     def tracing_config(self):
-        return self.func_data.get('tracing_config', {})
+        # Default
+        return self.func_data.get(
+            'tracing_config', LAMBDA_EMPTY_VALUES['TracingConfig'])
 
     @property
     def tags(self):
@@ -715,11 +745,13 @@ def subnets(self):
 
     @property
     def dead_letter_config(self):
-        return self.policy.data['mode'].get('dead_letter_config', {})
+        return self.policy.data['mode'].get(
+            'dead_letter_config', LAMBDA_EMPTY_VALUES['DeadLetterConfig'])
 
     @property
     def environment(self):
-        return self.policy.data['mode'].get('environment', {})
+        return self.policy.data['mode'].get(
+            'environment', LAMBDA_EMPTY_VALUES['Environment'])
 
     @property
     def kms_key_arn(self):
@@ -727,7 +759,9 @@ def kms_key_arn(self):
 
     @property
     def tracing_config(self):
-        return self.policy.data['mode'].get('tracing_config', {})
+        # Default
+        return self.policy.data['mode'].get(
+            'tracing_config', {'Mode': 'PassThrough'})
 
     @property
     def tags(self):


Test patch:
diff --git a/tests/test_mu.py b/tests/test_mu.py
--- a/tests/test_mu.py
+++ b/tests/test_mu.py
@@ -29,11 +29,11 @@
 import zipfile
 
 from c7n.mu import (
-    custodian_archive, LambdaFunction, LambdaManager, PolicyLambda, PythonPackageArchive,
-    CloudWatchLogSubscription, SNSSubscription)
+    custodian_archive, LambdaFunction, LambdaManager, PolicyLambda,
+    PythonPackageArchive, CloudWatchLogSubscription, SNSSubscription)
 from c7n.policy import Policy
 from c7n.ufuncs import logsub
-from .common import BaseTest, Config, event_data, functional
+from .common import BaseTest, Config, event_data, functional, Bag
 from .data import helloworld
 
 
@@ -58,10 +58,8 @@ def make_func(self, **kw):
             '''def handler(*a, **kw):\n    print("Greetings, program!")''')
         archive.close()
         self.addCleanup(archive.remove)
-
         return LambdaFunction(func_data, archive)
 
-
     def test_publishes_a_lambda(self):
         session_factory = self.replay_flight_data('test_publishes_a_lambda')
         mgr = LambdaManager(session_factory)
@@ -539,6 +537,57 @@ def test_optional_packages(self):
         self.assertTrue('boto3/utils.py' in pl.archive.get_filenames())
         self.assertTrue('botocore/utils.py' in pl.archive.get_filenames())
 
+    def test_delta_config_diff(self):
+        delta = LambdaManager.delta_function
+        self.assertFalse(
+            delta({'VpcConfig': {'SubnetIds': ['s-1', 's-2'],
+                                 'SecurityGroupIds': ['sg-1', 'sg-2']}},
+                  {'VpcConfig': {'SubnetIds': ['s-2', 's-1'],
+                                 'SecurityGroupIds': ['sg-2', 'sg-1']}})
+            )
+        self.assertTrue(
+            delta({'VpcConfig': {'SubnetIds': ['s-1', 's-2'],
+                                 'SecurityGroupIds': ['sg-1', 'sg-2']}},
+                  {'VpcConfig': {'SubnetIds': ['s-2', 's-1'],
+                                 'SecurityGroupIds': ['sg-3', 'sg-1']}})
+            )
+        self.assertFalse(
+            delta({}, {'DeadLetterConfig': {'TargetArn': ''}}))
+
+        self.assertTrue(
+            delta({}, {'DeadLetterConfig': {'TargetArn': 'arn'}}))
+
+        self.assertFalse(
+            delta({}, {'Environment': {'Variables': {}}}))
+
+        self.assertTrue(
+            delta({}, {'Environment': {'Variables': {'k': 'v'}}}))
+
+        self.assertFalse(
+            delta({}, {'KMSKeyArn': ''}))
+
+        self.assertFalse(
+            delta({}, {'VpcConfig': {'SecurityGroupIds': [], 'SubnetIds': []}}))
+
+    def test_config_defaults(self):
+        p = PolicyLambda(Bag({'name': 'hello', 'data': {'mode': {}}}))
+        self.maxDiff = None
+        self.assertEqual(
+            p.get_config(),
+            {'DeadLetterConfig': {'TargetArn': ''},
+             'Description': 'cloud-custodian lambda policy',
+             'Environment': {'Variables': {}},
+             'FunctionName': 'custodian-hello',
+             'Handler': 'custodian_policy.run',
+             'KMSKeyArn': '',
+             'MemorySize': 512,
+             'Role': '',
+             'Runtime': 'python2.7',
+             'Tags': {},
+             'Timeout': 60,
+             'TracingConfig': {'Mode': 'PassThrough'},
+             'VpcConfig': {'SecurityGroupIds': [], 'SubnetIds': []}})
+
 
 class PythonArchiveTest(unittest.TestCase):
 
@@ -555,7 +604,6 @@ def make_open_archive(self, *a, **kw):
     def get_filenames(self, *a, **kw):
         return self.make_archive(*a, **kw).get_filenames()
 
-
     def test_handles_stdlib_modules(self):
         filenames = self.get_filenames('webbrowser')
         self.assertTrue('webbrowser.py' in filenames)