diff --git a/packages/cdktn/src/asset-staging.ts b/packages/cdktn/src/asset-staging.ts new file mode 100644 index 000000000..9e8c758a3 --- /dev/null +++ b/packages/cdktn/src/asset-staging.ts @@ -0,0 +1,226 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { Construct, IConstruct } from "constructs"; +import * as crypto from "crypto"; +import { AssetHashType, AssetOptions, IAsset, IAssetPackaging } from "./assets"; +import { + assetHashConflictingExcludeOptions, + assetHashConflictingHashType, + assetHashInvalid, + assetHashTypeCustomRequiresHash, + assetHashTypeUnknown, +} from "./errors"; +import { CANONICAL_ASSET_HASHES } from "./features"; +import { ExcludeIgnoreStrategy, IIgnoreStrategy } from "./ignore-strategy"; +import { hashPath } from "./private/fs"; + +// A resolved hash is used verbatim as a path segment (see `TerraformAsset.path`), +// so it may only contain characters that are always safe there. +const SAFE_ASSET_HASH = /^[A-Za-z0-9_.-]+$/; + +/** + * Context key for a value folded into every computed asset hash in the + * construct tree, alongside `extraHash`. A bulk cache-busting escape hatch — + * `extraHash` is scoped to one asset, this is scoped to the whole app. + */ +export const ASSET_HASH_SALT_CONTEXT_KEY = "cdktn:assetHashSalt"; + +/** + * Caches the base (pre `extraHash`/salt) hash of a `SOURCE`/`OUTPUT` walk, + * so that multiple `AssetStaging` instances with identical inputs — the same + * asset referenced from more than one resource or stack — hash the source + * tree once per synth instead of once per reference. + * + * Keyed per construct-tree root (the `App`) rather than at module scope: an + * App instance lives exactly as long as one synth, so a long-running process + * that synths repeatedly against changing files (e.g. `cdktn watch`) always + * gets a fresh cache instead of a stale hash from a previous synth. + */ +const hashCachesByRoot = new WeakMap>(); + +/** + * @param root - the construct tree root to scope the cache to, see {@link hashCachesByRoot} + */ +function hashCacheFor(root: IConstruct): Map { + let cache = hashCachesByRoot.get(root); + if (!cache) { + cache = new Map(); + hashCachesByRoot.set(root, cache); + } + return cache; +} + +/** + * Options for {@link AssetStaging}. + */ +export interface AssetStagingOptions extends AssetOptions { + /** + * Absolute path to the source file or directory. Resolving a relative path + * against `cdktf.json` is the caller's responsibility. + */ + readonly sourcePath: string; + + /** + * How the staged result is produced and shaped. The caller decides this + * (e.g. from its own `AssetType`) — `AssetStaging` never infers or changes + * it based on `exclude`/`extraHash`. + */ + readonly packaging: IAssetPackaging; + + /** + * Paths to exclude, relative to `sourcePath`. Cannot be combined with + * `ignoreStrategy`, which replaces this matcher rather than layering on + * top of it. + * + * @default - nothing is excluded + */ + readonly exclude?: string[]; + + /** + * Exclusion matching, for callers that need `.gitignore` / `.dockerignore` + * parity rather than the built-in exact-path / suffix / directory matcher. + * + * @default - `exclude` is used with the built-in matcher + */ + readonly ignoreStrategy?: IIgnoreStrategy; + + /** + * Extra information to fold into the hash (e.g. build instructions and + * other inputs). + * + * @default - no extra hash + */ + readonly extraHash?: string; +} + +/** + * Resolves an asset's identity (`SOURCE`/`OUTPUT`/`CUSTOM` hashing, with + * `exclude`/`extraHash`) and stages it to disk. + * + * Hashing happens eagerly in the constructor; staging the content to + * `targetPath` only happens when `stage()` is called, which callers do from + * their own `onSynthesize` hook. This keeps the filesystem side effect in the + * one window where it is safe to run, and keeps this class skippable once a + * bundler is introduced. + * + * `SOURCE` and `OUTPUT` compute identically here: without a bundler, the + * "output" of an asset is its source verbatim. A future bundler changes what + * `OUTPUT` hashes, not this class. + * + * The source-tree walk behind `SOURCE`/`OUTPUT` is cached per synth (see + * {@link hashCachesByRoot}), so referencing the same asset from more than one + * resource or stack hashes it once. `ASSET_HASH_SALT_CONTEXT_KEY` folds an + * app-wide value into every computed hash, for bulk cache-busting across an + * entire tree rather than one asset's `extraHash`. + */ +export class AssetStaging extends Construct implements IAsset { + private readonly sourcePath: string; + private readonly ignoreStrategy: IIgnoreStrategy; + private readonly hashCache: Map; + + public readonly packaging: IAssetPackaging; + public readonly isDirectory: boolean; + public readonly assetHash: string; + + public constructor(scope: Construct, id: string, props: AssetStagingOptions) { + super(scope, id); + + this.sourcePath = props.sourcePath; + this.packaging = props.packaging; + this.isDirectory = props.packaging.producesDirectory; + this.hashCache = hashCacheFor(this.node.root); + + if (props.exclude?.length && props.ignoreStrategy) { + throw assetHashConflictingExcludeOptions(); + } + this.ignoreStrategy = + props.ignoreStrategy ?? new ExcludeIgnoreStrategy(props.exclude ?? []); + + this.assetHash = this.resolveAssetHash(id, props); + } + + private resolveAssetHash(id: string, props: AssetStagingOptions): string { + const { assetHash, assetHashType, extraHash } = props; + + if (assetHash !== undefined) { + if ( + assetHashType !== undefined && + assetHashType !== AssetHashType.CUSTOM + ) { + throw assetHashConflictingHashType(id); + } + if (!SAFE_ASSET_HASH.test(assetHash)) { + throw assetHashInvalid(id, assetHash); + } + return assetHash; + } + + switch (assetHashType) { + case AssetHashType.CUSTOM: + throw assetHashTypeCustomRequiresHash(id); + case AssetHashType.SOURCE: + case AssetHashType.OUTPUT: + case undefined: { + const canonical = !!this.node.tryGetContext(CANONICAL_ASSET_HASHES); + const archive = this.packaging.omitsDirectoryEntries; + const salt = this.node.tryGetContext(ASSET_HASH_SALT_CONTEXT_KEY); + + // Only cacheable when the ignore strategy can summarize its behavior + // as a string (see `IIgnoreStrategy.cacheKey`); otherwise every call + // is treated as unique. + const cacheKey = + this.ignoreStrategy.cacheKey !== undefined + ? JSON.stringify({ + sourcePath: this.sourcePath, + canonical, + archive, + ignore: this.ignoreStrategy.cacheKey, + }) + : undefined; + + let baseHash = cacheKey ? this.hashCache.get(cacheKey) : undefined; + if (baseHash === undefined) { + baseHash = hashPath(this.sourcePath, { + canonical, + archive, + shouldExclude: (relativePath, isDirectory) => + this.ignoreStrategy.ignores({ relativePath, isDirectory }), + descendIntoExcludedDirectories: + this.ignoreStrategy.pruneExcludedDirectories === false, + }); + if (cacheKey) { + this.hashCache.set(cacheKey, baseHash); + } + } + + if (!extraHash && !salt) { + return baseHash; + } + const folded = crypto.createHash("md5").update(baseHash); + if (extraHash) { + folded.update(extraHash); + } + if (salt) { + folded.update(String(salt)); + } + return folded.digest("hex").slice(0, 32).toUpperCase(); + } + default: + // Out-of-range value from a non-TypeScript caller. + throw assetHashTypeUnknown(id, assetHashType); + } + } + + /** + * Write the staged content to `targetPath`. Called from the owning + * construct's `onSynthesize` hook, once the target path is known. + * @param targetPath - path the packaged result should be written to + */ + public stage(targetPath: string): void { + this.packaging.pack({ + source: this.sourcePath, + target: targetPath, + ignoreStrategy: this.ignoreStrategy, + }); + } +} diff --git a/packages/cdktn/src/assets.ts b/packages/cdktn/src/assets.ts index 2a379faf6..2fcfe80a1 100644 --- a/packages/cdktn/src/assets.ts +++ b/packages/cdktn/src/assets.ts @@ -102,6 +102,19 @@ export interface IAssetPackaging { */ readonly producesDirectory: boolean; + /** + * Whether `pack` emits an artifact with no directory entries of its own — + * only the ignore-strategy-aware source walk. `hashPath`'s `archive` frame + * must agree with this or the hash and the artifact describe different + * file sets. + * + * `ZipPackaging` sets this because `archiveSync` never emits ZIP directory + * entries. A directory-producing packaging that mirrors the source tree + * (e.g. `DirectoryPackaging`) leaves this false, since its directories are + * real entries on disk. + */ + readonly omitsDirectoryEntries: boolean; + /** * Perform the staging transformation, writing the packaged result to * `options.target`. @@ -144,6 +157,7 @@ export interface PackOptions { class FilePackaging implements IAssetPackaging { public readonly extension = ""; public readonly producesDirectory = false; + public readonly omitsDirectoryEntries = false; public pack(options: PackOptions): void { fs.copyFileSync(options.source, options.target); } @@ -155,6 +169,7 @@ class FilePackaging implements IAssetPackaging { class DirectoryPackaging implements IAssetPackaging { public readonly extension = ""; public readonly producesDirectory = true; + public readonly omitsDirectoryEntries = false; public pack(options: PackOptions): void { copySync(options.source, options.target, { shouldExclude: options.ignoreStrategy @@ -173,6 +188,7 @@ class DirectoryPackaging implements IAssetPackaging { class ZipPackaging implements IAssetPackaging { public readonly extension = ".zip"; public readonly producesDirectory = false; + public readonly omitsDirectoryEntries = true; public pack(options: PackOptions): void { archiveSync( options.source, diff --git a/packages/cdktn/src/errors.ts b/packages/cdktn/src/errors.ts index 24f07101c..c99fa3df1 100644 --- a/packages/cdktn/src/errors.ts +++ b/packages/cdktn/src/errors.ts @@ -67,12 +67,6 @@ export const assetHashConflictingExcludeOptions = () => `Both 'exclude' and 'ignoreStrategy' were passed to AssetHash.of(), but 'ignoreStrategy' replaces 'exclude' rather than combining with it. Pass only one.`, ); -export const assetHashTypeOutputNotSupported = (id: string) => - new Error( - `TerraformAsset ${id} was configured with assetHashType 'OUTPUT', but bundling is not implemented yet, so there is no output to hash. Use 'SOURCE' (the default) to hash the source, or 'CUSTOM' with an explicit 'assetHash'. -Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, - ); - export const assetHashTypeCustomRequiresHash = (id: string) => new Error( `TerraformAsset ${id} was configured with assetHashType 'CUSTOM' but no 'assetHash'. A custom hash type requires an explicit 'assetHash' value. @@ -101,6 +95,12 @@ Place a cdktf.json at the root of your project, or pass an absolute path. Learn `, ); +export const assetHashInvalid = (id: string, assetHash: string) => + new Error( + `TerraformAsset ${id} resolved an 'assetHash' of '${assetHash}', but it names the staged asset file and so may only contain letters, digits, '_', '.' and '-'. +Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, + ); + export const dynamicBlockNotSupported = (_foreachExpression: string) => new Error( `We do not support directly resolving a TerraformDynamicBlock. Dynamic blocks are only supported on block attributes of resources, data sources, and providers. diff --git a/packages/cdktn/src/index.ts b/packages/cdktn/src/index.ts index f56cbc04c..ebb6f6207 100644 --- a/packages/cdktn/src/index.ts +++ b/packages/cdktn/src/index.ts @@ -47,6 +47,7 @@ export * from "./terraform-data-resource"; export * from "./assets"; export * from "./ignore-strategy"; export * from "./asset-hash"; +export * from "./asset-staging"; // required for JSII because Fn extends from it export * from "./functions/terraform-functions.generated"; export * from "./functions/provider-function"; diff --git a/packages/cdktn/src/terraform-asset.ts b/packages/cdktn/src/terraform-asset.ts index 5e872d803..d0e6a48eb 100644 --- a/packages/cdktn/src/terraform-asset.ts +++ b/packages/cdktn/src/terraform-asset.ts @@ -9,8 +9,8 @@ import { IAsset, IAssetPackaging, } from "./assets"; -import { hashPath, findFileAboveCwd } from "./private/fs"; -import { CANONICAL_ASSET_HASHES } from "./features"; +import { AssetStaging } from "./asset-staging"; +import { findFileAboveCwd } from "./private/fs"; import { ISynthesisSession } from "./synthesize"; import { addCustomSynthesis } from "./synthesize/synthesizer"; import { TerraformStack } from "./terraform-stack"; @@ -18,10 +18,6 @@ import { assetExpectsDirectory, assetOutOfScopeOfCDKTFJson, assetTypeNotImplemented, - assetHashTypeOutputNotSupported, - assetHashTypeCustomRequiresHash, - assetHashConflictingHashType, - assetHashTypeUnknown, } from "./errors"; export interface TerraformAssetConfig { @@ -35,15 +31,33 @@ export interface TerraformAssetConfig { * How the `assetHash` is derived. * * `SOURCE` (the default) hashes the source path. `CUSTOM` uses the - * `assetHash` value verbatim and requires it to be set. `OUTPUT` is not - * supported yet — there is no bundling step to produce an output to hash — - * and throws if requested. + * `assetHash` value verbatim and requires it to be set. `OUTPUT` also + * hashes the source path today — there is no bundling step yet, so the + * "output" of an asset is its source verbatim — but will hash the + * bundler's output once bundling is introduced. * * If `assetHash` is set, this must be `undefined` or `AssetHashType.CUSTOM`. * * @default AssetHashType.SOURCE */ readonly assetHashType?: AssetHashType; + + /** + * Paths to exclude from the asset, relative to `path`. See + * `AssetStagingOptions.exclude` for the accepted forms. Both the computed + * hash and the staged/packed content honor the exclusion. + * + * @default - nothing is excluded + */ + readonly exclude?: string[]; + + /** + * Extra information to fold into the hash (e.g. build instructions and + * other inputs). + * + * @default - no extra hash + */ + readonly extraHash?: string; } export enum AssetType { @@ -77,6 +91,8 @@ export class TerraformAsset extends Construct implements IAsset { public readonly assetHash: string; // file type of the asset, either AssetType.FILE, AssetType.DIRECTORY, AssetType.ARCHIVE public type: AssetType; + // owns hashing and packing; `AssetStaging` also validates a custom `assetHash` + private readonly staging: AssetStaging; /** * A Terraform Asset takes a file or directory outside of the CDK Terrain context and moves it into it. @@ -111,7 +127,16 @@ export class TerraformAsset extends Construct implements IAsset { const stat = fs.statSync(this.sourcePath); const inferredType = stat.isFile() ? AssetType.FILE : AssetType.DIRECTORY; this.type = config.type ?? inferredType; - this.assetHash = this.resolveAssetHash(id, config); + + this.staging = new AssetStaging(this, "Staging", { + sourcePath: this.sourcePath, + packaging: this.packaging, + assetHash: config.assetHash, + assetHashType: config.assetHashType, + exclude: config.exclude, + extraHash: config.extraHash, + }); + this.assetHash = this.staging.assetHash; if (stat.isFile() && this.type !== AssetType.FILE) { throw assetExpectsDirectory(id, config.path); @@ -126,46 +151,6 @@ export class TerraformAsset extends Construct implements IAsset { }); } - /** - * Resolve the asset hash from `assetHash` and `assetHashType`. - * - * Honors the same contract `AssetOptions` documents: an explicit - * `assetHash` means the type is `CUSTOM`, `CUSTOM` requires a hash, and - * `OUTPUT` is rejected because there is no bundling step to hash yet. - * `SOURCE` (the default) hashes the source path as before. - * @param id - construct id, for error messages - * @param config - the asset configuration - */ - private resolveAssetHash(id: string, config: TerraformAssetConfig): string { - const { assetHash, assetHashType } = config; - - if (assetHash !== undefined) { - if ( - assetHashType !== undefined && - assetHashType !== AssetHashType.CUSTOM - ) { - throw assetHashConflictingHashType(id); - } - return assetHash; - } - - switch (assetHashType) { - case AssetHashType.CUSTOM: - throw assetHashTypeCustomRequiresHash(id); - case AssetHashType.OUTPUT: - throw assetHashTypeOutputNotSupported(id); - case AssetHashType.SOURCE: - case undefined: - return hashPath(this.sourcePath, { - canonical: !!this.node.tryGetContext(CANONICAL_ASSET_HASHES), - archive: this.type === AssetType.ARCHIVE, - }); - default: - // Out-of-range value from a non-TypeScript caller. - throw assetHashTypeUnknown(id, assetHashType); - } - } - private get namedFolder(): string { return path.posix.join( ASSETS_DIRECTORY, @@ -235,6 +220,6 @@ export class TerraformAsset extends Construct implements IAsset { fs.mkdirSync(path.dirname(targetPath), { recursive: true }); } - packaging.pack({ source: this.sourcePath, target: targetPath }); + this.staging.stage(targetPath); } } diff --git a/packages/cdktn/test/asset-staging.test.ts b/packages/cdktn/test/asset-staging.test.ts new file mode 100644 index 000000000..6c33932b3 --- /dev/null +++ b/packages/cdktn/test/asset-staging.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + AssetHashType, + AssetPackaging, + AssetStaging, + ASSET_HASH_SALT_CONTEXT_KEY, + ExcludeIgnoreStrategy, + type IAssetPackaging, + TerraformStack, + Testing, +} from "../src"; +import { CANONICAL_ASSET_HASHES } from "../src/features"; +import { hashPath } from "../src/private/fs"; + +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-asset-staging-test-")); +} + +describe("AssetStaging", () => { + let srcDir: string; + + beforeEach(() => { + srcDir = createTempDir(); + fs.writeFileSync(path.join(srcDir, "a.txt"), "content"); + fs.writeFileSync(path.join(srcDir, "b.md"), "docs"); + }); + + afterEach(() => { + fs.rmSync(srcDir, { recursive: true, force: true }); + }); + + const stack = (canonical = true) => + new TerraformStack( + canonical + ? Testing.app({ context: { [CANONICAL_ASSET_HASHES]: "true" } }) + : Testing.app({ enableFutureFlags: false }), + "s", + ); + + test("implements IAsset", () => { + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + expect(typeof staging.assetHash).toBe("string"); + }); + + test("SOURCE and OUTPUT hash the source identically", () => { + const source = new AssetStaging(stack(), "source", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHashType: AssetHashType.SOURCE, + }); + const output = new AssetStaging(stack(), "output", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHashType: AssetHashType.OUTPUT, + }); + + expect(output.assetHash).toEqual(source.assetHash); + expect(source.assetHash).toEqual(hashPath(srcDir, { canonical: true })); + }); + + test("CUSTOM uses the provided assetHash verbatim", () => { + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHash: "my-custom-hash", + assetHashType: AssetHashType.CUSTOM, + }); + + expect(staging.assetHash).toBe("my-custom-hash"); + }); + + test("CUSTOM without an assetHash throws", () => { + expect( + () => + new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHashType: AssetHashType.CUSTOM, + }), + ).toThrow(/CUSTOM/); + }); + + test("an assetHash with a non-CUSTOM type throws", () => { + expect( + () => + new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHash: "my-custom-hash", + assetHashType: AssetHashType.SOURCE, + }), + ).toThrow(/CUSTOM/); + }); + + test("a custom assetHash with unsafe characters throws", () => { + expect( + () => + new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHash: "not/a/safe/hash", + assetHashType: AssetHashType.CUSTOM, + }), + ).toThrow(/may only contain/); + }); + + test("an out-of-range hash type throws", () => { + expect( + () => + new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHashType: "bogus" as unknown as AssetHashType, + }), + ).toThrow(/unknown assetHashType/i); + }); + + test("exclude changes the hash relative to the unexcluded source", () => { + const plain = new AssetStaging(stack(), "plain", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + const excluded = new AssetStaging(stack(), "excluded", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + exclude: ["*.md"], + }); + + expect(excluded.assetHash).not.toEqual(plain.assetHash); + }); + + test("exclude and ignoreStrategy together throw", () => { + expect( + () => + new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + exclude: ["*.md"], + ignoreStrategy: new ExcludeIgnoreStrategy([]), + }), + ).toThrow(/exclude.*ignoreStrategy|ignoreStrategy/i); + }); + + test("extraHash changes the hash", () => { + const withoutExtra = new AssetStaging(stack(), "without", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + const withExtra = new AssetStaging(stack(), "with", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + extraHash: "v2", + }); + + expect(withExtra.assetHash).not.toEqual(withoutExtra.assetHash); + }); + + test("the canonicalAssetHashes flag gates which scheme is used", () => { + const canonicalOn = new AssetStaging(stack(true), "on", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + const canonicalOff = new AssetStaging(stack(false), "off", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + expect(canonicalOn.assetHash).toEqual( + hashPath(srcDir, { canonical: true }), + ); + expect(canonicalOff.assetHash).toEqual( + hashPath(srcDir, { canonical: false }), + ); + expect(canonicalOn.assetHash).not.toEqual(canonicalOff.assetHash); + }); + + test("stage() copies content while honoring exclude, and hash/content agree", () => { + const s = stack(); + const staging = new AssetStaging(s, "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + exclude: ["*.md"], + }); + const targetPath = path.join(createTempDir(), "out"); + fs.mkdirSync(targetPath, { recursive: true }); + + staging.stage(targetPath); + + expect(fs.existsSync(path.join(targetPath, "a.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetPath, "b.md"))).toBe(false); + expect(staging.assetHash).toEqual( + hashPath(srcDir, { + canonical: true, + shouldExclude: (relativePath) => relativePath.endsWith(".md"), + }), + ); + }); + + test("identical inputs on the same root reuse the cached hash instead of re-reading the source", () => { + const s = stack(); + + const first = new AssetStaging(s, "first", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + // If the second instance didn't hit the cache, it would try to walk a + // directory that no longer exists and throw. + fs.rmSync(srcDir, { recursive: true, force: true }); + + const second = new AssetStaging(s, "second", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + expect(second.assetHash).toEqual(first.assetHash); + }); + + test("a different root does not reuse another root's cache (no stale hash after a source change)", () => { + const first = new AssetStaging(stack(), "first", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + fs.writeFileSync(path.join(srcDir, "a.txt"), "changed content"); + + const second = new AssetStaging(stack(), "second", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + expect(second.assetHash).not.toEqual(first.assetHash); + expect(second.assetHash).toEqual(hashPath(srcDir, { canonical: true })); + }); + + test("ASSET_HASH_SALT_CONTEXT_KEY changes the hash for every asset in the tree", () => { + const withoutSalt = new AssetStaging(stack(), "without", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + const saltedStack = new TerraformStack( + Testing.app({ + context: { + [CANONICAL_ASSET_HASHES]: "true", + [ASSET_HASH_SALT_CONTEXT_KEY]: "bump-everything", + }, + }), + "s", + ); + const withSalt = new AssetStaging(saltedStack, "with", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + + expect(withSalt.assetHash).not.toEqual(withoutSalt.assetHash); + }); + + test("isDirectory reflects the packaging passed in", () => { + fs.writeFileSync(path.join(srcDir, "single-file.txt"), "content"); + const directory = new AssetStaging(stack(), "dir", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + }); + const file = new AssetStaging(stack(), "file", { + sourcePath: path.join(srcDir, "single-file.txt"), + packaging: AssetPackaging.FILE, + }); + const zip = new AssetStaging(stack(), "zip", { + sourcePath: srcDir, + packaging: AssetPackaging.ZIP, + }); + + expect(directory.isDirectory).toBe(true); + expect(file.isDirectory).toBe(false); + expect(zip.isDirectory).toBe(false); + }); + + test("a custom packaging's own omitsDirectoryEntries decides the hash frame, not identity with AssetPackaging.ZIP", () => { + const customZip: IAssetPackaging = { + ...AssetPackaging.ZIP, + omitsDirectoryEntries: true, + pack: AssetPackaging.ZIP.pack.bind(AssetPackaging.ZIP), + }; + + const builtinZip = new AssetStaging(stack(), "builtin", { + sourcePath: srcDir, + packaging: AssetPackaging.ZIP, + }); + const custom = new AssetStaging(stack(), "custom", { + sourcePath: srcDir, + packaging: customZip, + }); + + expect(custom.assetHash).toEqual(builtinZip.assetHash); + expect(custom.assetHash).toEqual( + hashPath(srcDir, { canonical: true, archive: true }), + ); + }); +}); diff --git a/packages/cdktn/test/assets-types.test.ts b/packages/cdktn/test/assets-types.test.ts index 7a9386d56..0fec52170 100644 --- a/packages/cdktn/test/assets-types.test.ts +++ b/packages/cdktn/test/assets-types.test.ts @@ -29,6 +29,9 @@ describe("Assets Types", () => { expect(AssetPackaging.DIRECTORY.producesDirectory).toBe(true); expect(AssetPackaging.ZIP.producesDirectory).toBe(false); expect(AssetPackaging.ZIP.extension).toBe(".zip"); + expect(AssetPackaging.FILE.omitsDirectoryEntries).toBe(false); + expect(AssetPackaging.DIRECTORY.omitsDirectoryEntries).toBe(false); + expect(AssetPackaging.ZIP.omitsDirectoryEntries).toBe(true); }); describe("pack", () => { diff --git a/packages/cdktn/test/canonical-asset-hash.test.ts b/packages/cdktn/test/canonical-asset-hash.test.ts index 263c25710..e58a862a3 100644 --- a/packages/cdktn/test/canonical-asset-hash.test.ts +++ b/packages/cdktn/test/canonical-asset-hash.test.ts @@ -436,28 +436,132 @@ describe("TerraformAsset assetHashType", () => { ).toThrow(/assetHashType.*CUSTOM|CUSTOM/i); }); - test("OUTPUT is rejected until bundling exists", () => { + test("OUTPUT hashes the source, same as SOURCE, until bundling exists", () => { + const output = new TerraformAsset(stack(), "asset", { + path: srcDir, + type: AssetType.DIRECTORY, + assetHashType: AssetHashType.OUTPUT, + }); + const source = new TerraformAsset(stack(), "asset2", { + path: srcDir, + type: AssetType.DIRECTORY, + assetHashType: AssetHashType.SOURCE, + }); + + expect(output.assetHash).toEqual(source.assetHash); + }); + + test("an out-of-range hash type throws instead of returning undefined", () => { + // Models a value another jsii language could pass that TypeScript's type + // system would reject; the switch's default guards it at runtime. expect( () => new TerraformAsset(stack(), "asset", { path: srcDir, type: AssetType.DIRECTORY, - assetHashType: AssetHashType.OUTPUT, + assetHashType: "bogus" as unknown as AssetHashType, }), - ).toThrow(/OUTPUT/); + ).toThrow(/unknown assetHashType/i); }); - test("an out-of-range hash type throws instead of returning undefined", () => { - // Models a value another jsii language could pass that TypeScript's type - // system would reject; the switch's default guards it at runtime. + test("a resolved assetHash with unsafe characters throws, even with no exclude/extraHash set", () => { + // TerraformAsset always routes through AssetStaging, so this path (no + // advanced options) gets the same safety check as the exclude/extraHash + // path — an assetHash is used as a path segment in `TerraformAsset.path`, + // so an unsafe one could otherwise escape the assets directory at synth. expect( () => new TerraformAsset(stack(), "asset", { path: srcDir, type: AssetType.DIRECTORY, - assetHashType: "bogus" as unknown as AssetHashType, + assetHash: "../../escape", + assetHashType: AssetHashType.CUSTOM, }), - ).toThrow(/unknown assetHashType/i); + ).toThrow(/may only contain/); + }); +}); + +describe("TerraformAsset with exclude/extraHash (AssetStaging integration)", () => { + let srcDir: string; + + beforeEach(() => { + srcDir = createTempDir(); + fs.writeFileSync(path.join(srcDir, "a.txt"), "content"); + fs.writeFileSync(path.join(srcDir, "b.md"), "docs"); + }); + + afterEach(() => { + fs.rmSync(srcDir, { recursive: true, force: true }); + }); + + const stack = () => + new TerraformStack( + Testing.app({ context: { [CANONICAL_ASSET_HASHES]: "true" } }), + "s", + ); + + test("exclude changes the hash relative to the unexcluded asset", () => { + const plain = new TerraformAsset(stack(), "asset", { + path: srcDir, + type: AssetType.DIRECTORY, + }); + const excluded = new TerraformAsset(stack(), "asset2", { + path: srcDir, + type: AssetType.DIRECTORY, + exclude: ["*.md"], + }); + + expect(excluded.assetHash).not.toEqual(plain.assetHash); + }); + + test("exclude does not change the packaging (a directory stays a directory)", () => { + const asset = new TerraformAsset(stack(), "asset", { + path: srcDir, + type: AssetType.DIRECTORY, + exclude: ["*.md"], + }); + + expect(asset.type).toBe(AssetType.DIRECTORY); + expect(asset.path.endsWith(asset.assetHash)).toBe(true); + }); + + test("excluded files are absent from the staged/packed output", () => { + const s = stack(); + const asset = new TerraformAsset(s, "asset", { + path: srcDir, + type: AssetType.DIRECTORY, + exclude: ["*.md"], + }); + const outdir = Testing.fullSynth(s); + const stagedDir = path.join(outdir, "stacks", s.node.id, asset.path); + + expect(fs.existsSync(path.join(stagedDir, "a.txt"))).toBe(true); + expect(fs.existsSync(path.join(stagedDir, "b.md"))).toBe(false); + }); + + test("extraHash changes the hash", () => { + const withoutExtra = new TerraformAsset(stack(), "asset", { + path: srcDir, + type: AssetType.DIRECTORY, + }); + const withExtra = new TerraformAsset(stack(), "asset2", { + path: srcDir, + type: AssetType.DIRECTORY, + extraHash: "v2", + }); + + expect(withExtra.assetHash).not.toEqual(withoutExtra.assetHash); + }); + + test("an explicit assetHash is used verbatim even with exclude set", () => { + const asset = new TerraformAsset(stack(), "asset", { + path: srcDir, + type: AssetType.DIRECTORY, + assetHash: "my-custom-hash", + exclude: ["*.md"], + }); + + expect(asset.assetHash).toBe("my-custom-hash"); }); });