Skip to content

Commit 6eca979

Browse files
authored
feat(core): customize bundling output packaging (#13152)
Redo of #13076 after #13131. The fix is [`7b3d829` (#13152)](7b3d829). If the bundling output contains a single archive file (zip or jar), upload it as-is to S3 without zipping it. Allow to customize this behavior with `bundling.outputType`: * `NOT_ARCHIVED`: The bundling output will always be zipped and uploaded to S3. * `ARCHIVED`: The bundling output will not be zipped. Bundling will fail if the bundling output doesn't contain a single archive file. * `AUTO_DISCOVER`: If the bundling output contains a single archive file (zip or jar) it will not be zipped. Otherwise it will be zipped. This is the default. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 6de533f commit 6eca979

File tree

8 files changed

+360
-48
lines changed

8 files changed

+360
-48
lines changed

allowed-breaking-changes.txt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,3 @@ incompatible-argument:@aws-cdk/aws-ecs.TaskDefinition.addVolume
5656
# We made properties optional and it's really fine but our differ doesn't think so.
5757
weakened:@aws-cdk/cloud-assembly-schema.DockerImageSource
5858
weakened:@aws-cdk/cloud-assembly-schema.FileSource
59-
60-
# https://github.com/aws/aws-cdk/pull/13145
61-
removed:@aws-cdk/core.AssetStaging.isArchive
62-
removed:@aws-cdk/core.AssetStaging.packaging
63-
removed:@aws-cdk/core.BundlingOutput
64-
removed:@aws-cdk/core.BundlingOptions.outputType
65-

packages/@aws-cdk/aws-s3-assets/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,27 @@ new assets.Asset(this, 'BundledAsset', {
124124
Although optional, it's recommended to provide a local bundling method which can
125125
greatly improve performance.
126126

127+
If the bundling output contains a single archive file (zip or jar) it will be
128+
uploaded to S3 as-is and will not be zipped. Otherwise the contents of the
129+
output directory will be zipped and the zip file will be uploaded to S3. This
130+
is the default behavior for `bundling.outputType` (`BundlingOutput.AUTO_DISCOVER`).
131+
132+
Use `BundlingOutput.NOT_ARCHIVED` if the bundling output must always be zipped:
133+
134+
```ts
135+
const asset = new assets.Asset(this, 'BundledAsset', {
136+
path: '/path/to/asset',
137+
bundling: {
138+
image: BundlingDockerImage.fromRegistry('alpine'),
139+
command: ['command-that-produces-an-archive.sh'],
140+
outputType: BundlingOutput.NOT_ARCHIVED, // Bundling output will be zipped even though it produces a single archive file.
141+
},
142+
});
143+
```
144+
145+
Use `BundlingOutput.ARCHIVED` if the bundling output contains a single archive file and
146+
you don't want it to be zippped.
147+
127148
## CloudFormation Resource Metadata
128149

129150
> NOTE: This section is relevant for authors of AWS Resource Constructs.

packages/@aws-cdk/aws-s3-assets/lib/asset.ts

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import * as fs from 'fs';
21
import * as path from 'path';
32
import * as assets from '@aws-cdk/assets';
43
import * as iam from '@aws-cdk/aws-iam';
@@ -13,8 +12,6 @@ import { toSymlinkFollow } from './compat';
1312
// eslint-disable-next-line no-duplicate-imports, import/order
1413
import { Construct as CoreConstruct } from '@aws-cdk/core';
1514

16-
const ARCHIVE_EXTENSIONS = ['.zip', '.jar'];
17-
1815
export interface AssetOptions extends assets.CopyOptions, cdk.AssetOptions {
1916
/**
2017
* A list of principals that should be able to read this asset from S3.
@@ -139,17 +136,12 @@ export class Asset extends CoreConstruct implements cdk.IAsset {
139136

140137
this.assetPath = staging.relativeStagedPath(stack);
141138

142-
const packaging = determinePackaging(staging.sourcePath);
143-
144-
this.isFile = packaging === cdk.FileAssetPackaging.FILE;
139+
this.isFile = staging.packaging === cdk.FileAssetPackaging.FILE;
145140

146-
// sets isZipArchive based on the type of packaging and file extension
147-
this.isZipArchive = packaging === cdk.FileAssetPackaging.ZIP_DIRECTORY
148-
? true
149-
: ARCHIVE_EXTENSIONS.some(ext => staging.sourcePath.toLowerCase().endsWith(ext));
141+
this.isZipArchive = staging.isArchive;
150142

151143
const location = stack.synthesizer.addFileAsset({
152-
packaging,
144+
packaging: staging.packaging,
153145
sourceHash: this.sourceHash,
154146
fileName: this.assetPath,
155147
});
@@ -210,19 +202,3 @@ export class Asset extends CoreConstruct implements cdk.IAsset {
210202
this.bucket.grantRead(grantee);
211203
}
212204
}
213-
214-
function determinePackaging(assetPath: string): cdk.FileAssetPackaging {
215-
if (!fs.existsSync(assetPath)) {
216-
throw new Error(`Cannot find asset at ${assetPath}`);
217-
}
218-
219-
if (fs.statSync(assetPath).isDirectory()) {
220-
return cdk.FileAssetPackaging.ZIP_DIRECTORY;
221-
}
222-
223-
if (fs.statSync(assetPath).isFile()) {
224-
return cdk.FileAssetPackaging.FILE;
225-
}
226-
227-
throw new Error(`Asset ${assetPath} is expected to be either a directory or a regular file`);
228-
}

packages/@aws-cdk/core/lib/asset-staging.ts

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import * as cxapi from '@aws-cdk/cx-api';
55
import { Construct } from 'constructs';
66
import * as fs from 'fs-extra';
77
import * as minimatch from 'minimatch';
8-
import { AssetHashType, AssetOptions } from './assets';
9-
import { BundlingOptions } from './bundling';
8+
import { AssetHashType, AssetOptions, FileAssetPackaging } from './assets';
9+
import { BundlingOptions, BundlingOutput } from './bundling';
1010
import { FileSystem, FingerprintOptions } from './fs';
1111
import { Names } from './names';
1212
import { Cache } from './private/cache';
@@ -17,6 +17,8 @@ import { Stage } from './stage';
1717
// eslint-disable-next-line
1818
import { Construct as CoreConstruct } from './construct-compat';
1919

20+
const ARCHIVE_EXTENSIONS = ['.zip', '.jar'];
21+
2022
/**
2123
* A previously staged asset
2224
*/
@@ -30,6 +32,16 @@ interface StagedAsset {
3032
* The hash we used previously
3133
*/
3234
readonly assetHash: string;
35+
36+
/**
37+
* The packaging of the asset
38+
*/
39+
readonly packaging: FileAssetPackaging,
40+
41+
/**
42+
* Whether this asset is an archive
43+
*/
44+
readonly isArchive: boolean;
3345
}
3446

3547
/**
@@ -124,6 +136,16 @@ export class AssetStaging extends CoreConstruct {
124136
*/
125137
public readonly assetHash: string;
126138

139+
/**
140+
* How this asset should be packaged.
141+
*/
142+
public readonly packaging: FileAssetPackaging;
143+
144+
/**
145+
* Whether this asset is an archive (zip or jar).
146+
*/
147+
public readonly isArchive: boolean;
148+
127149
private readonly fingerprintOptions: FingerprintOptions;
128150

129151
private readonly hashType: AssetHashType;
@@ -138,12 +160,20 @@ export class AssetStaging extends CoreConstruct {
138160

139161
private readonly cacheKey: string;
140162

163+
private readonly sourceStats: fs.Stats;
164+
141165
constructor(scope: Construct, id: string, props: AssetStagingProps) {
142166
super(scope, id);
143167

144168
this.sourcePath = path.resolve(props.sourcePath);
145169
this.fingerprintOptions = props;
146170

171+
if (!fs.existsSync(this.sourcePath)) {
172+
throw new Error(`Cannot find asset at ${this.sourcePath}`);
173+
}
174+
175+
this.sourceStats = fs.statSync(this.sourcePath);
176+
147177
const outdir = Stage.of(this)?.assetOutdir;
148178
if (!outdir) {
149179
throw new Error('unable to determine cloud assembly asset output directory. Assets must be defined indirectly within a "Stage" or an "App" scope');
@@ -192,6 +222,8 @@ export class AssetStaging extends CoreConstruct {
192222
this.stagedPath = staged.stagedPath;
193223
this.absoluteStagedPath = staged.stagedPath;
194224
this.assetHash = staged.assetHash;
225+
this.packaging = staged.packaging;
226+
this.isArchive = staged.isArchive;
195227
}
196228

197229
/**
@@ -248,8 +280,18 @@ export class AssetStaging extends CoreConstruct {
248280
? this.sourcePath
249281
: path.resolve(this.assetOutdir, renderAssetFilename(assetHash, path.extname(this.sourcePath)));
250282

283+
if (!this.sourceStats.isDirectory() && !this.sourceStats.isFile()) {
284+
throw new Error(`Asset ${this.sourcePath} is expected to be either a directory or a regular file`);
285+
}
286+
251287
this.stageAsset(this.sourcePath, stagedPath, 'copy');
252-
return { assetHash, stagedPath };
288+
289+
return {
290+
assetHash,
291+
stagedPath,
292+
packaging: this.sourceStats.isDirectory() ? FileAssetPackaging.ZIP_DIRECTORY : FileAssetPackaging.FILE,
293+
isArchive: this.sourceStats.isDirectory() || ARCHIVE_EXTENSIONS.includes(path.extname(this.sourcePath).toLowerCase()),
294+
};
253295
}
254296

255297
/**
@@ -258,6 +300,10 @@ export class AssetStaging extends CoreConstruct {
258300
* Optionally skip, in which case we pretend we did something but we don't really.
259301
*/
260302
private stageByBundling(bundling: BundlingOptions, skip: boolean): StagedAsset {
303+
if (!this.sourceStats.isDirectory()) {
304+
throw new Error(`Asset ${this.sourcePath} is expected to be a directory when bundling`);
305+
}
306+
261307
if (skip) {
262308
// We should have bundled, but didn't to save time. Still pretend to have a hash.
263309
// If the asset uses OUTPUT or BUNDLE, we use a CUSTOM hash to avoid fingerprinting
@@ -270,6 +316,8 @@ export class AssetStaging extends CoreConstruct {
270316
return {
271317
assetHash: this.calculateHash(hashType, bundling),
272318
stagedPath: this.sourcePath,
319+
packaging: FileAssetPackaging.ZIP_DIRECTORY,
320+
isArchive: true,
273321
};
274322
}
275323

@@ -281,12 +329,21 @@ export class AssetStaging extends CoreConstruct {
281329
const bundleDir = this.determineBundleDir(this.assetOutdir, assetHash);
282330
this.bundle(bundling, bundleDir);
283331

284-
// Calculate assetHash afterwards if we still must
285-
assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundleDir);
286-
const stagedPath = path.resolve(this.assetOutdir, renderAssetFilename(assetHash));
332+
// Check bundling output content and determine if we will need to archive
333+
const bundlingOutputType = bundling.outputType ?? BundlingOutput.AUTO_DISCOVER;
334+
const bundledAsset = determineBundledAsset(bundleDir, bundlingOutputType);
287335

288-
this.stageAsset(bundleDir, stagedPath, 'move');
289-
return { assetHash, stagedPath };
336+
// Calculate assetHash afterwards if we still must
337+
assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundledAsset.path);
338+
const stagedPath = path.resolve(this.assetOutdir, renderAssetFilename(assetHash, bundledAsset.extension));
339+
340+
this.stageAsset(bundledAsset.path, stagedPath, 'move');
341+
return {
342+
assetHash,
343+
stagedPath,
344+
packaging: bundledAsset.packaging,
345+
isArchive: true, // bundling always produces an archive
346+
};
290347
}
291348

292349
/**
@@ -320,10 +377,9 @@ export class AssetStaging extends CoreConstruct {
320377
}
321378

322379
// Copy file/directory to staging directory
323-
const stat = fs.statSync(sourcePath);
324-
if (stat.isFile()) {
380+
if (this.sourceStats.isFile()) {
325381
fs.copyFileSync(sourcePath, targetPath);
326-
} else if (stat.isDirectory()) {
382+
} else if (this.sourceStats.isDirectory()) {
327383
fs.mkdirSync(targetPath);
328384
FileSystem.copyDirectory(sourcePath, targetPath, this.fingerprintOptions);
329385
} else {
@@ -502,3 +558,57 @@ function sortObject(object: { [key: string]: any }): { [key: string]: any } {
502558
}
503559
return ret;
504560
}
561+
562+
/**
563+
* Returns the single archive file of a directory or undefined
564+
*/
565+
function singleArchiveFile(directory: string): string | undefined {
566+
if (!fs.existsSync(directory)) {
567+
throw new Error(`Directory ${directory} does not exist.`);
568+
}
569+
570+
if (!fs.statSync(directory).isDirectory()) {
571+
throw new Error(`${directory} is not a directory.`);
572+
}
573+
574+
const content = fs.readdirSync(directory);
575+
if (content.length === 1) {
576+
const file = path.join(directory, content[0]);
577+
const extension = path.extname(content[0]).toLowerCase();
578+
if (fs.statSync(file).isFile() && ARCHIVE_EXTENSIONS.includes(extension)) {
579+
return file;
580+
}
581+
}
582+
583+
return undefined;
584+
}
585+
586+
interface BundledAsset {
587+
path: string,
588+
packaging: FileAssetPackaging,
589+
extension?: string
590+
}
591+
592+
/**
593+
* Returns the bundled asset to use based on the content of the bundle directory
594+
* and the type of output.
595+
*/
596+
function determineBundledAsset(bundleDir: string, outputType: BundlingOutput): BundledAsset {
597+
const archiveFile = singleArchiveFile(bundleDir);
598+
599+
// auto-discover means that if there is an archive file, we take it as the
600+
// bundle, otherwise, we will archive here.
601+
if (outputType === BundlingOutput.AUTO_DISCOVER) {
602+
outputType = archiveFile ? BundlingOutput.ARCHIVED : BundlingOutput.NOT_ARCHIVED;
603+
}
604+
605+
switch (outputType) {
606+
case BundlingOutput.NOT_ARCHIVED:
607+
return { path: bundleDir, packaging: FileAssetPackaging.ZIP_DIRECTORY };
608+
case BundlingOutput.ARCHIVED:
609+
if (!archiveFile) {
610+
throw new Error('Bundling output directory is expected to include only a single .zip or .jar file when `output` is set to `ARCHIVED`');
611+
}
612+
return { path: archiveFile, packaging: FileAssetPackaging.FILE, extension: path.extname(archiveFile) };
613+
}
614+
}

packages/@aws-cdk/core/lib/bundling.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,41 @@ export interface BundlingOptions {
8080
* @experimental
8181
*/
8282
readonly local?: ILocalBundling;
83+
84+
/**
85+
* The type of output that this bundling operation is producing.
86+
*
87+
* @default BundlingOutput.AUTO_DISCOVER
88+
*
89+
* @experimental
90+
*/
91+
readonly outputType?: BundlingOutput;
92+
}
93+
94+
/**
95+
* The type of output that a bundling operation is producing.
96+
*
97+
* @experimental
98+
*/
99+
export enum BundlingOutput {
100+
/**
101+
* The bundling output directory includes a single .zip or .jar file which
102+
* will be used as the final bundle. If the output directory does not
103+
* include exactly a single archive, bundling will fail.
104+
*/
105+
ARCHIVED = 'archived',
106+
107+
/**
108+
* The bundling output directory contains one or more files which will be
109+
* archived and uploaded as a .zip file to S3.
110+
*/
111+
NOT_ARCHIVED = 'not-archived',
112+
113+
/**
114+
* If the bundling output directory contains a single archive file (zip or jar)
115+
* it will be used as the bundle output as-is. Otherwise all the files in the bundling output directory will be zipped.
116+
*/
117+
AUTO_DISCOVER = 'auto-discover',
83118
}
84119

85120
/**

packages/@aws-cdk/core/test/archive/archive.zip

Whitespace-only changes.

packages/@aws-cdk/core/test/docker-stub.sh

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,18 @@ if echo "$@" | grep "DOCKER_STUB_SUCCESS"; then
2424
exit 0
2525
fi
2626

27-
echo "Docker mock only supports one of the following commands: DOCKER_STUB_SUCCESS_NO_OUTPUT,DOCKER_STUB_FAIL,DOCKER_STUB_SUCCESS"
27+
if echo "$@" | grep "DOCKER_STUB_MULTIPLE_FILES"; then
28+
outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1)
29+
touch ${outdir}/test1.txt
30+
touch ${outdir}/test2.txt
31+
exit 0
32+
fi
33+
34+
if echo "$@" | grep "DOCKER_STUB_SINGLE_ARCHIVE"; then
35+
outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1)
36+
touch ${outdir}/test.zip
37+
exit 0
38+
fi
39+
40+
echo "Docker mock only supports one of the following commands: DOCKER_STUB_SUCCESS_NO_OUTPUT,DOCKER_STUB_FAIL,DOCKER_STUB_SUCCESS,DOCKER_STUB_MULTIPLE_FILES,DOCKER_SINGLE_ARCHIVE"
2841
exit 1

0 commit comments

Comments
 (0)