Skip to content

Commit 2f74eb4

Browse files
author
Elad Ben-Israel
authored
feat: stage assets under .cdk.assets (#2182)
To ensure that assets are available for the toolchain to deploy after the CDK app exists, the CLI will, by default, request that the app will stage the assets under the `.cdk.assets` directory (relative to working directory). The CDK will then *copy* all assets from their source locations to this staging directory and will refer to the staging location as the asset path. Assets will be stored using their content fingerprint (md5 hash) so they will never be copied twice unless they change. Docker build context directories will also be staged. Staging is disabled by default and in cdk-integ. Added .cdk.staging to all .gitignore files in cdk init templates. Fixes #1716 Fixes #2096
1 parent 5d52624 commit 2f74eb4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+881
-25
lines changed

packages/@aws-cdk/assets-docker/lib/image-asset.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import assets = require('@aws-cdk/assets');
12
import ecr = require('@aws-cdk/aws-ecr');
23
import cdk = require('@aws-cdk/cdk');
34
import cxapi = require('@aws-cdk/cx-api');
@@ -49,14 +50,20 @@ export class DockerImageAsset extends cdk.Construct {
4950
super(scope, id);
5051

5152
// resolve full path
52-
this.directory = path.resolve(props.directory);
53-
if (!fs.existsSync(this.directory)) {
54-
throw new Error(`Cannot find image directory at ${this.directory}`);
53+
const dir = path.resolve(props.directory);
54+
if (!fs.existsSync(dir)) {
55+
throw new Error(`Cannot find image directory at ${dir}`);
5556
}
56-
if (!fs.existsSync(path.join(this.directory, 'Dockerfile'))) {
57-
throw new Error(`No 'Dockerfile' found in ${this.directory}`);
57+
if (!fs.existsSync(path.join(dir, 'Dockerfile'))) {
58+
throw new Error(`No 'Dockerfile' found in ${dir}`);
5859
}
5960

61+
const staging = new assets.Staging(this, 'Staging', {
62+
sourcePath: dir
63+
});
64+
65+
this.directory = staging.stagedPath;
66+
6067
const imageNameParameter = new cdk.CfnParameter(this, 'ImageName', {
6168
type: 'String',
6269
description: `ECR repository name and tag asset "${this.node.path}"`,

packages/@aws-cdk/assets-docker/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@aws-cdk/assets-docker/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,15 @@
6969
"@aws-cdk/aws-iam": "^0.28.0",
7070
"@aws-cdk/aws-lambda": "^0.28.0",
7171
"@aws-cdk/aws-s3": "^0.28.0",
72+
"@aws-cdk/assets": "^0.28.0",
7273
"@aws-cdk/cdk": "^0.28.0",
7374
"@aws-cdk/cx-api": "^0.28.0"
7475
},
7576
"homepage": "https://github.com/awslabs/aws-cdk",
7677
"peerDependencies": {
7778
"@aws-cdk/aws-ecr": "^0.28.0",
7879
"@aws-cdk/aws-iam": "^0.28.0",
80+
"@aws-cdk/assets": "^0.28.0",
7981
"@aws-cdk/aws-s3": "^0.28.0",
8082
"@aws-cdk/cdk": "^0.28.0"
8183
},

packages/@aws-cdk/assets-docker/test/test.image-asset.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { expect, haveResource, SynthUtils } from '@aws-cdk/assert';
22
import iam = require('@aws-cdk/aws-iam');
33
import cdk = require('@aws-cdk/cdk');
4+
import cxapi = require('@aws-cdk/cx-api');
5+
import fs = require('fs');
46
import { Test } from 'nodeunit';
7+
import os = require('os');
58
import path = require('path');
69
import { DockerImageAsset } from '../lib';
710

@@ -143,5 +146,30 @@ export = {
143146
});
144147
}, /No 'Dockerfile' found in/);
145148
test.done();
149+
},
150+
151+
'docker directory is staged if asset staging is enabled'(test: Test) {
152+
const workdir = mkdtempSync();
153+
process.chdir(workdir);
154+
155+
const app = new cdk.App({
156+
context: { [cxapi.ASSET_STAGING_DIR_CONTEXT]: '.stage-me' }
157+
});
158+
159+
const stack = new cdk.Stack(app, 'stack');
160+
161+
new DockerImageAsset(stack, 'MyAsset', {
162+
directory: path.join(__dirname, 'demo-image')
163+
});
164+
165+
app.run();
166+
167+
test.ok(fs.existsSync('.stage-me/96e3ffe92a19cbaa6c558942f7a60246/Dockerfile'));
168+
test.ok(fs.existsSync('.stage-me/96e3ffe92a19cbaa6c558942f7a60246/index.py'));
169+
test.done();
146170
}
147171
};
172+
173+
function mkdtempSync() {
174+
return fs.mkdtempSync(path.join(os.tmpdir(), 'test.assets'));
175+
}

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import cdk = require('@aws-cdk/cdk');
44
import cxapi = require('@aws-cdk/cx-api');
55
import fs = require('fs');
66
import path = require('path');
7+
import { Staging } from './staging';
78

89
/**
910
* Defines the way an asset is packaged before it is uploaded to S3.
@@ -61,7 +62,10 @@ export class Asset extends cdk.Construct {
6162
public readonly s3Url: string;
6263

6364
/**
64-
* Resolved full-path location of this asset.
65+
* The path to the asset (stringinfied token).
66+
*
67+
* If asset staging is disabled, this will just be the original path.
68+
* If asset staging is enabled it will be the staged path.
6569
*/
6670
public readonly assetPath: string;
6771

@@ -84,16 +88,20 @@ export class Asset extends cdk.Construct {
8488
constructor(scope: cdk.Construct, id: string, props: GenericAssetProps) {
8589
super(scope, id);
8690

87-
// resolve full path
88-
this.assetPath = path.resolve(props.path);
91+
// stage the asset source (conditionally).
92+
const staging = new Staging(this, 'Stage', {
93+
sourcePath: path.resolve(props.path)
94+
});
95+
96+
this.assetPath = staging.stagedPath;
8997

9098
// sets isZipArchive based on the type of packaging and file extension
9199
const allowedExtensions: string[] = ['.jar', '.zip'];
92100
this.isZipArchive = props.packaging === AssetPackaging.ZipDirectory
93101
? true
94-
: allowedExtensions.some(ext => this.assetPath.toLowerCase().endsWith(ext));
102+
: allowedExtensions.some(ext => staging.sourcePath.toLowerCase().endsWith(ext));
95103

96-
validateAssetOnDisk(this.assetPath, props.packaging);
104+
validateAssetOnDisk(staging.sourcePath, props.packaging);
97105

98106
// add parameters for s3 bucket and s3 key. those will be set by
99107
// the toolkit or by CI/CD when the stack is deployed and will include
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import fs = require('fs');
2+
import minimatch = require('minimatch');
3+
import path = require('path');
4+
import { FollowMode } from './follow-mode';
5+
6+
export interface CopyOptions {
7+
/**
8+
* @default External only follows symlinks that are external to the source directory
9+
*/
10+
follow?: FollowMode;
11+
12+
/**
13+
* glob patterns to exclude from the copy.
14+
*/
15+
exclude?: string[];
16+
}
17+
18+
export function copyDirectory(srcDir: string, destDir: string, options: CopyOptions = { }, rootDir?: string) {
19+
const follow = options.follow !== undefined ? options.follow : FollowMode.External;
20+
const exclude = options.exclude || [];
21+
22+
rootDir = rootDir || srcDir;
23+
24+
if (!fs.statSync(srcDir).isDirectory()) {
25+
throw new Error(`${srcDir} is not a directory`);
26+
}
27+
28+
const files = fs.readdirSync(srcDir);
29+
for (const file of files) {
30+
const sourceFilePath = path.join(srcDir, file);
31+
32+
if (shouldExclude(path.relative(rootDir, sourceFilePath))) {
33+
continue;
34+
}
35+
36+
const destFilePath = path.join(destDir, file);
37+
38+
let stat: fs.Stats | undefined = follow === FollowMode.Always
39+
? fs.statSync(sourceFilePath)
40+
: fs.lstatSync(sourceFilePath);
41+
42+
if (stat && stat.isSymbolicLink()) {
43+
const target = fs.readlinkSync(sourceFilePath);
44+
45+
// determine if this is an external link (i.e. the target's absolute path
46+
// is outside of the root directory).
47+
const targetPath = path.normalize(path.resolve(srcDir, target));
48+
const rootPath = path.normalize(rootDir);
49+
const external = !targetPath.startsWith(rootPath);
50+
51+
if (follow === FollowMode.External && external) {
52+
stat = fs.statSync(sourceFilePath);
53+
} else {
54+
fs.symlinkSync(target, destFilePath);
55+
stat = undefined;
56+
}
57+
}
58+
59+
if (stat && stat.isDirectory()) {
60+
fs.mkdirSync(destFilePath);
61+
copyDirectory(sourceFilePath, destFilePath, options, rootDir);
62+
stat = undefined;
63+
}
64+
65+
if (stat && stat.isFile()) {
66+
fs.copyFileSync(sourceFilePath, destFilePath);
67+
stat = undefined;
68+
}
69+
}
70+
71+
function shouldExclude(filePath: string): boolean {
72+
let excludeOutput = false;
73+
74+
for (const pattern of exclude) {
75+
const negate = pattern.startsWith('!');
76+
const match = minimatch(filePath, pattern, { matchBase: true, flipNegate: true });
77+
78+
if (!negate && match) {
79+
excludeOutput = true;
80+
}
81+
82+
if (negate && match) {
83+
excludeOutput = false;
84+
}
85+
}
86+
87+
return excludeOutput;
88+
}
89+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import crypto = require('crypto');
2+
import fs = require('fs');
3+
import path = require('path');
4+
import { FollowMode } from './follow-mode';
5+
6+
const BUFFER_SIZE = 8 * 1024;
7+
8+
export interface FingerprintOptions {
9+
/**
10+
* Extra information to encode into the fingerprint (e.g. build instructions
11+
* and other inputs)
12+
*/
13+
extra?: string;
14+
15+
/**
16+
* List of exclude patterns (see `CopyOptions`)
17+
* @default include all files
18+
*/
19+
exclude?: string[];
20+
21+
/**
22+
* What to do when we encounter symlinks.
23+
* @default External only follows symlinks that are external to the source
24+
* directory
25+
*/
26+
follow?: FollowMode;
27+
}
28+
29+
/**
30+
* Produces fingerprint based on the contents of a single file or an entire directory tree.
31+
*
32+
* The fingerprint will also include:
33+
* 1. An extra string if defined in `options.extra`.
34+
* 2. The set of exclude patterns, if defined in `options.exclude`
35+
* 3. The symlink follow mode value.
36+
*
37+
* @param fileOrDirectory The directory or file to fingerprint
38+
* @param options Fingerprinting options
39+
*/
40+
export function fingerprint(fileOrDirectory: string, options: FingerprintOptions = { }) {
41+
const follow = options.follow !== undefined ? options.follow : FollowMode.External;
42+
const hash = crypto.createHash('md5');
43+
addToHash(fileOrDirectory);
44+
45+
hash.update(`==follow==${follow}==\n\n`);
46+
47+
if (options.extra) {
48+
hash.update(`==extra==${options.extra}==\n\n`);
49+
}
50+
51+
for (const ex of options.exclude || []) {
52+
hash.update(`==exclude==${ex}==\n\n`);
53+
}
54+
55+
return hash.digest('hex');
56+
57+
function addToHash(pathToAdd: string) {
58+
hash.update('==\n');
59+
const relativePath = path.relative(fileOrDirectory, pathToAdd);
60+
hash.update(relativePath + '\n');
61+
hash.update('~~~~~~~~~~~~~~~~~~\n');
62+
const stat = fs.statSync(pathToAdd);
63+
64+
if (stat.isSymbolicLink()) {
65+
const target = fs.readlinkSync(pathToAdd);
66+
hash.update(target);
67+
} else if (stat.isDirectory()) {
68+
for (const file of fs.readdirSync(pathToAdd)) {
69+
addToHash(path.join(pathToAdd, file));
70+
}
71+
} else {
72+
const file = fs.openSync(pathToAdd, 'r');
73+
const buffer = Buffer.alloc(BUFFER_SIZE);
74+
75+
try {
76+
let bytesRead;
77+
do {
78+
bytesRead = fs.readSync(file, buffer, 0, BUFFER_SIZE, null);
79+
hash.update(buffer.slice(0, bytesRead));
80+
} while (bytesRead === BUFFER_SIZE);
81+
} finally {
82+
fs.closeSync(file);
83+
}
84+
}
85+
}
86+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export enum FollowMode {
2+
/**
3+
* Never follow symlinks.
4+
*/
5+
Never = 'never',
6+
7+
/**
8+
* Materialize all symlinks, whether they are internal or external to the source directory.
9+
*/
10+
Always = 'always',
11+
12+
/**
13+
* Only follows symlinks that are external to the source directory.
14+
*/
15+
External = 'external',
16+
17+
// ----------------- TODO::::::::::::::::::::::::::::::::::::::::::::
18+
/**
19+
* Forbids source from having any symlinks pointing outside of the source
20+
* tree.
21+
*
22+
* This is the safest mode of operation as it ensures that copy operations
23+
* won't materialize files from the user's file system. Internal symlinks are
24+
* not followed.
25+
*
26+
* If the copy operation runs into an external symlink, it will fail.
27+
*/
28+
BlockExternal = 'internal-only',
29+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './fingerprint';
2+
export * from './follow-mode';
3+
export * from './copy';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './asset';
2+
export * from './staging';

0 commit comments

Comments
 (0)