├── .gitignore ├── LICENSE ├── README.rst └── service.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Wellcome Trust 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | terraform-apply-lambda 2 | ====================== 3 | 4 | A way to run ``terraform apply`` inside an AWS Lambda function. 5 | 6 | Motivation 7 | ---------- 8 | 9 | We use `Terraform `_ to manage our infrastructure. 10 | Creating the plan files can be done anywhere -- we use Circle CI -- but 11 | actually applying the plan file requires powerful administrative permissions. 12 | 13 | By running the ``apply`` step inside AWS itself, we don't have to create and 14 | manage keys for these permissions -- and so there's no risk of these keys 15 | being lost or leaked. With a Lambda function, we just use IAM roles, and 16 | never have to do explicit key management. 17 | 18 | Installation 19 | ------------ 20 | 21 | 1. In the Lambda section of your AWS Console, create a new, blank Lambda 22 | function. 23 | 24 | 2. Add an S3 PUT trigger to your Lambda that fires whenever you upload a 25 | new Terraform plan file. 26 | 27 | 3. Select the "Python 2.7" runtime, then copy and paste the code in 28 | ``service.py`` into the code editor. 29 | 30 | License 31 | ------- 32 | 33 | MIT license. 34 | -------------------------------------------------------------------------------- /service.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import subprocess 5 | import urllib 6 | 7 | import boto3 8 | 9 | 10 | # Version of Terraform that we're using 11 | TERRAFORM_VERSION = '0.8.5' 12 | 13 | # Download URL for Terraform 14 | TERRAFORM_DOWNLOAD_URL = ( 15 | 'https://releases.hashicorp.com/terraform/%s/terraform_%s_linux_amd64.zip' 16 | % (TERRAFORM_VERSION, TERRAFORM_VERSION)) 17 | 18 | # Paths where Terraform should be installed 19 | TERRAFORM_DIR = os.path.join('/tmp', 'terraform_%s' % TERRAFORM_VERSION) 20 | TERRAFORM_PATH = os.path.join(TERRAFORM_DIR, 'terraform') 21 | 22 | 23 | def check_call(args): 24 | """Wrapper for subprocess that checks if a process runs correctly, 25 | and if not, prints stdout and stderr. 26 | """ 27 | proc = subprocess.Popen(args, 28 | stdout=subprocess.PIPE, 29 | stderr=subprocess.PIPE, 30 | cwd='/tmp') 31 | stdout, stderr = proc.communicate() 32 | if proc.returncode != 0: 33 | print(stdout) 34 | print(stderr) 35 | raise subprocess.CalledProcessError( 36 | returncode=proc.returncode, 37 | cmd=args) 38 | 39 | 40 | def install_terraform(): 41 | """Install Terraform on the Lambda instance.""" 42 | # Most of a Lambda's disk is read-only, but some transient storage is 43 | # provided in /tmp, so we install Terraform here. This storage may 44 | # persist between invocations, so we skip downloading a new version if 45 | # it already exists. 46 | # http://docs.aws.amazon.com/lambda/latest/dg/lambda-introduction.html 47 | if os.path.exists(TERRAFORM_PATH): 48 | return 49 | 50 | urllib.urlretrieve(TERRAFORM_DOWNLOAD_URL, '/tmp/terraform.zip') 51 | 52 | # Flags: 53 | # '-o' = overwrite existing files without prompting 54 | # '-d' = output directory 55 | check_call(['unzip', '-o', '/tmp/terraform.zip', '-d', TERRAFORM_DIR]) 56 | 57 | check_call([TERRAFORM_PATH, '--version']) 58 | 59 | 60 | def apply_terraform_plan(s3_bucket, path): 61 | """Download a Terraform plan from S3 and run a 'terraform apply'. 62 | 63 | :param s3_bucket: Name of the S3 bucket where the plan is stored. 64 | :param path: Path to the Terraform planfile in the S3 bucket. 65 | 66 | """ 67 | # Although the /tmp directory may persist between invocations, we always 68 | # download a new copy of the planfile, as it may have changed externally. 69 | s3 = boto3.resource('s3') 70 | planfile = s3.Object(s3_bucket, path) 71 | planfile.download_file('/tmp/terraform.plan') 72 | check_call([TERRAFORM_PATH, 'apply', '/tmp/terraform.plan']) 73 | 74 | 75 | def handler(event, context): 76 | s3_bucket = event['Records'][0]['s3']['bucket']['name'] 77 | path = event['Records'][0]['s3']['object']['key'] 78 | 79 | install_terraform() 80 | apply_terraform_plan(s3_bucket=s3_bucket, path=path) 81 | --------------------------------------------------------------------------------