Initial commit
contains: * Actual template * Example function * Tests * Integration test (make file) * Integration tests (deployed as lambda) * drone CI and CI scripts
This commit is contained in:
commit
36c6d1b283
29
.drone.yml
Normal file
29
.drone.yml
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: deploy-ci
|
||||
image: python:latest
|
||||
commands:
|
||||
- ci/deploy.sh
|
||||
when:
|
||||
branch:
|
||||
exclude:
|
||||
- master
|
||||
|
||||
- name: deploy-prod
|
||||
image: python:latest
|
||||
commands:
|
||||
- make all env=prod
|
||||
when:
|
||||
branch:
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
|
||||
- name: cleanup-env
|
||||
image: python:latest
|
||||
commands:
|
||||
- ci/cleanup.sh
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
*.pyc
|
||||
.*-generated.yaml
|
||||
*.kate-swp
|
||||
test-response.json
|
||||
.coverage
|
||||
environments/prod.env
|
6
.yamllint.yaml
Normal file
6
.yamllint.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
line-length:
|
||||
max: 120
|
22
LICENSE
Normal file
22
LICENSE
Normal file
@ -0,0 +1,22 @@
|
||||
Copyright 2020 Juan Canham
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
68
Makefile
Normal file
68
Makefile
Normal file
@ -0,0 +1,68 @@
|
||||
.PHONY: help all install lint clean test deploy integration-test delete
|
||||
|
||||
ifdef $(env)
|
||||
include environments/$(env).env
|
||||
endif
|
||||
|
||||
CFN := cloudformation
|
||||
CFN_NAME := example-cloud-function
|
||||
BUCKET := $(shell aws s3 ls | grep templates | cut -c 24-)
|
||||
TEST_DATA := 100
|
||||
TEST_RESULT := "2,4,5,10,20,25,50"
|
||||
EMAIL := "cv@juancanham.com"
|
||||
|
||||
help:
|
||||
@echo 'Targets:'
|
||||
@echo ' * all [install clean lint test deploy integration-test]'
|
||||
@echo ' * delete'
|
||||
@echo ' - optionally you can specify an env to load overrides'
|
||||
|
||||
all: install clean lint test deploy integration-test
|
||||
|
||||
install:
|
||||
pip install -r requirements.txt
|
||||
|
||||
clean:
|
||||
rm -f */**/*.pyc
|
||||
rm -f $(CFN)/.*-generated.yaml
|
||||
rm -f test-response.txt
|
||||
|
||||
lint:
|
||||
yamllint .
|
||||
black .
|
||||
pylint --disable line-too-long src/ tests/
|
||||
cfn-lint $(CFN)/*.yaml
|
||||
shellcheck ci/*.sh
|
||||
|
||||
test:
|
||||
pytest
|
||||
|
||||
deploy:
|
||||
aws cloudformation package \
|
||||
--template-file $(CFN)/lambda-sam-template.yaml \
|
||||
--s3-bucket $(BUCKET) \
|
||||
--output-template-file $(CFN)/.$(CFN_NAME)-generated.yaml
|
||||
|
||||
aws cloudformation deploy \
|
||||
--stack-name $(CFN_NAME) \
|
||||
--template-file $(CFN)/.$(CFN_NAME)-generated.yaml \
|
||||
--capabilities CAPABILITY_IAM \
|
||||
--parameter-overrides \
|
||||
NotificationEmail=$(EMAIL) \
|
||||
TestData=$(TEST_DATA) \
|
||||
TestExpected=$(TEST_RESULT)
|
||||
|
||||
delete:
|
||||
aws cloudformation delete-stack --stack-name $(CFN_NAME)
|
||||
|
||||
integration-test:
|
||||
aws lambda invoke \
|
||||
--payload $(TEST_DATA)
|
||||
--log-type Tail \
|
||||
--function-name $$(aws cloudformation describe-stacks \
|
||||
--stack-name $(CFN_NAME) \
|
||||
--output text \
|
||||
--query 'Stacks[0].Outputs[?OutputKey==`LambdaArn`].OutputValue' \
|
||||
) \
|
||||
test-response.txt
|
||||
grep $(TEST_RESULT) test-response.txt
|
33
README.md
Normal file
33
README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Overview
|
||||
|
||||
A simple example project to use SAM/CodeDeploy preTrafficHooks
|
||||
to validate a lambda before finishing the deployment
|
||||
|
||||
## Install, Linting, Testing, Deploying, Integration-Testing
|
||||
|
||||
* Code uses black for formatting and pylint for code-standards
|
||||
* Tests use pytest
|
||||
* Deployment uses cloudformation via [SAM] for simplification
|
||||
* Integration Tests are part of the cloudformation
|
||||
|
||||
See `make help` for more details
|
||||
|
||||
### Deployment
|
||||
|
||||
Environment overrides can be written in environments/<env>.vars
|
||||
then launched with `make all env=<env>`
|
||||
|
||||
#### CI
|
||||
|
||||
This folder has scripts to
|
||||
|
||||
* Deploy and Delete a CI environment
|
||||
* Deploy a permanent environment based on environments/<env>
|
||||
* Cleanup environments after merging
|
||||
|
||||
## Contributing
|
||||
|
||||
Before contributing, please run all tests in a clean environment,
|
||||
e.g run `make all`
|
||||
|
||||
[SAM]: https://github.com/awsdocs/aws-sam-developer-guide/blob/master/doc_source/sam-specification.md
|
23
ci/cleanup.sh
Normal file
23
ci/cleanup.sh
Normal file
@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Delete a deployed environment after a merge
|
||||
# checks parent commits for environment/<env>.env
|
||||
|
||||
PROTECTED=master
|
||||
|
||||
function try_and_delete_environment () {
|
||||
if [ -f "environments/${1}.env" ]; then
|
||||
make delete env="${1}"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
MERGED_ENVIRONMENT="${DRONE_SOURCE_BRANCH##*/}"
|
||||
if [[ ! "$MERGED_ENVIRONMENT" =~ $PROTECTED ]]; then
|
||||
try_and_delete_environment "$MERGED_ENVIRONMENT"
|
||||
|
||||
echo "Checking out parent commits for env file"
|
||||
for PARENT in $(git log --pretty=%P -n 1 | tac -s ' '); do
|
||||
git checkout "$PARENT"
|
||||
try_and_delete_environment "$MERGED_ENVIRONMENT"
|
||||
done
|
||||
fi
|
17
ci/deploy.sh
Normal file
17
ci/deploy.sh
Normal file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
PROTECTED=master
|
||||
|
||||
ENVIRONMENT="${DRONE_BRANCH##*/}"
|
||||
[ -z "$ENVIRONMENT" ] && ENVIRONMENT="$DRONE_TAG"
|
||||
|
||||
if [[ "$ENVIRONMENT" =~ $PROTECTED ]] && [ "$ENVIRONMENT" != "$DRONE_BRANCH" ] ; then
|
||||
echo "Can only deploy to $PROTECTED from named branches"
|
||||
unset ENVIRONMENT
|
||||
fi
|
||||
|
||||
if [ -f "environments/${ENVIRONMENT}.env" ] ; then
|
||||
make all "env=${ENVIRONMENT}"
|
||||
else
|
||||
make all env=ci
|
||||
make delete env=ci
|
||||
fi
|
103
cloudformation/lambda-sam-template.yaml
Normal file
103
cloudformation/lambda-sam-template.yaml
Normal file
@ -0,0 +1,103 @@
|
||||
---
|
||||
AWSTemplateFormatVersion: 2010-09-09
|
||||
Transform: AWS::Serverless-2016-10-31
|
||||
Description: An Lambda function with a monitoring alarm, DLQ & PreTraffic Test
|
||||
Metadata:
|
||||
License: magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
cfn-lint:
|
||||
config:
|
||||
ignore_checks:
|
||||
- W3011 # UpdatePolicy generated by SAM
|
||||
|
||||
Parameters:
|
||||
NotificationEmail:
|
||||
Type: String
|
||||
TestData:
|
||||
Type: String
|
||||
TestExpected:
|
||||
Type: String
|
||||
|
||||
Resources:
|
||||
Function:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: lambda_function.handler
|
||||
Runtime: python3.7
|
||||
CodeUri: src/lambda_function.py
|
||||
DeadLetterQueue:
|
||||
TargetArn: !GetAtt DeadLetterQueue.Arn
|
||||
Type: SQS
|
||||
AutoPublishAlias: live
|
||||
DeploymentPreference:
|
||||
Type: CodeDeployDefault.LambdaAllAtOnce
|
||||
Alarms:
|
||||
- !Ref Alarm
|
||||
Hooks:
|
||||
PreTraffic: !Ref TestFunction
|
||||
|
||||
|
||||
Alarm:
|
||||
Type: AWS::CloudWatch::Alarm
|
||||
Properties:
|
||||
ActionsEnabled: true
|
||||
AlarmActions:
|
||||
- !Ref NotificationTopic
|
||||
AlarmDescription: Notify on failures of cloud-interest-rate-calculator function
|
||||
AlarmName: !Sub ${AWS::StackName}-errors
|
||||
MetricName: Errors
|
||||
Namespace: AWS/Lambda
|
||||
Statistic: Sum
|
||||
Dimensions:
|
||||
- Name: FunctionName
|
||||
Value: !Select [6, !Split [':', !GetAtt Function.Arn]]
|
||||
- Name: Resource
|
||||
Value: !Select [6, !Split [':', !GetAtt Function.Arn]]
|
||||
Period: 300
|
||||
EvaluationPeriods: 1
|
||||
DatapointsToAlarm: 1
|
||||
Threshold: 0
|
||||
ComparisonOperator: GreaterThanThreshold
|
||||
|
||||
NotificationTopic:
|
||||
Type: AWS::SNS::Topic
|
||||
Properties:
|
||||
DisplayName: !Sub ${AWS::StackName}-errors
|
||||
Subscription:
|
||||
- Endpoint: !Ref NotificationEmail
|
||||
Protocol: email
|
||||
Tags:
|
||||
- Key: Type
|
||||
Value: Monitoring
|
||||
|
||||
DeadLetterQueue:
|
||||
Type: AWS::SQS::Queue
|
||||
Properties:
|
||||
Tags:
|
||||
- Key: Type
|
||||
Value: DeadLetterQueue
|
||||
|
||||
TestFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
FunctionName: !Sub CodeDeployHook_PreHook_${AWS::StackName}
|
||||
Handler: lambda_verification.handler
|
||||
Runtime: python3.7
|
||||
CodeUri: src/lambda_verification.py
|
||||
Environment:
|
||||
Variables:
|
||||
CurrentVersion: !Ref Function.Version
|
||||
TestData: !Ref TestData
|
||||
TestExpected: !Ref TestExpected
|
||||
Policies:
|
||||
- Version: 2012-10-17
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action: codedeploy:PutLifecycleEventHookExecutionStatus
|
||||
Resource: "*"
|
||||
- Effect: Allow
|
||||
Action: lambda:InvokeFunction
|
||||
Resource: !Sub "${Function.Arn}:*"
|
||||
|
||||
Outputs:
|
||||
LambdaArn:
|
||||
Value: !GetAtt Function.Arn
|
1
environments/ci.env
Normal file
1
environments/ci.env
Normal file
@ -0,0 +1 @@
|
||||
CFN_NAME = example-cloud-function-ci-$(git rev-parse HEAD | cut -c 1-8)
|
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
awscli==1.*
|
||||
black==19.10b0
|
||||
cfn-lint==0.28.*
|
||||
mock==4.*
|
||||
pylint==2.*
|
||||
pytest==5.*
|
||||
yamllint==1.20.*
|
||||
pytest-cov==2.8.*
|
||||
shellcheck-py==0.7.*
|
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
45
src/lambda_function.py
Normal file
45
src/lambda_function.py
Normal file
@ -0,0 +1,45 @@
|
||||
""" An Simple example lambda"""
|
||||
from typing import Set
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def handler(event, context):
|
||||
"""
|
||||
Lambda event handler takes a number and returns it's factors
|
||||
|
||||
event must have an value key which can be cast to an int
|
||||
|
||||
return value is a comma separated list of numbers as a string
|
||||
"""
|
||||
try:
|
||||
value = int(event["value"])
|
||||
except Exception as error:
|
||||
print(f"Failure\tFailed to read value\t{error}\t{event}")
|
||||
raise error
|
||||
try:
|
||||
factors = get_factors(value)
|
||||
response = stringify(factors)
|
||||
print(f"Success\tCalculated\t{value}\t{response}")
|
||||
return response
|
||||
except Exception as error:
|
||||
print(f"Failure\tFailed to calculate response\t{error}\t{value}")
|
||||
raise error
|
||||
|
||||
|
||||
def get_factors(number: int) -> Set[int]:
|
||||
""" Calculate factors of an integer """
|
||||
result = set()
|
||||
for i in range(1, int(number ** 0.5) + 1):
|
||||
div, mod = divmod(number, i)
|
||||
if mod == 0:
|
||||
result.add(i)
|
||||
result.add(div)
|
||||
return result
|
||||
|
||||
|
||||
def stringify(numbers: Set[int]) -> str:
|
||||
""" Convert a list of numbers to an order comma separated list """
|
||||
numbers = list(numbers)
|
||||
numbers.sort()
|
||||
numbers = [str(i) for i in numbers]
|
||||
response = ",".join(numbers)
|
||||
return response
|
51
src/lambda_verification.py
Normal file
51
src/lambda_verification.py
Normal file
@ -0,0 +1,51 @@
|
||||
""" Generic lambda function tester that uses environmental variables """
|
||||
# pylint: disable=broad-except
|
||||
import os
|
||||
import boto3
|
||||
|
||||
AWSLAMBDA = boto3.client("lambda")
|
||||
CODEDEPLOY = boto3.client("codedeploy")
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def handler(event, context):
|
||||
""" Entry point for test """
|
||||
try:
|
||||
function = os.environ["CurrentVersion"]
|
||||
expected = os.environ["TestExpected"]
|
||||
payload = os.environ["TestData"]
|
||||
|
||||
codedeploy_params = {
|
||||
"deploymentId": event["DeploymentId"],
|
||||
"lifecycleEventHookExecutionId": event["LifecycleEventHookExecutionId"],
|
||||
"status": "Failed",
|
||||
}
|
||||
except Exception as error:
|
||||
print(f"Exception, failed to unpack event, {function}, {error}, event, {event}")
|
||||
raise error
|
||||
|
||||
try:
|
||||
print(f"Info, testing, {function}, {payload}")
|
||||
response = AWSLAMBDA.invoke(FunctionName=function, Payload=payload)
|
||||
response_value = response["Payload"].read()
|
||||
except Exception as error:
|
||||
print(f"Failed, invocation failed, {function}, {error}")
|
||||
CODEDEPLOY.put_lifecycle_event_hook_execution_status(**codedeploy_params)
|
||||
return
|
||||
|
||||
try:
|
||||
assert response_value == expected
|
||||
except Exception as error:
|
||||
print(
|
||||
f"Failed, incorrect response, {function}, {error}, expected, {expected}, actual, {response}"
|
||||
)
|
||||
CODEDEPLOY.put_lifecycle_event_hook_execution_status(**codedeploy_params)
|
||||
return
|
||||
|
||||
try:
|
||||
print(f"Success, notifying codedeploy, {function}, {event['DeploymentId']}")
|
||||
codedeploy_params["status"] = "Succeeded"
|
||||
CODEDEPLOY.put_lifecycle_event_hook_execution_status(**codedeploy_params)
|
||||
return
|
||||
except Exception as error:
|
||||
print(f"Exception, failed to notify codedeploy, {function}, {error}")
|
||||
raise error
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
46
tests/test_lambda_function.py
Normal file
46
tests/test_lambda_function.py
Normal file
@ -0,0 +1,46 @@
|
||||
""" Tests for cloud_interest_rate_calculator """
|
||||
# pylint: disable=no-self-use,missing-function-docstring,missing-class-docstring
|
||||
|
||||
import pytest
|
||||
import mock
|
||||
from src import lambda_function
|
||||
|
||||
|
||||
class TestExampleFunction:
|
||||
@pytest.mark.parametrize("event", [{"value": "One Thousand"}, {"not_value": 1000}])
|
||||
def test_handler_bad_input(self, event):
|
||||
with pytest.raises(Exception):
|
||||
assert lambda_function.handler(event, {})
|
||||
|
||||
@mock.patch("src.lambda_function.get_factors")
|
||||
def test_handler_unexpected_error(self, mock_calculate_interest):
|
||||
event = {"value": "10"}
|
||||
mock_calculate_interest.side_effect = Exception()
|
||||
with pytest.raises(Exception):
|
||||
assert lambda_function.handler(event, {})
|
||||
|
||||
def test_handler(self):
|
||||
event = {"value": "10"}
|
||||
response = lambda_function.handler(event, {})
|
||||
assert response == "1,2,5,10"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial,expected",
|
||||
[
|
||||
(1, {1}),
|
||||
(10, {1, 2, 5, 10}),
|
||||
(60, {1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60}),
|
||||
(64, {1, 2, 4, 8, 16, 32, 64}),
|
||||
],
|
||||
)
|
||||
def test_get_factors(self, initial, expected):
|
||||
response = lambda_function.get_factors(initial)
|
||||
assert response == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial,expected",
|
||||
[([], ""), ([1], "1"), ([2], "2"), ([1, 2, 3], "1,2,3"), ([1, 3, 2], "1,2,3"),],
|
||||
)
|
||||
def test_stringify(self, initial, expected):
|
||||
response = lambda_function.stringify(initial)
|
||||
assert response == expected
|
66
tests/test_lambda_verification.py
Normal file
66
tests/test_lambda_verification.py
Normal file
@ -0,0 +1,66 @@
|
||||
""" Tests for lambda_verification function """
|
||||
# pylint: disable=no-self-use,missing-function-docstring,missing-class-docstring
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import mock
|
||||
from src import lambda_verification
|
||||
|
||||
PAYLOAD = {
|
||||
"DeploymentId": "TestDeployment001",
|
||||
"LifecycleEventHookExecutionId": "LifeCycleTest001",
|
||||
}
|
||||
|
||||
os.environ["CurrentVersion"] = "CurVer"
|
||||
os.environ["TestData"] = '{"value":"10"}'
|
||||
os.environ["TestExpected"] = "2,5"
|
||||
|
||||
|
||||
class TestLambdaVerfication:
|
||||
def test_handler_bad_payload(self):
|
||||
with pytest.raises(Exception):
|
||||
assert lambda_verification.handler({}, {})
|
||||
|
||||
@mock.patch("src.lambda_verification.CODEDEPLOY")
|
||||
@mock.patch("src.lambda_verification.AWSLAMBDA")
|
||||
@pytest.mark.parametrize(
|
||||
"response,expected", [("2,4", "Failed"), ("2,5", "Succeeded")],
|
||||
)
|
||||
def test_handler(self, mock_lambda, mock_codedeploy, response, expected):
|
||||
response_mock = mock.MagicMock()
|
||||
response_mock.read.return_value = response
|
||||
mock_lambda.invoke.return_value = {"Payload": response_mock}
|
||||
|
||||
lambda_verification.handler(PAYLOAD, {})
|
||||
|
||||
mock_codedeploy.put_lifecycle_event_hook_execution_status.assert_called_with(
|
||||
status=expected,
|
||||
deploymentId="TestDeployment001",
|
||||
lifecycleEventHookExecutionId="LifeCycleTest001",
|
||||
)
|
||||
|
||||
@mock.patch("src.lambda_verification.CODEDEPLOY")
|
||||
@mock.patch("src.lambda_verification.AWSLAMBDA")
|
||||
def test_handler_lambda_failure(self, mock_lambda, mock_codedeploy):
|
||||
mock_lambda.invoke.side_effect = Exception()
|
||||
with pytest.raises(Exception):
|
||||
assert lambda_verification.handler(PAYLOAD, {})
|
||||
|
||||
mock_codedeploy.put_lifecycle_event_hook_execution_status.assert_called_with(
|
||||
status="Failed",
|
||||
deploymentId="TestDeployment001",
|
||||
lifecycleEventHookExecutionId="LifeCycleTest001",
|
||||
)
|
||||
|
||||
@mock.patch("src.lambda_verification.CODEDEPLOY")
|
||||
@mock.patch("src.lambda_verification.AWSLAMBDA")
|
||||
def test_handler_codedeploy_failure(self, mock_lambda, mock_codedeploy):
|
||||
response_mock = mock.MagicMock()
|
||||
response_mock.read.return_value = "2,5"
|
||||
mock_lambda.invoke.return_value = {"Payload": response_mock}
|
||||
mock_codedeploy.put_lifecycle_event_hook_execution_status.side_effect = (
|
||||
Exception()
|
||||
)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
assert lambda_verification.handler(PAYLOAD, {})
|
Loading…
Reference in New Issue
Block a user