diff --git a/packages/cdktn/src/asset-staging.ts b/packages/cdktn/src/asset-staging.ts index eb8e2249a..82ac65e38 100644 --- a/packages/cdktn/src/asset-staging.ts +++ b/packages/cdktn/src/asset-staging.ts @@ -8,19 +8,22 @@ import * as path from "path"; import { AssetHashType, AssetOptions, + BundleOutputType, IAsset, IAssetBundler, IAssetPackaging, } from "./assets"; import { - assetFilePackagingWithBundlerUnsupported, assetHashConflictingExcludeOptions, assetHashConflictingHashType, assetHashInvalid, assetHashTypeCustomRequiresHash, assetHashTypeUnknown, assetStagingAlreadyStaged, + assetStagingBundlerDirectoryOutputNeedsDirectoryPackaging, + assetStagingBundlerFileOutputNeedsFilePackaging, assetStagingBundlerOutputNotDirectory, + assetStagingBundlerOutputNotFile, } from "./errors"; import { CANONICAL_ASSET_HASHES } from "./features"; import { ExcludeIgnoreStrategy, IIgnoreStrategy } from "./ignore-strategy"; @@ -51,7 +54,9 @@ export const ASSET_HASH_SALT_CONTEXT_KEY = "cdktn:assetHashSalt"; const hashCachesByRoot = new WeakMap>(); /** - * @param root - the construct tree root to scope the cache to, see {@link hashCachesByRoot} + * The hash cache scoped to a construct-tree root, created on first use. + * + * See {@link hashCachesByRoot} for why the cache is scoped per root. */ function hashCacheFor(root: IConstruct): Map { let cache = hashCachesByRoot.get(root); @@ -164,8 +169,10 @@ export interface AssetStagingOptions extends AssetOptions { * * Under the default `SOURCE` hashing the build is deferred to `stage()` and * stays skippable; `OUTPUT` hashing builds eagerly at construction time to - * hash the artifact, forgoing skippability. A bundler always produces a - * directory, so single-file packaging (`AssetType.FILE`) is rejected. + * hash the artifact, forgoing skippability. The bundler's declared output + * shape must match the packaging: a `BundleResult.directory` needs a + * directory-accepting packaging, a `BundleResult.file` a single-file one + * (`AssetType.FILE`). The mismatch is caught once the build runs. * * @default - the source is staged verbatim, with no build step */ @@ -203,7 +210,11 @@ export class AssetStaging extends Construct implements IAsset { * * Undefined on every other path, where the build is deferred or absent. */ - private eagerBuild?: { readonly scratch: string; readonly produced: string }; + private eagerBuild?: { + readonly scratch: string; + readonly produced: string; + readonly outputType: BundleOutputType; + }; /** * Set once `stage()` has run, so a second call cannot rebuild. An eager @@ -238,11 +249,9 @@ export class AssetStaging extends Construct implements IAsset { this.hasExclusions = props.ignoreStrategy !== undefined || !!props.exclude?.length; - // A bundler always produces a directory, so packaging that cannot take a - // directory source would fail with an opaque EISDIR/EPERM at synth. - if (this.bundler && !this.packaging.acceptsDirectorySource) { - throw assetFilePackagingWithBundlerUnsupported(this.displayName); - } + // Bundler output shape (directory vs file) is only known once `bundle` + // runs, so its compatibility with the packaging is checked then, not here + // — a deferred SOURCE build has not run yet at construction time. this.assetHash = this.resolveAssetHash(this.displayName, props); } @@ -354,35 +363,107 @@ export class AssetStaging extends Construct implements IAsset { // Registered so the exit sweep reclaims it if stage() never runs for an // unsynthesized stack; stage() removes and unregisters it otherwise. registerStrandedScratch(scratch); - const produced = this.runBundle(scratch); - this.eagerBuild = { scratch, produced }; - return hashPath(produced, { canonical, archive }); + try { + const { produced, outputType } = this.runBundle(scratch); + this.eagerBuild = { scratch, produced, outputType }; + // A single-file artifact is hashed as a file (no archive framing); a + // directory is hashed as the packaged tree would be. + const isFile = outputType === BundleOutputType.FILE; + return hashPath(produced, { canonical, archive: archive && !isFile }); + } catch (e) { + // The build failed before `eagerBuild` was set, so stage() can never + // reach this scratch to clean it. Reclaim it now rather than leaving a + // potentially large tree (node_modules, build caches) to the exit sweep. + this.cleanupScratch(scratch); + throw e; + } } /** * Run the bundler against the filtered source and return its output. * - * The bundler is handed an absolute `source` (its cwd is its own — docker - * `-w`, esbuild — so a relative path would resolve elsewhere) that has - * already had `exclude` applied. Materialising the filtered input here is - * what makes the bundler read exactly the tree the hash was taken over: the - * two would otherwise disagree, since `hashSource` honours `exclude` but a - * raw source hand-off does not. The returned path is validated to be a - * directory before packaging. - * - * @param scratch - caller-owned scratch directory to build within + * The bundler is handed an absolute `source` (its cwd is its own, so a + * relative path would resolve elsewhere) that has already had `exclude` + * applied. Materialising the filtered input here is what makes the bundler + * read exactly the tree the hash was taken over, which a raw source + * hand-off would not, since `hashSource` honours `exclude`. */ - private runBundle(scratch: string): string { + private runBundle(scratch: string): { + produced: string; + outputType: BundleOutputType; + } { const outputDir = path.join(scratch, "output"); fs.mkdirSync(outputDir); - const produced = this.bundler!.bundle({ + const result = this.bundler!.bundle({ source: this.filteredSource(scratch), outputDir, }); - if (!fs.existsSync(produced) || !fs.statSync(produced).isDirectory()) { + + // A bare bundler that declines has nothing to fall back to — declining is + // only meaningful inside a ChainBundler, which consumes it before it + // reaches here. A non-declined result always carries a path and shape. + if ( + result.isDeclined || + result.path === undefined || + result.outputType === undefined + ) { + throw assetStagingBundlerOutputNotDirectory( + this.displayName, + String(result.path), + ); + } + + const produced = result.path; + this.validateOutputShape(produced, result.outputType); + return { produced, outputType: result.outputType }; + } + + /** + * Reject a bundler result whose declared shape does not match what is on + * disk, or does not match the packaging. + * + * Catching the mismatch here turns an opaque EISDIR/EPERM at pack time into + * a clear error naming the packaging the bundler's output shape needs. + */ + private validateOutputShape( + produced: string, + outputType: BundleOutputType, + ): void { + const exists = fs.existsSync(produced); + const stat = exists ? fs.statSync(produced) : undefined; + + if (outputType === BundleOutputType.FILE) { + if (!stat?.isFile()) { + throw assetStagingBundlerOutputNotFile(this.displayName, produced); + } + if (!this.stagesSingleFile) { + throw assetStagingBundlerFileOutputNeedsFilePackaging(this.displayName); + } + return; + } + + if (!stat?.isDirectory()) { throw assetStagingBundlerOutputNotDirectory(this.displayName, produced); } - return produced; + if (!this.packaging.acceptsDirectorySource) { + throw assetStagingBundlerDirectoryOutputNeedsDirectoryPackaging( + this.displayName, + ); + } + } + + /** + * Whether the packaging stages a single file verbatim (AssetType.FILE). + * + * A verbatim-file packaging neither produces a directory nor reads one as + * its source. Derived from existing `IAssetPackaging` flags so out-of-tree + * packagings need no new interface member. + */ + private get stagesSingleFile(): boolean { + return ( + !this.packaging.producesDirectory && + !this.packaging.acceptsDirectorySource + ); } /** @@ -391,8 +472,6 @@ export class AssetStaging extends Construct implements IAsset { * With no exclusions the resolved source is handed over directly. Otherwise * the excluded source tree is materialised into the scratch directory, so * the bundler sees the same file set the hash was taken over. - * - * @param scratch - caller-owned scratch directory to materialise into */ private filteredSource(scratch: string): string { const absoluteSource = path.resolve(this.sourcePath); @@ -418,10 +497,8 @@ export class AssetStaging extends Construct implements IAsset { * eagerly for `OUTPUT` hashing or deferred to here for `SOURCE`. */ public stage(targetPath: string): void { - // Stage exactly once. An eager OUTPUT build is captured at construction - // and consumed below; a second call would otherwise fall through to the - // deferred path and rebuild, staging bytes a non-deterministic bundler - // does not match its already-computed hash. + // Stage exactly once: a rebuild could stage bytes not matching the + // already-computed hash (see the `staged` field). if (this.staged) { throw assetStagingAlreadyStaged(this.displayName); } @@ -452,7 +529,7 @@ export class AssetStaging extends Construct implements IAsset { // `SOURCE` hashing defers the build to here. const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-bundle-")); try { - const produced = this.runBundle(scratch); + const { produced } = this.runBundle(scratch); this.packBundlerOutput(produced, targetPath); } finally { this.cleanupScratch(scratch); @@ -470,6 +547,10 @@ export class AssetStaging extends Construct implements IAsset { /** * Package a bundler's output into `targetPath`, verbatim. * + * The output's shape was already validated against the packaging in + * {@link runBundle}, so the packaging handles the source directly — a file + * source for AssetType.FILE, a directory source otherwise. + * * The ignore strategy is deliberately not applied. `exclude` filters the * source a bundler reads, not its product: an install-style bundler * (`npm install --production`, `pip install -t`) writes exactly the diff --git a/packages/cdktn/src/assets.ts b/packages/cdktn/src/assets.ts index ef0d50659..8357f268a 100644 --- a/packages/cdktn/src/assets.ts +++ b/packages/cdktn/src/assets.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: MPL-2.0 import * as fs from "fs"; +import * as path from "path"; +import { + chainBundlerAllDeclined, + chainBundlerConflictingOutputFileName, + chainBundlerRequiresAtLeastOneBundler, +} from "./errors"; import { archiveSync, copySync } from "./private/fs"; import { IIgnoreStrategy } from "./ignore-strategy"; @@ -10,15 +16,16 @@ import { IIgnoreStrategy } from "./ignore-strategy"; */ export interface IAsset { /** - * A hash of this asset, which is available at construction time. As this is a plain string, it - * can be used in construct IDs in order to enforce creation of a new resource when the content - * hash has changed. + * A hash of this asset, available at construction time. + * + * Being a plain string, it can be used in construct IDs to force a new + * resource when the content hash changes. */ readonly assetHash: string; } /** - * Asset hash options + * Options controlling how an asset's hash is derived. */ export interface AssetOptions { /** @@ -27,11 +34,9 @@ export interface AssetOptions { * hash, and because it names the staged asset file it may only contain * letters, digits, `_`, `.` and `-`. * - * NOTE: the hash is used in order to identify a specific revision of the asset, and - * used for optimizing and caching deployment activities related to this asset such as - * packaging, uploading to cloud storage, etc. If you chose to customize the hash, you will - * need to make sure it is updated every time the asset changes, or otherwise it is - * possible that some deployments will not be invalidated. + * The hash identifies a specific revision of the asset and caches deployment + * work (packaging, uploading). A custom hash must be updated whenever the + * asset changes, or some deployments will not be invalidated. * * @default - based on `assetHashType` */ @@ -50,31 +55,29 @@ export interface AssetOptions { } /** - * The type of asset hash + * The type of asset hash. * - * NOTE: the hash is used in order to identify a specific revision of the asset, and - * used for optimizing and caching deployment activities related to this asset such as - * packaging, uploading to cloud storage, etc. + * The hash identifies a specific revision of the asset and caches deployment + * work such as packaging and uploading. */ export enum AssetHashType { /** - * Based on the content of the source path + * Based on the content of the source path. * - * Use `SOURCE` when the content of the asset changes frequently or when - * you want to track changes to the source files directly. + * Use `SOURCE` to track changes to the source files directly. */ SOURCE = "source", /** - * Based on the content of the bundling output + * Based on the content of the bundling output. * - * Use `OUTPUT` when the source of the asset is a top level folder containing - * code and/or dependencies that are not directly linked to the asset. + * Use `OUTPUT` when the source is a top-level folder holding code and/or + * dependencies not directly linked to the asset. */ OUTPUT = "output", /** - * Use a custom hash + * Use a custom hash. */ CUSTOM = "custom", } @@ -257,44 +260,122 @@ export interface BundleOptions { /** * A scratch directory the bundler may write into, owned and created by the * caller. The bundler produces its output here (or in a subdirectory) and - * returns the directory that holds the finished artifact — see + * returns a {@link BundleResult} pointing at the finished artifact — see * {@link IAssetBundler.bundle}. */ readonly outputDir: string; } +/** + * The shape of a bundler's output, so staging and packaging can treat a + * single-file artifact (a tarball, a `.zip`) differently from a directory + * tree without inferring it from the path. + */ +export enum BundleOutputType { + /** + * The output is a directory tree. + * + * Packaged like an unbundled source directory. The default, and the only + * shape that predates archive support. + */ + DIRECTORY = "directory", + + /** + * The output is a single file the bundler already produced in its final + * form. + * + * A tarball or a deterministic `.zip`. Staged verbatim rather than + * re-archived, so `AssetType.FILE` no longer has to reject a bundler. + */ + FILE = "file", +} + +/** + * What a bundler produced, returned from {@link IAssetBundler.bundle}. + * + * Carries the artifact path and its shape, or a declined state signalling the + * caller should fall back. See {@link declined} for the decline protocol. + */ +export class BundleResult { + /** + * A directory-tree artifact at `path`. + */ + public static directory(path: string): BundleResult { + return new BundleResult(BundleOutputType.DIRECTORY, path, false); + } + + /** + * A single-file artifact at `path` (a tarball, a `.zip`), staged verbatim. + */ + public static file(path: string): BundleResult { + return new BundleResult(BundleOutputType.FILE, path, false); + } + + /** + * The bundler declined to run here; the caller should fall back. + * + * Distinct from a thrown error, which is a hard failure. + */ + public static declined(): BundleResult { + return new BundleResult(undefined, undefined, true); + } + + /** + * The artifact's shape, or undefined when {@link isDeclined}. + */ + public readonly outputType?: BundleOutputType; + + /** + * The artifact path, or undefined when {@link isDeclined}. + */ + public readonly path?: string; + + /** + * Whether the bundler declined to run, signalling the caller to fall back. + */ + public readonly isDeclined: boolean; + + private constructor( + outputType: BundleOutputType | undefined, + path: string | undefined, + isDeclined: boolean, + ) { + this.outputType = outputType; + this.path = path; + this.isDeclined = isDeclined; + } +} + /** * Transforms a source tree into a built artifact. Runs at synth, before the * output is packaged and staged. * * This is the extension point for asset bundling: core ships no bundler. - * Docker, esbuild, pip, `go build`, and similar are an open-ended set that - * is not cloud-specific, so each lives in its own package and implements this + * Docker, esbuild, pip, `go build`, and similar are an open-ended, non + * cloud-specific set, so each lives in its own package and implements this * one interface — the same way {@link IIgnoreStrategy} lets richer exclusion - * live outside core without core taking on a glob parser. A third party - * develops a bundler by implementing this interface and publishing it as a - * package; users pass an instance via the consuming construct's `bundler` - * option. + * live outside core. Users pass an instance via the consuming construct's + * `bundler` option. * * `bundle` runs during the owning construct's `onSynthesize` hook and may * touch the filesystem. Deferring it there keeps it skippable when the asset's * stack is not being synthesized, which holds as long as the hash is taken * over the source rather than the built output. + * + * Bundlers compose through {@link ChainBundler} rather than a hierarchy: a + * bundler declines (see {@link BundleResult.declined}) instead of failing when + * it cannot run, and the chain falls through to the next. */ export interface IAssetBundler { /** * A value identifying the build, folded into the asset hash. * * The source tree alone cannot see the build, so swapping a `node:18` base - * image for `node:20` would otherwise leave identity unchanged. A value - * capturing the build (e.g. `docker::`) closes that gap. - * - * Under `SOURCE` hashing this is the only channel by which the build reaches - * identity, so it must serialize every input that can move the output — - * base image, command, tool version, environment, arguments. Anything left - * out means a changed build silently reuses a stale artifact. {@link - * BundlerKey} builds one from an ordered set of parts so the format is not - * reinvented per bundler. + * image for `node:20` would otherwise leave identity unchanged. Under + * `SOURCE` hashing this is the only channel by which the build reaches + * identity, so it must serialize every input that can move the output, or a + * changed build silently reuses a stale artifact. {@link BundlerKey} builds + * one from an ordered set of parts. * * Mirrors {@link IIgnoreStrategy.cacheKey}: omit it when the build cannot be * summarized as a string, and fall back to `extraHash`. @@ -304,15 +385,34 @@ export interface IAssetBundler { readonly bundlerKey?: string; /** - * Produce the artifact and return the directory holding it. + * The name a single-file artifact is staged under (e.g. `archive.zip`). + * + * A file-producing bundler (`BundleResult.file`) staged with + * `AssetType.FILE` would otherwise take the source path's basename, which is + * a directory name when the source is a directory. Declaring the name here + * lets the artifact reflect what the bundler produces. It is static + * configuration, needed at construction before a deferred `SOURCE` build + * runs, not the file's runtime name. + * + * Valid only for a file-producing bundler: setting it with a directory + * packaging (anything but `AssetType.FILE`) is rejected at construction. + * + * @default - the source path's basename + */ + readonly outputFileName?: string; + + /** + * Produce the artifact and return a {@link BundleResult} describing it. + * + * Implementations write into `options.outputDir` and never write back to + * `options.source`. The returned path must exist and match its declared + * shape, or staging rejects it. * - * Implementations write into `options.outputDir` and return it or a - * subdirectory, never writing back to `options.source`. The returned - * directory is then packaged as an unbundled source directory would be. - * Returning a file, or a path that does not exist, is rejected — the - * contract is a directory, and packaging always treats the result as one. + * A bundler that cannot run in this environment returns + * `BundleResult.declined()` so a {@link ChainBundler} can fall through to + * the next; a thrown error is a hard failure, not a decline. */ - bundle(options: BundleOptions): string; + bundle(options: BundleOptions): BundleResult; } /** @@ -321,9 +421,8 @@ export interface IAssetBundler { * A `bundlerKey` has to serialize everything that can move a build's output; * done ad hoc, every bundler invents its own delimiter and forgets an input * differently. This gives the convention one implementation: parts are joined - * with a separator that is escaped where it appears in a value, so distinct - * inputs can never collide into the same key (`["a:b", "c"]` and - * `["a", "b:c"]` stay different). + * with a separator that is escaped inside values, so distinct inputs cannot + * collide into the same key. * * @example * const key = BundlerKey.of("docker", image, command) @@ -372,6 +471,84 @@ export class BundlerKey { } } +/** + * Composes bundlers into a "try each in order until one runs" chain. + * + * This is how a local bundler and a Docker bundler compose without being + * rewritten as one: each leg declines (`BundleResult.declined()`) when it + * cannot run here, and the chain moves to the next. The first non-declining + * result wins; if all decline, `bundle` throws, since staging has nothing to + * fall back to. Each leg builds into its own output directory, so a leg that + * writes before declining cannot leak partial files into the leg that wins. + * + * The chain's `bundlerKey` folds in *every* leg's key, so identity is the same + * regardless of which leg ends up running — a build that could have gone local + * or Docker is one asset, not two. + * + * This makes the legs interchangeable only if they produce equivalent output. + * Under `SOURCE` hashing they must: a machine with a host tool and one without + * run different legs, and non-equivalent legs would stage different bytes under + * the same hash. Use `OUTPUT` hashing when legs may diverge, so identity tracks + * the artifact each leg actually produced. + */ +export class ChainBundler implements IAssetBundler { + /** + * Chain bundlers in the order given; earlier bundlers are preferred. + */ + public static of(...bundlers: IAssetBundler[]): ChainBundler { + return new ChainBundler(bundlers); + } + + public readonly bundlerKey?: string; + + public readonly outputFileName?: string; + + private constructor(private readonly bundlers: IAssetBundler[]) { + if (bundlers.length === 0) { + throw chainBundlerRequiresAtLeastOneBundler(); + } + // Fold in every leg's key so which leg runs cannot change identity. A leg + // without a key contributes an empty part, still distinguishing "two legs" + // from "one leg" positionally. + const keys = bundlers.map((b) => b.bundlerKey ?? ""); + this.bundlerKey = keys.some((k) => k !== "") + ? BundlerKey.of("chain", ...keys).toString() + : undefined; + + // The staged file name is fixed at construction, before any leg runs, so + // legs that declare one must agree — otherwise the name would depend on + // which leg happens to run. + const names = new Set( + bundlers + .map((b) => b.outputFileName) + .filter((n): n is string => n !== undefined), + ); + if (names.size > 1) { + throw chainBundlerConflictingOutputFileName([...names]); + } + this.outputFileName = names.size === 1 ? [...names][0] : undefined; + } + + public bundle(options: BundleOptions): BundleResult { + for (let i = 0; i < this.bundlers.length; i++) { + // Each leg gets its own output directory. A leg is free to write before + // it declines (esbuild failing on an unsupported target, a Docker leg + // creating output before finding the daemon down), and sharing one + // directory would leak those partial files into the leg that succeeds. + const legOutputDir = path.join(options.outputDir, `leg-${i}`); + fs.mkdirSync(legOutputDir); + const result = this.bundlers[i].bundle({ + source: options.source, + outputDir: legOutputDir, + }); + if (!result.isDeclined) { + return result; + } + } + throw chainBundlerAllDeclined(this.bundlers.length); + } +} + /** * A staged artifact, ready to hand to an `IAssetPublisher`. * @@ -382,9 +559,9 @@ export class BundlerKey { */ export interface StagedAsset { /** - * A hash on the content source. This hash is used to uniquely identify this - * asset throughout the system. If this value doesn't change, the asset will - * not be rebuilt or republished. + * A hash on the content source, uniquely identifying this asset. + * + * The asset is not rebuilt or republished while this value is unchanged. */ readonly assetHash: string; diff --git a/packages/cdktn/src/errors.ts b/packages/cdktn/src/errors.ts index e1e2b8330..5dccecdfb 100644 --- a/packages/cdktn/src/errors.ts +++ b/packages/cdktn/src/errors.ts @@ -95,26 +95,41 @@ Place a cdktf.json at the root of your project, or pass an absolute path. Learn `, ); -export const assetFilePackagingWithBundlerUnsupported = (id: string) => +export const assetHashInvalid = (id: string, assetHash: string) => new Error( - `TerraformAsset ${id} was configured with a 'bundler' and file packaging (AssetType.FILE). A bundler produces a directory of output, which cannot be staged as a single file. - -Use AssetType.ARCHIVE to zip the bundler's output, or AssetType.DIRECTORY to stage it as a tree. + `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 assetHashInvalid = (id: string, assetHash: string) => +export const assetStagingBundlerOutputNotDirectory = ( + id: string, + produced: 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 '-'. + `TerraformAsset ${id}'s bundler returned a directory result at '${produced}', which is not a directory. Return 'BundleResult.file(...)' for a single-file artifact, or write a directory into 'options.outputDir' and return 'BundleResult.directory(...)'. Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, ); -export const assetStagingBundlerOutputNotDirectory = ( +export const assetStagingBundlerOutputNotFile = ( id: string, produced: string, ) => new Error( - `TerraformAsset ${id}'s bundler returned '${produced}', which is not a directory. A bundler must write its output into 'options.outputDir' and return that directory (or a subdirectory of it); the returned tree is then packaged. + `TerraformAsset ${id}'s bundler returned a file result at '${produced}', which is not a file. Return 'BundleResult.directory(...)' for a directory artifact, or write a single file into 'options.outputDir' and return 'BundleResult.file(...)'. +Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, + ); + +export const assetStagingBundlerFileOutputNeedsFilePackaging = (id: string) => + new Error( + `TerraformAsset ${id}'s bundler produced a single-file artifact (BundleResult.file), which stages verbatim and so needs AssetType.FILE. Use AssetType.FILE for a file-producing bundler, or have the bundler return a directory (BundleResult.directory) for AssetType.DIRECTORY or AssetType.ARCHIVE. +Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, + ); + +export const assetStagingBundlerDirectoryOutputNeedsDirectoryPackaging = ( + id: string, +) => + new Error( + `TerraformAsset ${id}'s bundler produced a directory artifact (BundleResult.directory), but AssetType.FILE stages a single file verbatim. Use AssetType.DIRECTORY or AssetType.ARCHIVE with a directory-producing bundler, or have the bundler return a single file (BundleResult.file) for AssetType.FILE. Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, ); @@ -124,6 +139,30 @@ export const assetStagingAlreadyStaged = (id: string) => Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, ); +export const assetBundlerOutputFileNameInvalid = ( + id: string, + outputFileName: string, +) => + new Error( + `TerraformAsset ${id} was given a bundler with an invalid outputFileName '${outputFileName}'. It names the staged artifact file, so it must be a plain file name — not empty, '.', '..', absolute, or containing '/' or '\\'. +Learn more about TerraformAsset: https://cdktn.io/docs/concepts/assets`, + ); + +export const chainBundlerRequiresAtLeastOneBundler = () => + new Error( + `ChainBundler.of() requires at least one bundler. At least one bundler must be able to run in this environment.`, + ); + +export const chainBundlerConflictingOutputFileName = (names: string[]) => + new Error( + `ChainBundler legs declare conflicting outputFileName values (${names.join(", ")}). The staged name is fixed before any leg runs, so all legs that set it must agree.`, + ); + +export const chainBundlerAllDeclined = (tried: number) => + new Error( + `Every bundler in the ChainBundler declined to run (${tried} tried). At least one must be able to run in this environment.`, + ); + 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/terraform-asset.ts b/packages/cdktn/src/terraform-asset.ts index 82e7f854c..6a86826d6 100644 --- a/packages/cdktn/src/terraform-asset.ts +++ b/packages/cdktn/src/terraform-asset.ts @@ -16,8 +16,10 @@ import { ISynthesisSession } from "./synthesize"; import { addCustomSynthesis } from "./synthesize/synthesizer"; import { TerraformStack } from "./terraform-stack"; import { + assetBundlerOutputFileNameInvalid, assetExpectsDirectory, assetOutOfScopeOfCDKTFJson, + assetStagingBundlerFileOutputNeedsFilePackaging, assetTypeNotImplemented, } from "./errors"; @@ -65,8 +67,8 @@ export interface TerraformAssetConfig { * Core ships no bundler; implement `IAssetBundler` or use one from a bundler * package. Under the default `SOURCE` hashing the build is deferred to synth * and stays skippable; `OUTPUT` hashing builds eagerly to hash the artifact. - * `AssetType.FILE` is rejected, since bundler output is always a directory. - * See `AssetStagingOptions.bundler`. + * The bundler's output shape must match `type`. See + * `AssetStagingOptions.bundler`. * * @default - the source is staged verbatim, with no build step */ @@ -85,10 +87,16 @@ export enum AssetType { const ARCHIVE_BASENAME = "archive"; const ASSETS_DIRECTORY = "assets"; +// A bundler's outputFileName becomes a path segment under the asset's hash +// directory (see `path`/`fileName`), so it must be a plain file name that +// cannot traverse or absolutize. Rejects empty, `.`, `..`, and any `/` or `\`. +const SAFE_OUTPUT_FILE_NAME = /^(?!\.\.?$)[^/\\]+$/; + /** - * How each `AssetType` is actually written to disk at synthesis time. - * Internal wiring only: swapping this map's values is how a future format - * would be added, without any change to the public `AssetType` surface. + * Maps each `AssetType` to how it is written to disk at synthesis time. + * + * Internal only: a future format is added by changing this map's values, + * without any change to the public `AssetType` surface. */ const PACKAGING_BY_TYPE: Record = { [AssetType.FILE]: AssetPackaging.FILE, @@ -106,13 +114,13 @@ export class TerraformAsset extends Construct implements IAsset { public type: AssetType; // owns hashing and packing; `AssetStaging` also validates a custom `assetHash` private readonly staging: AssetStaging; + // set when a bundler builds the artifact; names a single-file result + private readonly bundler?: IAssetBundler; /** * A Terraform Asset takes a file or directory outside of the CDK Terrain context and moves it into it. + * * Assets copy referenced files into the stacks context for further usage in other resources. - * @param scope - * @param id - * @param config */ constructor(scope: Construct, id: string, config: TerraformAssetConfig) { super(scope, id); @@ -141,13 +149,30 @@ export class TerraformAsset extends Construct implements IAsset { const inferredType = stat.isFile() ? AssetType.FILE : AssetType.DIRECTORY; this.type = config.type ?? inferredType; - // Validate the type against the source before staging, so an invalid - // combination is rejected here rather than after AssetStaging has already - // run an eager bundler build. - if (stat.isFile() !== (this.type === AssetType.FILE)) { + // Without a bundler the source is staged verbatim, so its shape must match + // the type. A bundler decouples them, so the check moves to the bundler + // output shape, validated in AssetStaging once the build runs. + if (!config.bundler && stat.isFile() !== (this.type === AssetType.FILE)) { throw assetExpectsDirectory(id, config.path); } + this.bundler = config.bundler; + + const outputFileName = config.bundler?.outputFileName; + if (outputFileName !== undefined) { + if (!SAFE_OUTPUT_FILE_NAME.test(outputFileName)) { + throw assetBundlerOutputFileNameInvalid(id, outputFileName); + } + // outputFileName is declared statically, so a bundler that sets it is + // announcing FILE output. Catch a FILE/packaging mismatch now rather + // than after a (possibly minutes-long) build; validateOutputShape stays + // the backstop for the dynamic case where the shape is only known once + // bundle() returns. + if (this.type !== AssetType.FILE) { + throw assetStagingBundlerFileOutputNeedsFilePackaging(id); + } + } + this.staging = new AssetStaging(this, "Staging", { sourcePath: this.sourcePath, displayName: id, @@ -204,11 +229,16 @@ export class TerraformAsset extends Construct implements IAsset { */ public get fileName(): string { const { extension } = this.packaging; - // Repackaged artifacts (extension set) get a stable base + extension; - // verbatim copies keep the source name. - return extension - ? `${ARCHIVE_BASENAME}${extension}` - : path.basename(this.sourcePath); + // Repackaged artifacts (extension set) get a stable base + extension. + if (extension) { + return `${ARCHIVE_BASENAME}${extension}`; + } + // A single-file bundler artifact uses the name the bundler declares, + // falling back to the source basename. + if (this.bundler?.outputFileName) { + return this.bundler.outputFileName; + } + return path.basename(this.sourcePath); } private _onSynthesize(session: ISynthesisSession) { diff --git a/packages/cdktn/test/asset-staging.test.ts b/packages/cdktn/test/asset-staging.test.ts index 6cf3b0301..cb78fe2cb 100644 --- a/packages/cdktn/test/asset-staging.test.ts +++ b/packages/cdktn/test/asset-staging.test.ts @@ -8,6 +8,8 @@ import { AssetPackaging, AssetStaging, ASSET_HASH_SALT_CONTEXT_KEY, + BundleResult, + ChainBundler, ExcludeIgnoreStrategy, type IAssetPackaging, TerraformStack, @@ -293,13 +295,12 @@ describe("AssetStaging", () => { const withBundler = new AssetStaging(stack(), "bundled", { sourcePath: srcDir, packaging: AssetPackaging.DIRECTORY, - // Under the default SOURCE hashing, a bundler that produces entirely - // different bytes must not change the hash — identity is a function - // of the inputs, and the build is deferred (never runs here). + // Under SOURCE hashing the build is deferred and never runs here, so + // bundler output cannot change the hash. bundler: { bundle: (opts) => { fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -318,7 +319,7 @@ describe("AssetStaging", () => { path.join(opts.outputDir, "built.txt"), "output", ); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -332,7 +333,7 @@ describe("AssetStaging", () => { path.join(opts.outputDir, "built.txt"), "output", ); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -350,7 +351,7 @@ describe("AssetStaging", () => { bundler: { bundle: (opts) => { fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "one"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -361,7 +362,7 @@ describe("AssetStaging", () => { bundler: { bundle: (opts) => { fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "two"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -384,7 +385,7 @@ describe("AssetStaging", () => { path.join(opts.outputDir, "built.txt"), "output", ); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -414,7 +415,7 @@ describe("AssetStaging", () => { path.join(opts.outputDir, "built.txt"), "output", ); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -427,17 +428,39 @@ describe("AssetStaging", () => { expect(fs.existsSync(observedOutputDir!)).toBe(false); }); + test("cleans up the eager-build scratch when the bundler throws in the constructor", () => { + let observedOutputDir: string | undefined; + + expect( + () => + new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + assetHashType: AssetHashType.OUTPUT, + bundler: { + bundle: (opts) => { + observedOutputDir = opts.outputDir; + throw new Error("build failed"); + }, + }, + }), + ).toThrow(/build failed/); + + // The eager build failed before eagerBuild was set, so stage() can + // never reach this scratch; hashOutput must reclaim it immediately. + expect(observedOutputDir).toBeDefined(); + expect(fs.existsSync(path.dirname(observedOutputDir!))).toBe(false); + }); + test("sweeps the scratch directory on process exit when stage() never runs", () => { - // The eager build happens in the constructor but is normally cleaned - // up in stage(). When stage() never runs (an unsynthesized stack), - // the process-exit hook is the safety net. Exercised in a child - // process against the compiled lib so a real `exit` fires; the child - // prints the scratch path it created, and the parent asserts it was + // When stage() never runs (an unsynthesized stack), the process-exit + // hook is the safety net. Run in a child process so a real `exit` + // fires: the child prints the scratch path, the parent asserts it was // swept. const marker = path.join(createTempDir(), "scratch-path.txt"); const script = ` const fs = require("fs"); - const { AssetStaging, App, TerraformStack } = require(${JSON.stringify( + const { AssetStaging, App, BundleResult, TerraformStack } = require(${JSON.stringify( path.resolve(__dirname, "../lib"), )}); const stack = new TerraformStack(new App(), "s"); @@ -451,7 +474,7 @@ describe("AssetStaging", () => { bundle: (opts) => { fs.writeFileSync(${JSON.stringify(marker)}, opts.outputDir); fs.writeFileSync(opts.outputDir + "/built.txt", "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -473,7 +496,7 @@ describe("AssetStaging", () => { bundle: (opts) => { expect(opts.source).toBe(srcDir); fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -501,7 +524,7 @@ describe("AssetStaging", () => { const built = path.join(opts.outputDir, "node_modules"); fs.mkdirSync(built); fs.writeFileSync(path.join(built, "dep.js"), "dependency"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -520,14 +543,14 @@ describe("AssetStaging", () => { const withoutKey = new AssetStaging(stack(), "without", { sourcePath: srcDir, packaging: AssetPackaging.DIRECTORY, - bundler: { bundle: (opts) => opts.outputDir }, + bundler: { bundle: (opts) => BundleResult.directory(opts.outputDir) }, }); const withKey = new AssetStaging(stack(), "with", { sourcePath: srcDir, packaging: AssetPackaging.DIRECTORY, bundler: { bundlerKey: "docker:node:20:npm run build", - bundle: (opts) => opts.outputDir, + bundle: (opts) => BundleResult.directory(opts.outputDir), }, }); const withDifferentKey = new AssetStaging(stack(), "different", { @@ -535,7 +558,7 @@ describe("AssetStaging", () => { packaging: AssetPackaging.DIRECTORY, bundler: { bundlerKey: "docker:node:18:npm run build", - bundle: (opts) => opts.outputDir, + bundle: (opts) => BundleResult.directory(opts.outputDir), }, }); @@ -543,18 +566,6 @@ describe("AssetStaging", () => { expect(withKey.assetHash).not.toEqual(withDifferentKey.assetHash); }); - test("FILE packaging with a bundler throws", () => { - fs.writeFileSync(path.join(srcDir, "single.txt"), "content"); - expect( - () => - new AssetStaging(stack(), "staging", { - sourcePath: path.join(srcDir, "single.txt"), - packaging: AssetPackaging.FILE, - bundler: { bundle: (opts) => opts.outputDir }, - }), - ).toThrow(/file packaging|AssetType\.FILE/i); - }); - test("ARCHIVE packaging with a bundler is allowed", () => { const staging = new AssetStaging(stack(), "staging", { sourcePath: srcDir, @@ -562,7 +573,7 @@ describe("AssetStaging", () => { bundler: { bundle: (opts) => { fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -574,10 +585,9 @@ describe("AssetStaging", () => { }); test("a directory-source single-file packaging (e.g. tar.gz) with a bundler is allowed", () => { - // Rejection keys on `acceptsDirectorySource`, not on producing a - // directory or omitting directory entries. A tar.gz-style packaging - // takes a directory source, emits one file, and keeps directory - // entries (omitsDirectoryEntries: false) — it must not be rejected. + // A tar.gz-style packaging takes a directory source but emits one file; + // acceptance keys on `acceptsDirectorySource`, so it must not be + // rejected. const tarLike: IAssetPackaging = { extension: ".tar.gz", producesDirectory: false, @@ -595,7 +605,9 @@ describe("AssetStaging", () => { new AssetStaging(stack(), "staging", { sourcePath: srcDir, packaging: tarLike, - bundler: { bundle: (opts) => opts.outputDir }, + bundler: { + bundle: (opts) => BundleResult.directory(opts.outputDir), + }, }), ).not.toThrow(); }); @@ -609,7 +621,7 @@ describe("AssetStaging", () => { const dist = path.join(opts.outputDir, "dist"); fs.mkdirSync(dist); fs.writeFileSync(path.join(dist, "bundle.js"), "built"); - return dist; + return BundleResult.directory(dist); }, }, }); @@ -631,7 +643,7 @@ describe("AssetStaging", () => { bundle: (opts) => { observedOutputDir = opts.outputDir; fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -677,7 +689,7 @@ describe("AssetStaging", () => { bundle: (opts) => { observedSource = opts.source; fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -691,10 +703,8 @@ describe("AssetStaging", () => { }); test("exclude filters what the bundler reads, and the source is a copy", () => { - // With exclusions configured, the bundler must see a filtered copy — - // not the original tree — so it reads exactly the file set the hash was - // taken over. b.md is excluded, so it must be absent from what the - // bundler is handed. + // With exclusions, the bundler must see a filtered copy, not the + // original tree, so it reads the same file set the hash was taken over. let sawExcluded = true; let sawIncluded = false; let handedSource: string | undefined; @@ -708,7 +718,7 @@ describe("AssetStaging", () => { sawExcluded = fs.existsSync(path.join(opts.source, "b.md")); sawIncluded = fs.existsSync(path.join(opts.source, "a.txt")); fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -733,7 +743,7 @@ describe("AssetStaging", () => { bundle: (opts) => { handedSource = opts.source; fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -745,7 +755,7 @@ describe("AssetStaging", () => { expect(handedSource).toEqual(path.resolve(srcDir)); }); - test("a bundler returning a non-directory throws", () => { + test("a directory result that is actually a file throws", () => { const staging = new AssetStaging(stack(), "staging", { sourcePath: srcDir, packaging: AssetPackaging.DIRECTORY, @@ -753,7 +763,8 @@ describe("AssetStaging", () => { bundle: (opts) => { const file = path.join(opts.outputDir, "artifact.js"); fs.writeFileSync(file, "built"); - return file; + // Declares a directory but points at a file. + return BundleResult.directory(file); }, }, }); @@ -763,12 +774,13 @@ describe("AssetStaging", () => { expect(() => staging.stage(targetPath)).toThrow(/not a directory/i); }); - test("a bundler returning a nonexistent path throws", () => { + test("a directory result at a nonexistent path throws", () => { const staging = new AssetStaging(stack(), "staging", { sourcePath: srcDir, packaging: AssetPackaging.DIRECTORY, bundler: { - bundle: (opts) => path.join(opts.outputDir, "does-not-exist"), + bundle: (opts) => + BundleResult.directory(path.join(opts.outputDir, "does-not-exist")), }, }); const targetPath = path.join(createTempDir(), "out"); @@ -777,6 +789,26 @@ describe("AssetStaging", () => { expect(() => staging.stage(targetPath)).toThrow(/not a directory/i); }); + test("a file result that is actually a directory throws", () => { + fs.writeFileSync(path.join(srcDir, "single.txt"), "content"); + const staging = new AssetStaging(stack(), "staging", { + sourcePath: path.join(srcDir, "single.txt"), + packaging: AssetPackaging.FILE, + bundler: { + bundle: (opts) => { + const dir = path.join(opts.outputDir, "not-a-file"); + fs.mkdirSync(dir); + // Declares a file but points at a directory. + return BundleResult.file(dir); + }, + }, + }); + const targetPath = path.join(createTempDir(), "artifact.txt"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + expect(() => staging.stage(targetPath)).toThrow(/not a file/i); + }); + test("a second stage() throws instead of rebuilding (OUTPUT)", () => { let buildCount = 0; const staging = new AssetStaging(stack(), "staging", { @@ -787,7 +819,7 @@ describe("AssetStaging", () => { bundle: (opts) => { buildCount++; fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); - return opts.outputDir; + return BundleResult.directory(opts.outputDir); }, }, }); @@ -820,15 +852,263 @@ describe("AssetStaging", () => { test("errors name the caller's displayName, not the staging child id", () => { fs.writeFileSync(path.join(srcDir, "single.txt"), "content"); - expect( - () => - new AssetStaging(stack(), "Staging", { - sourcePath: path.join(srcDir, "single.txt"), - packaging: AssetPackaging.FILE, - displayName: "MyAsset", - bundler: { bundle: (opts) => opts.outputDir }, - }), - ).toThrow(/TerraformAsset MyAsset/); + // A directory-producing bundler with FILE packaging is a shape mismatch, + // caught when the build runs. The message must name the caller. + const staging = new AssetStaging(stack(), "Staging", { + sourcePath: path.join(srcDir, "single.txt"), + packaging: AssetPackaging.FILE, + displayName: "MyAsset", + bundler: { + bundle: (opts) => BundleResult.directory(opts.outputDir), + }, + }); + const targetPath = path.join(createTempDir(), "artifact.txt"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + expect(() => staging.stage(targetPath)).toThrow(/TerraformAsset MyAsset/); + }); + + describe("output shape (archive-producing bundlers)", () => { + test("a file-producing bundler stages verbatim with FILE packaging", () => { + fs.writeFileSync(path.join(srcDir, "single.txt"), "content"); + const staging = new AssetStaging(stack(), "staging", { + sourcePath: path.join(srcDir, "single.txt"), + packaging: AssetPackaging.FILE, + bundler: { + bundle: (opts) => { + const archive = path.join(opts.outputDir, "archive.zip"); + fs.writeFileSync(archive, "zip-bytes"); + return BundleResult.file(archive); + }, + }, + }); + const targetPath = path.join(createTempDir(), "archive.zip"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + staging.stage(targetPath); + + // Staged as the single file the bundler produced — no wrapper + // directory, no double archive. + expect(fs.statSync(targetPath).isFile()).toBe(true); + expect(fs.readFileSync(targetPath, "utf-8")).toBe("zip-bytes"); + }); + + test("a file-producing bundler under OUTPUT hashing hashes the file", () => { + fs.writeFileSync(path.join(srcDir, "single.txt"), "content"); + const one = new AssetStaging(stack(), "one", { + sourcePath: path.join(srcDir, "single.txt"), + packaging: AssetPackaging.FILE, + assetHashType: AssetHashType.OUTPUT, + bundler: { + bundle: (opts) => { + const archive = path.join(opts.outputDir, "archive.zip"); + fs.writeFileSync(archive, "bytes-one"); + return BundleResult.file(archive); + }, + }, + }); + const two = new AssetStaging(stack(), "two", { + sourcePath: path.join(srcDir, "single.txt"), + packaging: AssetPackaging.FILE, + assetHashType: AssetHashType.OUTPUT, + bundler: { + bundle: (opts) => { + const archive = path.join(opts.outputDir, "archive.zip"); + fs.writeFileSync(archive, "bytes-two"); + return BundleResult.file(archive); + }, + }, + }); + + // Different archive bytes -> different identity. + expect(one.assetHash).not.toEqual(two.assetHash); + }); + + test("a file-producing bundler with DIRECTORY packaging throws", () => { + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + bundler: { + bundle: (opts) => { + const archive = path.join(opts.outputDir, "archive.zip"); + fs.writeFileSync(archive, "zip-bytes"); + return BundleResult.file(archive); + }, + }, + }); + const targetPath = path.join(createTempDir(), "out"); + fs.mkdirSync(targetPath, { recursive: true }); + + expect(() => staging.stage(targetPath)).toThrow( + /AssetType\.FILE|single-file/i, + ); + }); + + test("a directory-producing bundler with FILE packaging throws", () => { + fs.writeFileSync(path.join(srcDir, "single.txt"), "content"); + const staging = new AssetStaging(stack(), "staging", { + sourcePath: path.join(srcDir, "single.txt"), + packaging: AssetPackaging.FILE, + bundler: { + bundle: (opts) => BundleResult.directory(opts.outputDir), + }, + }); + const targetPath = path.join(createTempDir(), "artifact.txt"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + expect(() => staging.stage(targetPath)).toThrow( + /AssetType\.DIRECTORY|AssetType\.ARCHIVE|single file/i, + ); + }); + }); + + describe("decline protocol and ChainBundler", () => { + test("ChainBundler falls through a declining bundler to the next", () => { + const calls: string[] = []; + const local = { + bundlerKey: "local:v1", + bundle: () => { + calls.push("local"); + return BundleResult.declined(); + }, + }; + const docker = { + bundlerKey: "docker:v1", + bundle: (opts: { outputDir: string; source: string }) => { + calls.push("docker"); + fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); + return BundleResult.directory(opts.outputDir); + }, + }; + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + bundler: ChainBundler.of(local, docker), + }); + const targetPath = path.join(createTempDir(), "out"); + fs.mkdirSync(targetPath, { recursive: true }); + + staging.stage(targetPath); + + expect(calls).toEqual(["local", "docker"]); + expect(fs.existsSync(path.join(targetPath, "built.txt"))).toBe(true); + }); + + test("a leg that writes before declining does not leak into the winning leg", () => { + // A declining leg may still have written to its output directory + // (esbuild failing partway, a Docker leg creating output before the + // daemon check). Per-leg isolation must keep that out of the result. + const writesThenDeclines = { + bundle: (opts: { outputDir: string; source: string }) => { + fs.writeFileSync(path.join(opts.outputDir, "sentinel.txt"), "leak"); + return BundleResult.declined(); + }, + }; + const succeeds = { + bundle: (opts: { outputDir: string; source: string }) => { + fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "output"); + return BundleResult.directory(opts.outputDir); + }, + }; + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + bundler: ChainBundler.of(writesThenDeclines, succeeds), + }); + const targetPath = path.join(createTempDir(), "out"); + fs.mkdirSync(targetPath, { recursive: true }); + + staging.stage(targetPath); + + expect(fs.existsSync(path.join(targetPath, "built.txt"))).toBe(true); + expect(fs.existsSync(path.join(targetPath, "sentinel.txt"))).toBe( + false, + ); + }); + + test("ChainBundler prefers the first bundler that runs", () => { + const calls: string[] = []; + const local = { + bundle: (opts: { outputDir: string; source: string }) => { + calls.push("local"); + fs.writeFileSync(path.join(opts.outputDir, "built.txt"), "local"); + return BundleResult.directory(opts.outputDir); + }, + }; + const docker = { + bundle: () => { + calls.push("docker"); + return BundleResult.declined(); + }, + }; + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + bundler: ChainBundler.of(local, docker), + }); + const targetPath = path.join(createTempDir(), "out"); + fs.mkdirSync(targetPath, { recursive: true }); + + staging.stage(targetPath); + + expect(calls).toEqual(["local"]); + }); + + test("ChainBundler throws when every bundler declines", () => { + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + bundler: ChainBundler.of( + { bundle: () => BundleResult.declined() }, + { bundle: () => BundleResult.declined() }, + ), + }); + const targetPath = path.join(createTempDir(), "out"); + fs.mkdirSync(targetPath, { recursive: true }); + + expect(() => staging.stage(targetPath)).toThrow(/declined/i); + }); + + test("a bare bundler that declines throws (nothing to fall back to)", () => { + const staging = new AssetStaging(stack(), "staging", { + sourcePath: srcDir, + packaging: AssetPackaging.DIRECTORY, + bundler: { bundle: () => BundleResult.declined() }, + }); + const targetPath = path.join(createTempDir(), "out"); + fs.mkdirSync(targetPath, { recursive: true }); + + expect(() => staging.stage(targetPath)).toThrow(); + }); + + test("ChainBundler identity is stable regardless of which leg runs", () => { + const localKey = "local:v1"; + const dockerKey = "docker:v1"; + // Same legs, but the first declines in one and runs in the other; the + // chain's bundlerKey folds in both, so identity must match. + const chainA = ChainBundler.of( + { bundlerKey: localKey, bundle: () => BundleResult.declined() }, + { + bundlerKey: dockerKey, + bundle: (opts: { outputDir: string; source: string }) => + BundleResult.directory(opts.outputDir), + }, + ); + const chainB = ChainBundler.of( + { + bundlerKey: localKey, + bundle: (opts: { outputDir: string; source: string }) => + BundleResult.directory(opts.outputDir), + }, + { bundlerKey: dockerKey, bundle: () => BundleResult.declined() }, + ); + + expect(chainA.bundlerKey).toEqual(chainB.bundlerKey); + }); + + test("ChainBundler.of() with no bundlers throws", () => { + expect(() => ChainBundler.of()).toThrow(/at least one/i); + }); }); }); diff --git a/packages/cdktn/test/assets.test.ts b/packages/cdktn/test/assets.test.ts index cbdc42b33..4d0aad2b9 100644 --- a/packages/cdktn/test/assets.test.ts +++ b/packages/cdktn/test/assets.test.ts @@ -1,7 +1,10 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 import { + BundleOutputType, + BundleResult, BundlerKey, + ChainBundler, TerraformHclModule, TerraformStack, Testing, @@ -187,3 +190,99 @@ describe("BundlerKey", () => { expect(dev.toString()).not.toBe(prod.toString()); }); }); + +describe("BundleResult", () => { + test("directory carries a DIRECTORY output type and the path", () => { + const result = BundleResult.directory("/tmp/out"); + expect(result.outputType).toBe(BundleOutputType.DIRECTORY); + expect(result.path).toBe("/tmp/out"); + expect(result.isDeclined).toBe(false); + }); + + test("file carries a FILE output type and the path", () => { + const result = BundleResult.file("/tmp/archive.zip"); + expect(result.outputType).toBe(BundleOutputType.FILE); + expect(result.path).toBe("/tmp/archive.zip"); + expect(result.isDeclined).toBe(false); + }); + + test("declined has no path and is marked declined", () => { + const result = BundleResult.declined(); + expect(result.isDeclined).toBe(true); + expect(result.path).toBeUndefined(); + }); +}); + +describe("ChainBundler", () => { + const declines = { bundle: () => BundleResult.declined() }; + const runs = { + bundle: (opts: { outputDir: string; source: string }) => + BundleResult.directory(opts.outputDir), + }; + + test("of() requires at least one bundler", () => { + expect(() => ChainBundler.of()).toThrow(/at least one/i); + }); + + test("bundlerKey folds in every leg's key so it depends on both", () => { + const both = ChainBundler.of( + { bundlerKey: "local:v1", bundle: runs.bundle }, + { bundlerKey: "docker:v1", bundle: declines.bundle }, + ); + const localOnly = ChainBundler.of({ + bundlerKey: "local:v1", + bundle: runs.bundle, + }); + const differentDocker = ChainBundler.of( + { bundlerKey: "local:v1", bundle: runs.bundle }, + { bundlerKey: "docker:v2", bundle: declines.bundle }, + ); + + expect(both.bundlerKey).not.toEqual(localOnly.bundlerKey); + expect(both.bundlerKey).not.toEqual(differentDocker.bundlerKey); + }); + + test("bundlerKey is undefined when no leg contributes a key", () => { + const chain = ChainBundler.of(runs, declines); + expect(chain.bundlerKey).toBeUndefined(); + }); + + test("two legs with the same single key differ from one leg", () => { + const one = ChainBundler.of({ bundlerKey: "k", bundle: runs.bundle }); + const two = ChainBundler.of( + { bundlerKey: "k", bundle: declines.bundle }, + { bundlerKey: "k", bundle: runs.bundle }, + ); + expect(one.bundlerKey).not.toEqual(two.bundlerKey); + }); + + test("outputFileName is taken from the legs that declare one", () => { + const chain = ChainBundler.of( + { outputFileName: "archive.zip", bundle: declines.bundle }, + { bundle: runs.bundle }, + ); + expect(chain.outputFileName).toBe("archive.zip"); + }); + + test("legs may agree on outputFileName", () => { + const chain = ChainBundler.of( + { outputFileName: "archive.zip", bundle: declines.bundle }, + { outputFileName: "archive.zip", bundle: runs.bundle }, + ); + expect(chain.outputFileName).toBe("archive.zip"); + }); + + test("conflicting outputFileName across legs throws", () => { + expect(() => + ChainBundler.of( + { outputFileName: "a.zip", bundle: declines.bundle }, + { outputFileName: "b.zip", bundle: runs.bundle }, + ), + ).toThrow(/conflicting outputFileName/i); + }); + + test("outputFileName is undefined when no leg declares one", () => { + const chain = ChainBundler.of(runs, declines); + expect(chain.outputFileName).toBeUndefined(); + }); +}); diff --git a/packages/cdktn/test/canonical-asset-hash.test.ts b/packages/cdktn/test/canonical-asset-hash.test.ts index 03bde2a16..b7af5316d 100644 --- a/packages/cdktn/test/canonical-asset-hash.test.ts +++ b/packages/cdktn/test/canonical-asset-hash.test.ts @@ -12,6 +12,7 @@ import { AssetType, AssetHash, AssetHashType, + BundleResult, IAsset, } from "../src"; import { CANONICAL_ASSET_HASHES } from "../src/features"; @@ -302,10 +303,9 @@ describe("TerraformAsset with the canonicalAssetHashes flag", () => { expect(asset.assetHash).not.toBe(canonical(srcDir)); }); - // Pins the relationship between AssetHash.of (a packaging-independent - // source-tree identity) and TerraformAsset (whose framing depends on type). - // The subdirectory matters: it is what makes DIRECTORY and ARCHIVE framing - // diverge, since ARCHIVE omits directory records. + // AssetHash.of is packaging-independent; TerraformAsset framing depends on + // type. The subdirectory makes DIRECTORY and ARCHIVE framing diverge, since + // ARCHIVE omits directory records. describe("AssetHash.of relationship to TerraformAsset", () => { beforeEach(() => { fs.mkdirSync(path.join(srcDir, "sub")); @@ -465,10 +465,9 @@ describe("TerraformAsset assetHashType", () => { }); 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. + // An assetHash is a path segment in `TerraformAsset.path`, so an unsafe + // one could escape the assets directory at synth; the check must apply + // even with no exclude/extraHash set. expect( () => new TerraformAsset(stack(), "asset", { @@ -612,30 +611,151 @@ describe("TerraformAsset artifact layout derives from the packaging", () => { }); // ZipPackaging.extension is ".zip", so the artifact is archive.zip - - // unchanged from before the wiring, but now derived rather than hardcoded. + // unchanged from before, but now derived rather than hardcoded. expect(asset.fileName).toBe("archive.zip"); expect(asset.path.endsWith("/archive.zip")).toBe(true); }); - test("an invalid type is rejected before the bundler runs", () => { - let bundled = false; + test("a directory source with a file-producing bundler is accepted", () => { + // With a bundler in play, the source shape no longer has to match the + // type, so construction does not reject a directory source with FILE. expect( () => new TerraformAsset(stack(), "asset", { path: srcDir, type: AssetType.FILE, - assetHashType: AssetHashType.OUTPUT, bundler: { bundle: (opts) => { - bundled = true; - return opts.outputDir; + const archive = path.join(opts.outputDir, "archive.zip"); + fs.writeFileSync(archive, "zip-bytes"); + return BundleResult.file(archive); }, }, }), - ).toThrow(/directory/i); + ).not.toThrow(); + }); + + test("a file-producing bundler names the artifact from outputFileName", () => { + const s = stack(); + const asset = new TerraformAsset(s, "asset", { + path: srcDir, + type: AssetType.FILE, + bundler: { + outputFileName: "archive.zip", + bundle: (opts) => { + // The runtime name differs from the declared one; the staged name + // comes from outputFileName, not this temp path. + const built = path.join(opts.outputDir, "build-output-xyz.zip"); + fs.writeFileSync(built, "zip-bytes"); + return BundleResult.file(built); + }, + }, + }); + + expect(asset.fileName).toBe("archive.zip"); + expect(asset.path.endsWith("/archive.zip")).toBe(true); + + const outdir = Testing.fullSynth(s); + const stagedFile = path.join(outdir, "stacks", s.node.id, asset.path); + expect(fs.existsSync(stagedFile)).toBe(true); + expect(fs.readFileSync(stagedFile, "utf-8")).toBe("zip-bytes"); + }); + + test("a file-producing bundler without outputFileName falls back to the source basename", () => { + const asset = new TerraformAsset(stack(), "asset", { + path: srcFile, + type: AssetType.FILE, + bundler: { + bundle: (opts) => { + const built = path.join(opts.outputDir, "whatever.bin"); + fs.writeFileSync(built, "bytes"); + return BundleResult.file(built); + }, + }, + }); + + // No outputFileName: the source basename ("a.txt") is used as before. + expect(asset.fileName).toBe("a.txt"); + }); - // The type/source mismatch is caught before staging, so no eager build ran. - expect(bundled).toBe(false); + // outputFileName becomes a path segment under the asset's hash directory, so + // an unsafe value could escape the stack dir or overwrite the just-cleaned + // named folder. A buggy bundler is the realistic source, not an attacker. + test.each([ + ["a traversal segment", "../../../../outside-stack.bin"], + ["a bare parent ref", ".."], + ["a current-dir ref", "."], + ["an empty name", ""], + ["a posix-absolute path", "/etc/evil.bin"], + ["a nested path", "nested/archive.zip"], + ["a windows path", "C:\\evil.bin"], + ["a backslash segment", "..\\..\\evil.bin"], + ])("outputFileName rejects %s", (_label, outputFileName) => { + expect( + () => + new TerraformAsset(stack(), "asset", { + path: srcFile, + type: AssetType.FILE, + bundler: { + outputFileName, + bundle: (opts) => { + const built = path.join(opts.outputDir, "built.bin"); + fs.writeFileSync(built, "bytes"); + return BundleResult.file(built); + }, + }, + }), + ).toThrow(/invalid outputFileName/i); + }); + + test("outputFileName accepts a plain file name with dots", () => { + expect( + () => + new TerraformAsset(stack(), "asset", { + path: srcFile, + type: AssetType.FILE, + bundler: { + outputFileName: "archive.tar.gz", + bundle: (opts) => { + const built = path.join(opts.outputDir, "built.bin"); + fs.writeFileSync(built, "bytes"); + return BundleResult.file(built); + }, + }, + }), + ).not.toThrow(); + }); + + test("a bundler declaring outputFileName with non-FILE packaging is rejected at construction", () => { + // outputFileName announces FILE output statically, so the mismatch is + // caught before the (possibly slow) build runs, not inside stage(). + expect( + () => + new TerraformAsset(stack(), "asset", { + path: srcDir, + type: AssetType.DIRECTORY, + bundler: { + outputFileName: "archive.zip", + bundle: (opts) => BundleResult.directory(opts.outputDir), + }, + }), + ).toThrow(/AssetType\.FILE|single-file/i); + }); + + test("a directory-producing bundler with FILE packaging is rejected", () => { + // OUTPUT hashing builds eagerly in the constructor, so the shape mismatch + // (directory output, single-file packaging) surfaces there. + expect( + () => + new TerraformAsset(stack(), "asset", { + path: srcDir, + type: AssetType.FILE, + assetHashType: AssetHashType.OUTPUT, + bundler: { + bundle: (opts) => BundleResult.directory(opts.outputDir), + }, + }), + ).toThrow(/AssetType\.DIRECTORY|AssetType\.ARCHIVE|single file/i); }); });