|
| 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