From c84217f94cf66cae800d434350b3b3d7676a03b3 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 26 Oct 2020 13:14:04 +0100 Subject: [PATCH] fix(core): assets are duplicated between nested Cloud Assemblies (#11008) We stage assets into the Cloud Assembly directory. If there are multiple nested Cloud Assemblies, the same asset will be staged multiple times. This leads to an N-fold increase in size of the Cloud Assembly when used in combination with CDK Pipelines (where N is the number of stages deployed), and may even lead the Cloud Assembly to exceed CodePipeline's maximum artifact size of 250MB. Add the concept of an `assetOutdir` next to a regular Cloud Assembly `outDir`, so that multiple Cloud Assemblies can share an asset directory. As an initial implementation, the `assetOutdir` of nested Cloud Assemblies is just the regular `outdir` of the root Assembly. We are playing a bit fast and loose with the semantics of file paths across our code base; many properties just say "the path of X" without making clear whether it's absolute or relative, and if it's relative what it's relative to (`cwd()`? Or the Cloud Assembly directory?). Turns out that especially in dealing with assets, the answer is "can be anything" and things just happen to work out based on who is providing the path and who is consuming it. In order to limit the scope of the changes I needed to make I kept modifications to the `AssetStaging` class: * `stagedPath` now consistently returns an absolute path. * `relativeStagedPath()` a path relative to the Cloud Assembly or an absolute path, as appropriate. Related changes in this PR: - Refactor the *copying* vs. *bundling* logic in `AssetStaging`. I found the current maze of `if`s and member variable changes too hard to follow to convince myself the new code would be doing the right thing, so I refactored it to reduce the branching factor. - Switch the tests of `aws-ecr-assets` over to Jest using `nodeunitShim`. Fixes #10877, fixes #9627, fixes #9917. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/assets/test/test.staging.ts | 4 +- packages/@aws-cdk/aws-ecr-assets/.gitignore | 3 +- packages/@aws-cdk/aws-ecr-assets/.npmignore | 3 +- .../@aws-cdk/aws-ecr-assets/jest.config.js | 10 + .../aws-ecr-assets/lib/image-asset.ts | 2 +- packages/@aws-cdk/aws-ecr-assets/package.json | 4 +- ...est.image-asset.ts => image-asset.test.ts} | 74 +++- packages/@aws-cdk/aws-s3-assets/lib/asset.ts | 10 +- .../@aws-cdk/aws-s3-assets/test/asset.test.ts | 63 ++++ packages/@aws-cdk/core/lib/asset-staging.ts | 346 +++++++++++------- packages/@aws-cdk/core/lib/private/cache.ts | 29 ++ packages/@aws-cdk/core/lib/stage.ts | 7 + .../@aws-cdk/core/test/private/cache.test.ts | 42 +++ packages/@aws-cdk/core/test/staging.test.ts | 54 ++- packages/@aws-cdk/cx-api/lib/app.ts | 15 +- .../@aws-cdk/cx-api/lib/cloud-assembly.ts | 44 ++- scripts/foreach.sh | 4 +- 17 files changed, 537 insertions(+), 177 deletions(-) create mode 100644 packages/@aws-cdk/aws-ecr-assets/jest.config.js rename packages/@aws-cdk/aws-ecr-assets/test/{test.image-asset.ts => image-asset.test.ts} (78%) create mode 100644 packages/@aws-cdk/core/lib/private/cache.ts create mode 100644 packages/@aws-cdk/core/test/private/cache.test.ts diff --git a/packages/@aws-cdk/assets/test/test.staging.ts b/packages/@aws-cdk/assets/test/test.staging.ts index ca299049377d5..65190e267bf9d 100644 --- a/packages/@aws-cdk/assets/test/test.staging.ts +++ b/packages/@aws-cdk/assets/test/test.staging.ts @@ -16,7 +16,7 @@ export = { test.deepEqual(staging.sourceHash, '2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); test.deepEqual(staging.sourcePath, sourcePath); - test.deepEqual(stack.resolve(staging.stagedPath), 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); + test.deepEqual(staging.relativeStagedPath(stack), 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); test.done(); }, @@ -31,7 +31,7 @@ export = { test.deepEqual(staging.sourceHash, '2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); test.deepEqual(staging.sourcePath, sourcePath); - test.deepEqual(stack.resolve(staging.stagedPath), sourcePath); + test.deepEqual(staging.stagedPath, sourcePath); test.done(); }, diff --git a/packages/@aws-cdk/aws-ecr-assets/.gitignore b/packages/@aws-cdk/aws-ecr-assets/.gitignore index 7e3964a75701e..cc09865158319 100644 --- a/packages/@aws-cdk/aws-ecr-assets/.gitignore +++ b/packages/@aws-cdk/aws-ecr-assets/.gitignore @@ -16,4 +16,5 @@ nyc.config.js *.snk !.eslintrc.js -junit.xml \ No newline at end of file +junit.xml +!jest.config.js \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecr-assets/.npmignore b/packages/@aws-cdk/aws-ecr-assets/.npmignore index a94c531529866..9e88226921c33 100644 --- a/packages/@aws-cdk/aws-ecr-assets/.npmignore +++ b/packages/@aws-cdk/aws-ecr-assets/.npmignore @@ -23,4 +23,5 @@ tsconfig.json # exclude cdk artifacts **/cdk.out junit.xml -test/ \ No newline at end of file +test/ +jest.config.js \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecr-assets/jest.config.js b/packages/@aws-cdk/aws-ecr-assets/jest.config.js new file mode 100644 index 0000000000000..12d0151b1bb3b --- /dev/null +++ b/packages/@aws-cdk/aws-ecr-assets/jest.config.js @@ -0,0 +1,10 @@ +const baseConfig = require('cdk-build-tools/config/jest.config'); +module.exports = { + ...baseConfig, + coverageThreshold: { + global: { + branches: 80, + statements: 80, + } + } +}; diff --git a/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts b/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts index 4e223f23e42d1..93d96360b6ae3 100644 --- a/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts +++ b/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts @@ -142,7 +142,7 @@ export class DockerImageAsset extends CoreConstruct implements assets.IAsset { const stack = Stack.of(this); const location = stack.synthesizer.addDockerImageAsset({ - directoryName: staging.stagedPath, + directoryName: staging.relativeStagedPath(stack), dockerBuildArgs: props.buildArgs, dockerBuildTarget: props.target, dockerFile: props.file, diff --git a/packages/@aws-cdk/aws-ecr-assets/package.json b/packages/@aws-cdk/aws-ecr-assets/package.json index 1164eccaf5f27..74eab5c05d71c 100644 --- a/packages/@aws-cdk/aws-ecr-assets/package.json +++ b/packages/@aws-cdk/aws-ecr-assets/package.json @@ -65,12 +65,11 @@ "license": "Apache-2.0", "devDependencies": { "@aws-cdk/assert": "0.0.0", - "@types/nodeunit": "^0.0.31", "@types/proxyquire": "^1.3.28", "aws-cdk": "0.0.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", - "nodeunit": "^0.11.3", + "nodeunit-shim": "0.0.0", "pkglint": "0.0.0", "proxyquire": "^2.1.3", "@aws-cdk/cloud-assembly-schema": "0.0.0" @@ -112,6 +111,7 @@ "announce": false }, "cdk-build": { + "jest": true, "env": { "AWSLINT_BASE_CONSTRUCT": true } diff --git a/packages/@aws-cdk/aws-ecr-assets/test/test.image-asset.ts b/packages/@aws-cdk/aws-ecr-assets/test/image-asset.test.ts similarity index 78% rename from packages/@aws-cdk/aws-ecr-assets/test/test.image-asset.ts rename to packages/@aws-cdk/aws-ecr-assets/test/image-asset.test.ts index fb8722870362d..27ee1a7eb8abd 100644 --- a/packages/@aws-cdk/aws-ecr-assets/test/test.image-asset.ts +++ b/packages/@aws-cdk/aws-ecr-assets/test/image-asset.test.ts @@ -1,15 +1,18 @@ import * as fs from 'fs'; import * as path from 'path'; -import { expect, haveResource } from '@aws-cdk/assert'; +import { expect as ourExpect, haveResource } from '@aws-cdk/assert'; import * as iam from '@aws-cdk/aws-iam'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { App, Lazy, Stack } from '@aws-cdk/core'; -import { Test } from 'nodeunit'; +import { App, DefaultStackSynthesizer, Lazy, LegacyStackSynthesizer, Stack, Stage } from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; +import { nodeunitShim, Test } from 'nodeunit-shim'; import { DockerImageAsset } from '../lib'; /* eslint-disable quote-props */ -export = { +const DEMO_IMAGE_ASSET_HASH = 'baa2d6eb2a17c75424df631c8c70ff39f2d5f3bee8b9e1a109ee24ca17300540'; + +nodeunitShim({ 'test instantiating Asset Image'(test: Test) { // GIVEN const app = new App(); @@ -103,7 +106,7 @@ export = { asset.repository.grantPull(user); // THEN - expect(stack).to(haveResource('AWS::IAM::Policy', { + ourExpect(stack).to(haveResource('AWS::IAM::Policy', { PolicyDocument: { 'Statement': [ { @@ -306,4 +309,63 @@ export = { test.deepEqual(asset7.sourceHash, 'bc007f81fe1dd0f0bbb24af898eba3f4f15edbff19b7abb3fac928439486d667'); test.done(); }, -}; +}); + +test('nested assemblies share assets: legacy synth edition', () => { + // GIVEN + const app = new App(); + const stack1 = new Stack(new Stage(app, 'Stage1'), 'Stack', { synthesizer: new LegacyStackSynthesizer() }); + const stack2 = new Stack(new Stage(app, 'Stage2'), 'Stack', { synthesizer: new LegacyStackSynthesizer() }); + + // WHEN + new DockerImageAsset(stack1, 'Image', { directory: path.join(__dirname, 'demo-image') }); + new DockerImageAsset(stack2, 'Image', { directory: path.join(__dirname, 'demo-image') }); + + // THEN + const assembly = app.synth(); + + // Read the assets from the stack metadata + for (const stageName of ['Stage1', 'Stage2']) { + const stackArtifact = assembly.getNestedAssembly(`assembly-${stageName}`).artifacts.filter(isStackArtifact)[0]; + const assetMeta = stackArtifact.findMetadataByType(cxschema.ArtifactMetadataEntryType.ASSET); + expect(assetMeta[0]).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + path: `../asset.${DEMO_IMAGE_ASSET_HASH}`, + }), + }), + ); + } +}); + +test('nested assemblies share assets: default synth edition', () => { + // GIVEN + const app = new App(); + const stack1 = new Stack(new Stage(app, 'Stage1'), 'Stack', { synthesizer: new DefaultStackSynthesizer() }); + const stack2 = new Stack(new Stage(app, 'Stage2'), 'Stack', { synthesizer: new DefaultStackSynthesizer() }); + + // WHEN + new DockerImageAsset(stack1, 'Image', { directory: path.join(__dirname, 'demo-image') }); + new DockerImageAsset(stack2, 'Image', { directory: path.join(__dirname, 'demo-image') }); + + // THEN + const assembly = app.synth(); + + // Read the asset manifests to verify the file paths + for (const stageName of ['Stage1', 'Stage2']) { + const manifestArtifact = assembly.getNestedAssembly(`assembly-${stageName}`).artifacts.filter(isAssetManifestArtifact)[0]; + const manifest = JSON.parse(fs.readFileSync(manifestArtifact.file, { encoding: 'utf-8' })); + + expect(manifest.dockerImages[DEMO_IMAGE_ASSET_HASH].source).toEqual({ + directory: `../asset.${DEMO_IMAGE_ASSET_HASH}`, + }); + } +}); + +function isStackArtifact(x: any): x is cxapi.CloudFormationStackArtifact { + return x instanceof cxapi.CloudFormationStackArtifact; +} + +function isAssetManifestArtifact(x: any): x is cxapi.AssetManifestArtifact { + return x instanceof cxapi.AssetManifestArtifact; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3-assets/lib/asset.ts b/packages/@aws-cdk/aws-s3-assets/lib/asset.ts index d1cb59520e0f0..cebd05e3ba31a 100644 --- a/packages/@aws-cdk/aws-s3-assets/lib/asset.ts +++ b/packages/@aws-cdk/aws-s3-assets/lib/asset.ts @@ -84,7 +84,7 @@ export class Asset extends cdk.Construct implements cdk.IAsset { public readonly s3ObjectUrl: string; /** - * The path to the asset (stringinfied token). + * The path to the asset, relative to the current Cloud Assembly * * If asset staging is disabled, this will just be the original path. * If asset staging is enabled it will be the staged path. @@ -125,7 +125,9 @@ export class Asset extends cdk.Construct implements cdk.IAsset { this.assetHash = staging.assetHash; this.sourceHash = this.assetHash; - this.assetPath = staging.stagedPath; + const stack = cdk.Stack.of(this); + + this.assetPath = staging.relativeStagedPath(stack); const packaging = determinePackaging(staging.sourcePath); @@ -134,12 +136,10 @@ export class Asset extends cdk.Construct implements cdk.IAsset { ? true : ARCHIVE_EXTENSIONS.some(ext => staging.sourcePath.toLowerCase().endsWith(ext)); - const stack = cdk.Stack.of(this); - const location = stack.synthesizer.addFileAsset({ packaging, sourceHash: this.sourceHash, - fileName: staging.stagedPath, + fileName: this.assetPath, }); this.s3BucketName = location.bucketName; diff --git a/packages/@aws-cdk/aws-s3-assets/test/asset.test.ts b/packages/@aws-cdk/aws-s3-assets/test/asset.test.ts index 199594ef95bf0..194454cc6cb61 100644 --- a/packages/@aws-cdk/aws-s3-assets/test/asset.test.ts +++ b/packages/@aws-cdk/aws-s3-assets/test/asset.test.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { Asset } from '../lib/asset'; const SAMPLE_ASSET_DIR = path.join(__dirname, 'sample-asset-directory'); +const SAMPLE_ASSET_HASH = '6b84b87243a4a01c592d78e1fd3855c4bfef39328cd0a450cc97e81717fea2a2'; test('simple use case', () => { const app = new cdk.App({ @@ -208,6 +209,60 @@ test('asset metadata is only emitted if ASSET_RESOURCE_METADATA_ENABLED_CONTEXT }, ResourcePart.CompleteDefinition); }); +test('nested assemblies share assets: legacy synth edition', () => { + // GIVEN + const app = new cdk.App(); + const stack1 = new cdk.Stack(new cdk.Stage(app, 'Stage1'), 'Stack', { synthesizer: new cdk.LegacyStackSynthesizer() }); + const stack2 = new cdk.Stack(new cdk.Stage(app, 'Stage2'), 'Stack', { synthesizer: new cdk.LegacyStackSynthesizer() }); + + // WHEN + new Asset(stack1, 'MyAsset', { path: SAMPLE_ASSET_DIR }); + new Asset(stack2, 'MyAsset', { path: SAMPLE_ASSET_DIR }); + + // THEN + const assembly = app.synth(); + + // Read the assets from the stack metadata + for (const stageName of ['Stage1', 'Stage2']) { + const stackArtifact = assembly.getNestedAssembly(`assembly-${stageName}`).artifacts.filter(isStackArtifact)[0]; + const assetMeta = stackArtifact.findMetadataByType(cxschema.ArtifactMetadataEntryType.ASSET); + expect(assetMeta[0]).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + packaging: 'zip', + path: `../asset.${SAMPLE_ASSET_HASH}`, + }), + }), + ); + } +}); + +test('nested assemblies share assets: default synth edition', () => { + // GIVEN + const app = new cdk.App(); + const stack1 = new cdk.Stack(new cdk.Stage(app, 'Stage1'), 'Stack', { synthesizer: new cdk.DefaultStackSynthesizer() }); + const stack2 = new cdk.Stack(new cdk.Stage(app, 'Stage2'), 'Stack', { synthesizer: new cdk.DefaultStackSynthesizer() }); + + // WHEN + new Asset(stack1, 'MyAsset', { path: SAMPLE_ASSET_DIR }); + new Asset(stack2, 'MyAsset', { path: SAMPLE_ASSET_DIR }); + + // THEN + const assembly = app.synth(); + + // Read the asset manifests to verify the file paths + for (const stageName of ['Stage1', 'Stage2']) { + const manifestArtifact = assembly.getNestedAssembly(`assembly-${stageName}`).artifacts.filter(isAssetManifestArtifact)[0]; + const manifest = JSON.parse(fs.readFileSync(manifestArtifact.file, { encoding: 'utf-8' })); + + expect(manifest.files[SAMPLE_ASSET_HASH].source).toEqual({ + packaging: 'zip', + path: `../asset.${SAMPLE_ASSET_HASH}`, + }); + } +}); + + describe('staging', () => { test('copy file assets under /${fingerprint}.ext', () => { const tempdir = mkdtempSync(); @@ -326,3 +381,11 @@ describe('staging', () => { function mkdtempSync() { return fs.mkdtempSync(path.join(os.tmpdir(), 'assets.test')); } + +function isStackArtifact(x: any): x is cxapi.CloudFormationStackArtifact { + return x instanceof cxapi.CloudFormationStackArtifact; +} + +function isAssetManifestArtifact(x: any): x is cxapi.AssetManifestArtifact { + return x instanceof cxapi.AssetManifestArtifact; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/asset-staging.ts b/packages/@aws-cdk/core/lib/asset-staging.ts index e6029a34d7f66..6d63f7a449414 100644 --- a/packages/@aws-cdk/core/lib/asset-staging.ts +++ b/packages/@aws-cdk/core/lib/asset-staging.ts @@ -8,6 +8,7 @@ import * as minimatch from 'minimatch'; import { AssetHashType, AssetOptions } from './assets'; import { BundlingOptions } from './bundling'; import { FileSystem, FingerprintOptions } from './fs'; +import { Cache } from './private/cache'; import { Stack } from './stack'; import { Stage } from './stage'; @@ -15,6 +16,21 @@ import { Stage } from './stage'; // eslint-disable-next-line import { Construct as CoreConstruct } from './construct-compat'; +/** + * A previously staged asset + */ +interface StagedAsset { + /** + * The path where we wrote this asset previously + */ + readonly stagedPath: string; + + /** + * The hash we used previously + */ + readonly assetHash: string; +} + /** * Initialization properties for `AssetStaging`. */ @@ -60,43 +76,30 @@ export class AssetStaging extends CoreConstruct { * Clears the asset hash cache */ public static clearAssetHashCache() { - this.assetHashCache = {}; + this.assetCache.clear(); } /** * Cache of asset hashes based on asset configuration to avoid repeated file * system and bundling operations. */ - private static assetHashCache: { [key: string]: string } = {}; + private static assetCache = new Cache(); /** - * Get asset hash from cache or calculate it in case of cache miss. - */ - private static getOrCalcAssetHash(cacheKey: string, calcFn: () => string) { - this.assetHashCache[cacheKey] = this.assetHashCache[cacheKey] ?? calcFn(); - return this.assetHashCache[cacheKey]; - } - - /** - * The path to the asset (stringinfied token). + * Absolute path to the asset data. + * + * If asset staging is disabled, this will just be the source path or + * a temporary directory used for bundling. * - * If asset staging is disabled, this will just be the original path. * If asset staging is enabled it will be the staged path. */ public readonly stagedPath: string; /** - * The path of the asset as it was referenced by the user. + * The absolute path of the asset as it was referenced by the user. */ public readonly sourcePath: string; - /** - * A cryptographic hash of the asset. - * - * @deprecated see `assetHash`. - */ - public readonly sourceHash: string; - /** * A cryptographic hash of the asset. */ @@ -104,150 +107,233 @@ export class AssetStaging extends CoreConstruct { private readonly fingerprintOptions: FingerprintOptions; - private readonly relativePath?: string; + private readonly hashType: AssetHashType; + private readonly assetOutdir: string; - private bundleDir?: string; + /** + * A custom source fingerprint given by the user + * + * Will not be used literally, always hashed later on. + */ + private readonly customSourceFingerprint?: string; private readonly cacheKey: string; constructor(scope: Construct, id: string, props: AssetStagingProps) { super(scope, id); - this.sourcePath = props.sourcePath; + this.sourcePath = path.resolve(props.sourcePath); this.fingerprintOptions = props; - const outdir = Stage.of(this)?.outdir; + const outdir = Stage.of(this)?.assetOutdir; if (!outdir) { - throw new Error('unable to determine cloud assembly output directory. Assets must be defined indirectly within a "Stage" or an "App" scope'); + throw new Error('unable to determine cloud assembly asset output directory. Assets must be defined indirectly within a "Stage" or an "App" scope'); } + this.assetOutdir = outdir; // Determine the hash type based on the props as props.assetHashType is // optional from a caller perspective. - const hashType = determineHashType(props.assetHashType, props.assetHash); + this.customSourceFingerprint = props.assetHash; + this.hashType = determineHashType(props.assetHashType, this.customSourceFingerprint); + + // Decide what we're going to do, without actually doing it yet + let stageThisAsset: () => StagedAsset; + let skip = false; + if (props.bundling) { + // Check if we actually have to bundle for this stack + const bundlingStacks: string[] = this.node.tryGetContext(cxapi.BUNDLING_STACKS) ?? ['*']; + skip = !bundlingStacks.find(pattern => minimatch(Stack.of(this).stackName, pattern)); + const bundling = props.bundling; + stageThisAsset = () => this.stageByBundling(bundling, skip); + } else { + stageThisAsset = () => this.stageByCopying(); + } // Calculate a cache key from the props. This way we can check if we already - // staged this asset (e.g. the same asset with the same configuration is used - // in multiple stacks). In this case we can completely skip file system and - // bundling operations. + // staged this asset and reuse the result (e.g. the same asset with the same + // configuration is used in multiple stacks). In this case we can completely + // skip file system and bundling operations. + // + // The output directory and whether this asset is skipped or not should also be + // part of the cache key to make sure we don't accidentally return the wrong + // staged asset from the cache. this.cacheKey = calculateCacheKey({ + outdir: this.assetOutdir, sourcePath: path.resolve(props.sourcePath), bundling: props.bundling, - assetHashType: hashType, + assetHashType: this.hashType, + customFingerprint: this.customSourceFingerprint, extraHash: props.extraHash, exclude: props.exclude, + skip, }); - if (props.bundling) { - // Check if we actually have to bundle for this stack - const bundlingStacks: string[] = this.node.tryGetContext(cxapi.BUNDLING_STACKS) ?? ['*']; - const runBundling = !!bundlingStacks.find(pattern => minimatch(Stack.of(this).stackName, pattern)); - if (runBundling) { - const bundling = props.bundling; - this.assetHash = AssetStaging.getOrCalcAssetHash(this.cacheKey, () => { - // Determine the source hash in advance of bundling if the asset hash type - // is SOURCE so that the bundler can opt to re-use its previous output. - const sourceHash = hashType === AssetHashType.SOURCE - ? this.calculateHash(hashType, props.assetHash, props.bundling) - : undefined; - this.bundleDir = this.bundle(bundling, outdir, sourceHash); - return sourceHash ?? this.calculateHash(hashType, props.assetHash, props.bundling); - }); - this.relativePath = renderAssetFilename(this.assetHash); - this.stagedPath = this.relativePath; - } else { // Bundling is skipped - this.assetHash = AssetStaging.getOrCalcAssetHash(this.cacheKey, () => { - return props.assetHashType === AssetHashType.BUNDLE || props.assetHashType === AssetHashType.OUTPUT - ? this.calculateHash(AssetHashType.CUSTOM, this.node.path) // Use node path as dummy hash because we're not bundling - : this.calculateHash(hashType, props.assetHash); - }); - this.stagedPath = this.sourcePath; - } - } else { - this.assetHash = AssetStaging.getOrCalcAssetHash(this.cacheKey, () => this.calculateHash(hashType, props.assetHash)); - this.relativePath = renderAssetFilename(this.assetHash, path.extname(this.sourcePath)); - this.stagedPath = this.relativePath; - } + const staged = AssetStaging.assetCache.obtain(this.cacheKey, stageThisAsset); + this.stagedPath = staged.stagedPath; + this.assetHash = staged.assetHash; + } + + /** + * A cryptographic hash of the asset. + * + * @deprecated see `assetHash`. + */ + public get sourceHash(): string { + return this.assetHash; + } + + /** + * Return the path to the staged asset, relative to the Cloud Assembly (manifest) directory of the given stack + * + * Only returns a relative path if the asset was staged, returns an absolute path if + * it was not staged. + * + * A bundled asset might end up in the outDir and still not count as + * "staged"; if asset staging is disabled we're technically expected to + * reference source directories, but we don't have a source directory for the + * bundled outputs (as the bundle output is written to a temporary + * directory). Nevertheless, we will still return an absolute path. + * + * A non-obvious directory layout may look like this: + * + * ``` + * CLOUD ASSEMBLY ROOT + * +-- asset.12345abcdef/ + * +-- assembly-Stage + * +-- MyStack.template.json + * +-- MyStack.assets.json <- will contain { "path": "../asset.12345abcdef" } + * ``` + */ + public relativeStagedPath(stack: Stack) { + const asmManifestDir = Stage.of(stack)?.outdir; + if (!asmManifestDir) { return this.stagedPath; } - const stagingDisabled = this.node.tryGetContext(cxapi.DISABLE_ASSET_STAGING_CONTEXT); - if (stagingDisabled) { - this.relativePath = undefined; - this.stagedPath = this.bundleDir ?? this.sourcePath; + const isOutsideAssetDir = path.relative(this.assetOutdir, this.stagedPath).startsWith('..'); + if (isOutsideAssetDir || this.stagingDisabled) { + return this.stagedPath; } - this.sourceHash = this.assetHash; + return path.relative(asmManifestDir, this.stagedPath); + } - this.stageAsset(outdir); + /** + * Stage the source to the target by copying + * + * Optionally skip if staging is disabled, in which case we pretend we did something but we don't really. + */ + private stageByCopying(): StagedAsset { + const assetHash = this.calculateHash(this.hashType); + const stagedPath = this.stagingDisabled + ? this.sourcePath + : path.resolve(this.assetOutdir, renderAssetFilename(assetHash, path.extname(this.sourcePath))); + + this.stageAsset(this.sourcePath, stagedPath, 'copy'); + return { assetHash, stagedPath }; } - private stageAsset(outdir: string) { - // Staging is disabled - if (!this.relativePath) { - return; + /** + * Stage the source to the target by bundling + * + * Optionally skip, in which case we pretend we did something but we don't really. + */ + private stageByBundling(bundling: BundlingOptions, skip: boolean): StagedAsset { + if (skip) { + // We should have bundled, but didn't to save time. Still pretend to have a hash, + // but always base it on sources. + return { + assetHash: this.calculateHash(AssetHashType.SOURCE), + stagedPath: this.sourcePath, + }; } - const targetPath = path.join(outdir, this.relativePath); + // Try to calculate assetHash beforehand (if we can) + let assetHash = this.hashType === AssetHashType.SOURCE || this.hashType === AssetHashType.CUSTOM + ? this.calculateHash(this.hashType, bundling) + : undefined; - // Staging the bundling asset. - if (this.bundleDir) { - const isAlreadyStaged = fs.existsSync(targetPath); + const bundleDir = this.determineBundleDir(this.assetOutdir, assetHash); + this.bundle(bundling, bundleDir); - if (isAlreadyStaged && path.resolve(this.bundleDir) !== path.resolve(targetPath)) { - // When an identical asset is already staged and the bundler used an - // intermediate bundling directory, we remove the extra directory. - fs.removeSync(this.bundleDir); - } else if (!isAlreadyStaged) { - fs.renameSync(this.bundleDir, targetPath); - } + // Calculate assetHash afterwards if we still must + assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundleDir); + const stagedPath = path.resolve(this.assetOutdir, renderAssetFilename(assetHash)); + this.stageAsset(bundleDir, stagedPath, 'move'); + return { assetHash, stagedPath }; + } + + /** + * Whether staging has been disabled + */ + private get stagingDisabled() { + return !!this.node.tryGetContext(cxapi.DISABLE_ASSET_STAGING_CONTEXT); + } + + /** + * Copies or moves the files from sourcePath to targetPath. + * + * Moving implies the source directory is temporary and can be trashed. + * + * Will not do anything if source and target are the same. + */ + private stageAsset(sourcePath: string, targetPath: string, style: 'move' | 'copy') { + // Is the work already done? + const isAlreadyStaged = fs.existsSync(targetPath); + if (isAlreadyStaged) { + if (style === 'move' && sourcePath !== targetPath) { + fs.removeSync(sourcePath); + } return; } - // Already staged - if (fs.existsSync(targetPath)) { + // Moving can be done quickly + if (style == 'move') { + fs.renameSync(sourcePath, targetPath); return; } // Copy file/directory to staging directory - const stat = fs.statSync(this.sourcePath); + const stat = fs.statSync(sourcePath); if (stat.isFile()) { - fs.copyFileSync(this.sourcePath, targetPath); + fs.copyFileSync(sourcePath, targetPath); } else if (stat.isDirectory()) { fs.mkdirSync(targetPath); - FileSystem.copyDirectory(this.sourcePath, targetPath, this.fingerprintOptions); + FileSystem.copyDirectory(sourcePath, targetPath, this.fingerprintOptions); } else { - throw new Error(`Unknown file type: ${this.sourcePath}`); + throw new Error(`Unknown file type: ${sourcePath}`); } } /** - * Bundles an asset and provides the emitted asset's directory in return. + * Determine the directory where we're going to write the bundling output * - * @param options Bundling options - * @param outdir Parent directory to create the bundle output directory in - * @param sourceHash The asset source hash if known in advance. If this field - * is provided, the bundler may opt to skip bundling, providing any already- - * emitted bundle. If this field is not provided, the bundler uses an - * intermediate directory in outdir. - * @returns The fully resolved bundle output directory. + * This is the target directory where we're going to write the staged output + * files if we can (if the hash is fully known), or a temporary directory + * otherwise. */ - private bundle(options: BundlingOptions, outdir: string, sourceHash?: string): string { - let bundleDir: string; + private determineBundleDir(outdir: string, sourceHash?: string) { if (sourceHash) { - // When an asset hash is known in advance of bundling, the bundler outputs - // directly to the assembly output directory. - bundleDir = path.resolve(path.join(outdir, renderAssetFilename(sourceHash))); - - if (fs.existsSync(bundleDir)) { - // Pre-existing bundle directory. The bundle has already been generated - // once before, so we'll give the caller nothing. - return bundleDir; - } - } else { - // When the asset hash isn't known in advance, bundler outputs to an - // intermediate directory named after the asset's cache key - bundleDir = path.resolve(path.join(outdir, `bundling-temp-${this.cacheKey}`)); + return path.resolve(outdir, renderAssetFilename(sourceHash)); } + // When the asset hash isn't known in advance, bundler outputs to an + // intermediate directory named after the asset's cache key + return path.resolve(outdir, `bundling-temp-${this.cacheKey}`); + } + + /** + * Bundles an asset to the given directory + * + * If the given directory already exists, assume that everything's already + * in order and don't do anything. + * + * @param options Bundling options + * @param bundleDir Where to create the bundle directory + * @returns The fully resolved bundle output directory. + */ + private bundle(options: BundlingOptions, bundleDir: string) { + if (fs.existsSync(bundleDir)) { return; } + fs.ensureDirSync(bundleDir); // Chmod the bundleDir to full access. fs.chmodSync(bundleDir, 0o777); @@ -307,15 +393,9 @@ export class AssetStaging extends CoreConstruct { const outputDir = localBundling ? bundleDir : AssetStaging.BUNDLING_OUTPUT_DIR; throw new Error(`Bundling did not produce any output. Check that content is written to ${outputDir}.`); } - - return bundleDir; } - private calculateHash(hashType: AssetHashType, assetHash?: string, bundling?: BundlingOptions): string { - if (hashType === AssetHashType.CUSTOM && !assetHash) { - throw new Error('`assetHash` must be specified when `assetHashType` is set to `AssetHashType.CUSTOM`.'); - } - + private calculateHash(hashType: AssetHashType, bundling?: BundlingOptions, outputDir?: string): string { // When bundling a CUSTOM or SOURCE asset hash type, we want the hash to include // the bundling configuration. We handle CUSTOM and bundled SOURCE hash types // as a special case to preserve existing user asset hashes in all other cases. @@ -323,7 +403,7 @@ export class AssetStaging extends CoreConstruct { const hash = crypto.createHash('sha256'); // if asset hash is provided by user, use it, otherwise fingerprint the source. - hash.update(assetHash ?? FileSystem.fingerprint(this.sourcePath, this.fingerprintOptions)); + hash.update(this.customSourceFingerprint ?? FileSystem.fingerprint(this.sourcePath, this.fingerprintOptions)); // If we're bundling an asset, include the bundling configuration in the hash if (bundling) { @@ -338,10 +418,10 @@ export class AssetStaging extends CoreConstruct { return FileSystem.fingerprint(this.sourcePath, this.fingerprintOptions); case AssetHashType.BUNDLE: case AssetHashType.OUTPUT: - if (!this.bundleDir) { + if (!outputDir) { throw new Error(`Cannot use \`${hashType}\` hash type when \`bundling\` is not specified.`); } - return FileSystem.fingerprint(this.bundleDir, this.fingerprintOptions); + return FileSystem.fingerprint(outputDir, this.fingerprintOptions); default: throw new Error('Unknown asset hash type.'); } @@ -356,25 +436,27 @@ function renderAssetFilename(assetHash: string, extension = '') { * Determines the hash type from user-given prop values. * * @param assetHashType Asset hash type construct prop - * @param assetHash Asset hash given in the construct props + * @param customSourceFingerprint Asset hash seed given in the construct props */ -function determineHashType(assetHashType?: AssetHashType, assetHash?: string) { - if (assetHash) { - if (assetHashType && assetHashType !== AssetHashType.CUSTOM) { - throw new Error(`Cannot specify \`${assetHashType}\` for \`assetHashType\` when \`assetHash\` is specified. Use \`CUSTOM\` or leave \`undefined\`.`); - } - return AssetHashType.CUSTOM; - } else if (assetHashType) { - return assetHashType; - } else { - return AssetHashType.SOURCE; +function determineHashType(assetHashType?: AssetHashType, customSourceFingerprint?: string) { + const hashType = customSourceFingerprint + ? (assetHashType ?? AssetHashType.CUSTOM) + : (assetHashType ?? AssetHashType.SOURCE); + + if (customSourceFingerprint && hashType !== AssetHashType.CUSTOM) { + throw new Error(`Cannot specify \`${assetHashType}\` for \`assetHashType\` when \`assetHash\` is specified. Use \`CUSTOM\` or leave \`undefined\`.`); } + if (hashType === AssetHashType.CUSTOM && !customSourceFingerprint) { + throw new Error('`assetHash` must be specified when `assetHashType` is set to `AssetHashType.CUSTOM`.'); + } + + return hashType; } /** * Calculates a cache key from the props. Normalize by sorting keys. */ -function calculateCacheKey(props: AssetStagingProps): string { +function calculateCacheKey(props: A): string { return crypto.createHash('sha256') .update(JSON.stringify(sortObject(props))) .digest('hex'); diff --git a/packages/@aws-cdk/core/lib/private/cache.ts b/packages/@aws-cdk/core/lib/private/cache.ts new file mode 100644 index 0000000000000..c8bd6ebba119c --- /dev/null +++ b/packages/@aws-cdk/core/lib/private/cache.ts @@ -0,0 +1,29 @@ +/** + * A simple cache class. + * + * Must be declared at the top of the file because we're going to use it statically in the + * AssetStaging class. + */ +export class Cache { + private cache = new Map(); + + /** + * Clears the cache + */ + public clear() { + this.cache.clear(); + } + + /** + * Get a value from the cache or calculate it + */ + public obtain(cacheKey: string, calcFn: () => A): A { + let value = this.cache.get(cacheKey); + if (value) { return value; } + + value = calcFn(); + this.cache.set(cacheKey, value); + return value; + } +} + diff --git a/packages/@aws-cdk/core/lib/stage.ts b/packages/@aws-cdk/core/lib/stage.ts index 50ef0b7c5b081..072a4b9cc34c3 100644 --- a/packages/@aws-cdk/core/lib/stage.ts +++ b/packages/@aws-cdk/core/lib/stage.ts @@ -153,6 +153,13 @@ export class Stage extends CoreConstruct { return this._assemblyBuilder.outdir; } + /** + * The cloud assembly asset output directory. + */ + public get assetOutdir() { + return this._assemblyBuilder.assetOutdir; + } + /** * Artifact ID of the assembly if it is a nested stage. The root stage (app) * will return an empty string. diff --git a/packages/@aws-cdk/core/test/private/cache.test.ts b/packages/@aws-cdk/core/test/private/cache.test.ts new file mode 100644 index 0000000000000..35d76fe609eef --- /dev/null +++ b/packages/@aws-cdk/core/test/private/cache.test.ts @@ -0,0 +1,42 @@ +import { Cache } from '../../lib/private/cache'; + +let invocations = 0; +let cache: Cache; + +function returnFoo() { + invocations++; + return 'foo'; +} + +beforeEach(() => { + cache = new Cache(); + invocations = 0; +}); + +test('invoke retrieval function only once per key', () => { + // First call + const value = cache.obtain('key', returnFoo); + expect(value).toEqual('foo'); + expect(invocations).toEqual(1); + + // Second call + const value2 = cache.obtain('key', returnFoo); + expect(value2).toEqual('foo'); + expect(invocations).toEqual(1); + + // Different key + const value3 = cache.obtain('key2', returnFoo); + expect(value3).toEqual('foo'); + expect(invocations).toEqual(2); +}); + +test('cache can be cleared', () => { + // First call + expect(cache.obtain('key', returnFoo)).toEqual('foo'); + + cache.clear(); + + expect(cache.obtain('key', returnFoo)).toEqual('foo'); + + expect(invocations).toEqual(2); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/staging.test.ts b/packages/@aws-cdk/core/test/staging.test.ts index 5a6d144fea1de..31b00700d7068 100644 --- a/packages/@aws-cdk/core/test/staging.test.ts +++ b/packages/@aws-cdk/core/test/staging.test.ts @@ -4,7 +4,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import { nodeunitShim, Test } from 'nodeunit-shim'; import * as sinon from 'sinon'; -import { App, AssetHashType, AssetStaging, BundlingDockerImage, BundlingOptions, FileSystem, Stack } from '../lib'; +import { App, AssetHashType, AssetStaging, BundlingDockerImage, BundlingOptions, FileSystem, Stack, Stage } from '../lib'; const STUB_INPUT_FILE = '/tmp/docker-stub.input'; const STUB_INPUT_CONCAT_FILE = '/tmp/docker-stub.input.concat'; @@ -15,6 +15,9 @@ enum DockerStubCommand { SUCCESS_NO_OUTPUT = 'DOCKER_STUB_SUCCESS_NO_OUTPUT' } +const FIXTURE_TEST1_DIR = path.join(__dirname, 'fs', 'fixtures', 'test1'); +const FIXTURE_TARBALL = path.join(__dirname, 'fs', 'fixtures.tar.gz'); + const userInfo = os.userInfo(); const USER_ARG = `-u ${userInfo.uid}:${userInfo.gid}`; @@ -45,7 +48,8 @@ nodeunitShim({ test.deepEqual(staging.sourceHash, '2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); test.deepEqual(staging.sourcePath, sourcePath); - test.deepEqual(stack.resolve(staging.stagedPath), 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); + test.deepEqual(path.basename(staging.stagedPath), 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); + test.deepEqual(path.basename(staging.relativeStagedPath(stack)), 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); test.done(); }, @@ -60,7 +64,8 @@ nodeunitShim({ test.deepEqual(staging.sourceHash, '2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); test.deepEqual(staging.sourcePath, sourcePath); - test.deepEqual(stack.resolve(staging.stagedPath), sourcePath); + test.deepEqual(staging.stagedPath, sourcePath); + test.deepEqual(staging.relativeStagedPath(stack), sourcePath); test.done(); }, @@ -68,12 +73,10 @@ nodeunitShim({ // GIVEN const app = new App(); const stack = new Stack(app, 'stack'); - const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); - const file = path.join(__dirname, 'fs', 'fixtures.tar.gz'); // WHEN - new AssetStaging(stack, 's1', { sourcePath: directory }); - new AssetStaging(stack, 'file', { sourcePath: file }); + new AssetStaging(stack, 's1', { sourcePath: FIXTURE_TEST1_DIR }); + new AssetStaging(stack, 'file', { sourcePath: FIXTURE_TARBALL }); // THEN const assembly = app.synth(); @@ -88,6 +91,31 @@ nodeunitShim({ test.done(); }, + 'assets in nested assemblies get staged into assembly root directory'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new Stack(new Stage(app, 'Stage1'), 'Stack'); + const stack2 = new Stack(new Stage(app, 'Stage2'), 'Stack'); + + // WHEN + new AssetStaging(stack1, 's1', { sourcePath: FIXTURE_TEST1_DIR }); + new AssetStaging(stack2, 's1', { sourcePath: FIXTURE_TEST1_DIR }); + + // THEN + const assembly = app.synth(); + + // One asset directory at the top + test.deepEqual(fs.readdirSync(assembly.directory), [ + 'assembly-Stage1', + 'assembly-Stage2', + 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00', + 'cdk.out', + 'manifest.json', + 'tree.json', + ]); + test.done(); + }, + 'allow specifying extra data to include in the source hash'(test: Test) { // GIVEN const app = new App(); @@ -141,7 +169,7 @@ nodeunitShim({ test.done(); }, - 'bundler succeeds when staging is disabled'(test: Test) { + 'bundled resources have absolute path when staging is disabled'(test: Test) { // GIVEN const app = new App(); const stack = new Stack(app, 'stack'); @@ -171,7 +199,7 @@ nodeunitShim({ test.equal(asset.sourceHash, 'b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4'); test.equal(asset.sourcePath, directory); - const resolvedStagePath = stack.resolve(asset.stagedPath); + const resolvedStagePath = asset.relativeStagedPath(stack); // absolute path ending with bundling dir test.ok(path.isAbsolute(resolvedStagePath)); test.ok(new RegExp('asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4$').test(resolvedStagePath)); @@ -682,7 +710,7 @@ nodeunitShim({ test.done(); }, - 'bundling looks at bundling stacks in context'(test: Test) { + 'bundling can be skipped by setting context'(test: Test) { // GIVEN const app = new App(); const stack = new Stack(app, 'MyStack'); @@ -700,9 +728,9 @@ nodeunitShim({ }); test.throws(() => readDockerStubInput()); // Bundling did not run - test.equal(asset.assetHash, '3d96e735e26b857743a7c44523c9160c285c2d3ccf273d80fa38a1e674c32cb3'); // hash of MyStack/Asset test.equal(asset.sourcePath, directory); - test.equal(stack.resolve(asset.stagedPath), directory); + test.equal(asset.stagedPath, directory); + test.equal(asset.relativeStagedPath(stack), directory); test.done(); }, @@ -776,4 +804,4 @@ function readDockerStubInput() { // Concatenated docker inputs since last teardown function readDockerStubInputConcat() { return readAndCleanDockerStubInput(STUB_INPUT_CONCAT_FILE); -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/lib/app.ts b/packages/@aws-cdk/cx-api/lib/app.ts index 24be06efc6797..41c03f374b408 100644 --- a/packages/@aws-cdk/cx-api/lib/app.ts +++ b/packages/@aws-cdk/cx-api/lib/app.ts @@ -16,8 +16,19 @@ export const PATH_METADATA_ENABLE_CONTEXT = 'aws:cdk:enable-path-metadata'; export const ANALYTICS_REPORTING_ENABLED_CONTEXT = 'aws:cdk:version-reporting'; /** - * If this is set, asset staging is disabled. This means that assets will not be copied to - * the output directory and will be referenced with absolute source paths. + * Disable asset staging (for use with SAM CLI). + * + * Disabling asset staging means that copyable assets will not be copied to the + * output directory and will be referenced with absolute paths. + * + * Not copied to the output directory: this is so users can iterate on the + * Lambda source and run SAM CLI without having to re-run CDK (note: we + * cannot achieve this for bundled assets, if assets are bundled they + * will have to re-run CDK CLI to re-bundle updated versions). + * + * Absolute path: SAM CLI expects `cwd`-relative paths in a resource's + * `aws:asset:path` metadata. In order to be predictable, we will always output + * absolute paths. */ export const DISABLE_ASSET_STAGING_CONTEXT = 'aws:cdk:disable-asset-staging'; diff --git a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts index 6d58bb6ae38b5..df947f379ab46 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts @@ -209,6 +209,18 @@ export class CloudAssembly { } } +/** + * Construction properties for CloudAssemblyBuilder + */ +export interface CloudAssemblyBuilderProps { + /** + * Use the given asset output directory + * + * @default - Same as the manifest outdir + */ + readonly assetOutdir?: string; +} + /** * Can be used to build a cloud assembly. */ @@ -218,6 +230,11 @@ export class CloudAssemblyBuilder { */ public readonly outdir: string; + /** + * The directory where assets of this Cloud Assembly should be stored + */ + public readonly assetOutdir: string; + private readonly artifacts: { [id: string]: cxschema.ArtifactManifest } = { }; private readonly missing = new Array(); @@ -225,21 +242,15 @@ export class CloudAssemblyBuilder { * Initializes a cloud assembly builder. * @param outdir The output directory, uses temporary directory if undefined */ - constructor(outdir?: string) { + constructor(outdir?: string, props: CloudAssemblyBuilderProps = {}) { this.outdir = determineOutputDirectory(outdir); + this.assetOutdir = props.assetOutdir ?? this.outdir; // we leverage the fact that outdir is long-lived to avoid staging assets into it // that were already staged (copying can be expensive). this is achieved by the fact // that assets use a source hash as their name. other artifacts, and the manifest itself, // will overwrite existing files as needed. - - if (fs.existsSync(this.outdir)) { - if (!fs.statSync(this.outdir).isDirectory()) { - throw new Error(`${this.outdir} must be a directory`); - } - } else { - fs.mkdirSync(this.outdir, { recursive: true }); - } + ensureDirSync(this.outdir); } /** @@ -306,7 +317,10 @@ export class CloudAssemblyBuilder { } as cxschema.NestedCloudAssemblyProperties, }); - return new CloudAssemblyBuilder(innerAsmDir); + return new CloudAssemblyBuilder(innerAsmDir, { + // Reuse the same asset output directory as the current Casm builder + assetOutdir: this.assetOutdir, + }); } } @@ -407,3 +421,13 @@ function ignore(_x: any) { function determineOutputDirectory(outdir?: string) { return outdir ?? fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'cdk.out')); } + +function ensureDirSync(dir: string) { + if (fs.existsSync(dir)) { + if (!fs.statSync(dir).isDirectory()) { + throw new Error(`${dir} must be a directory`); + } + } else { + fs.mkdirSync(dir, { recursive: true }); + } +} \ No newline at end of file diff --git a/scripts/foreach.sh b/scripts/foreach.sh index 19f8ccd8e6a02..8e20b83b49a09 100755 --- a/scripts/foreach.sh +++ b/scripts/foreach.sh @@ -56,7 +56,7 @@ command_arg="" for arg in "$@" do - case "$arg" in + case "$arg" in -r | --reset) RESET=1 ;; -s | --skip) SKIP=1 ;; -u | --up) DIRECTION="UP" ;; @@ -66,7 +66,7 @@ do shift done -if [[ "$RESET" -eq 1 ]]; then +if [[ "$RESET" -eq 1 ]]; then reset fi