Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions packages/cdktn/src/asset-staging.ts
Original file line number Diff line number Diff line change
@@ -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<IConstruct, Map<string, string>>();

/**
* @param root - the construct tree root to scope the cache to, see {@link hashCachesByRoot}
*/
function hashCacheFor(root: IConstruct): Map<string, string> {
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<string, string>;

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,
});
}
}
16 changes: 16 additions & 0 deletions packages/cdktn/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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);
}
Expand All @@ -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
Expand All @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions packages/cdktn/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/cdktn/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading