Skip to content

Commit bfc59a9

Browse files
Merge pull request claranet#34 from claranet/ray/custom-build-script
Support custom build command
2 parents 2c2da68 + 9835cdd commit bfc59a9

File tree

12 files changed

+155
-78
lines changed

12 files changed

+155
-78
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ function name unique per region, for example by setting
7171
| attach\_dead\_letter\_config | Set this to true if using the dead_letter_config variable | string | `"false"` | no |
7272
| attach\_policy | Set this to true if using the policy variable | string | `"false"` | no |
7373
| attach\_vpc\_config | Set this to true if using the vpc_config variable | string | `"false"` | no |
74+
| build\_command | The command that creates the Lambda package zip file | string | `"python build.py '$filename' '$runtime' '$source'"` | no |
75+
| build\_paths | The files or directories used by the build command, to trigger new Lambda package builds whenever build scripts change | list | `<list>` | no |
7476
| dead\_letter\_config | Dead letter configuration for the Lambda function | map | `<map>` | no |
7577
| description | Description of what your Lambda function does | string | `"Managed by Terraform"` | no |
7678
| enable\_cloudwatch\_logs | Set this to false to disable logging your Lambda output to CloudWatch Logs | string | `"true"` | no |

archive.tf

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
locals {
2+
module_relpath = "${substr(path.module, length(path.cwd) + 1, -1)}"
3+
}
4+
15
# Generates a filename for the zip archive based on the contents of the files
26
# in source_path. The filename will change when the source code changes.
37
data "external" "archive" {
48
program = ["python", "${path.module}/hash.py"]
59

610
query = {
7-
runtime = "${var.runtime}"
8-
source_path = "${var.source_path}"
11+
build_command = "${var.build_command}"
12+
build_paths = "${jsonencode(var.build_paths)}"
13+
module_relpath = "${local.module_relpath}"
14+
runtime = "${var.runtime}"
15+
source_path = "${var.source_path}"
916
}
1017
}
1118

@@ -16,7 +23,8 @@ resource "null_resource" "archive" {
1623
}
1724

1825
provisioner "local-exec" {
19-
command = "${lookup(data.external.archive.result, "build_command")}"
26+
command = "${lookup(data.external.archive.result, "build_command")}"
27+
working_dir = "${path.module}"
2028
}
2129
}
2230

@@ -29,8 +37,9 @@ data "external" "built" {
2937
program = ["python", "${path.module}/built.py"]
3038

3139
query = {
32-
build_command = "${lookup(data.external.archive.result, "build_command")}"
33-
filename_old = "${lookup(null_resource.archive.triggers, "filename")}"
34-
filename_new = "${lookup(data.external.archive.result, "filename")}"
40+
build_command = "${lookup(data.external.archive.result, "build_command")}"
41+
filename_old = "${lookup(null_resource.archive.triggers, "filename")}"
42+
filename_new = "${lookup(data.external.archive.result, "filename")}"
43+
module_relpath = "${local.module_relpath}"
3544
}
3645
}

build.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# Builds a zip file from the source_dir or source_file.
22
# Installs dependencies with pip automatically.
33

4-
import base64
5-
import json
64
import os
75
import shutil
86
import subprocess
@@ -105,11 +103,10 @@ def create_zip_file(source_dir, target_file):
105103
root_dir=source_dir,
106104
)
107105

108-
json_payload = bytes.decode(base64.b64decode(sys.argv[1]))
109-
query = json.loads(json_payload)
110-
filename = query['filename']
111-
runtime = query['runtime']
112-
source_path = query['source_path']
106+
107+
filename = sys.argv[1]
108+
runtime = sys.argv[2]
109+
source_path = sys.argv[3]
113110

114111
absolute_filename = os.path.abspath(filename)
115112

builds/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.zip

built.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
build_command = query['build_command']
1313
filename_old = query['filename_old']
1414
filename_new = query['filename_new']
15+
module_relpath = query['module_relpath']
1516

1617
# If the old filename (from the Terraform state) matches the new filename
1718
# (from hash.py) then the source code has not changed and thus the zip file
@@ -29,10 +30,10 @@
2930
# console) then it is possible that Terraform will try to upload
3031
# the missing file. I don't know how to tell if Terraform is going
3132
# to try to upload the file or not, so always ensure the file exists.
32-
subprocess.check_output(build_command, shell=True)
33+
subprocess.check_output(build_command, shell=True, cwd=module_relpath)
3334

3435
# Output the filename to Terraform.
3536
json.dump({
36-
'filename': filename_new,
37+
'filename': module_relpath + '/' + filename_new,
3738
}, sys.stdout, indent=2)
3839
sys.stdout.write('\n')

hash.py

Lines changed: 46 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,14 @@
33
#
44
# Outputs a filename and a command to run if the archive needs to be built.
55

6-
import base64
76
import datetime
87
import errno
98
import hashlib
109
import json
1110
import os
12-
import re
1311
import sys
1412

1513

16-
FILENAME_PREFIX = 'terraform-aws-lambda-'
17-
FILENAME_PATTERN = re.compile(r'^' + FILENAME_PREFIX + r'[0-9a-f]{64}\.zip$')
18-
19-
2014
def abort(message):
2115
"""
2216
Exits with an error message.
@@ -36,24 +30,21 @@ def delete_old_archives():
3630
now = datetime.datetime.now()
3731
delete_older_than = now - datetime.timedelta(days=7)
3832

39-
top = '.terraform'
40-
if os.path.isdir(top):
41-
for name in os.listdir(top):
42-
if FILENAME_PATTERN.match(name):
43-
path = os.path.join(top, name)
44-
try:
45-
file_modified = datetime.datetime.fromtimestamp(
46-
os.path.getmtime(path)
47-
)
48-
if file_modified < delete_older_than:
49-
os.remove(path)
50-
except OSError as error:
51-
if error.errno == errno.ENOENT:
52-
# Ignore "not found" errors as they are probably race
53-
# conditions between multiple usages of this module.
54-
pass
55-
else:
56-
raise
33+
for name in os.listdir('builds'):
34+
if name.endswith('.zip'):
35+
try:
36+
file_modified = datetime.datetime.fromtimestamp(
37+
os.path.getmtime(name)
38+
)
39+
if file_modified < delete_older_than:
40+
os.remove(name)
41+
except OSError as error:
42+
if error.errno == errno.ENOENT:
43+
# Ignore "not found" errors as they are probably race
44+
# conditions between multiple usages of this module.
45+
pass
46+
else:
47+
raise
5748

5849

5950
def list_files(top_path):
@@ -72,22 +63,23 @@ def list_files(top_path):
7263
return results
7364

7465

75-
def generate_content_hash(source_path):
66+
def generate_content_hash(source_paths):
7667
"""
77-
Generate a content hash of the source path.
68+
Generate a content hash of the source paths.
7869
7970
"""
8071

8172
sha256 = hashlib.sha256()
8273

83-
if os.path.isdir(source_path):
84-
source_dir = source_path
85-
for source_file in list_files(source_dir):
74+
for source_path in source_paths:
75+
if os.path.isdir(source_path):
76+
source_dir = source_path
77+
for source_file in list_files(source_dir):
78+
update_hash(sha256, source_dir, source_file)
79+
else:
80+
source_dir = os.path.dirname(source_path)
81+
source_file = source_path
8682
update_hash(sha256, source_dir, source_file)
87-
else:
88-
source_dir = os.path.dirname(source_path)
89-
source_file = source_path
90-
update_hash(sha256, source_dir, source_file)
9183

9284
return sha256
9385

@@ -109,51 +101,42 @@ def update_hash(hash_obj, file_root, file_path):
109101
hash_obj.update(data)
110102

111103

112-
113-
current_dir = os.path.dirname(__file__)
114-
115104
# Parse the query.
116-
if len(sys.argv) > 1 and sys.argv[1] == '--test':
117-
query = {
118-
'runtime': 'python3.6',
119-
'source_path': os.path.join(current_dir, 'tests', 'python3-pip', 'lambda'),
120-
}
121-
else:
122-
query = json.load(sys.stdin)
105+
query = json.load(sys.stdin)
106+
build_command = query['build_command']
107+
build_paths = json.loads(query['build_paths'])
108+
module_relpath = query['module_relpath']
123109
runtime = query['runtime']
124110
source_path = query['source_path']
125111

126112
# Validate the query.
127113
if not source_path:
128114
abort('source_path must be set.')
129115

116+
# Change working directory to the module path
117+
# so references to build.py will work.
118+
os.chdir(module_relpath)
119+
130120
# Generate a hash based on file names and content. Also use the
131-
# runtime value and content of build.py because they can have an
132-
# effect on the resulting archive.
133-
content_hash = generate_content_hash(source_path)
121+
# runtime value, build command, and content of the build paths
122+
# because they can have an effect on the resulting archive.
123+
content_hash = generate_content_hash([source_path] + build_paths)
134124
content_hash.update(runtime.encode())
135-
with open(os.path.join(current_dir, 'build.py'), 'rb') as build_script_file:
136-
content_hash.update(build_script_file.read())
125+
content_hash.update(build_command.encode())
137126

138127
# Generate a unique filename based on the hash.
139-
filename = '.terraform/{prefix}{content_hash}.zip'.format(
140-
prefix=FILENAME_PREFIX,
128+
filename = 'builds/{content_hash}.zip'.format(
141129
content_hash=content_hash.hexdigest(),
142130
)
143131

144-
# Determine the command to run if Terraform wants to build a new archive.
145-
build_command = "python {build_script} {build_data}".format(
146-
build_script=os.path.join(current_dir, 'build.py'),
147-
build_data=bytes.decode(base64.b64encode(str.encode(
148-
json.dumps({
149-
'filename': filename,
150-
'source_path': source_path,
151-
'runtime': runtime,
152-
})
153-
)
154-
),
155-
)
156-
)
132+
# Replace variables in the build command with calculated values.
133+
replacements = {
134+
'$filename': filename,
135+
'$runtime': runtime,
136+
'$source': source_path,
137+
}
138+
for old, new in replacements.items():
139+
build_command = build_command.replace(old, new)
157140

158141
# Delete previous archives.
159142
delete_old_archives()

tests/.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform 0.11.11
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/bash
2+
#
3+
# Compiles a Python package into a zip deployable on AWS Lambda.
4+
#
5+
# - Builds Python dependencies into the package, using a Docker image to
6+
# correctly build native extensions
7+
# - Able to be used with the terraform-aws-lambda module
8+
#
9+
# Dependencies:
10+
#
11+
# - Docker
12+
#
13+
# Usage:
14+
#
15+
# $ ./build.sh <output-zip-filename> <runtime> <source-path>
16+
17+
set -euo pipefail
18+
19+
# Read variables from command line arguments
20+
FILENAME=$1
21+
RUNTIME=$2
22+
SOURCE_PATH=$3
23+
24+
# Convert to absolute paths
25+
SOURCE_DIR=$(cd "$SOURCE_PATH" && pwd)
26+
ZIP_DIR=$(cd "$(dirname "$FILENAME")" && pwd)
27+
ZIP_NAME=$(basename "$FILENAME")
28+
29+
# Install dependencies, using a Docker image to correctly build native extensions
30+
docker run --rm -t -v "$SOURCE_DIR:/src" -v "$ZIP_DIR:/out" lambci/lambda:build-$RUNTIME sh -c "
31+
cp -r /src /build &&
32+
cd /build &&
33+
pip install --progress-bar off -r requirements.txt -t . &&
34+
chmod -R 755 . &&
35+
zip -r /out/$ZIP_NAME * &&
36+
chown \$(stat -c '%u:%g' /out) /out/$ZIP_NAME
37+
"
38+
39+
echo "Created $FILENAME from $SOURCE_PATH"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def lambda_handler(event, context):
2+
print('importing numpy package')
3+
import numpy as np
4+
print('checking numpy works correctly')
5+
assert np.array_equal(np.array([1, 2]) + 3, np.array([4, 5]))
6+
return 'test passed'
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# numpy has native extensions, needs a custom build script to
2+
# install correctly if your host OS differs to Lambda OS
3+
numpy

0 commit comments

Comments
 (0)