Skip to content

Commit 08598dc

Browse files
Elad Ben-IsraelTikiTDO
Elad Ben-Israel
authored andcommitted
feat(s3-deployment): deploy data with deploy-time values (aws#18659)
Allow deploying test-based content that can potentially include deploy-time values such as attributes of cloud resources. Introduce a `Source.data(objectKey, text)` and `Source.jsonData(objectKey, obj)` where the data can naturally include deploy-time tokens such as references to resources (`Ref`) or to resource attributes (`Fn::GetAtt`). For example: ```ts const appConfig = { topic_arn: topic.topicArn, base_url: 'https://my-endpoint', }; new s3deploy.BucketDeployment(this, 'BucketDeployment', { sources: [s3deploy.Source.jsonData('config.json', config)], destinationBucket: destinationBucket, }); ``` This is implemented by replacing the deploy-time tokens with markers that are replaced inside the s3-deployment custom resource. Fixes aws#12903 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 0fa2761 commit 08598dc

17 files changed

+1132
-81
lines changed

Diff for: packages/@aws-cdk/aws-s3-deployment/README.md

+33-1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ The following source types are supported for bucket deployments:
4949
- Local .zip file: `s3deploy.Source.asset('/path/to/local/file.zip')`
5050
- Local directory: `s3deploy.Source.asset('/path/to/local/directory')`
5151
- Another bucket: `s3deploy.Source.bucket(bucket, zipObjectKey)`
52+
- String data: `s3deploy.Source.data('object-key.txt', 'hello, world!')`
53+
(supports [deploy-time values](#data-with-deploy-time-values))
54+
- JSON data: `s3deploy.Source.jsonData('object-key.json', { json: 'object' })`
55+
(supports [deploy-time values](#data-with-deploy-time-values))
5256

5357
To create a source from a single file, you can pass `AssetOptions` to exclude
5458
all but a single file:
@@ -268,6 +272,34 @@ new s3deploy.BucketDeployment(this, 'DeployMeWithEfsStorage', {
268272
});
269273
```
270274

275+
## Data with deploy-time values
276+
277+
The content passed to `Source.data()` or `Source.jsonData()` can include
278+
references that will get resolved only during deployment.
279+
280+
For example:
281+
282+
```ts
283+
import * as sns from '@aws-cdk/aws-sns';
284+
285+
declare const destinationBucket: s3.Bucket;
286+
declare const topic: sns.Topic;
287+
288+
const appConfig = {
289+
topic_arn: topic.topicArn,
290+
base_url: 'https://my-endpoint',
291+
};
292+
293+
new s3deploy.BucketDeployment(this, 'BucketDeployment', {
294+
sources: [s3deploy.Source.jsonData('config.json', appConfig)],
295+
destinationBucket,
296+
});
297+
```
298+
299+
The value in `topic.topicArn` is a deploy-time value. It only gets resolved
300+
during deployment by placing a marker in the generated source file and
301+
substituting it when its deployed to the destination with the actual value.
302+
271303
## Notes
272304

273305
- This library uses an AWS CloudFormation custom resource which about 10MiB in
@@ -282,7 +314,7 @@ new s3deploy.BucketDeployment(this, 'DeployMeWithEfsStorage', {
282314
be good enough: the custom resource will simply not run if the properties don't
283315
change.
284316
- If you use assets (`s3deploy.Source.asset()`) you don't need to worry
285-
about this: the asset system will make sure that if the files have changed,
317+
about this: the asset system will make sure that if the files have changed,
286318
the file name is unique and the deployment will run.
287319

288320
## Development

Diff for: packages/@aws-cdk/aws-s3-deployment/lib/bucket-deployment.ts

+5
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,18 @@ export class BucketDeployment extends CoreConstruct {
323323
}));
324324
}
325325

326+
// to avoid redundant stack updates, only include "SourceMarkers" if one of
327+
// the sources actually has markers.
328+
const hasMarkers = sources.some(source => source.markers);
329+
326330
const crUniqueId = `CustomResource${this.renderUniqueId(props.memoryLimit, props.vpc)}`;
327331
const cr = new cdk.CustomResource(this, crUniqueId, {
328332
serviceToken: handler.functionArn,
329333
resourceType: 'Custom::CDKBucketDeployment',
330334
properties: {
331335
SourceBucketNames: sources.map(source => source.bucket.bucketName),
332336
SourceObjectKeys: sources.map(source => source.zipObjectKey),
337+
SourceMarkers: hasMarkers ? sources.map(source => source.markers ?? {}) : undefined,
333338
DestinationBucketName: props.destinationBucket.bucketName,
334339
DestinationBucketKeyPrefix: props.destinationKeyPrefix,
335340
RetainOnDelete: props.retainOnDelete,

Diff for: packages/@aws-cdk/aws-s3-deployment/lib/lambda/index.py

+48-7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
CFN_SUCCESS = "SUCCESS"
2121
CFN_FAILED = "FAILED"
2222
ENV_KEY_MOUNT_PATH = "MOUNT_PATH"
23+
ENV_KEY_SKIP_CLEANUP = "SKIP_CLEANUP"
2324

2425
CUSTOM_RESOURCE_OWNER_TAG = "aws-cdk:cr-owned"
2526

@@ -45,6 +46,7 @@ def cfn_error(message=None):
4546
try:
4647
source_bucket_names = props['SourceBucketNames']
4748
source_object_keys = props['SourceObjectKeys']
49+
source_markers = props.get('SourceMarkers', None)
4850
dest_bucket_name = props['DestinationBucketName']
4951
dest_bucket_prefix = props.get('DestinationBucketKeyPrefix', '')
5052
retain_on_delete = props.get('RetainOnDelete', "true") == "true"
@@ -55,6 +57,11 @@ def cfn_error(message=None):
5557
exclude = props.get('Exclude', [])
5658
include = props.get('Include', [])
5759

60+
# backwards compatibility - if "SourceMarkers" is not specified,
61+
# assume all sources have an empty market map
62+
if source_markers is None:
63+
source_markers = [{} for i in range(len(source_bucket_names))]
64+
5865
default_distribution_path = dest_bucket_prefix
5966
if not default_distribution_path.endswith("/"):
6067
default_distribution_path += "/"
@@ -71,7 +78,7 @@ def cfn_error(message=None):
7178
if dest_bucket_prefix == "/":
7279
dest_bucket_prefix = ""
7380

74-
s3_source_zips = map(lambda name, key: "s3://%s/%s" % (name, key), source_bucket_names, source_object_keys)
81+
s3_source_zips = list(map(lambda name, key: "s3://%s/%s" % (name, key), source_bucket_names, source_object_keys))
7582
s3_dest = "s3://%s/%s" % (dest_bucket_name, dest_bucket_prefix)
7683
old_s3_dest = "s3://%s/%s" % (old_props.get("DestinationBucketName", ""), old_props.get("DestinationBucketKeyPrefix", ""))
7784

@@ -106,7 +113,7 @@ def cfn_error(message=None):
106113
aws_command("s3", "rm", old_s3_dest, "--recursive")
107114

108115
if request_type == "Update" or request_type == "Create":
109-
s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include)
116+
s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include, source_markers)
110117

111118
if distribution_id:
112119
cloudfront_invalidate(distribution_id, distribution_paths)
@@ -120,7 +127,11 @@ def cfn_error(message=None):
120127

121128
#---------------------------------------------------------------------------------------------------
122129
# populate all files from s3_source_zips to a destination bucket
123-
def s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include):
130+
def s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include, source_markers):
131+
# list lengths are equal
132+
if len(s3_source_zips) != len(source_markers):
133+
raise Exception("'source_markers' and 's3_source_zips' must be the same length")
134+
124135
# create a temporary working directory in /tmp or if enabled an attached efs volume
125136
if ENV_KEY_MOUNT_PATH in os.environ:
126137
workdir = os.getenv(ENV_KEY_MOUNT_PATH) + "/" + str(uuid4())
@@ -136,13 +147,16 @@ def s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, ex
136147

137148
try:
138149
# download the archive from the source and extract to "contents"
139-
for s3_source_zip in s3_source_zips:
150+
for i in range(len(s3_source_zips)):
151+
s3_source_zip = s3_source_zips[i]
152+
markers = source_markers[i]
153+
140154
archive=os.path.join(workdir, str(uuid4()))
141155
logger.info("archive: %s" % archive)
142156
aws_command("s3", "cp", s3_source_zip, archive)
143157
logger.info("| extracting archive to: %s\n" % contents_dir)
144-
with ZipFile(archive, "r") as zip:
145-
zip.extractall(contents_dir)
158+
logger.info("| markers: %s" % markers)
159+
extract_and_replace_markers(archive, contents_dir, markers)
146160

147161
# sync from "contents" to destination
148162

@@ -163,7 +177,8 @@ def s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, ex
163177
s3_command.extend(create_metadata_args(user_metadata, system_metadata))
164178
aws_command(*s3_command)
165179
finally:
166-
shutil.rmtree(workdir)
180+
if not os.getenv(ENV_KEY_SKIP_CLEANUP):
181+
shutil.rmtree(workdir)
167182

168183
#---------------------------------------------------------------------------------------------------
169184
# invalidate files in the CloudFront distribution edge caches
@@ -257,3 +272,29 @@ def bucket_owned(bucketName, keyPrefix):
257272
logger.info("| error getting tags from bucket")
258273
logger.exception(e)
259274
return False
275+
276+
# extract archive and replace markers in output files
277+
def extract_and_replace_markers(archive, contents_dir, markers):
278+
with ZipFile(archive, "r") as zip:
279+
zip.extractall(contents_dir)
280+
281+
# replace markers for this source
282+
for file in zip.namelist():
283+
file_path = os.path.join(contents_dir, file)
284+
if os.path.isdir(file_path): continue
285+
replace_markers(file_path, markers)
286+
287+
def replace_markers(filename, markers):
288+
# convert the dict of string markers to binary markers
289+
replace_tokens = dict([(k.encode('utf-8'), v.encode('utf-8')) for k, v in markers.items()])
290+
291+
outfile = filename + '.new'
292+
with open(filename, 'rb') as fi, open(outfile, 'wb') as fo:
293+
for line in fi:
294+
for token in replace_tokens:
295+
line = line.replace(token, replace_tokens[token])
296+
fo.write(line)
297+
298+
# # delete the original file and rename the new one to the original
299+
os.remove(filename)
300+
os.rename(outfile, filename)
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Stack } from '@aws-cdk/core';
2+
3+
// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
4+
// eslint-disable-next-line no-duplicate-imports, import/order
5+
import { Construct } from '@aws-cdk/core';
6+
7+
export interface Content {
8+
readonly text: string;
9+
readonly markers: Record<string, any>;
10+
}
11+
12+
/**
13+
* Renders the given string data as deployable content with markers substituted
14+
* for all "Ref" and "Fn::GetAtt" objects.
15+
*
16+
* @param scope Construct scope
17+
* @param data The input data
18+
* @returns The markered text (`text`) and a map that maps marker names to their
19+
* values (`markers`).
20+
*/
21+
export function renderData(scope: Construct, data: string): Content {
22+
const obj = Stack.of(scope).resolve(data);
23+
if (typeof(obj) === 'string') {
24+
return { text: obj, markers: {} };
25+
}
26+
27+
if (typeof(obj) !== 'object') {
28+
throw new Error(`Unexpected: after resolve() data must either be a string or a CloudFormation intrinsic. Got: ${JSON.stringify(obj)}`);
29+
}
30+
31+
let markerIndex = 0;
32+
const markers: Record<string, FnJoinPart> = {};
33+
const result = new Array<string>();
34+
const fnJoin: FnJoin | undefined = obj['Fn::Join'];
35+
36+
if (fnJoin) {
37+
const sep = fnJoin[0];
38+
const parts = fnJoin[1];
39+
40+
if (sep !== '') {
41+
throw new Error(`Unexpected "Fn::Join", expecting separator to be an empty string but got "${sep}"`);
42+
}
43+
44+
for (const part of parts) {
45+
if (typeof (part) === 'string') {
46+
result.push(part);
47+
continue;
48+
}
49+
50+
if (typeof (part) === 'object') {
51+
addMarker(part);
52+
continue;
53+
}
54+
55+
throw new Error(`Unexpected "Fn::Join" part, expecting string or object but got ${typeof (part)}`);
56+
}
57+
58+
} else if (obj.Ref || obj['Fn::GetAtt']) {
59+
addMarker(obj);
60+
} else {
61+
throw new Error('Unexpected: Expecting `resolve()` to return "Fn::Join", "Ref" or "Fn::GetAtt"');
62+
}
63+
64+
function addMarker(part: Ref | GetAtt) {
65+
const keys = Object.keys(part);
66+
if (keys.length !== 1 || (keys[0] != 'Ref' && keys[0] != 'Fn::GetAtt')) {
67+
throw new Error(`Invalid CloudFormation reference. "Ref" or "Fn::GetAtt". Got ${JSON.stringify(part)}`);
68+
}
69+
70+
const marker = `<<marker:0xbaba:${markerIndex++}>>`;
71+
result.push(marker);
72+
markers[marker] = part;
73+
}
74+
75+
return { text: result.join(''), markers };
76+
}
77+
78+
type FnJoin = [string, FnJoinPart[]];
79+
type FnJoinPart = string | Ref | GetAtt;
80+
type Ref = { Ref: string };
81+
type GetAtt = { 'Fn::GetAtt': [string, string] };

Diff for: packages/@aws-cdk/aws-s3-deployment/lib/source.ts

+58
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import * as fs from 'fs';
2+
import { join, dirname } from 'path';
13
import * as iam from '@aws-cdk/aws-iam';
24
import * as s3 from '@aws-cdk/aws-s3';
35
import * as s3_assets from '@aws-cdk/aws-s3-assets';
6+
import { FileSystem, Stack } from '@aws-cdk/core';
7+
import { renderData } from './render-data';
48

59
// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
610
// eslint-disable-next-line no-duplicate-imports, import/order
@@ -19,6 +23,12 @@ export interface SourceConfig {
1923
* An S3 object key in the source bucket that points to a zip file.
2024
*/
2125
readonly zipObjectKey: string;
26+
27+
/**
28+
* A set of markers to substitute in the source content.
29+
* @default - no markers
30+
*/
31+
readonly markers?: Record<string, any>;
2232
}
2333

2434
/**
@@ -50,6 +60,8 @@ export interface ISource {
5060
* Source.bucket(bucket, key)
5161
* Source.asset('/local/path/to/directory')
5262
* Source.asset('/local/path/to/a/file.zip')
63+
* Source.data('hello/world/file.txt', 'Hello, world!')
64+
* Source.data('config.json', { baz: topic.topicArn })
5365
*
5466
*/
5567
export class Source {
@@ -110,5 +122,51 @@ export class Source {
110122
};
111123
}
112124

125+
/**
126+
* Deploys an object with the specified string contents into the bucket. The
127+
* content can include deploy-time values (such as `snsTopic.topicArn`) that
128+
* will get resolved only during deployment.
129+
*
130+
* To store a JSON object use `Source.jsonData()`.
131+
*
132+
* @param objectKey The destination S3 object key (relative to the root of the
133+
* S3 deployment).
134+
* @param data The data to be stored in the object.
135+
*/
136+
public static data(objectKey: string, data: string): ISource {
137+
return {
138+
bind: (scope: Construct, context?: DeploymentSourceContext) => {
139+
const workdir = FileSystem.mkdtemp('s3-deployment');
140+
const outputPath = join(workdir, objectKey);
141+
const rendered = renderData(scope, data);
142+
fs.mkdirSync(dirname(outputPath), { recursive: true });
143+
fs.writeFileSync(outputPath, rendered.text);
144+
const asset = this.asset(workdir).bind(scope, context);
145+
return {
146+
bucket: asset.bucket,
147+
zipObjectKey: asset.zipObjectKey,
148+
markers: rendered.markers,
149+
};
150+
},
151+
};
152+
}
153+
154+
/**
155+
* Deploys an object with the specified JSON object into the bucket. The
156+
* object can include deploy-time values (such as `snsTopic.topicArn`) that
157+
* will get resolved only during deployment.
158+
*
159+
* @param objectKey The destination S3 object key (relative to the root of the
160+
* S3 deployment).
161+
* @param obj A JSON object.
162+
*/
163+
public static jsonData(objectKey: string, obj: any): ISource {
164+
return {
165+
bind: (scope: Construct, context?: DeploymentSourceContext) => {
166+
return Source.data(objectKey, Stack.of(scope).toJsonString(obj)).bind(scope, context);
167+
},
168+
};
169+
}
170+
113171
private constructor() { }
114172
}

0 commit comments

Comments
 (0)