Skip to content

Commit

Permalink
Cleanup lambda_alias (ansible-collections#396)
Browse files Browse the repository at this point in the history
* Update lambda_alias to use AnsibleAWSModule.client

* Update lambda_alias to use fail_json_aws

* Replace custom snake/camel conversion

* lambda_alias replace use of AWSConnection with passing a standard (wrapped) boto3 connection

* Enable Retries

* Fix idempotency when description isn't set.

* Don't throw an exception when attempting to create a new alias in check mode

* Add revision_id to return docs

* Add integration tests

* add changelog
  • Loading branch information
tremble authored and danielcotton committed Nov 23, 2021
1 parent b01aa57 commit 7d59e69
Show file tree
Hide file tree
Showing 8 changed files with 735 additions and 82 deletions.
4 changes: 4 additions & 0 deletions changelogs/fragments/396-lambda_alias.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
minor_changes:
- lambda_alias - use common helper functions to create AWS connections (https://github.com/ansible-collections/community.aws/pull/396).
- lambda_alias - use common helper functions to perform snake_case to CamelCase conversions (https://github.com/ansible-collections/community.aws/pull/396).
- lambda_alias - add retries on common AWS failures (https://github.com/ansible-collections/community.aws/pull/396).
116 changes: 34 additions & 82 deletions plugins/modules/lambda_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@
returned: success
type: str
sample: dev
revision_id:
description: A unique identifier that changes when you update the alias.
returned: success
type: str
sample: 12345678-1234-1234-1234-123456789abc
'''

import re
Expand All @@ -149,67 +154,16 @@
pass # Handled by AnsibleAWSModule

from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict

from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_conn
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import get_aws_connection_info


class AWSConnection:
"""
Create the connection object and client objects as required.
"""

def __init__(self, ansible_obj, resources, boto3_=True):

try:
self.region, self.endpoint, aws_connect_kwargs = get_aws_connection_info(ansible_obj, boto3=boto3_)

self.resource_client = dict()
if not resources:
resources = ['lambda']

resources.append('iam')

for resource in resources:
aws_connect_kwargs.update(dict(region=self.region,
endpoint=self.endpoint,
conn_type='client',
resource=resource
))
self.resource_client[resource] = boto3_conn(ansible_obj, **aws_connect_kwargs)

# if region is not provided, then get default profile/session region
if not self.region:
self.region = self.resource_client['lambda'].meta.region_name

except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
ansible_obj.fail_json(msg="Unable to connect, authorize or access resource: {0}".format(e))

try:
self.account_id = self.resource_client['iam'].get_user()['User']['Arn'].split(':')[4]
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError, ValueError, KeyError, IndexError):
self.account_id = ''

def client(self, resource='lambda'):
return self.resource_client[resource]


def pc(key):
"""
Changes python key into Pascale case equivalent. For example, 'this_function_name' becomes 'ThisFunctionName'.
:param key:
:return:
"""

return "".join([token.capitalize() for token in key.split('_')])
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry


def set_api_params(module, module_params):
"""
Sets module parameters to those expected by the boto3 API.
Sets non-None module parameters to those expected by the boto3 API.
:param module:
:param module_params:
Expand All @@ -221,17 +175,16 @@ def set_api_params(module, module_params):
for param in module_params:
module_param = module.params.get(param, None)
if module_param:
api_params[pc(param)] = module_param
api_params[param] = module_param

return api_params
return snake_dict_to_camel_dict(api_params, capitalize_first=True)


def validate_params(module, aws):
def validate_params(module):
"""
Performs basic parameter validation.
:param module: Ansible module reference
:param aws: AWS client connection
:param module: AnsibleAWSModule reference
:return:
"""

Expand All @@ -254,23 +207,21 @@ def validate_params(module, aws):
return


def get_lambda_alias(module, aws):
def get_lambda_alias(module, client):
"""
Returns the lambda function alias if it exists.
:param module: Ansible module reference
:param aws: AWS client connection
:param module: AnsibleAWSModule
:param client: (wrapped) boto3 lambda client
:return:
"""

client = aws.client('lambda')

# set API parameters
api_params = set_api_params(module, ('function_name', 'name'))

# check if alias exists and get facts
try:
results = client.get_alias(**api_params)
results = client.get_alias(aws_retry=True, **api_params)
except is_boto3_error_code('ResourceNotFoundException'):
results = None
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
Expand All @@ -279,31 +230,33 @@ def get_lambda_alias(module, aws):
return results


def lambda_alias(module, aws):
def lambda_alias(module, client):
"""
Adds, updates or deletes lambda function aliases.
:param module: Ansible module reference
:param aws: AWS client connection
:param module: AnsibleAWSModule
:param client: (wrapped) boto3 lambda client
:return dict:
"""
client = aws.client('lambda')
results = dict()
changed = False
current_state = 'absent'
state = module.params['state']

facts = get_lambda_alias(module, aws)
facts = get_lambda_alias(module, client)
if facts:
current_state = 'present'

if state == 'present':
if current_state == 'present':
snake_facts = camel_dict_to_snake_dict(facts)

# check if alias has changed -- only version and description can change
alias_params = ('function_version', 'description')
for param in alias_params:
if module.params.get(param) != facts.get(pc(param)):
if module.params.get(param) is None:
continue
if module.params.get(param) != snake_facts.get(param):
changed = True
break

Expand All @@ -313,20 +266,20 @@ def lambda_alias(module, aws):

if not module.check_mode:
try:
results = client.update_alias(**api_params)
results = client.update_alias(aws_retry=True, **api_params)
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json(msg='Error updating function alias: {0}'.format(e))
module.fail_json_aws(e, msg='Error updating function alias')

else:
# create new function alias
api_params = set_api_params(module, ('function_name', 'name', 'function_version', 'description'))

try:
if not module.check_mode:
results = client.create_alias(**api_params)
results = client.create_alias(aws_retry=True, **api_params)
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json(msg='Error creating function alias: {0}'.format(e))
module.fail_json_aws(e, msg='Error creating function alias')

else: # state = 'absent'
if current_state == 'present':
Expand All @@ -335,12 +288,12 @@ def lambda_alias(module, aws):

try:
if not module.check_mode:
results = client.delete_alias(**api_params)
results = client.delete_alias(aws_retry=True, **api_params)
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json(msg='Error deleting function alias: {0}'.format(e))
module.fail_json_aws(e, msg='Error deleting function alias')

return dict(changed=changed, **dict(results or facts))
return dict(changed=changed, **dict(results or facts or {}))


def main():
Expand All @@ -364,11 +317,10 @@ def main():
required_together=[],
)

aws = AWSConnection(module, ['lambda'])

validate_params(module, aws)
client = module.client('lambda', retry_decorator=AWSRetry.jittered_backoff())

results = lambda_alias(module, aws)
validate_params(module)
results = lambda_alias(module, client)

module.exit_json(**camel_dict_to_snake_dict(results))

Expand Down
2 changes: 2 additions & 0 deletions tests/integration/targets/lambda_alias/aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cloud/aws
shippable/aws/group3
10 changes: 10 additions & 0 deletions tests/integration/targets/lambda_alias/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
# defaults file for lambda integration test
lambda_function_name: '{{ resource_prefix }}'
# IAM role names have to be less than 64 characters
# The 8 digit identifier at the end of resource_prefix helps determine during
# which test something was created and allows tests to be run in parallel
# Shippable resource_prefixes are in the format shippable-123456-123, so in those cases
# we need both sets of digits to keep the resource name unique
unique_id: "{{ resource_prefix | regex_search('(\\d+-?)(\\d+)$') }}"
lambda_role_name: 'ansible-test-{{ unique_id }}-lambda'
48 changes: 48 additions & 0 deletions tests/integration/targets/lambda_alias/files/mini_lambda.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import json
import os


def handler(event, context):
"""
The handler function is the function which gets called each time
the lambda is run.
"""
# printing goes to the cloudwatch log allowing us to simply debug the lambda if we can find
# the log entry.
print("got event:\n" + json.dumps(event))

# if the name parameter isn't present this can throw an exception
# which will result in an amazon chosen failure from the lambda
# which can be completely fine.

name = event["name"]

# we can use environment variables as part of the configuration of the lambda
# which can change the behaviour of the lambda without needing a new upload

extra = os.environ.get("EXTRA_MESSAGE")
if extra is not None and len(extra) > 0:
greeting = "hello {0}. {1}".format(name, extra)
else:
greeting = "hello " + name

return {"message": greeting}


def main():
"""
This main function will normally never be called during normal
lambda use. It is here for testing the lambda program only.
"""
event = {"name": "james"}
context = None
print(handler(event, context))


if __name__ == '__main__':
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
3 changes: 3 additions & 0 deletions tests/integration/targets/lambda_alias/meta/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies:
- prepare_tests
- setup_ec2
Loading

0 comments on commit 7d59e69

Please sign in to comment.