From eebb4e6fe36f3bc4bfdb3c2fdf566bfb62c8e97c Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Fri, 29 Sep 2017 15:10:25 +0200 Subject: [PATCH 01/10] "Enable deploy to s3 and lambda in single command. Introduce creating trigger while deploying lambda" --- .gitignore | 3 + aws_lambda/aws_lambda.py | 123 +++++++++++++++++++---- aws_lambda/project_templates/config.yaml | 19 ++++ scripts/lambda | 14 +++ setup.py | 5 + 5 files changed, 145 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index a65824ab..fedee1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ target/ # Jetbrains/PyCharm project files .idea/ + +# virtualenv +.virtual diff --git a/aws_lambda/aws_lambda.py b/aws_lambda/aws_lambda.py index 836b6334..7cb57d8c 100755 --- a/aws_lambda/aws_lambda.py +++ b/aws_lambda/aws_lambda.py @@ -25,7 +25,6 @@ from .helpers import read from .helpers import timestamp - ARN_PREFIXES = { 'us-gov-west-1': 'aws-us-gov', } @@ -78,7 +77,7 @@ def cleanup_old_versions(src, keep_last_versions): .format(version_number, e.message)) -def deploy(src, requirements=False, local_package=None): +def deploy(src, requirements=False, local_package=None, upload_to_s3=False): """Deploys a new function to AWS Lambda. :param str src: @@ -97,11 +96,14 @@ def deploy(src, requirements=False, local_package=None): # Zip the contents of this folder into a single file and output to the dist # directory. path_to_zip_file = build(src, requirements, local_package) + filename = upload_s3(cfg, path_to_zip_file) if function_exists(cfg, cfg.get('function_name')): - update_function(cfg, path_to_zip_file) + update_function(cfg, path_to_zip_file, upload_to_s3, filename=filename) else: - create_function(cfg, path_to_zip_file) + create_function(cfg, path_to_zip_file, upload_to_s3, filename=filename) + if cfg.get('trigger'): + create_trigger(cfg) def upload(src, requirements=False, local_package=None): @@ -144,8 +146,9 @@ def invoke(src, alt_event=None, verbose=False): # Load environment variables from the config file into the actual # environment. - for key, value in cfg.get('environment_variables').items(): - os.environ[key] = value + if cfg.get('environment_variables').items(): + for key, value in cfg.get('environment_variables').items(): + os.environ[key] = value # Load and parse event file. if alt_event: @@ -223,12 +226,13 @@ def build(src, requirements=False, local_package=None): # for the output filename. function_name = cfg.get('function_name') output_filename = '{0}-{1}.zip'.format(timestamp(), function_name) - + build_config = defaultdict(**cfg.get('build', {})) path_to_temp = mkdtemp(prefix='aws-lambda') pip_install_to_target( path_to_temp, requirements=requirements, local_package=local_package, + **cfg['build'] ) # Hack for Zope. @@ -250,7 +254,6 @@ def build(src, requirements=False, local_package=None): # Allow definition of source code directories we want to build into our # zipped package. - build_config = defaultdict(**cfg.get('build', {})) build_source_directories = build_config.get('source_directories', '') build_source_directories = ( build_source_directories @@ -346,7 +349,7 @@ def _filter_blacklist(package): pip.main(['install', package, '-t', path, '--ignore-installed']) -def pip_install_to_target(path, requirements=False, local_package=None): +def pip_install_to_target(path, requirements=False, local_package=None, **kwargs): """For a given active virtualenv, gather all installed pip packages then copy (re-install) them to the path provided. @@ -371,6 +374,8 @@ def pip_install_to_target(path, requirements=False, local_package=None): print('Gathering requirement packages') data = read('requirements.txt') packages.extend(data.splitlines()) + if 'remote_packages' in kwargs.keys(): + packages.extend(kwargs['remote_packages'].split(',')) if not packages: print('No dependency packages installed!') @@ -406,7 +411,7 @@ def get_client(client, aws_access_key_id, aws_secret_access_key, region=None): ) -def create_function(cfg, path_to_zip_file): +def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): """Register and upload a function to AWS Lambda.""" print('Creating your new Lambda function') @@ -430,12 +435,16 @@ def create_function(cfg, path_to_zip_file): os.environ.get('LAMBDA_FUNCTION_NAME') or cfg.get('function_name') ) print('Creating lambda function with name: {}'.format(func_name)) + code_params_dict = {} + code_params_dict.update([('ZipFile', byte_stream)]) if not upload_to_s3 else code_params_dict.update( + [('S3Bucket', cfg.get('bucket_name')), ('S3Key', filename)] + ) kwargs = { 'FunctionName': func_name, 'Runtime': cfg.get('runtime', 'python2.7'), 'Role': role, 'Handler': cfg.get('handler'), - 'Code': {'ZipFile': byte_stream}, + 'Code': code_params_dict, 'Description': cfg.get('description'), 'Timeout': cfg.get('timeout', 15), 'MemorySize': cfg.get('memory_size', 512), @@ -456,7 +465,7 @@ def create_function(cfg, path_to_zip_file): client.create_function(**kwargs) -def update_function(cfg, path_to_zip_file): +def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): """Updates the code of an existing Lambda function""" print('Updating your Lambda function') @@ -474,12 +483,19 @@ def update_function(cfg, path_to_zip_file): 'lambda', aws_access_key_id, aws_secret_access_key, cfg.get('region'), ) - - client.update_function_code( - FunctionName=cfg.get('function_name'), - ZipFile=byte_stream, - Publish=False, - ) + if not upload_to_s3: + client.update_function_code( + FunctionName=cfg.get('function_name'), + ZipFile=byte_stream, + Publish=False, + ) + else: + client.update_function_code( + FunctionName=cfg.get('function_name'), + S3Bucket=cfg.get('bucket_name'), + S3Key=filename, + Publish=False, + ) kwargs = { 'FunctionName': cfg.get('function_name'), @@ -548,6 +564,7 @@ def upload_s3(cfg, path_to_zip_file): client.put_object(**kwargs) print('Finished uploading {} to S3 bucket {}'.format(func_name, buck_name)) + return filename def function_exists(cfg, function_name): @@ -567,7 +584,7 @@ def function_exists(cfg, function_name): functions.extend([ f['FunctionName'] for f in functions_resp.get('Functions', []) ]) - while('NextMarker' in functions_resp): + while ('NextMarker' in functions_resp): functions_resp = client.list_functions( Marker=functions_resp.get('NextMarker'), ) @@ -575,3 +592,71 @@ def function_exists(cfg, function_name): f['FunctionName'] for f in functions_resp.get('Functions', []) ]) return function_name in functions + + +def create_trigger(cfg): + """Creates trigger and associates it with function function (S3 or CloudWatch)""" + trigger_type = cfg.get('trigger')['type'] + log.info("Creating trigger: {}".format(trigger_type)) + return { + "bucket": create_trigger_s3, + "event": create_trigger_cloud_watch + }[trigger_type](cfg) + + +def create_trigger_s3(cfg): + aws_access_key_id = cfg.get('aws_access_key_id') + aws_secret_access_key = cfg.get('aws_secret_access_key') + s3_client = get_client('s3', aws_access_key_id, aws_secret_access_key, cfg.get('region')) + bucket_notification = s3_client.BucketNotification(cfg.get('trigger')['bucket_name']) + response = bucket_notification.put( + NotificationConfiguration={ + 'LambdaFunctionConfigurations': [ + { + 'LambdaFunctionArn': get_function_arn_name(cfg), + 'Events': cfg.get('trigger')['events'] + } + ] + } + ) + + +def create_trigger_cloud_watch(cfg): + """Creates or updates cron trigger and associates it with lambda function""" + aws_access_key_id = cfg.get('aws_access_key_id') + aws_secret_access_key = cfg.get('aws_secret_access_key') + lambda_client = get_client('lambda', aws_access_key_id, aws_secret_access_key, cfg.get("region")) + events_client = get_client('events', aws_access_key_id, aws_secret_access_key, cfg.get("region")) + function_arn = get_function_arn_name(cfg) + frequency = cfg.get('trigger')['frequency'] + trigger_name = "{}-Trigger".format(cfg.get('function_name')) + + rule_response = events_client.put_rule( + Name=trigger_name, + ScheduleExpression=frequency, + State='DISABLED' + ) + + lambda_client.add_permission( + FunctionName=function_arn, + StatementId="{}-Event".format(trigger_name), + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_response['RuleArn'] + ) + + events_client.put_targets( + Rule=trigger_name, + Targets=[ + { + 'Id': "1", + 'Arn': function_arn + } + ] + ) + + +def get_function_arn_name(cfg): + """Retrieves arn name of an existing function""" + client = get_client('lambda', cfg.get('aws_access_key_id'), cfg.get('aws_secret_access_key'), cfg.get('region')) + return client.get_function(FunctionName=cfg.get('function_name'))['Configuration']['FunctionArn'] diff --git a/aws_lambda/project_templates/config.yaml b/aws_lambda/project_templates/config.yaml index 72bfdab4..46d1c922 100644 --- a/aws_lambda/project_templates/config.yaml +++ b/aws_lambda/project_templates/config.yaml @@ -29,3 +29,22 @@ environment_variables: # Build options build: source_directories: lib # a comma delimited list of directories in your project root that contains source to package. +trigger: + name: 'trigger_name' + type: bucket | event # bucket if lambda is suppose to ba launchede on S3 event, event in case of CloudWatchEvent + # Configuration template below, edit according to your configuration + # S3 configuration + bucket_name: 'bucket_name' + events: + - 's3:ReducedRedundancyLostObject' + - 's3:ObjectCreated:*' + - 's3:ObjectCreated:Put' + - 's3:ObjectCreated:Post' + - 's3:ObjectCreated:Copy' + - 's3:ObjectCreated:CompleteMultipartUpload' + - 's3:ObjectRemoved:*' + - 's3:ObjectRemoved:Delete' + - 's3:ObjectRemoved:DeleteMarkerCreated' + # S3 configuration end + # CloudWatch configuration (cron) + frequency: "rate(1 hour)" # cron(0 12 * * ? *) - daily at 12.00 UTC diff --git a/scripts/lambda b/scripts/lambda index e245ba11..6f7302c6 100755 --- a/scripts/lambda +++ b/scripts/lambda @@ -66,6 +66,19 @@ def deploy(use_requirements, local_package): aws_lambda.deploy(CURRENT_DIR, use_requirements, local_package) +@click.command(help='Deploy your code to S3 and register to lambda.') +@click.option( + '--use-requirements', default=False, is_flag=True, + help='Install all packages defined in requirements.txt', +) +@click.option( + '--local-package', default=None, type=click.Path(), + help='Install local package as well.', multiple=True, +) +def deploy_s3(use_requirements, local_package): + aws_lambda.deploy(CURRENT_DIR, use_requirements, local_package, upload_to_s3=True) + + @click.command(help='Upload your lambda to S3.') @click.option( '--use-requirements', default=False, is_flag=True, @@ -92,6 +105,7 @@ if __name__ == '__main__': cli.add_command(init) cli.add_command(invoke) cli.add_command(deploy) + cli.add_command(deploy_s3) cli.add_command(upload) cli.add_command(build) cli.add_command(cleanup) diff --git a/setup.py b/setup.py index 4381d6ca..5c741fa3 100755 --- a/setup.py +++ b/setup.py @@ -51,4 +51,9 @@ ], test_suite='tests', tests_require=test_requirements, + options={ + 'build_scripts': { + 'executable': "/usr/bin/env python" + } + } ) From 8cb8ac9c860f6fba8cf0953aee8efd1118b0e71f Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Fri, 29 Sep 2017 15:17:26 +0200 Subject: [PATCH 02/10] =?UTF-8?q?Bump=20version:=202.1.1=20=E2=86=92=202.2?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aws_lambda/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_lambda/__init__.py b/aws_lambda/__init__.py index 231c19e0..b2a0fcf8 100755 --- a/aws_lambda/__init__.py +++ b/aws_lambda/__init__.py @@ -2,7 +2,7 @@ # flake8: noqa __author__ = 'Nick Ficano' __email__ = 'nficano@gmail.com' -__version__ = '2.1.1' +__version__ = '2.2.0' from .aws_lambda import deploy, invoke, init, build, upload, cleanup_old_versions diff --git a/setup.cfg b/setup.cfg index 3061c582..b11ebe70 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 2.1.1 +current_version = 2.2.0 parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? serialize = {major}.{minor}.{patch} diff --git a/setup.py b/setup.py index 5c741fa3..a87077e1 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ setup( name='python-lambda', - version='2.1.1', + version='2.2.0', description='The bare minimum for a Python app running on Amazon Lambda.', long_description=readme, author='Nick Ficano', From b1b35e32c1a8bd7776bff8b2a62ffdbe9e65b711 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Tue, 3 Oct 2017 12:29:38 +0200 Subject: [PATCH 03/10] Added support to create role while creating function --- aws_lambda/aws_lambda.py | 159 ++++++++++++++--------- aws_lambda/project_templates/__init__.py | 0 aws_lambda/project_templates/config.yaml | 5 +- aws_lambda/project_templates/policy.json | 35 +++++ 4 files changed, 134 insertions(+), 65 deletions(-) create mode 100644 aws_lambda/project_templates/__init__.py create mode 100644 aws_lambda/project_templates/policy.json diff --git a/aws_lambda/aws_lambda.py b/aws_lambda/aws_lambda.py index 7cb57d8c..88af972d 100755 --- a/aws_lambda/aws_lambda.py +++ b/aws_lambda/aws_lambda.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import print_function -import hashlib import json import logging import os @@ -49,13 +48,7 @@ def cleanup_old_versions(src, keep_last_versions): path_to_config_file = os.path.join(src, 'config.yaml') cfg = read(path_to_config_file, loader=yaml.load) - aws_access_key_id = cfg.get('aws_access_key_id') - aws_secret_access_key = cfg.get('aws_secret_access_key') - - client = get_client( - 'lambda', aws_access_key_id, aws_secret_access_key, - cfg.get('region'), - ) + client = get_client('lambda', cfg) response = client.list_versions_by_function( FunctionName=cfg.get('function_name'), @@ -80,6 +73,8 @@ def cleanup_old_versions(src, keep_last_versions): def deploy(src, requirements=False, local_package=None, upload_to_s3=False): """Deploys a new function to AWS Lambda. + :param upload_to_s3: upload function code to S3 + :param requirements: :param str src: The path to your Lambda ready project (folder must contain a valid config.yaml and handler module (e.g.: service.py). @@ -109,6 +104,7 @@ def deploy(src, requirements=False, local_package=None, upload_to_s3=False): def upload(src, requirements=False, local_package=None): """Uploads a new function to AWS S3. + :param requirements: :param str src: The path to your Lambda ready project (folder must contain a valid config.yaml and handler module (e.g.: service.py). @@ -205,6 +201,7 @@ def init(src, minimal=False): def build(src, requirements=False, local_package=None): """Builds the file bundle. + :param requirements: :param str src: The path to your Lambda ready project (folder must contain a valid config.yaml and handler module (e.g.: service.py). @@ -337,9 +334,11 @@ def _install_packages(path, packages): :param list packages: A list of packages to be installed via pip. """ + def _filter_blacklist(package): blacklist = ['-i', '#', 'Python==', 'python-lambda=='] return all(package.startswith(entry) is False for entry in blacklist) + filtered_packages = filter(_filter_blacklist, packages) for package in filtered_packages: if package.startswith('-e '): @@ -394,20 +393,20 @@ def get_role_name(region, account_id, role): return 'arn:{0}:iam::{1}:role/{2}'.format(prefix, account_id, role) -def get_account_id(aws_access_key_id, aws_secret_access_key): +def get_account_id(cfg): """Query STS for a users' account_id""" - client = get_client('sts', aws_access_key_id, aws_secret_access_key) + client = get_client('sts', cfg) return client.get_caller_identity().get('Account') -def get_client(client, aws_access_key_id, aws_secret_access_key, region=None): +def get_client(client, cfg): """Shortcut for getting an initialized instance of the boto3 client.""" return boto3.client( client, - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - region_name=region, + aws_access_key_id=cfg.get('aws_access_key_id'), + aws_secret_access_key=cfg.get('aws_secret_access_key'), + region_name=cfg.get('region'), ) @@ -416,19 +415,9 @@ def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): print('Creating your new Lambda function') byte_stream = read(path_to_zip_file, binary_file=True) - aws_access_key_id = cfg.get('aws_access_key_id') - aws_secret_access_key = cfg.get('aws_secret_access_key') + role = create_role_for_function(cfg) - account_id = get_account_id(aws_access_key_id, aws_secret_access_key) - role = get_role_name( - cfg.get('region'), account_id, - cfg.get('role', 'lambda_basic_execution'), - ) - - client = get_client( - 'lambda', aws_access_key_id, aws_secret_access_key, - cfg.get('region'), - ) + client = get_client('lambda', cfg) # Do we prefer development variable over config? func_name = ( @@ -470,19 +459,11 @@ def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): print('Updating your Lambda function') byte_stream = read(path_to_zip_file, binary_file=True) - aws_access_key_id = cfg.get('aws_access_key_id') - aws_secret_access_key = cfg.get('aws_secret_access_key') - account_id = get_account_id(aws_access_key_id, aws_secret_access_key) - role = get_role_name( - cfg.get('region'), account_id, - cfg.get('role', 'lambda_basic_execution'), - ) + role = create_role_for_function(cfg) client = get_client( - 'lambda', aws_access_key_id, aws_secret_access_key, - cfg.get('region'), - ) + 'lambda', cfg) if not upload_to_s3: client.update_function_code( FunctionName=cfg.get('function_name'), @@ -529,25 +510,31 @@ def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): ) +def create_role_for_function(cfg): + role = None + role_cfg = cfg.get('role') + if role_cfg is not None: + if not get_role(role_cfg['name'], cfg): + log.info("Creating new role: {}".format(role_cfg['name'])) + role = create_role(role_cfg['name'], cfg) + else: + log.info("Found an existing role, updating policies") + put_role_policy(role_cfg['name'], cfg) + else: + log.info("No roles found. You can create one by updating your configuration and calling $lambda deploy.") + return role + + def upload_s3(cfg, path_to_zip_file): """Upload a function to AWS S3.""" print('Uploading your new Lambda function') - aws_access_key_id = cfg.get('aws_access_key_id') - aws_secret_access_key = cfg.get('aws_secret_access_key') - client = get_client( - 's3', aws_access_key_id, aws_secret_access_key, - cfg.get('region'), - ) + client = get_client('s3', cfg) byte_stream = b'' with open(path_to_zip_file, mode='rb') as fh: byte_stream = fh.read() s3_key_prefix = cfg.get('s3_key_prefix', '/dist') - checksum = hashlib.new('md5', byte_stream).hexdigest() - timestamp = str(time.time()) - filename = '{prefix}{checksum}-{ts}.zip'.format( - prefix=s3_key_prefix, checksum=checksum, ts=timestamp, - ) + filename = '{prefix}.zip'.format(prefix=s3_key_prefix) # Do we prefer development variable over config? buck_name = ( @@ -569,13 +556,7 @@ def upload_s3(cfg, path_to_zip_file): def function_exists(cfg, function_name): """Check whether a function exists or not""" - - aws_access_key_id = cfg.get('aws_access_key_id') - aws_secret_access_key = cfg.get('aws_secret_access_key') - client = get_client( - 'lambda', aws_access_key_id, aws_secret_access_key, - cfg.get('region'), - ) + client = get_client('lambda', cfg) # Need to loop through until we get all of the lambda functions returned. # It appears to be only returning 50 functions at a time. @@ -584,7 +565,7 @@ def function_exists(cfg, function_name): functions.extend([ f['FunctionName'] for f in functions_resp.get('Functions', []) ]) - while ('NextMarker' in functions_resp): + while 'NextMarker' in functions_resp: functions_resp = client.list_functions( Marker=functions_resp.get('NextMarker'), ) @@ -605,11 +586,9 @@ def create_trigger(cfg): def create_trigger_s3(cfg): - aws_access_key_id = cfg.get('aws_access_key_id') - aws_secret_access_key = cfg.get('aws_secret_access_key') - s3_client = get_client('s3', aws_access_key_id, aws_secret_access_key, cfg.get('region')) + s3_client = get_client('s3', cfg) bucket_notification = s3_client.BucketNotification(cfg.get('trigger')['bucket_name']) - response = bucket_notification.put( + bucket_notification.put( NotificationConfiguration={ 'LambdaFunctionConfigurations': [ { @@ -623,10 +602,8 @@ def create_trigger_s3(cfg): def create_trigger_cloud_watch(cfg): """Creates or updates cron trigger and associates it with lambda function""" - aws_access_key_id = cfg.get('aws_access_key_id') - aws_secret_access_key = cfg.get('aws_secret_access_key') - lambda_client = get_client('lambda', aws_access_key_id, aws_secret_access_key, cfg.get("region")) - events_client = get_client('events', aws_access_key_id, aws_secret_access_key, cfg.get("region")) + lambda_client = get_client('lambda', cfg) + events_client = get_client('events', cfg) function_arn = get_function_arn_name(cfg) frequency = cfg.get('trigger')['frequency'] trigger_name = "{}-Trigger".format(cfg.get('function_name')) @@ -658,5 +635,59 @@ def create_trigger_cloud_watch(cfg): def get_function_arn_name(cfg): """Retrieves arn name of an existing function""" - client = get_client('lambda', cfg.get('aws_access_key_id'), cfg.get('aws_secret_access_key'), cfg.get('region')) + client = get_client('lambda', cfg) return client.get_function(FunctionName=cfg.get('function_name'))['Configuration']['FunctionArn'] + + +def get_role(role_name, cfg): + client = get_client("iam", cfg) + response = None + try: + response = client.get_role( + RoleName=role_name + ) + except Exception as e: + pass + return response + + +def create_role(role_name, cfg): + client = get_client('iam', cfg) + response = client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }""" + ) + role_arn = response['Role']['Arn'] + put_role_policy(role_name, cfg) + return role_arn + + +def put_role_policy(role_name, cfg): + client = get_client('iam', cfg) + role_cfg = cfg.get('role') + if os.path.exists(role_cfg['policy_document']): + try: + with open(role_cfg['policy_document']) as policy: + client.put_role_policy( + RoleName=role_name, + PolicyName=role_cfg['policy_name'], + PolicyDocument=json.dumps(json.load(policy)) + ) + except Exception as e: + log.warn(e.message) + else: + log.debug("No policy file found") + + + diff --git a/aws_lambda/project_templates/__init__.py b/aws_lambda/project_templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aws_lambda/project_templates/config.yaml b/aws_lambda/project_templates/config.yaml index 46d1c922..c36e4687 100644 --- a/aws_lambda/project_templates/config.yaml +++ b/aws_lambda/project_templates/config.yaml @@ -4,7 +4,10 @@ function_name: my_lambda_function handler: service.handler description: My first lambda function runtime: python2.7 -# role: lambda_basic_execution +# role: +# name: lambda_basic_execution +# policy_name: lambda_basic_policy +# policy_document: policy.json # S3 upload requires appropriate role with s3:PutObject permission # (ex. basic_s3_upload), a destination bucket, and the key prefix diff --git a/aws_lambda/project_templates/policy.json b/aws_lambda/project_templates/policy.json new file mode 100644 index 00000000..5fe215d9 --- /dev/null +++ b/aws_lambda/project_templates/policy.json @@ -0,0 +1,35 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::ha-db-appsflyer/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:GetObject" + ], + "Resource": [ + "arn:aws:s3:::ha-db-sku-dict/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:*:*:*" + } + ] +} From da41f874adee88639b66e1e5527d637121619c11 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Tue, 3 Oct 2017 12:29:44 +0200 Subject: [PATCH 04/10] =?UTF-8?q?Bump=20version:=202.2.0=20=E2=86=92=202.2?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aws_lambda/__init__.py | 2 +- aws_lambda/project_templates/__init__.py | 0 setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 aws_lambda/project_templates/__init__.py diff --git a/aws_lambda/__init__.py b/aws_lambda/__init__.py index b2a0fcf8..ac244b6c 100755 --- a/aws_lambda/__init__.py +++ b/aws_lambda/__init__.py @@ -2,7 +2,7 @@ # flake8: noqa __author__ = 'Nick Ficano' __email__ = 'nficano@gmail.com' -__version__ = '2.2.0' +__version__ = '2.2.1' from .aws_lambda import deploy, invoke, init, build, upload, cleanup_old_versions diff --git a/aws_lambda/project_templates/__init__.py b/aws_lambda/project_templates/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/setup.cfg b/setup.cfg index b11ebe70..01e3ac36 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 2.2.0 +current_version = 2.2.1 parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? serialize = {major}.{minor}.{patch} diff --git a/setup.py b/setup.py index a87077e1..0595019e 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ setup( name='python-lambda', - version='2.2.0', + version='2.2.1', description='The bare minimum for a Python app running on Amazon Lambda.', long_description=readme, author='Nick Ficano', From 7995d518d6d39e477f629dc65bb9bb31c6fd3f58 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Tue, 3 Oct 2017 14:35:01 +0200 Subject: [PATCH 05/10] Fix optional upload of function to s3 --- aws_lambda/aws_lambda.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/aws_lambda/aws_lambda.py b/aws_lambda/aws_lambda.py index 88af972d..56a97df9 100755 --- a/aws_lambda/aws_lambda.py +++ b/aws_lambda/aws_lambda.py @@ -91,7 +91,7 @@ def deploy(src, requirements=False, local_package=None, upload_to_s3=False): # Zip the contents of this folder into a single file and output to the dist # directory. path_to_zip_file = build(src, requirements, local_package) - filename = upload_s3(cfg, path_to_zip_file) + filename = upload_s3(cfg, path_to_zip_file) if upload_to_s3 else None if function_exists(cfg, cfg.get('function_name')): update_function(cfg, path_to_zip_file, upload_to_s3, filename=filename) @@ -399,15 +399,19 @@ def get_account_id(cfg): return client.get_caller_identity().get('Account') +client_cache = {} + + def get_client(client, cfg): """Shortcut for getting an initialized instance of the boto3 client.""" - - return boto3.client( - client, - aws_access_key_id=cfg.get('aws_access_key_id'), - aws_secret_access_key=cfg.get('aws_secret_access_key'), - region_name=cfg.get('region'), - ) + if client not in client_cache: + client_cache[client] = boto3.client( + client, + aws_access_key_id=cfg.get('aws_access_key_id'), + aws_secret_access_key=cfg.get('aws_secret_access_key'), + region_name=cfg.get('region'), + ) + return client_cache[client] def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): @@ -514,11 +518,12 @@ def create_role_for_function(cfg): role = None role_cfg = cfg.get('role') if role_cfg is not None: - if not get_role(role_cfg['name'], cfg): + if not get_role_arn(role_cfg['name'], cfg): log.info("Creating new role: {}".format(role_cfg['name'])) role = create_role(role_cfg['name'], cfg) else: log.info("Found an existing role, updating policies") + role = get_role_arn(role_cfg['name'], cfg) put_role_policy(role_cfg['name'], cfg) else: log.info("No roles found. You can create one by updating your configuration and calling $lambda deploy.") @@ -639,13 +644,13 @@ def get_function_arn_name(cfg): return client.get_function(FunctionName=cfg.get('function_name'))['Configuration']['FunctionArn'] -def get_role(role_name, cfg): +def get_role_arn(role_name, cfg): client = get_client("iam", cfg) response = None try: response = client.get_role( RoleName=role_name - ) + )['Role']['Arn'] except Exception as e: pass return response @@ -688,6 +693,3 @@ def put_role_policy(role_name, cfg): log.warn(e.message) else: log.debug("No policy file found") - - - From a9b358462bfb2875d54e3d06028339ab0ed64e38 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Tue, 3 Oct 2017 15:01:45 +0200 Subject: [PATCH 06/10] Package name changed to use hbi- prefix --- Makefile | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index ed7320a4..7361bd2e 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ clean-build: rm -fr dist/ rm -fr .eggs/ find . -name '*.egg-info' -exec rm -fr {} + - find . -name '*.egg' -exec rm -f {} + + find . -name '*.egg' -exec rm -fr {} + clean-pyc: find . -name '*.pyc' -exec rm -f {} + diff --git a/setup.py b/setup.py index 0595019e..099b94cf 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ ] setup( - name='python-lambda', + name='hbi-python-lambda', version='2.2.1', description='The bare minimum for a Python app running on Amazon Lambda.', long_description=readme, From 58863b796e0669df28ea810da6f6853a95038396 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Thu, 5 Oct 2017 11:28:10 +0200 Subject: [PATCH 07/10] Custom config file and custom aws profile --- aws_lambda/aws_lambda.py | 186 +++++++++++++---------- aws_lambda/project_templates/config.yaml | 2 +- scripts/lambda | 63 ++++++-- 3 files changed, 158 insertions(+), 93 deletions(-) diff --git a/aws_lambda/aws_lambda.py b/aws_lambda/aws_lambda.py index 56a97df9..e0805d8d 100755 --- a/aws_lambda/aws_lambda.py +++ b/aws_lambda/aws_lambda.py @@ -22,7 +22,6 @@ from .helpers import get_environment_variable_value from .helpers import mkdir from .helpers import read -from .helpers import timestamp ARN_PREFIXES = { 'us-gov-west-1': 'aws-us-gov', @@ -31,11 +30,12 @@ log = logging.getLogger(__name__) -def cleanup_old_versions(src, keep_last_versions): +def cleanup_old_versions(src, keep_last_versions, config_file_path=None, aws_profile=None): """Deletes old deployed versions of the function in AWS Lambda. Won't delete $Latest and any aliased version + :param config_file_path: path to custom config.yaml file :param str src: The path to your Lambda ready project (folder must contain a valid config.yaml and handler module (e.g.: service.py). @@ -45,10 +45,9 @@ def cleanup_old_versions(src, keep_last_versions): if keep_last_versions <= 0: print("Won't delete all versions. Please do this manually") else: - path_to_config_file = os.path.join(src, 'config.yaml') - cfg = read(path_to_config_file, loader=yaml.load) + cfg = read_config_file(config_file_path, src) - client = get_client('lambda', cfg) + client = get_client('lambda', cfg, aws_profile=aws_profile) response = client.list_versions_by_function( FunctionName=cfg.get('function_name'), @@ -70,9 +69,11 @@ def cleanup_old_versions(src, keep_last_versions): .format(version_number, e.message)) -def deploy(src, requirements=False, local_package=None, upload_to_s3=False): +def deploy(src, requirements=False, local_package=None, upload_to_s3=False, config_file_path=None, aws_profile=None): """Deploys a new function to AWS Lambda. + :param aws_profile: aws profile name stored in ~/.aws/credentials + :param config_file_path: path to custom config file :param upload_to_s3: upload function code to S3 :param requirements: :param str src: @@ -83,27 +84,28 @@ def deploy(src, requirements=False, local_package=None, upload_to_s3=False): well (and/or is not available on PyPi) """ # Load and parse the config file. - path_to_config_file = os.path.join(src, 'config.yaml') - cfg = read(path_to_config_file, loader=yaml.load) + cfg = read_config_file(config_file_path, src) # Copy all the pip dependencies required to run your code into a temporary # folder then add the handler file in the root of this directory. # Zip the contents of this folder into a single file and output to the dist # directory. path_to_zip_file = build(src, requirements, local_package) - filename = upload_s3(cfg, path_to_zip_file) if upload_to_s3 else None + filename = upload_s3(cfg, path_to_zip_file, aws_profile=aws_profile) if upload_to_s3 else None - if function_exists(cfg, cfg.get('function_name')): - update_function(cfg, path_to_zip_file, upload_to_s3, filename=filename) + if function_exists(cfg, cfg.get('function_name'), aws_profile=aws_profile): + update_function(cfg, path_to_zip_file, upload_to_s3, filename=filename, aws_profile=aws_profile) else: - create_function(cfg, path_to_zip_file, upload_to_s3, filename=filename) + create_function(cfg, path_to_zip_file, upload_to_s3, filename=filename, aws_profile=aws_profile) if cfg.get('trigger'): - create_trigger(cfg) + create_trigger(cfg, aws_profile=aws_profile) -def upload(src, requirements=False, local_package=None): +def upload(src, requirements=False, local_package=None, config_file_path=None, aws_profile=None): """Uploads a new function to AWS S3. + :param aws_profile: aws profile name stored in ~/.aws/credentials + :param config_file_path: path to custom config.yaml file :param requirements: :param str src: The path to your Lambda ready project (folder must contain a valid @@ -113,8 +115,7 @@ def upload(src, requirements=False, local_package=None): well (and/or is not available on PyPi) """ # Load and parse the config file. - path_to_config_file = os.path.join(src, 'config.yaml') - cfg = read(path_to_config_file, loader=yaml.load) + cfg = read_config_file(config_file_path, src) # Copy all the pip dependencies required to run your code into a temporary # folder then add the handler file in the root of this directory. @@ -122,12 +123,13 @@ def upload(src, requirements=False, local_package=None): # directory. path_to_zip_file = build(src, requirements, local_package) - upload_s3(cfg, path_to_zip_file) + upload_s3(cfg, path_to_zip_file, aws_profile=aws_profile) -def invoke(src, alt_event=None, verbose=False): +def invoke(src, alt_event=None, verbose=False, config_file_path=None): """Simulates a call to your function. + :param config_file_path: path to custom config.yaml file :param str src: The path to your Lambda ready project (folder must contain a valid config.yaml and handler module (e.g.: service.py). @@ -137,13 +139,13 @@ def invoke(src, alt_event=None, verbose=False): Whether to print out verbose details. """ # Load and parse the config file. - path_to_config_file = os.path.join(src, 'config.yaml') - cfg = read(path_to_config_file, loader=yaml.load) + cfg = read_config_file(config_file_path, src) # Load environment variables from the config file into the actual # environment. - if cfg.get('environment_variables').items(): - for key, value in cfg.get('environment_variables').items(): + env_vars = cfg.get('environment_variables') + if env_vars: + for key, value in env_vars.items(): os.environ[key] = value # Load and parse event file. @@ -198,9 +200,14 @@ def init(src, minimal=False): copy(dest_path, src) -def build(src, requirements=False, local_package=None): +def filter_ignore(args): + pass + + +def build(src, requirements=False, local_package=None, config_file_path=None): """Builds the file bundle. + :param config_file_path: path to custom config.yam.file :param requirements: :param str src: The path to your Lambda ready project (folder must contain a valid @@ -210,8 +217,7 @@ def build(src, requirements=False, local_package=None): well (and/or is not available on PyPi) """ # Load and parse the config file. - path_to_config_file = os.path.join(src, 'config.yaml') - cfg = read(path_to_config_file, loader=yaml.load) + cfg = read_config_file(config_file_path, src) # Get the absolute path to the output directory and create it if it doesn't # already exist. @@ -222,7 +228,7 @@ def build(src, requirements=False, local_package=None): # Combine the name of the Lambda function with the current timestamp to use # for the output filename. function_name = cfg.get('function_name') - output_filename = '{0}-{1}.zip'.format(timestamp(), function_name) + output_filename = '{}.zip'.format(function_name) build_config = defaultdict(**cfg.get('build', {})) path_to_temp = mkdtemp(prefix='aws-lambda') pip_install_to_target( @@ -262,11 +268,13 @@ def build(src, requirements=False, local_package=None): ] files = [] - for filename in os.listdir(src): + listdir = os.listdir(src) + # filtered_files = filter(filter_ignore, listdir) + for filename in listdir: if os.path.isfile(filename): if filename == '.DS_Store': continue - if filename == 'config.yaml': + if 'yaml' in filename: continue print('Bundling: %r' % filename) files.append(os.path.join(src, filename)) @@ -292,6 +300,15 @@ def build(src, requirements=False, local_package=None): return path_to_zip_file +def read_config_file(config_file_path, src): + if config_file_path: + path_to_config_file = os.path.join(src, config_file_path) + else: + path_to_config_file = os.path.join(src, 'config.yaml') + cfg = read(path_to_config_file, loader=yaml.load) + return cfg + + def get_callable_handler_function(src, handler): """Tranlate a string of the form "module.function" into a callable function. @@ -336,7 +353,8 @@ def _install_packages(path, packages): """ def _filter_blacklist(package): - blacklist = ['-i', '#', 'Python==', 'python-lambda=='] + blacklist = ['-i', '#', 'Python==', 'python-lambda==', 'hbi-python-lambda==', 'boto3==', 'tox==', 'pip==', + 'setuptools', 'virtualenv==', 'click==', 'argparse==', 'botocore=='] return all(package.startswith(entry) is False for entry in blacklist) filtered_packages = filter(_filter_blacklist, packages) @@ -393,35 +411,34 @@ def get_role_name(region, account_id, role): return 'arn:{0}:iam::{1}:role/{2}'.format(prefix, account_id, role) -def get_account_id(cfg): - """Query STS for a users' account_id""" - client = get_client('sts', cfg) - return client.get_caller_identity().get('Account') - - client_cache = {} -def get_client(client, cfg): +def get_client(client, cfg, aws_profile=None): """Shortcut for getting an initialized instance of the boto3 client.""" if client not in client_cache: - client_cache[client] = boto3.client( - client, - aws_access_key_id=cfg.get('aws_access_key_id'), - aws_secret_access_key=cfg.get('aws_secret_access_key'), - region_name=cfg.get('region'), - ) + if aws_profile: + log.info('Using aws profile name: {}'.format(aws_profile)) + session = boto3.Session(profile_name=aws_profile) + client_cache[client] = session.client(client, region_name=cfg.get('region')) + else: + client_cache[client] = boto3.client( + client, + aws_access_key_id=cfg.get('aws_access_key_id'), + aws_secret_access_key=cfg.get('aws_secret_access_key'), + region_name=cfg.get('region'), + ) return client_cache[client] -def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): +def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None, aws_profile=None): """Register and upload a function to AWS Lambda.""" print('Creating your new Lambda function') byte_stream = read(path_to_zip_file, binary_file=True) - role = create_role_for_function(cfg) + role = create_role_for_function(cfg, aws_profile=aws_profile) - client = get_client('lambda', cfg) + client = get_client('lambda', cfg, aws_profile=aws_profile) # Do we prefer development variable over config? func_name = ( @@ -444,7 +461,7 @@ def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): 'Publish': True, } - if 'environment_variables' in cfg: + if 'environment_variables' in cfg and cfg.get('environment_variables'): kwargs.update( Environment={ 'Variables': { @@ -458,16 +475,15 @@ def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): client.create_function(**kwargs) -def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): +def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None, aws_profile=None): """Updates the code of an existing Lambda function""" print('Updating your Lambda function') byte_stream = read(path_to_zip_file, binary_file=True) - role = create_role_for_function(cfg) + role = create_role_for_function(cfg, aws_profile=aws_profile) - client = get_client( - 'lambda', cfg) + client = get_client('lambda', cfg, aws_profile=aws_profile) if not upload_to_s3: client.update_function_code( FunctionName=cfg.get('function_name'), @@ -514,27 +530,28 @@ def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None): ) -def create_role_for_function(cfg): - role = None +def create_role_for_function(cfg, aws_profile=None): role_cfg = cfg.get('role') if role_cfg is not None: - if not get_role_arn(role_cfg['name'], cfg): + if not get_role_arn(role_cfg['name'], cfg, aws_profile=aws_profile): log.info("Creating new role: {}".format(role_cfg['name'])) - role = create_role(role_cfg['name'], cfg) + role = create_role(role_cfg['name'], cfg, aws_profile=aws_profile) else: log.info("Found an existing role, updating policies") - role = get_role_arn(role_cfg['name'], cfg) - put_role_policy(role_cfg['name'], cfg) + role = get_role_arn(role_cfg['name'], cfg, aws_profile=aws_profile) + put_role_policy(role_cfg['name'], cfg, aws_profile=aws_profile) else: - log.info("No roles found. You can create one by updating your configuration and calling $lambda deploy.") + log.info("""No roles found. Will use role with name: lambda_basic_execution.\n + You can create one by updating your configuration and calling $lambda deploy.""") + role = get_role_arn("lambda_basic_execution", cfg=cfg, aws_profile=aws_profile) return role -def upload_s3(cfg, path_to_zip_file): +def upload_s3(cfg, path_to_zip_file, aws_profile=None): """Upload a function to AWS S3.""" print('Uploading your new Lambda function') - client = get_client('s3', cfg) + client = get_client('s3', cfg, aws_profile=aws_profile) byte_stream = b'' with open(path_to_zip_file, mode='rb') as fh: byte_stream = fh.read() @@ -559,9 +576,9 @@ def upload_s3(cfg, path_to_zip_file): return filename -def function_exists(cfg, function_name): +def function_exists(cfg, function_name, aws_profile=None): """Check whether a function exists or not""" - client = get_client('lambda', cfg) + client = get_client('lambda', cfg, aws_profile=aws_profile) # Need to loop through until we get all of the lambda functions returned. # It appears to be only returning 50 functions at a time. @@ -580,24 +597,24 @@ def function_exists(cfg, function_name): return function_name in functions -def create_trigger(cfg): +def create_trigger(cfg, aws_profile=None): """Creates trigger and associates it with function function (S3 or CloudWatch)""" trigger_type = cfg.get('trigger')['type'] log.info("Creating trigger: {}".format(trigger_type)) return { "bucket": create_trigger_s3, "event": create_trigger_cloud_watch - }[trigger_type](cfg) + }[trigger_type](cfg, aws_profile) -def create_trigger_s3(cfg): - s3_client = get_client('s3', cfg) +def create_trigger_s3(cfg, aws_profile=None): + s3_client = get_client('s3', cfg, aws_profile=aws_profile) bucket_notification = s3_client.BucketNotification(cfg.get('trigger')['bucket_name']) bucket_notification.put( NotificationConfiguration={ 'LambdaFunctionConfigurations': [ { - 'LambdaFunctionArn': get_function_arn_name(cfg), + 'LambdaFunctionArn': get_function_arn_name(cfg, aws_profile=aws_profile), 'Events': cfg.get('trigger')['events'] } ] @@ -605,11 +622,11 @@ def create_trigger_s3(cfg): ) -def create_trigger_cloud_watch(cfg): +def create_trigger_cloud_watch(cfg, aws_profile=None): """Creates or updates cron trigger and associates it with lambda function""" - lambda_client = get_client('lambda', cfg) - events_client = get_client('events', cfg) - function_arn = get_function_arn_name(cfg) + lambda_client = get_client('lambda', cfg, aws_profile=aws_profile) + events_client = get_client('events', cfg, aws_profile=aws_profile) + function_arn = get_function_arn_name(cfg, aws_profile=aws_profile) frequency = cfg.get('trigger')['frequency'] trigger_name = "{}-Trigger".format(cfg.get('function_name')) @@ -619,9 +636,18 @@ def create_trigger_cloud_watch(cfg): State='DISABLED' ) + statement_id = "{}-Event".format(trigger_name) + try: + lambda_client.remove_permission( + FunctionName=function_arn, + StatementId=statement_id, + ) + except Exception: # sanity check if resource is not found. boto uses its own factory to instantiate exceptions + pass # that's why exception clause is so broad + lambda_client.add_permission( FunctionName=function_arn, - StatementId="{}-Event".format(trigger_name), + StatementId=statement_id, Action="lambda:InvokeFunction", Principal="events.amazonaws.com", SourceArn=rule_response['RuleArn'] @@ -638,14 +664,14 @@ def create_trigger_cloud_watch(cfg): ) -def get_function_arn_name(cfg): +def get_function_arn_name(cfg, aws_profile): """Retrieves arn name of an existing function""" - client = get_client('lambda', cfg) + client = get_client('lambda', cfg, aws_profile=aws_profile) return client.get_function(FunctionName=cfg.get('function_name'))['Configuration']['FunctionArn'] -def get_role_arn(role_name, cfg): - client = get_client("iam", cfg) +def get_role_arn(role_name, cfg, aws_profile=None): + client = get_client("iam", cfg, aws_profile=aws_profile) response = None try: response = client.get_role( @@ -656,8 +682,8 @@ def get_role_arn(role_name, cfg): return response -def create_role(role_name, cfg): - client = get_client('iam', cfg) +def create_role(role_name, cfg, aws_profile=None): + client = get_client('iam', cfg, aws_profile=aws_profile) response = client.create_role( RoleName=role_name, AssumeRolePolicyDocument="""{ @@ -674,12 +700,12 @@ def create_role(role_name, cfg): }""" ) role_arn = response['Role']['Arn'] - put_role_policy(role_name, cfg) + put_role_policy(role_name, cfg, aws_profile) return role_arn -def put_role_policy(role_name, cfg): - client = get_client('iam', cfg) +def put_role_policy(role_name, cfg, aws_profile=None): + client = get_client('iam', cfg, aws_profile=aws_profile) role_cfg = cfg.get('role') if os.path.exists(role_cfg['policy_document']): try: diff --git a/aws_lambda/project_templates/config.yaml b/aws_lambda/project_templates/config.yaml index c36e4687..82c57d8a 100644 --- a/aws_lambda/project_templates/config.yaml +++ b/aws_lambda/project_templates/config.yaml @@ -15,7 +15,7 @@ runtime: python2.7 # s3_key_prefix: 'path/to/file/' # if access key and secret are left blank, boto will use the credentials -# defined in the [default] section of ~/.aws/credentials. +# defined in the [default] section of ~/.aws/credentials unless there is no aws_profile defined aws_access_key_id: aws_secret_access_key: diff --git a/scripts/lambda b/scripts/lambda index 6f7302c6..8c2d027e 100755 --- a/scripts/lambda +++ b/scripts/lambda @@ -42,15 +42,23 @@ def init(folder, minimal): '--local-package', default=None, type=click.Path(), help='Install local package as well.', multiple=True, ) -def build(use_requirements, local_package): - aws_lambda.build(CURRENT_DIR, use_requirements, local_package) +@click.option( + '--config-file-path', default=None, type=click.Path(), + help='Path to custom config.yaml file', multiple=False +) +def build(use_requirements, local_package, config_file_path=None): + aws_lambda.build(CURRENT_DIR, use_requirements, local_package, config_file_path) @click.command(help='Run a local test of your function.') @click.option('--event-file', default=None, help='Alternate event file.') @click.option('--verbose', '-v', is_flag=True) -def invoke(event_file, verbose): - aws_lambda.invoke(CURRENT_DIR, event_file, verbose) +@click.option( + '--config-file-path', default=None, type=click.Path(), + help='Path to custom config.yaml file', multiple=False +) +def invoke(event_file, verbose, config_file_path): + aws_lambda.invoke(CURRENT_DIR, event_file, verbose, config_file_path) @click.command(help='Register and deploy your code to lambda.') @@ -62,8 +70,17 @@ def invoke(event_file, verbose): '--local-package', default=None, type=click.Path(), help='Install local package as well.', multiple=True, ) -def deploy(use_requirements, local_package): - aws_lambda.deploy(CURRENT_DIR, use_requirements, local_package) +@click.option( + '--config-file-path', default=None, type=click.Path(), + help='Path to custom config.yaml file', multiple=False +) +@click.option( + '--aws-profile', default=None, type=click.STRING, + help='AWS profile name.', multiple=False +) +def deploy(use_requirements, local_package, config_file_path, aws_profile): + aws_lambda.deploy(CURRENT_DIR, use_requirements, local_package, config_file_path=config_file_path, + aws_profile=aws_profile) @click.command(help='Deploy your code to S3 and register to lambda.') @@ -75,8 +92,17 @@ def deploy(use_requirements, local_package): '--local-package', default=None, type=click.Path(), help='Install local package as well.', multiple=True, ) -def deploy_s3(use_requirements, local_package): - aws_lambda.deploy(CURRENT_DIR, use_requirements, local_package, upload_to_s3=True) +@click.option( + '--config-file-path', default=None, type=click.Path(), + help='Path to custom config.yaml file', multiple=False +) +@click.option( + '--aws-profile', default=None, type=click.STRING, + help='AWS profile name.', multiple=False +) +def deploy_s3(use_requirements, local_package, config_file_path, aws_profile): + aws_lambda.deploy(CURRENT_DIR, use_requirements, local_package, upload_to_s3=True, + config_file_path=config_file_path, aws_profile=aws_profile) @click.command(help='Upload your lambda to S3.') @@ -88,8 +114,17 @@ def deploy_s3(use_requirements, local_package): '--local-package', default=None, type=click.Path(), help='Install local package as well.', multiple=True, ) -def upload(use_requirements, local_package): - aws_lambda.upload(CURRENT_DIR, use_requirements, local_package) +@click.option( + '--config-file-path', default=None, type=click.Path(), + help='Path to custom config.yaml file', multiple=False +) +@click.option( + '--aws-profile', default=None, type=click.STRING, + help='AWS profile name.', multiple=False +) +def upload(use_requirements, local_package, config_file_path, aws_profile): + aws_lambda.upload(CURRENT_DIR, use_requirements, local_package, config_file_path=config_file_path, + aws_profile=aws_profile) @click.command(help='Delete old versions of your functions') @@ -97,8 +132,12 @@ def upload(use_requirements, local_package): '--keep-last', type=int, prompt='Please enter the number of recent versions to keep', ) -def cleanup(keep_last): - aws_lambda.cleanup_old_versions(CURRENT_DIR, keep_last) +@click.option( + '--config-file-path', default=None, type=click.Path(), + help='Path to custom config.yaml file', multiple=False +) +def cleanup(keep_last, config_file_path): + aws_lambda.cleanup_old_versions(CURRENT_DIR, keep_last, config_file_path=config_file_path) if __name__ == '__main__': From 55e4c846e04eb490122a72f3469962bea54d5453 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Thu, 5 Oct 2017 14:49:34 +0200 Subject: [PATCH 08/10] Implemente ignorefile behaviour --- aws_lambda/aws_lambda.py | 18 ++++++++++++------ aws_lambda/project_templates/.lambdaignore | 0 2 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 aws_lambda/project_templates/.lambdaignore diff --git a/aws_lambda/aws_lambda.py b/aws_lambda/aws_lambda.py index e0805d8d..ec238907 100755 --- a/aws_lambda/aws_lambda.py +++ b/aws_lambda/aws_lambda.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import print_function +import glob import json import logging import os @@ -200,10 +201,6 @@ def init(src, minimal=False): copy(dest_path, src) -def filter_ignore(args): - pass - - def build(src, requirements=False, local_package=None, config_file_path=None): """Builds the file bundle. @@ -267,15 +264,24 @@ def build(src, requirements=False, local_package=None, config_file_path=None): d.strip() for d in build_source_directories.split(',') ] + def filter_ignored_files(file_name): + ignore_file_path = os.path.join(src, ".lambdaignore") + if os.path.exists(ignore_file_path): + with open(ignore_file_path) as ignored: + ignored_patterns = map(str.strip, ignored.readlines()) + return all(file_name not in glob.glob(entry) for entry in ignored_patterns) + files = [] listdir = os.listdir(src) - # filtered_files = filter(filter_ignore, listdir) - for filename in listdir: + filtered_files = filter(filter_ignored_files, listdir) + for filename in filtered_files: if os.path.isfile(filename): if filename == '.DS_Store': continue if 'yaml' in filename: continue + if filename == '.lambdaignore': + continue print('Bundling: %r' % filename) files.append(os.path.join(src, filename)) elif os.path.isdir(filename) and filename in source_directories: diff --git a/aws_lambda/project_templates/.lambdaignore b/aws_lambda/project_templates/.lambdaignore new file mode 100644 index 00000000..e69de29b From 4e7eb565703e2f907245f60d8e1f5c1d5d287336 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Fri, 6 Oct 2017 11:41:21 +0200 Subject: [PATCH 09/10] Fixed ignore files bug --- aws_lambda/aws_lambda.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_lambda/aws_lambda.py b/aws_lambda/aws_lambda.py index ec238907..2795a280 100755 --- a/aws_lambda/aws_lambda.py +++ b/aws_lambda/aws_lambda.py @@ -270,6 +270,8 @@ def filter_ignored_files(file_name): with open(ignore_file_path) as ignored: ignored_patterns = map(str.strip, ignored.readlines()) return all(file_name not in glob.glob(entry) for entry in ignored_patterns) + else: + return True files = [] listdir = os.listdir(src) From ee28b2b1d57baf3da0a5a25f8d1863a2ec0c0940 Mon Sep 17 00:00:00 2001 From: Marcin Semczuk Date: Thu, 2 Nov 2017 12:48:54 +0100 Subject: [PATCH 10/10] Added sns topic as a trigger type, workaround to not available policy while creating function in Lambda --- README.rst | 82 ++++++++++++ aws_lambda/aws_lambda.py | 164 ++++++++++++++++++++--- aws_lambda/project_templates/config.yaml | 21 ++- aws_lambda/project_templates/policy.json | 4 +- 4 files changed, 246 insertions(+), 25 deletions(-) diff --git a/README.rst b/README.rst index 39f260ec..b50055ee 100644 --- a/README.rst +++ b/README.rst @@ -112,6 +112,20 @@ When you're ready to deploy your code to Lambda simply run: (pylambda) $ lambda deploy +There is also option to upload your lambda code to S3 and deploy to Lambda, run: + +.. code:: bash + + (pylambda) $ lambda deploy_s3 + +This command will: zip and upload you code to S3 storage basing on your configuration (bucket/s3_key_prefix) and then create (or update) lambda with you zipped code. + +If you want to use different configuration files (e.g dev and prod) simply create one and when deploying lambda add --config-file-path option: + +.. code:: bash + + (pylambda) $ lambda deploy_s3 --config-file-path config-dev.yaml + The deploy script will evaluate your virtualenv and identify your project dependencies. It will package these up along with your handler function to a zip file that it then uploads to AWS Lambda. You can now log into the `AWS Lambda management console `_ to verify the code deployed successfully. @@ -176,6 +190,74 @@ s3_key_prefix: 'path/to/file/' ``` Your role must have `s3:PutObject` permission on the bucket/key that you specify for the upload to work properly. Once you have that set, you can execute `lambda upload` to initiate the transfer. +Trigger +======= +It is possible to configure your lambda to use trigger. Currently supported events are: + * event (scheduled event/cron) + * sns topic + * S3 bucket +Before using trigger you need to provide configuration appropriate to trigger type (use only one that match your needs). +Event +``` +type: event +name: trigger_name +frequency: cron(rate 1 hour) +``` +SNS topic - if given topic doesn't exist it will be created +``` +type: sns +name: sns_topic_name +buckets: + bucket1: + bucket_name: 'bucket_name' + events: + - 's3:ReducedRedundancyLostObject' + - 's3:ObjectCreated:*' + - 's3:ObjectCreated:Put' + - 's3:ObjectCreated:Post' + - 's3:ObjectCreated:Copy' + - 's3:ObjectCreated:CompleteMultipartUpload' + - 's3:ObjectRemoved:*' + - 's3:ObjectRemoved:Delete' + - 's3:ObjectRemoved:DeleteMarkerCreated' + prefix: '' + suffix: '' + bucket2: + bucket_name: 'bucket_name' + events: + - 's3:ReducedRedundancyLostObject' + - 's3:ObjectCreated:*' + - 's3:ObjectCreated:Put' + - 's3:ObjectCreated:Post' + - 's3:ObjectCreated:Copy' + - 's3:ObjectCreated:CompleteMultipartUpload' + - 's3:ObjectRemoved:*' + - 's3:ObjectRemoved:Delete' + - 's3:ObjectRemoved:DeleteMarkerCreated' + prefix: '' + suffix: '' +``` +S3 Bucket +``` +type: bucket +bucket_name'bucket_name' + events: + - 's3:ReducedRedundancyLostObject' + - 's3:ObjectCreated:*' + - 's3:ObjectCreated:Put' + - 's3:ObjectCreated:Post' + - 's3:ObjectCreated:Copy' + - 's3:ObjectCreated:CompleteMultipartUpload' + - 's3:ObjectRemoved:*' + - 's3:ObjectRemoved:Delete' + - 's3:ObjectRemoved:DeleteMarkerCreated' +``` + +Ignore file +=========== +If you don't want to include some files in your zipped lambda package, simply add file patterns to +```.lambdaignore``` file. + Development =========== diff --git a/aws_lambda/aws_lambda.py b/aws_lambda/aws_lambda.py index 2795a280..df759e96 100755 --- a/aws_lambda/aws_lambda.py +++ b/aws_lambda/aws_lambda.py @@ -18,6 +18,7 @@ import botocore import pip import yaml +from pip._vendor.distlib._backport import shutil from .helpers import archive from .helpers import get_environment_variable_value @@ -91,7 +92,7 @@ def deploy(src, requirements=False, local_package=None, upload_to_s3=False, conf # folder then add the handler file in the root of this directory. # Zip the contents of this folder into a single file and output to the dist # directory. - path_to_zip_file = build(src, requirements, local_package) + path_to_zip_file = build(src, requirements, local_package, config_file_path=config_file_path) filename = upload_s3(cfg, path_to_zip_file, aws_profile=aws_profile) if upload_to_s3 else None if function_exists(cfg, cfg.get('function_name'), aws_profile=aws_profile): @@ -305,6 +306,8 @@ def filter_ignored_files(file_name): # Zip them together into a single file. # TODO: Delete temp directory created once the archive has been compiled. path_to_zip_file = archive('./', path_to_dist, output_filename) + shutil.rmtree(path_to_temp) + os.chdir(src) return path_to_zip_file @@ -479,8 +482,18 @@ def create_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None, aw }, }, ) - - client.create_function(**kwargs) + for i in range(5): + try: + if function_exists(cfg, func_name, aws_profile=aws_profile): + continue + else: + client.create_function(**kwargs) + print('Successfully created function {}'.format(func_name)) + except Exception as e: + print("Error while updating function, backing off.") + time.sleep(5) # aws tells that deploys everything almost immediately. Almost... + if i > 3: + raise e def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None, aws_profile=None): @@ -490,7 +503,6 @@ def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None, aw byte_stream = read(path_to_zip_file, binary_file=True) role = create_role_for_function(cfg, aws_profile=aws_profile) - client = get_client('lambda', cfg, aws_profile=aws_profile) if not upload_to_s3: client.update_function_code( @@ -505,7 +517,6 @@ def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None, aw S3Key=filename, Publish=False, ) - kwargs = { 'FunctionName': cfg.get('function_name'), 'Role': role, @@ -529,8 +540,16 @@ def update_function(cfg, path_to_zip_file, upload_to_s3=False, filename=None, aw }, }, ) - - client.update_function_configuration(**kwargs) + for i in range(5): + try: + if client.update_function_configuration(**kwargs): + print("Successfully updated function {}".format(cfg.get('function_name'))) + break + except Exception as e: + print("Error while updating function, backing off.") + time.sleep(5) # aws tells that deploys everything almost immediately. Almost... + if i > 3: + raise e # Publish last, so versions pick up eventually updated description... client.publish_version( @@ -611,7 +630,8 @@ def create_trigger(cfg, aws_profile=None): log.info("Creating trigger: {}".format(trigger_type)) return { "bucket": create_trigger_s3, - "event": create_trigger_cloud_watch + "event": create_trigger_cloud_watch, + "sns": create_sns_trigger }[trigger_type](cfg, aws_profile) @@ -636,7 +656,7 @@ def create_trigger_cloud_watch(cfg, aws_profile=None): events_client = get_client('events', cfg, aws_profile=aws_profile) function_arn = get_function_arn_name(cfg, aws_profile=aws_profile) frequency = cfg.get('trigger')['frequency'] - trigger_name = "{}-Trigger".format(cfg.get('function_name')) + trigger_name = "{}".format(cfg.get('trigger')['name']) rule_response = events_client.put_rule( Name=trigger_name, @@ -651,7 +671,7 @@ def create_trigger_cloud_watch(cfg, aws_profile=None): StatementId=statement_id, ) except Exception: # sanity check if resource is not found. boto uses its own factory to instantiate exceptions - pass # that's why exception clause is so broad + pass # that's why exception clause is so broad lambda_client.add_permission( FunctionName=function_arn, @@ -672,6 +692,96 @@ def create_trigger_cloud_watch(cfg, aws_profile=None): ) +def create_sns_trigger(cfg, aws_profile=None): + sns_client = get_client('sns', cfg, aws_profile) + lambda_client = get_client('lambda', cfg, aws_profile) + s3_client = get_client('s3', cfg, aws_profile) + + function_arn = get_function_arn_name(cfg, aws_profile) + trigger_name = cfg.get('trigger')['name'] + topic_arn = sns_client.create_topic( + Name=trigger_name + )['TopicArn'] + + topic_policy_document = """ + {{ + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + {{ + "Sid": "_s3", + "Effect": "Allow", + "Principal": {{ + "Service": "s3.amazonaws.com" + }}, + "Action": [ + "SNS:Publish" + ], + "Resource": "{topic_arn}", + "Condition": {{ + "StringLike": {{ + "aws:SourceArn": "arn:aws:s3:::*" + }} + }} + }} + ] +}}""" + sns_client.set_topic_attributes( + TopicArn=topic_arn, + AttributeName='Policy', + AttributeValue=topic_policy_document.format(topic_arn=topic_arn) + ) + + sns_client.subscribe( + TopicArn=topic_arn, + Protocol='lambda', + Endpoint=function_arn + ) + + statement_id = "{}-Topic".format(trigger_name) + try: + lambda_client.remove_permission( + FunctionName=function_arn, + StatementId=statement_id, + ) + except Exception: # sanity check if resource is not found. boto uses its own factory to instantiate exceptions + pass # that's why exception clause is so broad + lambda_client.add_permission( + FunctionName=function_arn, + StatementId=statement_id, + Action="lambda:InvokeFunction", + Principal="sns.amazonaws.com", + SourceArn=topic_arn + ) + for bucket in cfg.get('trigger')['buckets']: + bucket_values = bucket.values()[0] + s3_client.put_bucket_notification_configuration( + Bucket=bucket_values['bucket_name'], + NotificationConfiguration={ + 'TopicConfigurations': [ + { + 'TopicArn': topic_arn, + 'Events': bucket_values['events'], + 'Filter': { + 'Key': { + 'FilterRules': [ + { + 'Name': 'prefix', + 'Value': bucket_values['prefix'] + }, + { + 'Name': 'suffix', + 'Value': bucket_values['suffix'] + } + ] + } + } + } + ] + } + ) + + def get_function_arn_name(cfg, aws_profile): """Retrieves arn name of an existing function""" client = get_client('lambda', cfg, aws_profile=aws_profile) @@ -695,27 +805,37 @@ def create_role(role_name, cfg, aws_profile=None): response = client.create_role( RoleName=role_name, AssumeRolePolicyDocument="""{ - "Version": "2012-10-17", - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com" - } - } - ] - }""" + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + }, + { + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +}""" ) role_arn = response['Role']['Arn'] put_role_policy(role_name, cfg, aws_profile) + print("Checking if policy is available.") + policy = client.get_role_policy(RoleName=role_name, PolicyName=cfg.get('role')['policy_name']) + assert policy['ResponseMetadata']['HTTPStatusCode'] == 200 return role_arn def put_role_policy(role_name, cfg, aws_profile=None): client = get_client('iam', cfg, aws_profile=aws_profile) role_cfg = cfg.get('role') - if os.path.exists(role_cfg['policy_document']): + if os.path.exists(os.path.join(os.getcwd(), role_cfg['policy_document'])): try: with open(role_cfg['policy_document']) as policy: client.put_role_policy( diff --git a/aws_lambda/project_templates/config.yaml b/aws_lambda/project_templates/config.yaml index 82c57d8a..2424d5ff 100644 --- a/aws_lambda/project_templates/config.yaml +++ b/aws_lambda/project_templates/config.yaml @@ -34,7 +34,7 @@ build: source_directories: lib # a comma delimited list of directories in your project root that contains source to package. trigger: name: 'trigger_name' - type: bucket | event # bucket if lambda is suppose to ba launchede on S3 event, event in case of CloudWatchEvent + type: bucket | event | sns # bucket if lambda is suppose to ba launchede on S3 event, event in case of CloudWatchEvent, sns if sns topic # Configuration template below, edit according to your configuration # S3 configuration bucket_name: 'bucket_name' @@ -51,3 +51,22 @@ trigger: # S3 configuration end # CloudWatch configuration (cron) frequency: "rate(1 hour)" # cron(0 12 * * ? *) - daily at 12.00 UTC + # SNS configuration + name: 'sns_name' + # NOTE: For sns configuration you must provide a list all of your buckets in following format + # Add more buckets with next number as suffix + buckets: + - bucket1: + bucket_name: 'bucket_name' + events: + - 's3:ReducedRedundancyLostObject' + - 's3:ObjectCreated:*' + - 's3:ObjectCreated:Put' + - 's3:ObjectCreated:Post' + - 's3:ObjectCreated:Copy' + - 's3:ObjectCreated:CompleteMultipartUpload' + - 's3:ObjectRemoved:*' + - 's3:ObjectRemoved:Delete' + - 's3:ObjectRemoved:DeleteMarkerCreated' + prefix: '' + suffix: '' diff --git a/aws_lambda/project_templates/policy.json b/aws_lambda/project_templates/policy.json index 5fe215d9..e24d2f98 100644 --- a/aws_lambda/project_templates/policy.json +++ b/aws_lambda/project_templates/policy.json @@ -9,7 +9,7 @@ "s3:PutObject" ], "Resource": [ - "arn:aws:s3:::ha-db-appsflyer/*" + "arn:aws:s3:::*/*" ] }, { @@ -19,7 +19,7 @@ "s3:GetObject" ], "Resource": [ - "arn:aws:s3:::ha-db-sku-dict/*" + "arn:aws:s3:::*/*" ] }, {