Skip to content

Commit cad0635

Browse files
authored
fix(servicecatalog): support nested stacks in product stacks (#26311)
This PR fixes a bug that prevented a nested stack from being used within a product stack. The logic in the `addFileAsset` method as part of the `ProductStackSynthesizer` was updated to forward all assets to the parent stack and uses the `FileAssetLocation` returned from the parent stack synthesizer to create a bucket `Source` from the `bucketName` and `objectKey`. Closes #24317 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 43013d0 commit cad0635

22 files changed

+2082
-104
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)