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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ bootstrap.json
cdk-terrain.github-issues
.idea
tsconfig.tsbuildinfo
examples/java/gradle-shared-module/.gradle/
.gradle/

.nx/

Expand Down
131 changes: 131 additions & 0 deletions packages/cdktn/src/asset-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) HashiCorp, Inc
// SPDX-License-Identifier: MPL-2.0
import * as crypto from "crypto";
import * as path from "path";
import { hashPath, findFileAboveCwd } from "./private/fs";
import { ExcludeIgnoreStrategy, IIgnoreStrategy } from "./ignore-strategy";
import {
assetHashConflictingExcludeOptions,
assetHashOutOfScopeCdktnJson,
} from "./errors";

/**
* Options for {@link AssetHash.of}.
*/
export interface AssetHashOptions {
/**
* Paths to exclude, relative to the hashed path. Cannot be combined with
* `ignoreStrategy`, which replaces this matcher rather than layering on
* top of it.
*
* @default - nothing is excluded
*/
readonly exclude?: string[];
Comment thread
eduardomourar marked this conversation as resolved.

/**
* Extra information to fold into the hash.
*
* @default - no extra data
*/
readonly extraHash?: 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;
}

/**
* Computes a content hash of a file or directory without staging it.
*
* Providers that read a local path directly — a Docker build `context`, for
* example — need a content hash to drive `triggers`, but have no use for a
* staged copy of the source. `Asset` and `TerraformAsset` always produce a
* staged copy; this is the identity half without the staging half.
*/
export class AssetHash {
/**
* Content hash of a file or directory, without staging it.
*
* This is a hash of the source tree, and it does not depend on how the
* source is later packaged. `hashPath` is always called with `archive`
* unset, so directory records are part of the digest; the hash of a given
* tree is therefore the same whether it is later copied, zipped, or packed
* by a custom `IAssetPackaging` such as `tar.bz2`.
*
* A consequence worth stating: this does not equal
* `TerraformAsset(dir, { type: ARCHIVE }).assetHash` for a directory with
* subdirectories. That asset frames its hash to the emitted ZIP, which has
* no directory entries (see #323), so directory-only changes move it and
* not this. It equals a `FILE` / `DIRECTORY` `TerraformAsset` hash only
* when the `canonicalAssetHashes` flag is enabled, since that flag is what
* puts the asset on this same canonical scheme.
*
* A relative `filePath` is resolved against the directory containing
* `cdktf.json`, the same base `TerraformAsset` uses, so both hash the same
* source regardless of the process working directory. Absolute paths are
* used as-is. Throws if `filePath` is relative and no `cdktf.json` is found
* above the working directory.
* @param filePath - path to a file or directory to hash
* @param options - see {@link AssetHashOptions}
*/
public static of(filePath: string, options: AssetHashOptions = {}): string {
if (options.exclude?.length && options.ignoreStrategy) {
throw assetHashConflictingExcludeOptions();
}

const resolved = AssetHash.resolvePath(filePath);
const strategy =
options.ignoreStrategy ??
new ExcludeIgnoreStrategy(options.exclude ?? []);

// Pinned to the canonical scheme: this is a brand-new API with no
// existing hashes to preserve, so it has no reason to start on the
// legacy scheme that `canonicalAssetHashes` exists to move away from.
const baseHash = hashPath(resolved, {
Comment thread
eduardomourar marked this conversation as resolved.
canonical: true,
Comment thread
jsteinich marked this conversation as resolved.
shouldExclude: (relativePath, isDirectory) =>
strategy.ignores({ relativePath, isDirectory }),
descendIntoExcludedDirectories:
strategy.pruneExcludedDirectories === false,
});

if (!options.extraHash) {
return baseHash;
}

return crypto
.createHash("md5")
.update(baseHash)
.update(options.extraHash)
.digest("hex")
.slice(0, 32)
.toUpperCase();
}

/**
* Resolve `filePath` to an absolute path using the same base as
* `TerraformAsset`: relative paths are anchored to the directory holding
* `cdktf.json`, not `process.cwd()`, so a hash taken here matches the one
* `TerraformAsset` computes for the same source even when the app is run
* from a subdirectory or a test runner. Unlike `TerraformAsset`, there is
* no construct scope to read the `cdktfJsonPath` context from, so this uses
* the `findFileAboveCwd` fallback directly.
* @param filePath - path passed to {@link AssetHash.of}
*/
private static resolvePath(filePath: string): string {
if (path.isAbsolute(filePath)) {
return filePath;
}
const cdktfJsonPath = findFileAboveCwd("cdktf.json");
if (!cdktfJsonPath) {
throw assetHashOutOfScopeCdktnJson(filePath);
}
return path.resolve(path.dirname(cdktfJsonPath), filePath);
}

private constructor() {}
}
240 changes: 240 additions & 0 deletions packages/cdktn/src/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Copyright (c) HashiCorp, Inc
// SPDX-License-Identifier: MPL-2.0

import * as fs from "fs";
import { archiveSync, copySync } from "./private/fs";
import { IIgnoreStrategy } from "./ignore-strategy";

/**
* Common interface for all assets.
*/
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.
*/
readonly assetHash: string;
}

/**
* Asset hash options
*/
export interface AssetOptions {
/**
* Specify a custom hash for this asset. If `assetHashType` is set it must
* be set to `AssetHashType.CUSTOM`. The value is used verbatim as the asset
* 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.
*
* @default - based on `assetHashType`
*/
readonly assetHash?: string;

/**
* Specifies the type of hash to calculate for this asset.
*
* If `assetHash` is configured, this option must be `undefined` or
* `AssetHashType.CUSTOM`.
*
* @default - the default is `AssetHashType.SOURCE`, but if `assetHash` is
* explicitly specified this value defaults to `AssetHashType.CUSTOM`.
*/
readonly assetHashType?: AssetHashType;
}

/**
* 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.
*/
export enum AssetHashType {
Comment thread
jsteinich marked this conversation as resolved.
/**
* 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.
*/
SOURCE = "source",

/**
* 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.
*/
OUTPUT = "output",

/**
* Use a custom hash
*/
CUSTOM = "custom",
}

/**
* How a staged asset is produced and what shape it takes on disk.
*
* Packaging answers two independent questions: how the artifact is produced
* (copy, zip, tar.gz, ...) and whether the result is a directory or a single
* file. A closed enum can only ever answer the second one, so it is an
* interface rather than an enum — custom formats (e.g. `tar.bz2`) need no
* core change.
*/
export interface IAssetPackaging {
/**
* Appended to the staged artifact name, e.g. ".zip", "", ".tar.bz2".
*/
readonly extension: string;

/**
* Whether the staged result is a directory rather than a single file.
*
* Publishers branch on this to decide whether they upload one object or
* sync a tree.
*/
readonly producesDirectory: boolean;
Comment thread
jsteinich marked this conversation as resolved.

/**
* Perform the staging transformation, writing the packaged result to
* `options.target`.
* @param options - see {@link PackOptions}
*/
pack(options: PackOptions): void;
}

/**
* Options for {@link IAssetPackaging.pack}.
*
* A struct rather than positional parameters: adding a struct field is
* additive, adding a method parameter is not, and `pack` is called through
* JSII where that distinction is a breaking-change boundary.
*/
export interface PackOptions {
/**
* Path to the resolved (already bundled, if applicable) source.
*/
readonly source: string;

/**
* Path the packaged result should be written to.
*/
readonly target: string;

/**
* Entries to omit from the packaged result. Must match the strategy used
* to hash the same source, or the hash and the artifact describe
* different sets of files.
*
* @default - nothing is excluded
*/
readonly ignoreStrategy?: IIgnoreStrategy;
}

/**
* Copies a single file verbatim.
*/
class FilePackaging implements IAssetPackaging {
public readonly extension = "";
public readonly producesDirectory = false;
public pack(options: PackOptions): void {
fs.copyFileSync(options.source, options.target);
}
}

/**
* Copies a directory tree verbatim, without archiving it.
*/
class DirectoryPackaging implements IAssetPackaging {
public readonly extension = "";
public readonly producesDirectory = true;
public pack(options: PackOptions): void {
copySync(options.source, options.target, {
shouldExclude: options.ignoreStrategy
? (relativePath, isDirectory) =>
options.ignoreStrategy!.ignores({ relativePath, isDirectory })
: undefined,
descendIntoExcludedDirectories:
options.ignoreStrategy?.pruneExcludedDirectories === false,
});
}
}

/**
* Archives a directory tree into a single zip file.
*/
class ZipPackaging implements IAssetPackaging {
public readonly extension = ".zip";
public readonly producesDirectory = false;
public pack(options: PackOptions): void {
archiveSync(
options.source,
options.target,
options.ignoreStrategy
? (relativePath, isDirectory) =>
options.ignoreStrategy!.ignores({ relativePath, isDirectory })
: undefined,
options.ignoreStrategy?.pruneExcludedDirectories === false,
);
}
}

/**
* Built-in packaging strategies. Custom formats implement `IAssetPackaging`
* directly rather than extending this class.
*/
export class AssetPackaging {
/**
* Copy a single file as-is.
*/
public static readonly FILE: IAssetPackaging = new FilePackaging();

/**
* Copy a directory tree as-is, without archiving.
*/
public static readonly DIRECTORY: IAssetPackaging = new DirectoryPackaging();

/**
* Archive a directory tree into a single zip file.
*/
public static readonly ZIP: IAssetPackaging = new ZipPackaging();

private constructor() {}
}

/**
* A staged artifact, ready to hand to an `IAssetPublisher`.
*
* Deliberately narrower than a location: `path` and `isDirectory` are known
* once staging runs, at synth time, before anything is published. Where an
* asset ends up — a bucket name, an object key, a URL — is resolved at
* apply time and belongs on the publisher's own reference type instead.
*/
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.
*/
readonly assetHash: string;

/**
* The path to the staged artifact, relative to the stack directory.
*/
readonly path: string;

/**
* Whether the staged artifact is a directory rather than a single file.
*
* Publishers branch on this to decide whether they upload one object or
* sync a tree; see `IAssetPackaging.producesDirectory`.
*/
readonly isDirectory: boolean;
}
Loading
Loading