Skip to content

Commit ca2e6a2

Browse files
feat(s3-deployment): create DeployTimeSubstitutedFile to allow substitutions in file (#25876)
Closes #1461 The `DeployTimeSubstitutedFile` construct allows you to upload a file and specify substitutions to be made in it, which will be resolved during deployment. For example, if you wanted to create a REST API from a Swagger file spec but want to reference other CDK resources in your API spec, you can now do so in-line: ```ts const bucket: Bucket; const myLambdaFunction: lambda.Function; const deployment = new s3deploy.DeployTimeSubstitutedFile(this, 'MyApiFile', { source: 'my-swagger-spec.yaml', destinationBucket: bucket, substitutions: { xxxx: myLambdaFunction.functionArn, yyyy: 'mySubstitution', }, }); const api = new apigateway.SpecRestApi(this, 'books-api', { apiDefinition: apigateway.ApiDefinition.fromBucket(deployment.bucket, deployment.objectKey), }); ``` Where 'xxxx' and 'yyyy' are the examples of placeholder text you can add in your local file spec to be substituted by surrounding the placeholder with double curly braces, for example writing: `{{ xxxx }}` in your file where you want a substitution. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 4c9016a commit ca2e6a2

File tree

21 files changed

+4403
-1
lines changed

21 files changed

+4403
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import contextlib
2+
import json
3+
import logging
4+
import os
5+
import shutil
6+
import subprocess
7+
import tempfile
8+
from urllib.request import Request, urlopen
9+
from uuid import uuid4
10+
from zipfile import ZipFile
11+
12+
import boto3
13+
14+
logger = logging.getLogger()
15+
logger.setLevel(logging.INFO)
16+
17+
cloudfront = boto3.client('cloudfront')
18+
s3 = boto3.client('s3')
19+
20+
CFN_SUCCESS = "SUCCESS"
21+
CFN_FAILED = "FAILED"
22+
ENV_KEY_MOUNT_PATH = "MOUNT_PATH"
23+
ENV_KEY_SKIP_CLEANUP = "SKIP_CLEANUP"
24+
25+
AWS_CLI_CONFIG_FILE = "/tmp/aws_cli_config"
26+
CUSTOM_RESOURCE_OWNER_TAG = "aws-cdk:cr-owned"
27+
28+
os.putenv('AWS_CONFIG_FILE', AWS_CLI_CONFIG_FILE)
29+
30+
def handler(event, context):
31+
32+
def cfn_error(message=None):
33+
logger.error("| cfn_error: %s" % message)
34+
cfn_send(event, context, CFN_FAILED, reason=message, physicalResourceId=event.get('PhysicalResourceId', None))
35+
36+
37+
try:
38+
# We are not logging ResponseURL as this is a pre-signed S3 URL, and could be used to tamper
39+
# with the response CloudFormation sees from this Custom Resource execution.
40+
logger.info({ key:value for (key, value) in event.items() if key != 'ResponseURL'})
41+
42+
# cloudformation request type (create/update/delete)
43+
request_type = event['RequestType']
44+
45+
# extract resource properties
46+
props = event['ResourceProperties']
47+
old_props = event.get('OldResourceProperties', {})
48+
physical_id = event.get('PhysicalResourceId', None)
49+
50+
try:
51+
source_bucket_names = props['SourceBucketNames']
52+
source_object_keys = props['SourceObjectKeys']
53+
source_markers = props.get('SourceMarkers', None)
54+
dest_bucket_name = props['DestinationBucketName']
55+
dest_bucket_prefix = props.get('DestinationBucketKeyPrefix', '')
56+
extract = props.get('Extract', 'true') == 'true'
57+
retain_on_delete = props.get('RetainOnDelete', "true") == "true"
58+
distribution_id = props.get('DistributionId', '')
59+
user_metadata = props.get('UserMetadata', {})
60+
system_metadata = props.get('SystemMetadata', {})
61+
prune = props.get('Prune', 'true').lower() == 'true'
62+
exclude = props.get('Exclude', [])
63+
include = props.get('Include', [])
64+
sign_content = props.get('SignContent', 'false').lower() == 'true'
65+
66+
# backwards compatibility - if "SourceMarkers" is not specified,
67+
# assume all sources have an empty market map
68+
if source_markers is None:
69+
source_markers = [{} for i in range(len(source_bucket_names))]
70+
71+
default_distribution_path = dest_bucket_prefix
72+
if not default_distribution_path.endswith("/"):
73+
default_distribution_path += "/"
74+
if not default_distribution_path.startswith("/"):
75+
default_distribution_path = "/" + default_distribution_path
76+
default_distribution_path += "*"
77+
78+
distribution_paths = props.get('DistributionPaths', [default_distribution_path])
79+
except KeyError as e:
80+
cfn_error("missing request resource property %s. props: %s" % (str(e), props))
81+
return
82+
83+
# configure aws cli options after resetting back to the defaults for each request
84+
if os.path.exists(AWS_CLI_CONFIG_FILE):
85+
os.remove(AWS_CLI_CONFIG_FILE)
86+
if sign_content:
87+
aws_command("configure", "set", "default.s3.payload_signing_enabled", "true")
88+
89+
# treat "/" as if no prefix was specified
90+
if dest_bucket_prefix == "/":
91+
dest_bucket_prefix = ""
92+
93+
s3_source_zips = list(map(lambda name, key: "s3://%s/%s" % (name, key), source_bucket_names, source_object_keys))
94+
s3_dest = "s3://%s/%s" % (dest_bucket_name, dest_bucket_prefix)
95+
old_s3_dest = "s3://%s/%s" % (old_props.get("DestinationBucketName", ""), old_props.get("DestinationBucketKeyPrefix", ""))
96+
97+
98+
# obviously this is not
99+
if old_s3_dest == "s3:///":
100+
old_s3_dest = None
101+
102+
logger.info("| s3_dest: %s" % s3_dest)
103+
logger.info("| old_s3_dest: %s" % old_s3_dest)
104+
105+
# if we are creating a new resource, allocate a physical id for it
106+
# otherwise, we expect physical id to be relayed by cloudformation
107+
if request_type == "Create":
108+
physical_id = "aws.cdk.s3deployment.%s" % str(uuid4())
109+
else:
110+
if not physical_id:
111+
cfn_error("invalid request: request type is '%s' but 'PhysicalResourceId' is not defined" % request_type)
112+
return
113+
114+
# delete or create/update (only if "retain_on_delete" is false)
115+
if request_type == "Delete" and not retain_on_delete:
116+
if not bucket_owned(dest_bucket_name, dest_bucket_prefix):
117+
aws_command("s3", "rm", s3_dest, "--recursive")
118+
119+
# if we are updating without retention and the destination changed, delete first
120+
if request_type == "Update" and not retain_on_delete and old_s3_dest != s3_dest:
121+
if not old_s3_dest:
122+
logger.warn("cannot delete old resource without old resource properties")
123+
return
124+
125+
aws_command("s3", "rm", old_s3_dest, "--recursive")
126+
127+
if request_type == "Update" or request_type == "Create":
128+
s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include, source_markers, extract)
129+
130+
if distribution_id:
131+
cloudfront_invalidate(distribution_id, distribution_paths)
132+
133+
cfn_send(event, context, CFN_SUCCESS, physicalResourceId=physical_id, responseData={
134+
# Passing through the ARN sequences dependencees on the deployment
135+
'DestinationBucketArn': props.get('DestinationBucketArn'),
136+
'SourceObjectKeys': props.get('SourceObjectKeys'),
137+
})
138+
except KeyError as e:
139+
cfn_error("invalid request. Missing key %s" % str(e))
140+
except Exception as e:
141+
logger.exception(e)
142+
cfn_error(str(e))
143+
144+
#---------------------------------------------------------------------------------------------------
145+
# populate all files from s3_source_zips to a destination bucket
146+
def s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include, source_markers, extract):
147+
# list lengths are equal
148+
if len(s3_source_zips) != len(source_markers):
149+
raise Exception("'source_markers' and 's3_source_zips' must be the same length")
150+
151+
# create a temporary working directory in /tmp or if enabled an attached efs volume
152+
if ENV_KEY_MOUNT_PATH in os.environ:
153+
workdir = os.getenv(ENV_KEY_MOUNT_PATH) + "/" + str(uuid4())
154+
os.mkdir(workdir)
155+
else:
156+
workdir = tempfile.mkdtemp()
157+
158+
logger.info("| workdir: %s" % workdir)
159+
160+
# create a directory into which we extract the contents of the zip file
161+
contents_dir=os.path.join(workdir, 'contents')
162+
os.mkdir(contents_dir)
163+
164+
try:
165+
# download the archive from the source and extract to "contents"
166+
for i in range(len(s3_source_zips)):
167+
s3_source_zip = s3_source_zips[i]
168+
markers = source_markers[i]
169+
170+
if extract:
171+
archive=os.path.join(workdir, str(uuid4()))
172+
logger.info("archive: %s" % archive)
173+
aws_command("s3", "cp", s3_source_zip, archive)
174+
logger.info("| extracting archive to: %s\n" % contents_dir)
175+
logger.info("| markers: %s" % markers)
176+
extract_and_replace_markers(archive, contents_dir, markers)
177+
else:
178+
logger.info("| copying archive to: %s\n" % contents_dir)
179+
aws_command("s3", "cp", s3_source_zip, contents_dir)
180+
181+
# sync from "contents" to destination
182+
183+
s3_command = ["s3", "sync"]
184+
185+
if prune:
186+
s3_command.append("--delete")
187+
188+
if exclude:
189+
for filter in exclude:
190+
s3_command.extend(["--exclude", filter])
191+
192+
if include:
193+
for filter in include:
194+
s3_command.extend(["--include", filter])
195+
196+
s3_command.extend([contents_dir, s3_dest])
197+
s3_command.extend(create_metadata_args(user_metadata, system_metadata))
198+
aws_command(*s3_command)
199+
finally:
200+
if not os.getenv(ENV_KEY_SKIP_CLEANUP):
201+
shutil.rmtree(workdir)
202+
203+
#---------------------------------------------------------------------------------------------------
204+
# invalidate files in the CloudFront distribution edge caches
205+
def cloudfront_invalidate(distribution_id, distribution_paths):
206+
invalidation_resp = cloudfront.create_invalidation(
207+
DistributionId=distribution_id,
208+
InvalidationBatch={
209+
'Paths': {
210+
'Quantity': len(distribution_paths),
211+
'Items': distribution_paths
212+
},
213+
'CallerReference': str(uuid4()),
214+
})
215+
# by default, will wait up to 10 minutes
216+
cloudfront.get_waiter('invalidation_completed').wait(
217+
DistributionId=distribution_id,
218+
Id=invalidation_resp['Invalidation']['Id'])
219+
220+
#---------------------------------------------------------------------------------------------------
221+
# set metadata
222+
def create_metadata_args(raw_user_metadata, raw_system_metadata):
223+
if len(raw_user_metadata) == 0 and len(raw_system_metadata) == 0:
224+
return []
225+
226+
format_system_metadata_key = lambda k: k.lower()
227+
format_user_metadata_key = lambda k: k.lower()
228+
229+
system_metadata = { format_system_metadata_key(k): v for k, v in raw_system_metadata.items() }
230+
user_metadata = { format_user_metadata_key(k): v for k, v in raw_user_metadata.items() }
231+
232+
flatten = lambda l: [item for sublist in l for item in sublist]
233+
system_args = flatten([[f"--{k}", v] for k, v in system_metadata.items()])
234+
user_args = ["--metadata", json.dumps(user_metadata, separators=(',', ':'))] if len(user_metadata) > 0 else []
235+
236+
return system_args + user_args + ["--metadata-directive", "REPLACE"]
237+
238+
#---------------------------------------------------------------------------------------------------
239+
# executes an "aws" cli command
240+
def aws_command(*args):
241+
aws="/opt/awscli/aws" # from AwsCliLayer
242+
logger.info("| aws %s" % ' '.join(args))
243+
subprocess.check_call([aws] + list(args))
244+
245+
#---------------------------------------------------------------------------------------------------
246+
# sends a response to cloudformation
247+
def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None):
248+
249+
responseUrl = event['ResponseURL']
250+
251+
responseBody = {}
252+
responseBody['Status'] = responseStatus
253+
responseBody['Reason'] = reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name)
254+
responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name
255+
responseBody['StackId'] = event['StackId']
256+
responseBody['RequestId'] = event['RequestId']
257+
responseBody['LogicalResourceId'] = event['LogicalResourceId']
258+
responseBody['NoEcho'] = noEcho
259+
responseBody['Data'] = responseData
260+
261+
body = json.dumps(responseBody)
262+
logger.info("| response body:\n" + body)
263+
264+
headers = {
265+
'content-type' : '',
266+
'content-length' : str(len(body))
267+
}
268+
269+
try:
270+
request = Request(responseUrl, method='PUT', data=bytes(body.encode('utf-8')), headers=headers)
271+
with contextlib.closing(urlopen(request)) as response:
272+
logger.info("| status code: " + response.reason)
273+
except Exception as e:
274+
logger.error("| unable to send response to CloudFormation")
275+
logger.exception(e)
276+
277+
278+
#---------------------------------------------------------------------------------------------------
279+
# check if bucket is owned by a custom resource
280+
# if it is then we don't want to delete content
281+
def bucket_owned(bucketName, keyPrefix):
282+
tag = CUSTOM_RESOURCE_OWNER_TAG
283+
if keyPrefix != "":
284+
tag = tag + ':' + keyPrefix
285+
try:
286+
request = s3.get_bucket_tagging(
287+
Bucket=bucketName,
288+
)
289+
return any((x["Key"].startswith(tag)) for x in request["TagSet"])
290+
except Exception as e:
291+
logger.info("| error getting tags from bucket")
292+
logger.exception(e)
293+
return False
294+
295+
# extract archive and replace markers in output files
296+
def extract_and_replace_markers(archive, contents_dir, markers):
297+
with ZipFile(archive, "r") as zip:
298+
zip.extractall(contents_dir)
299+
300+
# replace markers for this source
301+
for file in zip.namelist():
302+
file_path = os.path.join(contents_dir, file)
303+
if os.path.isdir(file_path): continue
304+
replace_markers(file_path, markers)
305+
306+
def replace_markers(filename, markers):
307+
# convert the dict of string markers to binary markers
308+
replace_tokens = dict([(k.encode('utf-8'), v.encode('utf-8')) for k, v in markers.items()])
309+
310+
outfile = filename + '.new'
311+
with open(filename, 'rb') as fi, open(outfile, 'wb') as fo:
312+
for line in fi:
313+
for token in replace_tokens:
314+
line = line.replace(token, replace_tokens[token])
315+
fo.write(line)
316+
317+
# # delete the original file and rename the new one to the original
318+
os.remove(filename)
319+
os.rename(outfile, filename)

0 commit comments

Comments
 (0)