diff --git a/.gitignore b/.gitignore index ed9f99186..312579bb3 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ bootstrap.json cdk-terrain.github-issues .idea tsconfig.tsbuildinfo -examples/java/gradle-shared-module/.gradle/ +.gradle/ .nx/ diff --git a/packages/cdktn/src/asset-staging.ts b/packages/cdktn/src/asset-staging.ts new file mode 100644 index 000000000..389b36997 --- /dev/null +++ b/packages/cdktn/src/asset-staging.ts @@ -0,0 +1,541 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +// Simplified from AWS CDK and TerraConstructs patterns + +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Construct } from "constructs"; +import { AssetHashType, AssetOptions, FileAssetPackaging } from "./assets"; +import { + BundlingFileAccess, + BundlingOptions, + BundlingOutput, +} from "./bundling"; +import { + assetHashInvalid, + bundlingOutputEmpty, + bundlingOutputNotArchived, + bundlingOutputNotSingleFile, +} from "./errors"; +import { CANONICAL_ASSET_HASHES } from "./features"; +import { + AssetBundlingBindMount, + AssetBundlingVolumeCopy, +} from "./private/asset-staging"; +import { + copySync, + excludeMatcher, + hashPath as fsHashPath, + type ExcludePredicate, +} from "./private/fs"; + +/** + * Context key mixing an extra value into every asset hash in the tree. Intended + * for forcing global cache invalidation; composes with `extraHash` rather than + * replacing it. + */ +const ASSET_SALT_CONTEXT_KEY = "cdktn:assetHashSalt"; + +/** + * A custom `assetHash` becomes a path segment of the staged file, so it is + * restricted to characters that cannot escape the staging directory. + */ +const SAFE_ASSET_HASH = /^[A-Za-z0-9_.-]+$/; + +/** + * Bundling is expensive and its result is fully determined by the source and + * the staging options, so identical assets within one synth reuse the first + * staged result instead of running the container again. + */ +const stagingCache = new Map(); + +/** + * How an asset is packaged, together with the path that should be staged. + */ +interface ResolvedPackaging { + readonly packaging: FileAssetPackaging; + readonly isArchive: boolean; + readonly finalSourcePath: string; +} + +/** + * The outcome of staging, cached so repeated identical assets skip bundling. + */ +interface StagedAsset extends ResolvedPackaging { + readonly assetHash: string; + readonly absoluteStagedPath: string; +} + +/** + * Initialization properties for `AssetStaging`. + */ +export interface AssetStagingProps extends AssetOptions { + /** + * The source file or directory to copy from. A relative path is resolved + * against the current working directory, not against `cdktf.json`. + */ + readonly sourcePath: string; + + /** + * Paths to exclude, relative to `sourcePath` and always `/`-separated. Each + * entry may be an exact file path, a `*.ext` suffix match, or a directory + * (with or without a trailing `/`), which also excludes everything inside it. + * + * This is not full glob syntax: `**`, `?`, character classes and `!` + * negation are not supported. + * + * @default - nothing is excluded + */ + readonly exclude?: string[]; + + /** + * Extra information to encode into the fingerprint. + * + * @default - no extra data + */ + readonly extraHash?: string; + + /** + * Bundle the asset by executing a command in a Docker container. + * + * The asset path will be mounted at `/asset-input`. The Docker + * container is responsible for putting content at `/asset-output`. + * The content at `/asset-output` will be used as the final asset. + * + * @default - uploaded as-is + */ + readonly bundling?: BundlingOptions; +} + +/** + * Stages a file or directory from a location on the file system into a staging + * directory. + * + * This follows AWS CDK and TerraConstructs patterns but keeps implementation simple. + * Features can be added gradually as needed. + * + * The file/directory are staged based on their content hash (fingerprint). This + * means that only if content was changed, copy will happen. + */ +export class AssetStaging extends Construct { + /** + * The path in the container where the asset source will be mounted. + */ + public static readonly BUNDLING_INPUT_DIR = "/asset-input"; + + /** + * The path in the container where the bundled output should be written. + */ + public static readonly BUNDLING_OUTPUT_DIR = "/asset-output"; + + /** + * Absolute path to the asset data after staging. + */ + public readonly absoluteStagedPath: string; + + /** + * The absolute path of the asset as it was referenced by the user. + */ + public readonly sourcePath: string; + + /** + * A cryptographic hash of the asset. + */ + public readonly assetHash: string; + + /** + * How this asset should be packaged. + */ + public readonly packaging: FileAssetPackaging; + + /** + * Whether this asset is an archive (zip or jar). + */ + public readonly isArchive: boolean; + + private readonly assetOutdir: string; + private readonly sourceStats: fs.Stats; + private readonly shouldExclude: ExcludePredicate; + + constructor(scope: Construct, id: string, props: AssetStagingProps) { + super(scope, id); + + this.sourcePath = path.resolve(props.sourcePath); + + if (!fs.existsSync(this.sourcePath)) { + throw new Error(`Cannot find asset at ${this.sourcePath}`); + } + + this.sourceStats = fs.statSync(this.sourcePath); + this.shouldExclude = excludeMatcher(props.exclude ?? []); + this.assetOutdir = this.determineAssetOutdir(); + + const hashType = this.determineHashType(props); + + // Every input that can change the staged result must be in the key, + // including the context values that feed the hash. + const cacheKey = JSON.stringify({ + outdir: this.assetOutdir, + sourcePath: this.sourcePath, + hashType, + assetHash: props.assetHash, + extraHash: props.extraHash, + exclude: props.exclude, + bundling: props.bundling, + canonical: !!this.node.tryGetContext(CANONICAL_ASSET_HASHES), + salt: this.node.tryGetContext(ASSET_SALT_CONTEXT_KEY), + }); + const cached = stagingCache.get(cacheKey); + if (cached && fs.existsSync(cached.absoluteStagedPath)) { + this.assetHash = cached.assetHash; + this.packaging = cached.packaging; + this.isArchive = cached.isArchive; + this.absoluteStagedPath = cached.absoluteStagedPath; + return; + } + + // Bundling must happen before packaging is resolved, because the shape of + // the bundling output is what decides it. + let finalSourcePath = this.sourcePath; + let scratchDir: string | undefined; + if (props.bundling) { + if (!this.sourceStats.isDirectory()) { + throw new Error("Asset must be a directory when bundling"); + } + // Scratch lives in the system temp dir, never in assetOutdir, so a crash + // cannot leave non-asset entries in the output tree. + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-bundle-")); + finalSourcePath = this.bundle(props, scratchDir); + } + + try { + const bundlingOutputType = + props.bundling?.outputType ?? BundlingOutput.AUTO_DISCOVER; + + // Packaging must resolve before hashing: it selects the archive framing + // of the hash, and it narrows finalSourcePath to the actual output file, + // which sets the staged extension. + const resolved = this.resolvePackaging( + props, + finalSourcePath, + bundlingOutputType, + ); + this.packaging = resolved.packaging; + this.isArchive = resolved.isArchive; + finalSourcePath = resolved.finalSourcePath; + + this.assetHash = this.calculateHash(hashType, props, finalSourcePath); + + const extension = this.getExtension(finalSourcePath); + this.absoluteStagedPath = path.resolve( + this.assetOutdir, + `asset.${this.assetHash}${extension}`, + ); + + this.copyAsset(finalSourcePath, this.absoluteStagedPath); + + stagingCache.set(cacheKey, { + assetHash: this.assetHash, + packaging: this.packaging, + isArchive: this.isArchive, + absoluteStagedPath: this.absoluteStagedPath, + finalSourcePath, + }); + } finally { + if (scratchDir) { + fs.rmSync(scratchDir, { recursive: true, force: true }); + } + } + } + + /** + * Resolve where staged assets are written. The app's own outdir wins so that + * concurrent synths of different apps cannot collide; the `cdktf.json` walk + * remains as a fallback for trees built without an `App`. + */ + private determineAssetOutdir(): string { + const app = this.node.root; + if ("outdir" in app && typeof (app as any).outdir === "string") { + return path.join((app as any).outdir, "assets"); + } + + const cdktfJsonPath = this.findCdktfJson(); + if (cdktfJsonPath) { + return path.join(path.dirname(cdktfJsonPath), "cdktf.out", "assets"); + } + + return path.join("cdktf.out", "assets"); + } + + /** + * Run the bundler into `bundleDir`, preferring a local bundler when it + * reports that it handled the asset. + * @returns the directory holding the bundling output + */ + private bundle(props: AssetStagingProps, bundleDir: string): string { + if (props.bundling!.local?.tryBundle(bundleDir, props.bundling!)) { + return bundleDir; + } + + process.stdout.write(`Bundling asset ${this.node.path}...\n`); + + const fileAccess = + props.bundling!.bundlingFileAccess ?? BundlingFileAccess.BIND_MOUNT; + const bundlingProps = { + ...props.bundling!, + sourcePath: this.sourcePath, + bundleDir, + scope: this, + }; + + if (fileAccess === BundlingFileAccess.VOLUME_COPY) { + new AssetBundlingVolumeCopy(bundlingProps).run(); + } else { + new AssetBundlingBindMount(bundlingProps).run(); + } + + return bundleDir; + } + + /** + * Decide how the asset is packaged, validating the bundling output against + * the requested {@link BundlingOutput}. + * @returns the resolved packaging plus the path to stage, narrowed to a + * single file where applicable + */ + private resolvePackaging( + props: AssetStagingProps, + sourcePath: string, + bundlingOutputType: BundlingOutput, + ): ResolvedPackaging { + let packaging: FileAssetPackaging; + let isArchive: boolean; + let finalSourcePath = sourcePath; + if (props.bundling) { + const bundledStat = fs.statSync(finalSourcePath); + + if (bundledStat.isDirectory()) { + const files = fs.readdirSync(finalSourcePath); + + // Validate empty output + if (files.length === 0) { + throw bundlingOutputEmpty(this.node.path, finalSourcePath); + } + + if (files.length === 1) { + const singleFile = path.join(finalSourcePath, files[0]); + const singleStat = fs.statSync(singleFile); + + if ( + singleStat.isFile() && + this.isArchiveExtension(path.extname(files[0])) + ) { + // Single archive file found + if (bundlingOutputType === BundlingOutput.SINGLE_FILE) { + // SINGLE_FILE expects non-archive, but got archive - this is invalid + throw bundlingOutputNotSingleFile( + this.node.path, + finalSourcePath, + files.length, + files, + ); + } + + // Valid for AUTO_DISCOVER, ARCHIVED, NOT_ARCHIVED + if ( + bundlingOutputType === BundlingOutput.AUTO_DISCOVER || + bundlingOutputType === BundlingOutput.ARCHIVED + ) { + packaging = FileAssetPackaging.FILE; + isArchive = true; + finalSourcePath = singleFile; + } else { + // NOT_ARCHIVED: treat as directory to zip + packaging = FileAssetPackaging.ZIP_DIRECTORY; + isArchive = false; + } + } else if (singleStat.isFile()) { + // Single non-archive file found + if (bundlingOutputType === BundlingOutput.ARCHIVED) { + // ARCHIVED expects an archive, but got non-archive + throw bundlingOutputNotArchived( + this.node.path, + finalSourcePath, + files.length, + files, + ); + } + + if (bundlingOutputType === BundlingOutput.SINGLE_FILE) { + packaging = FileAssetPackaging.FILE; + isArchive = false; + finalSourcePath = singleFile; + } else { + // AUTO_DISCOVER or NOT_ARCHIVED: zip it + packaging = FileAssetPackaging.ZIP_DIRECTORY; + isArchive = false; + } + } else { + // Single directory or other non-file - always zip + packaging = FileAssetPackaging.ZIP_DIRECTORY; + isArchive = false; + } + } else { + // Multiple files + if (bundlingOutputType === BundlingOutput.ARCHIVED) { + throw bundlingOutputNotArchived( + this.node.path, + finalSourcePath, + files.length, + files, + ); + } + + if (bundlingOutputType === BundlingOutput.SINGLE_FILE) { + throw bundlingOutputNotSingleFile( + this.node.path, + finalSourcePath, + files.length, + files, + ); + } + + // AUTO_DISCOVER or NOT_ARCHIVED: zip everything + packaging = FileAssetPackaging.ZIP_DIRECTORY; + isArchive = false; + } + } else { + // Single file output (bundling directly produced a file, not a directory) + packaging = FileAssetPackaging.FILE; + isArchive = this.isArchiveExtension(this.getExtension(finalSourcePath)); + } + } else { + // No bundling - simple case + if (this.sourceStats.isDirectory()) { + packaging = FileAssetPackaging.ZIP_DIRECTORY; + isArchive = true; + } else { + packaging = FileAssetPackaging.FILE; + isArchive = this.isArchiveExtension(this.getExtension(finalSourcePath)); + } + } + + return { packaging, isArchive, finalSourcePath }; + } + + private findCdktfJson(): string | null { + const contextPath = this.node.tryGetContext("cdktfJsonPath"); + if (contextPath) return contextPath; + + let dir = process.cwd(); + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, "cdktf.json"); + if (fs.existsSync(candidate)) return candidate; + dir = path.dirname(dir); + } + return null; + } + + private determineHashType(props: AssetStagingProps): AssetHashType { + const customHash = props.assetHash; + const hashType = customHash + ? (props.assetHashType ?? AssetHashType.CUSTOM) + : (props.assetHashType ?? AssetHashType.SOURCE); + + if (customHash && hashType !== AssetHashType.CUSTOM) { + throw new Error( + `Cannot specify assetHashType when assetHash is provided. Use CUSTOM or leave undefined.`, + ); + } + + if (hashType === AssetHashType.CUSTOM && !customHash) { + throw new Error( + "assetHash must be specified when assetHashType is CUSTOM.", + ); + } + + return hashType; + } + + private calculateHash( + hashType: AssetHashType, + props: AssetStagingProps, + sourcePath?: string, + ): string { + if (hashType === AssetHashType.CUSTOM) { + // Used verbatim (matching TerraformAsset), so it must be safe as a path + // segment of the staged file name. + if (!SAFE_ASSET_HASH.test(props.assetHash!)) { + throw assetHashInvalid(this.node.path, props.assetHash!); + } + return props.assetHash!; + } + + // SOURCE hashes the original tree; OUTPUT hashes the bundling result. + const pathToHash = + hashType === AssetHashType.SOURCE + ? this.sourcePath + : sourcePath || this.sourcePath; + + const baseHash = fsHashPath(pathToHash, { + canonical: !!this.node.tryGetContext(CANONICAL_ASSET_HASHES), + archive: this.packaging === FileAssetPackaging.ZIP_DIRECTORY, + // OUTPUT hashes the already-filtered bundling output, so re-applying the + // source exclusions there would be wrong. + shouldExclude: + hashType === AssetHashType.SOURCE ? this.shouldExclude : undefined, + }); + + const salt = this.node.tryGetContext(ASSET_SALT_CONTEXT_KEY); + if (!salt && !props.extraHash) { + return baseHash; + } + + // Both fold into the digest: a salt must not mask an extraHash bump. + const combined = crypto.createHash("md5").update(baseHash); + if (props.extraHash) combined.update(props.extraHash); + if (salt) combined.update(salt); + return combined.digest("hex").slice(0, 32).toUpperCase(); + } + + /** + * Copy the resolved source into the staging directory. Skipped when the + * target already exists, since the path is content-keyed. + */ + private copyAsset(source: string, target: string) { + if (fs.existsSync(target)) return; + + fs.mkdirSync(path.dirname(target), { recursive: true }); + + // lstat, not stat: a symlinked source file must be staged as a file rather + // than crashing when the link dangles. + const stat = fs.lstatSync(source); + + if (stat.isDirectory()) { + copySync(source, target, { shouldExclude: this.shouldExclude }); + } else if (stat.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(source), target); + } else { + fs.copyFileSync(source, target); + } + } + + private getExtension(filePath: string): string { + const archiveExtensions = [".tar.gz", ".zip", ".jar", ".tar", ".tgz"]; + + for (const ext of archiveExtensions) { + if (filePath.toLowerCase().endsWith(ext)) { + return ext; + } + } + + return path.extname(filePath); + } + + private isArchiveExtension(ext: string): boolean { + const archiveExtensions = [".tar.gz", ".zip", ".jar", ".tar", ".tgz"]; + return archiveExtensions.includes(ext.toLowerCase()); + } +} diff --git a/packages/cdktn/src/assets.ts b/packages/cdktn/src/assets.ts new file mode 100644 index 000000000..2862cec6b --- /dev/null +++ b/packages/cdktn/src/assets.ts @@ -0,0 +1,420 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +/** + * 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 { + /** + * 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", +} + +/** + * Represents the source for a file asset. + */ +export interface FileAssetSource { + /** + * 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 sourceHash: string; + + /** + * The path, relative to the root of the cloud assembly, in which this asset + * source resides. This can be a path to a file or a directory, depending on the + * packaging type. + */ + readonly fileName: string; + + /** + * Which type of packaging to perform. + * + * @default - Required if `fileName` is specified. + */ + readonly packaging?: FileAssetPackaging; + + /** + * Whether or not the asset needs to exist beyond deployment time; i.e. + * are copied over to a different location and not needed afterwards. + * Setting this property to true has an impact on the lifecycle of the asset, + * because we will assume that it is safe to delete after the Terraform + * deployment succeeds. + * + * For example, Lambda Function assets or Azure Function assets are copied + * over during deployment. Therefore, it is not necessary to store the asset + * in cloud storage permanently, so we consider those deployTime assets. + * + * @default false + */ + readonly deployTime?: boolean; + + /** + * A display name for this asset + * + * If supplied, the display name will be used in locations where the asset + * identifier is printed, like in the CLI progress information. + * + * @default - The asset hash is used to display the asset + */ + readonly displayName?: string; +} + +/** + * Represents the source for a Docker image asset. + */ +export interface DockerImageAssetSource { + /** + * The hash of the contents of the docker build context. This hash is used + * throughout the system to identify this image and avoid duplicate work + * in case the source did not change. + * + * NOTE: this means that if you wish to update your docker image, you + * must make a modification to the source (e.g. add some metadata to your Dockerfile). + */ + readonly sourceHash: string; + + /** + * The directory where the Dockerfile is stored, must be relative + * to the cloud assembly root. + */ + readonly directoryName: string; + + /** + * Build args to pass to the `docker build` command. + * + * Since Docker build arguments are resolved before deployment, keys and + * values cannot refer to unresolved tokens (such as `resource.id` or + * `resource.arn`). + * + * Only allowed when `directoryName` is specified. + * + * @default - no build args are passed + */ + readonly dockerBuildArgs?: { [key: string]: string }; + + /** + * Build contexts to pass to the `docker build` command. + * + * Build contexts can be used to specify additional directories or images + * to use during the build. Each entry specifies a named build context + * and its source (a directory path, a URL, or a docker image). + * + * Only allowed when `directoryName` is specified. + * + * @see https://docs.docker.com/build/building/context/#additional-build-contexts + * + * @default - no additional build contexts + */ + readonly dockerBuildContexts?: { [key: string]: string }; + + /** + * Build secrets to pass to the `docker build` command. + * + * Since Docker build secrets are resolved before deployment, keys and + * values cannot refer to unresolved tokens (such as `resource.id` or + * `resource.arn`). + * + * Only allowed when `directoryName` is specified. + * + * @default - no build secrets are passed + */ + readonly dockerBuildSecrets?: { [key: string]: string }; + + /** + * SSH agent socket or keys to pass to the `docker buildx` command. + * + * @default - no ssh arg is passed + */ + readonly dockerBuildSsh?: string; + + /** + * Docker target to build to + * + * Only allowed when `directoryName` is specified. + * + * @default - no target + */ + readonly dockerBuildTarget?: string; + + /** + * Path to the Dockerfile (relative to the directory). + * + * Only allowed when `directoryName` is specified. + * + * @default - Dockerfile + */ + readonly dockerFile?: string; + + /** + * Networking mode for the RUN commands during build. _Requires Docker Engine API v1.25+_. + * + * Specify this property to build images on a specific networking mode. + * + * @default - no networking mode specified + */ + readonly networkMode?: string; + + /** + * Platform to build for. _Requires Docker Buildx_. + * + * Specify this property to build images on a specific platform. + * + * @default - no platform specified (the current machine architecture will be used) + */ + readonly platform?: string; + + /** + * Outputs to pass to the `docker build` command. + * + * @default - no outputs are passed + */ + readonly dockerOutputs?: string[]; + + /** + * Unique identifier of the docker image asset and its potential revisions. + * + * @default - no asset name + */ + readonly assetName?: string; + + /** + * Cache from options to pass to the `docker build` command. + * + * @default - no cache from args are passed + */ + readonly dockerCacheFrom?: DockerCacheOption[]; + + /** + * Cache to options to pass to the `docker build` command. + * + * @default - no cache to args are passed + */ + readonly dockerCacheTo?: DockerCacheOption; + + /** + * Disable the cache and pass `--no-cache` to the `docker build` command. + * + * @default - cache is used + */ + readonly dockerCacheDisabled?: boolean; + + /** + * A display name for this asset + * + * If supplied, the display name will be used in locations where the asset + * identifier is printed, like in the CLI progress information. + * + * @default - The asset hash is used to display the asset + */ + readonly displayName?: string; +} + +/** + * Packaging modes for file assets. + */ +export enum FileAssetPackaging { + /** + * The asset source path points to a directory, which should be archived using + * zip and then uploaded to cloud storage (e.g. S3, Azure Blob Storage, GCS). + */ + ZIP_DIRECTORY = "zip", + + /** + * The asset source path points to a single file, which should be uploaded + * to cloud storage (e.g. S3, Azure Blob Storage, GCS). + */ + FILE = "file", +} + +/** + * Generic location of a published file asset. + * + * This interface provides a cloud-agnostic representation of where an asset + * is stored. Specific cloud provider implementations should extend this interface + * with provider-specific properties (e.g., S3-specific, Azure-specific, GCS-specific). + */ +export interface FileAssetLocation { + /** + * The name of the storage bucket/container. + * + * - AWS: S3 bucket name + * - Azure: Storage account container name + * - GCP: GCS bucket name + */ + readonly bucketName: string; + + /** + * The object key/path within the bucket. + * + * - AWS: S3 object key + * - Azure: Blob name + * - GCP: Object name + */ + readonly objectKey: string; + + /** + * The HTTP/HTTPS URL of this asset. + * + * This value is suitable for inclusion in a Terraform configuration, and + * may be an encoded token. + * + * Example values: + * - AWS: `https://s3-us-east-1.amazonaws.com/mybucket/myobject` + * - Azure: `https://mystorageaccount.blob.core.windows.net/mycontainer/myblob` + * - GCP: `https://storage.googleapis.com/mybucket/myobject` + */ + readonly httpUrl: string; + + /** + * The protocol-specific URL of this asset. + * + * This value is suitable for inclusion in a Terraform configuration, and + * may be an encoded token. + * + * Example values: + * - AWS: `s3://mybucket/myobject` + * - Azure: `az://mycontainer/myblob` + * - GCP: `gs://mybucket/myobject` + */ + readonly objectUrl: string; + + /** + * Like `objectUrl`, but not suitable for Terraform consumption. + * + * If there are placeholders in the URL, they will be returned un-replaced + * and un-evaluated. + * + * @default - This feature cannot be used + */ + readonly objectUrlWithPlaceholders?: string; +} + +/** + * Generic location of a published docker image. + * + * This interface provides a cloud-agnostic representation of where a Docker image + * is stored. Specific cloud provider implementations should extend this interface + * with provider-specific properties (e.g., ECR-specific, ACR-specific, GCR-specific). + */ +export interface DockerImageAssetLocation { + /** + * The URI of the image (including a tag). + * + * Example values: + * - AWS ECR: `123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:tag` + * - Azure ACR: `myregistry.azurecr.io/my-repo:tag` + * - GCP GCR: `gcr.io/my-project/my-repo:tag` + * - GCP Artifact Registry: `us-docker.pkg.dev/my-project/my-repo/my-image:tag` + */ + readonly imageUri: string; + + /** + * The name of the repository. + * + * - AWS: ECR repository name + * - Azure: ACR repository name + * - GCP: GCR/Artifact Registry repository name + */ + readonly repositoryName: string; + + /** + * The tag of the image. + * + * @default - the hash of the asset + */ + readonly imageTag?: string; +} + +/** + * Options for configuring the Docker cache backend + */ +export interface DockerCacheOption { + /** + * The type of cache to use. + * Refer to https://docs.docker.com/build/cache/backends/ for full list of backends. + * + * @default - unspecified + * @example 'registry' + */ + readonly type: string; + + /** + * Any parameters to pass into the docker cache backend configuration. + * Refer to https://docs.docker.com/build/cache/backends/ for cache backend configuration. + * + * @default {} No options provided + * @example + * const params = { + * ref: `myregistry.azurecr.io/cache:branch`, + * mode: "max", + * }; + */ + readonly params?: { [key: string]: string }; +} diff --git a/packages/cdktn/src/bundling.ts b/packages/cdktn/src/bundling.ts new file mode 100644 index 000000000..ec3ec3068 --- /dev/null +++ b/packages/cdktn/src/bundling.ts @@ -0,0 +1,499 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +// Simplified Docker bundling - following AWS CDK patterns + +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { dockerExec } from "./private/asset-staging"; + +/** + * Bundling options for Docker-based builds + */ +export interface BundlingOptions { + /** + * The Docker image where the command will run. + * + * @example DockerImage.fromRegistry('node:18-alpine') + * @example DockerImage.fromRegistry('public.ecr.aws/lambda/python:3.11') + * @example DockerImage.fromBuild('./docker') + */ + readonly image: DockerImage; + + /** + * The command to run in the Docker container. + * + * @example ['npm', 'run', 'build'] + * @default - run the command defined in the image + */ + readonly command?: string[]; + + /** + * The entrypoint to run in the Docker container. + * + * @example ['/bin/sh', '-c'] + * @default - run the entrypoint defined in the image + */ + readonly entrypoint?: string[]; + + /** + * Environment variables to pass to the Docker container. + * + * @default - no environment variables + */ + readonly environment?: { [key: string]: string }; + + /** + * Working directory inside the Docker container. + * + * @default /asset-input + */ + readonly workingDirectory?: string; + + /** + * The user to use when running the Docker container. + * + * @example '1000:1000' + * @default - root + */ + readonly user?: string; + + /** + * Networking mode for the Docker container. + * + * @default - bridge + */ + readonly network?: string; + + /** + * Platform to build for (requires Docker Buildx). + * + * @example 'linux/amd64' + * @default - no platform specified + */ + readonly platform?: string; + + /** + * Security options for the container. + * + * @example 'no-new-privileges' + * @default - none + */ + readonly securityOpt?: string; + + /** + * Additional Docker volumes to mount. + * + * @default - no additional volumes + */ + readonly volumes?: DockerVolume[]; + + /** + * Mount volumes from other containers. + * + * @default - no volumes from other containers + */ + readonly volumesFrom?: string[]; + + /** + * The type of output that this bundling operation is producing. + * + * @default BundlingOutput.AUTO_DISCOVER + */ + readonly outputType?: BundlingOutput; + + /** + * The access mechanism used to make source files available to the bundling + * container and to return the bundling output back to the host. + * + * BIND_MOUNT mounts the source and output directories directly into the container. + * This is faster and simpler, but requires the Docker daemon to have access to the + * host filesystem. + * + * VOLUME_COPY creates temporary Docker volumes and containers to copy files to/from + * the bundling container. This is slower, but works in more complex situations + * (e.g., remote or shared Docker sockets, Docker-in-Docker, etc.). + * + * @default BundlingFileAccess.BIND_MOUNT + */ + readonly bundlingFileAccess?: BundlingFileAccess; + + /** + * Local bundling provider. + * + * If provided, this will be tried first before Docker bundling. + * If it returns true, Docker bundling will be skipped. + * + * @default - no local bundling + */ + readonly local?: ILocalBundling; +} + +/** + * The type of output that a bundling operation is producing. + */ +export enum BundlingOutput { + /** + * The bundling output directory includes a single archive file (zip or jar). + * If the output directory does not include exactly a single archive, bundling will fail. + */ + ARCHIVED = "archived", + + /** + * The bundling output directory contains one or more files which will be + * archived and uploaded as a .zip file. + */ + NOT_ARCHIVED = "not-archived", + + /** + * If the bundling output directory contains a single archive file (zip or jar) + * it will be used as-is. Otherwise, all files will be zipped. + */ + AUTO_DISCOVER = "auto-discover", + + /** + * The bundling output directory includes a single file. + * Similar to ARCHIVED but for non-archive files. + */ + SINGLE_FILE = "single-file", +} + +/** + * The access mechanism used to make source files available to the bundling + * container and to return the bundling output back to the host. + */ +export enum BundlingFileAccess { + /** + * Creates temporary volumes and containers to copy files from the host to + * the bundling container and back. This is slower, but works also in more + * complex situations with remote or shared docker sockets. + */ + VOLUME_COPY = "VOLUME_COPY", + + /** + * The source and output folders will be mounted as bind mount from the host + * system. This is faster and simpler, but less portable than `VOLUME_COPY`. + */ + BIND_MOUNT = "BIND_MOUNT", +} + +/** + * Local bundling interface + */ +export interface ILocalBundling { + /** + * Try to bundle locally. + * + * @param outputDir the directory where the bundled asset should be output + * @param options bundling options for this asset + * @returns true if local bundling was performed, false otherwise + */ + tryBundle(outputDir: string, options: BundlingOptions): boolean; +} + +/** + * A Docker volume mount configuration + */ +export interface DockerVolume { + /** + * Path on the host machine + */ + readonly hostPath: string; + + /** + * Path in the container + */ + readonly containerPath: string; + + /** + * Mount consistency. This is a macOS-only performance hint and is ignored by + * Docker on other platforms. + * + * @default - no consistency option is passed + */ + readonly consistency?: DockerVolumeConsistency; + + /** + * Mount the volume as read-only + * @default false + */ + readonly readOnly?: boolean; +} + +/** + * Docker volume consistency types (macOS optimization) + */ +export enum DockerVolumeConsistency { + /** + * Full consistency - slowest, most consistent + */ + CONSISTENT = "consistent", + + /** + * Delegated consistency - fast, eventual consistency + */ + DELEGATED = "delegated", + + /** + * Cached consistency - read-optimized + */ + CACHED = "cached", +} + +/** + * Options for running a Docker container + */ +export interface DockerRunOptions { + /** + * Container entrypoint override + */ + readonly entrypoint?: string[]; + + /** + * Command to run in container + */ + readonly command?: string[]; + + /** + * Volume mounts + */ + readonly volumes?: DockerVolume[]; + + /** + * Mount volumes from other containers + */ + readonly volumesFrom?: string[]; + + /** + * Environment variables + */ + readonly environment?: Record; + + /** + * Working directory in container + */ + readonly workingDirectory?: string; + + /** + * User to run as (uid:gid) + */ + readonly user?: string; + + /** + * Security options + */ + readonly securityOpt?: string; + + /** + * Network mode + */ + readonly network?: string; + + /** + * Platform (e.g., linux/amd64) + */ + readonly platform?: string; +} + +/** + * Options for building a Docker image + */ +export interface DockerBuildOptions { + /** + * Build arguments + */ + readonly buildArgs?: Record; + + /** + * Dockerfile name (relative to context) + * @default Dockerfile + */ + readonly file?: string; + + /** + * Platform to build for + */ + readonly platform?: string; + + /** + * Build target stage + */ + readonly targetStage?: string; + + /** + * Disable build cache + * @default false + */ + readonly cacheDisabled?: boolean; +} + +/** + * A Docker image reference for bundling operations + */ +export class DockerImage { + /** + * Reference an image from a registry + * + * @param image Image name (e.g., "node:18", "public.ecr.aws/lambda/python:3.11") + */ + public static fromRegistry(image: string): DockerImage { + return new DockerImage(image); + } + + /** + * Build an image from a Dockerfile. + * + * Note that this runs `docker build` eagerly, matching AWS CDK: merely + * describing an image performs the build. + * + * @param contextPath Path to directory containing Dockerfile + * @param options Build options + */ + public static fromBuild( + contextPath: string, + options: DockerBuildOptions = {}, + ): DockerImage { + if (options.file && path.isAbsolute(options.file)) { + throw new Error( + `Dockerfile path must be relative to context. Got: ${options.file}`, + ); + } + + // Validate that the Dockerfile stays within the context path + if (options.file) { + const resolvedContext = path.resolve(contextPath); + const resolvedDockerfile = path.resolve(contextPath, options.file); + if ( + !resolvedDockerfile.startsWith(resolvedContext + path.sep) && + resolvedDockerfile !== resolvedContext + ) { + throw new Error( + `Dockerfile must be within the build context. Context: ${contextPath}, Dockerfile: ${options.file}`, + ); + } + } + + // Stable tag derived from the build inputs' identity, not their contents: + // editing the Dockerfile reuses the tag. Do not feed this into an asset + // hash without also hashing the build context. + const input = JSON.stringify({ path: contextPath, ...options }); + const hash = crypto.createHash("sha256").update(input).digest("hex"); + const tag = `cdktn-${hash}`; + + // Build the image + const buildArgs: string[] = [ + "build", + "-t", + tag, + ...(options.file ? ["-f", path.join(contextPath, options.file)] : []), + ...(options.platform ? ["--platform", options.platform] : []), + ...(options.targetStage ? ["--target", options.targetStage] : []), + ...(options.cacheDisabled ? ["--no-cache"] : []), + ...Object.entries(options.buildArgs || {}).flatMap(([k, v]) => [ + "--build-arg", + `${k}=${v}`, + ]), + contextPath, + ]; + + dockerExec(buildArgs); + + return new DockerImage(tag, hash); + } + + constructor( + /** + * The image name/tag + */ + public readonly image: string, + /** + * Optional stable hash for the image + */ + private readonly _hash?: string, + ) {} + + /** + * Run a command in this Docker image + * + * @param options Run options + */ + public run(options: DockerRunOptions = {}): void { + const args = [ + "run", + "--rm", + ...(options.securityOpt ? ["--security-opt", options.securityOpt] : []), + ...(options.network ? ["--network", options.network] : []), + ...(options.platform ? ["--platform", options.platform] : []), + ...(options.user ? ["-u", options.user] : []), + ...(options.volumesFrom?.flatMap((v) => ["--volumes-from", v]) || []), + ...(options.volumes?.flatMap((v) => { + // `consistency` is a macOS-only hint, so it is only emitted when asked + // for; `ro` is meaningful everywhere. + const mode = [v.consistency, v.readOnly ? "ro" : undefined] + .filter(Boolean) + .join(","); + return [ + "-v", + `${v.hostPath}:${v.containerPath}${mode ? `:${mode}` : ""}`, + ]; + }) || []), + ...(Object.entries(options.environment || {}).flatMap(([k, v]) => [ + "--env", + `${k}=${v}`, + ]) || []), + ...(options.workingDirectory ? ["-w", options.workingDirectory] : []), + ...(options.entrypoint ? ["--entrypoint", options.entrypoint[0]] : []), + this.image, + ...(options.entrypoint ? options.entrypoint.slice(1) : []), + ...(options.command || []), + ]; + + dockerExec(args); + } + + /** + * Copy a file or directory from the image to the host + * + * @param imagePath Path in the image + * @param outputPath Path on host (creates temp dir if not specified) + * @returns The output path + */ + public cp(imagePath: string, outputPath?: string): string { + // Create temporary container + const result = dockerExec(["create", this.image], { stdio: "pipe" }); + const containerId = result.stdout.toString().trim(); + + if (!containerId) { + throw new Error("Failed to create temporary container"); + } + + try { + // Determine output path + const destPath = outputPath || this.createTempDir(); + + // Copy files from container + dockerExec(["cp", `${containerId}:${imagePath}`, destPath]); + + return destPath; + } finally { + // Clean up container + dockerExec(["rm", "-v", containerId]); + } + } + + /** + * Get a stable representation of this image for serialization + */ + public toJSON(): string { + return this._hash || this.image; + } + + private createTempDir(): string { + const tmpDir = os.tmpdir(); + const random = crypto.randomBytes(6).toString("hex"); + const dir = path.join(tmpDir, `cdktn-docker-cp-${random}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } +} diff --git a/packages/cdktn/src/errors.ts b/packages/cdktn/src/errors.ts index 13a58c7c6..cf89454f6 100644 --- a/packages/cdktn/src/errors.ts +++ b/packages/cdktn/src/errors.ts @@ -554,8 +554,49 @@ export const unknownProviderFeature = (feature: string) => `Unknown provider-protocol feature "${feature}" passed to registerProviderFeatureUsage. This is an internal cdktn API intended to be called by generated provider bindings, not user code; if you did not call it directly, please file a bug report.`, ); +export const bundlingOutputNotArchived = ( + id: string, + outputPath: string, + fileCount: number, + files: string[], +) => + new Error( + `AssetStaging ${id} expected BundlingOutput.ARCHIVED but the bundling output directory '${outputPath}' contains ${fileCount} file(s) instead of exactly one archive file (.zip, .jar, .tar, .tar.gz, .tgz).\n\nFiles found:\n${files.map((f) => ` - ${f}`).join("\n")}\n\nEither:\n 1. Adjust your bundling command to output a single archive file, or\n 2. Change outputType to BundlingOutput.NOT_ARCHIVED or BundlingOutput.AUTO_DISCOVER`, + ); + +export const bundlingOutputNotSingleFile = ( + id: string, + outputPath: string, + fileCount: number, + files: string[], +) => + new Error( + `AssetStaging ${id} expected BundlingOutput.SINGLE_FILE but the bundling output directory '${outputPath}' contains ${fileCount} file(s) instead of exactly one file.\n\nFiles found:\n${files.map((f) => ` - ${f}`).join("\n")}\n\nEither:\n 1. Adjust your bundling command to output a single file, or\n 2. Change outputType to BundlingOutput.NOT_ARCHIVED or BundlingOutput.AUTO_DISCOVER`, + ); + +export const assetHashInvalid = (id: string, assetHash: string) => + new Error( + `Asset ${id} was given the custom assetHash '${assetHash}', which is not usable as a file name. A custom assetHash is used verbatim to name the staged asset, so it may only contain letters, digits, '_', '.' and '-'.`, + ); + +export const bundlingOutputEmpty = (id: string, outputPath: string) => + new Error( + `AssetStaging ${id} bundling output directory '${outputPath}' is empty. The bundling command must produce at least one output file.`, + ); + export const terraformModuleHasChildren = (pathName: string) => { return new Error( `Trying to add children to a TerraformModule at '${pathName}'. TerraformModules cannot have children, if you want to group resources or constructs in general together please use the Constructs class instead. See https://cdktn.io/docs/concepts/constructs for more details.`, ); }; + +/** + * Error thrown when a command execution fails + */ +export class ExecutionError extends Error { + constructor(message: string) { + super(message); + this.name = "ExecutionError"; + Object.setPrototypeOf(this, ExecutionError.prototype); + } +} diff --git a/packages/cdktn/src/index.ts b/packages/cdktn/src/index.ts index 75ace0f6b..d2d918f39 100644 --- a/packages/cdktn/src/index.ts +++ b/packages/cdktn/src/index.ts @@ -44,6 +44,9 @@ export * from "./importable-resource"; export * from "./terraform-resource-targets"; export * from "./upgrade-id-aspect"; export * from "./terraform-data-resource"; +export * from "./assets"; +export * from "./bundling"; +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/private/asset-staging.ts b/packages/cdktn/src/private/asset-staging.ts new file mode 100644 index 000000000..a82863d2b --- /dev/null +++ b/packages/cdktn/src/private/asset-staging.ts @@ -0,0 +1,328 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { spawnSync, type SpawnSyncOptions } from "child_process"; +import { type IConstruct } from "constructs"; +import * as crypto from "crypto"; +import * as os from "os"; +import { Annotations } from "../annotations"; +import { AssetStaging } from "../asset-staging"; +import { type BundlingOptions } from "../bundling"; +import { ExecutionError } from "../errors"; + +/** + * Helper image used to own and seed the input/output volumes in VOLUME_COPY + * mode. Pinned so bundling does not change underneath users when the upstream + * tag moves; override with `CDKTN_BUNDLING_HELPER_IMAGE` if a mirror is needed. + */ +const DEFAULT_HELPER_IMAGE = "public.ecr.aws/docker/library/alpine:3.21"; + +/** + * Options for Docker based bundling of assets + */ +interface AssetBundlingOptions extends BundlingOptions { + /** + * Path where the source files are located + */ + readonly sourcePath: string; + /** + * Path where the output files should be stored + */ + readonly bundleDir: string; + /** + * Construct that owns this bundling run, used to report cleanup warnings. + */ + readonly scope: IConstruct; +} + +/** + * Base class for asset bundling implementations + */ +abstract class AssetBundlingBase { + protected options: AssetBundlingOptions; + constructor(options: AssetBundlingOptions) { + this.options = options; + } + /** + * Determines a useful default user if not given otherwise + */ + protected determineUser() { + let user: string; + if (this.options.user) { + user = this.options.user; + } else { + // Default to current user + const userInfo = os.userInfo(); + user = + userInfo.uid !== -1 // uid is -1 on Windows + ? `${userInfo.uid}:${userInfo.gid}` + : "1000:1000"; + } + return user; + } + + /** + * Surface best-effort cleanup failures without failing the synth: the + * bundling output is already valid, but leaked Docker resources are worth + * telling the user about. + */ + protected warnCleanupFailures(failures: string[]) { + Annotations.of(this.options.scope).addWarning( + `Failed to clean up Docker resources after bundling; they may need to be removed manually. ${failures.join("; ")}`, + ); + } +} + +/** + * Bundles files with bind mount as copy method + */ +export class AssetBundlingBindMount extends AssetBundlingBase { + /** + * Bundle files with bind mount as copy method + */ + public run() { + this.options.image.run({ + command: this.options.command, + user: this.determineUser(), + environment: this.options.environment, + entrypoint: this.options.entrypoint, + workingDirectory: + this.options.workingDirectory ?? AssetStaging.BUNDLING_INPUT_DIR, + securityOpt: this.options.securityOpt, + volumesFrom: this.options.volumesFrom, + volumes: [ + { + hostPath: this.options.sourcePath, + containerPath: AssetStaging.BUNDLING_INPUT_DIR, + readOnly: true, + }, + { + hostPath: this.options.bundleDir, + containerPath: AssetStaging.BUNDLING_OUTPUT_DIR, + }, + ...(this.options.volumes ?? []), + ], + network: this.options.network, + platform: this.options.platform, + }); + } +} + +/** + * Provides a helper container for copying bundling related files to specific input and output volumes + */ +export class AssetBundlingVolumeCopy extends AssetBundlingBase { + /** + * Name of the Docker volume that is used for the asset input + */ + private inputVolumeName: string; + /** + * Name of the Docker volume that is used for the asset output + */ + private outputVolumeName: string; + /** + * Name of the Docker helper container to copy files into the volume + */ + public copyContainerName: string; + + constructor(options: AssetBundlingOptions) { + super(options); + const copySuffix = crypto.randomBytes(12).toString("hex"); + this.inputVolumeName = `assetInput${copySuffix}`; + this.outputVolumeName = `assetOutput${copySuffix}`; + this.copyContainerName = `copyContainer${copySuffix}`; + } + + /** + * Creates volumes for asset input and output + */ + private prepareVolumes() { + dockerExec(["volume", "create", this.inputVolumeName]); + dockerExec(["volume", "create", this.outputVolumeName]); + } + + /** + * runs a helper container that holds volumes and does some preparation tasks + * @param user The user that will later access these files and needs permissions to do so + */ + private startHelperContainer(user: string) { + dockerExec([ + "run", + "--name", + this.copyContainerName, + "-v", + `${this.inputVolumeName}:${AssetStaging.BUNDLING_INPUT_DIR}`, + "-v", + `${this.outputVolumeName}:${AssetStaging.BUNDLING_OUTPUT_DIR}`, + process.env.CDKTN_BUNDLING_HELPER_IMAGE ?? DEFAULT_HELPER_IMAGE, + "sh", + "-c", + `mkdir -p ${AssetStaging.BUNDLING_INPUT_DIR} && chown -R ${user} ${AssetStaging.BUNDLING_OUTPUT_DIR} && chown -R ${user} ${AssetStaging.BUNDLING_INPUT_DIR}`, + ]); + } + + /** + * removes the Docker helper container + */ + private cleanHelperContainer() { + dockerExec(["rm", this.copyContainerName]); + } + + /** + * Tear down every resource this bundling run may have created. Each step is + * attempted independently so one failure cannot strand the others, and the + * whole teardown is best-effort: a cleanup failure must not mask the + * bundling error (or fail an otherwise successful synth). + */ + private cleanup() { + const failures: string[] = []; + + for (const [what, remove] of [ + [this.copyContainerName, () => this.cleanHelperContainer()], + [ + this.inputVolumeName, + () => dockerExec(["volume", "rm", this.inputVolumeName]), + ], + [ + this.outputVolumeName, + () => dockerExec(["volume", "rm", this.outputVolumeName]), + ], + ] as const) { + try { + remove(); + } catch (e) { + failures.push(`${what}: ${(e as Error).message}`); + } + } + + return failures; + } + + /** + * copy files from the host where this is executed into the input volume + * @param sourcePath - path to folder where files should be copied from - without trailing slash + */ + private copyInputFrom(sourcePath: string) { + dockerExec([ + "cp", + `${sourcePath}/.`, + `${this.copyContainerName}:${AssetStaging.BUNDLING_INPUT_DIR}`, + ]); + } + + /** + * copy files from the output volume to the host where this is executed + * @param outputPath - path to folder where files should be copied to - without trailing slash + */ + private copyOutputTo(outputPath: string) { + dockerExec([ + "cp", + `${this.copyContainerName}:${AssetStaging.BUNDLING_OUTPUT_DIR}/.`, + outputPath, + ]); + } + + /** + * Bundle files with VOLUME_COPY method + */ + public run() { + const user = this.determineUser(); + + // The try opens before any resource is created: a failure part-way through + // volume creation or helper startup must still be cleaned up. + try { + this.prepareVolumes(); + this.startHelperContainer(user); // TODO handle user properly + this.copyInputFrom(this.options.sourcePath); + + this.options.image.run({ + command: this.options.command, + user: user, + environment: this.options.environment, + entrypoint: this.options.entrypoint, + workingDirectory: + this.options.workingDirectory ?? AssetStaging.BUNDLING_INPUT_DIR, + securityOpt: this.options.securityOpt, + volumes: this.options.volumes, + volumesFrom: [ + this.copyContainerName, + ...(this.options.volumesFrom ?? []), + ], + platform: this.options.platform, + network: this.options.network, + }); + + this.copyOutputTo(this.options.bundleDir); + } finally { + const failures = this.cleanup(); + if (failures.length > 0) { + this.warnCleanupFailures(failures); + } + } + } +} + +/** + * Execute Docker CLI command + * + * @internal + */ +export function dockerExec(args: string[], options?: SpawnSyncOptions) { + const prog = process.env.CDK_DOCKER ?? "docker"; + const proc = spawnSync(prog, args, { + encoding: "utf-8", + stdio: [ + // show Docker output + "ignore", // ignore stdio + // AWSCDK: process.stderr, // redirect stdout to stderr (causes radix error in bun?) + "inherit", + "inherit", // inherit stderr + ], + ...options, + // Forwarded explicitly because a sandboxed `process.env` (as under Jest) is + // not otherwise visible to the child. + env: { ...process.env, ...options?.env }, + }); + + if (proc.error) { + throw proc.error; + } + + if (proc.status !== 0) { + const reason = + proc.signal != null ? `signal ${proc.signal}` : `status ${proc.status}`; + const command = [ + prog, + ...args.map((arg) => + /[^a-z0-9_-]/i.test(arg) ? JSON.stringify(arg) : arg, + ), + ].join(" "); + + /** + * Helper to prepend a label to each line of text + */ + function prependLines( + firstLine: string, + text: Buffer | string | undefined, + ): string[] { + if (!text || text.length === 0) { + return []; + } + const padding = " ".repeat(firstLine.length); + return text + .toString("utf-8") + .split("\n") + .map((line, idx) => `${idx === 0 ? firstLine : padding}${line}`); + } + + throw new ExecutionError( + [ + `${prog} exited with ${reason}`, + ...(prependLines("--> STDOUT: ", proc.stdout) ?? []), + ...(prependLines("--> STDERR: ", proc.stderr) ?? []), + `--> Command: ${command}`, + ].join("\n"), + ); + } + + return proc; +} diff --git a/packages/cdktn/src/private/fs.ts b/packages/cdktn/src/private/fs.ts index e33036c15..7c24cd66d 100644 --- a/packages/cdktn/src/private/fs.ts +++ b/packages/cdktn/src/private/fs.ts @@ -30,18 +30,47 @@ function zipAttrs(mode: number): number { return (mode << 16) >>> 0; } +/** + * Predicate deciding whether a tree entry is skipped. + * `relPath` is always `/`-separated and relative to the walk root, so patterns + * behave identically on Windows. + */ +export type ExcludePredicate = (relPath: string) => boolean; + +export interface CopySyncOptions { + /** + * Entries for which this returns true are not copied. Excluding a directory + * also skips everything below it. + * + * @default - nothing is excluded + */ + readonly shouldExclude?: ExcludePredicate; +} + // Full implementation at https://github.com/jprichardson/node-fs-extra/blob/master/lib/copy/copy-sync.js /** * Copy a file or directory. The directory can have contents and subfolders. + * Symlinks are recreated as symlinks rather than dereferenced, which keeps the + * copy consistent with {@link hashPath} (it hashes links by their target) and + * makes dangling links and link cycles harmless. * @param src - source path * @param dest - destination path + * @param options - copy behaviour, see {@link CopySyncOptions} */ -export function copySync(src: string, dest: string) { +export function copySync( + src: string, + dest: string, + options: CopySyncOptions = {}, +) { /** * Copies file if present otherwise walks subfolder. * @param p - path relative to src/dest + * @param relPath - `/`-separated path relative to the copy root */ - function copyItem(p: string) { + function copyItem(p: string, relPath: string) { + if (options.shouldExclude?.(relPath)) { + return; + } const sourcePath = path.resolve(src, p); const stat = fs.lstatSync(sourcePath); if (stat.isSymbolicLink()) { @@ -49,22 +78,23 @@ export function copySync(src: string, dest: string) { } else if (stat.isFile()) { fs.copyFileSync(sourcePath, path.resolve(dest, p)); } else if (stat.isDirectory()) { - walkSubfolder(p); + walkSubfolder(p, relPath); } } /** * Copies contents of subfolder. * @param p - path relative to src/dest + * @param relPath - `/`-separated path relative to the copy root */ - function walkSubfolder(p: string) { + function walkSubfolder(p: string, relPath: string) { const sourceDir = path.resolve(src, p); fs.mkdirSync(path.resolve(dest, p), { recursive: true }); fs.readdirSync(sourceDir).forEach((item: string) => - copyItem(path.join(p, item)), + copyItem(path.join(p, item), relPath ? `${relPath}/${item}` : item), ); } - walkSubfolder("."); + walkSubfolder(".", ""); } /** @@ -132,6 +162,15 @@ export interface HashPathOptions { * the legacy scheme, which never records directories. */ readonly archive?: boolean; + /** + * Entries for which this returns true are omitted from the digest. Excluding + * a directory also omits everything below it. The same predicate must be + * given to {@link copySync} so the hash and the emitted artifact describe the + * same set of files. + * + * @default - nothing is excluded + */ + readonly shouldExclude?: ExcludePredicate; } /** @@ -146,8 +185,8 @@ export interface HashPathOptions { */ export function hashPath(src: string, options: HashPathOptions = {}): string { const digest = options.canonical - ? canonicalHashPath(src, !options.archive) - : legacyHashPath(src); + ? canonicalHashPath(src, !options.archive, options.shouldExclude) + : legacyHashPath(src, options.shouldExclude); return digest.slice(0, HASH_LEN).toUpperCase(); } @@ -160,8 +199,9 @@ export function hashPath(src: string, options: HashPathOptions = {}): string { * bytes, so a file containing `foo` can never collide with a symlink * targeting `foo`. * @param src - path to a file or directory to hash + * @param shouldExclude - entries to omit, see {@link HashPathOptions.shouldExclude} */ -function legacyHashPath(src: string): string { +function legacyHashPath(src: string, shouldExclude?: ExcludePredicate): string { const content = crypto.createHash("md5"); const links = crypto.createHash("md5"); let linkCount = 0; @@ -182,12 +222,13 @@ function legacyHashPath(src: string): string { } else if (stat.isFile()) { content.update(fs.readFileSync(p)); } else if (stat.isDirectory()) { - fs.readdirSync(p).forEach((filename) => - hashRecursion( - path.resolve(p, filename), - relPath ? `${relPath}/${filename}` : filename, - ), - ); + fs.readdirSync(p).forEach((filename) => { + const entryRelPath = relPath ? `${relPath}/${filename}` : filename; + if (shouldExclude?.(entryRelPath)) { + return; + } + hashRecursion(path.resolve(p, filename), entryRelPath); + }); } } @@ -220,8 +261,13 @@ function legacyHashPath(src: string): string { * @param src - path to a file or directory to hash * @param includeDirectories - record directory entries; false for archive * artifacts, where the emitted zip has no directory entries + * @param shouldExclude - entries to omit, see {@link HashPathOptions.shouldExclude} */ -function canonicalHashPath(src: string, includeDirectories: boolean): string { +function canonicalHashPath( + src: string, + includeDirectories: boolean, + shouldExclude?: ExcludePredicate, +): string { const hash = crypto.createHash("md5"); /** @@ -247,10 +293,11 @@ function canonicalHashPath(src: string, includeDirectories: boolean): string { hash.update(`D ${relPath}\0`); } for (const filename of fs.readdirSync(p).sort()) { - hashRecursion( - path.resolve(p, filename), - relPath ? `${relPath}/${filename}` : filename, - ); + const entryRelPath = relPath ? `${relPath}/${filename}` : filename; + if (shouldExclude?.(entryRelPath)) { + continue; + } + hashRecursion(path.resolve(p, filename), entryRelPath); } } } @@ -259,6 +306,34 @@ function canonicalHashPath(src: string, includeDirectories: boolean): string { return hash.digest("hex"); } +/** + * Build a predicate matching the exclusion forms documented on + * `TerraformAssetConfig.exclude`: an exact relative path, a `*.ext` suffix, or a + * directory (with or without a trailing `/`), which also excludes its contents. + * Deliberately not a full glob implementation — `**`, `?`, character classes and + * `!` negation are not supported, and a pattern is never interpreted as + * anchoring to a subdirectory it does not name. + * @param exclude - patterns to exclude + * @returns predicate over `/`-separated paths relative to the asset root + */ +export function excludeMatcher(exclude: string[]): ExcludePredicate { + // `/`-separated throughout: relative paths are normalized before matching, so + // `dir/child` patterns work the same on Windows. + const patterns = exclude.map((p) => p.replace(/\\/g, "/")); + return (relativePath: string) => { + for (const pattern of patterns) { + if (pattern.startsWith("*.") && relativePath.endsWith(pattern.slice(1))) { + return true; + } + const dir = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern; + if (relativePath === dir || relativePath.startsWith(`${dir}/`)) { + return true; + } + } + return false; + }; +} + /** * Walk upward from `rootPath` looking for a file with the given name. * Returns the absolute path of the first match, or `null` if the search diff --git a/packages/cdktn/src/terraform-asset.ts b/packages/cdktn/src/terraform-asset.ts index dd2358bcc..0563817b2 100644 --- a/packages/cdktn/src/terraform-asset.ts +++ b/packages/cdktn/src/terraform-asset.ts @@ -18,6 +18,9 @@ import { assetOutOfScopeOfCDKTFJson, assetTypeNotImplemented, } from "./errors"; +import { type AssetHashType, FileAssetPackaging } from "./assets"; +import { AssetStaging } from "./asset-staging"; +import { type BundlingOptions } from "./bundling"; export interface TerraformAssetConfig { // path to the file or folder configured. If relative, the path is resolved from the location of cdktf.json @@ -26,6 +29,56 @@ export interface TerraformAssetConfig { readonly type?: AssetType; // hash value of the asset, if passed will be used as returned assetHash readonly assetHash?: string; + + /** + * Paths to exclude from the asset, relative to `path` and always + * `/`-separated. Each entry may be an exact file path, a `*.ext` suffix + * match, or a directory (with or without a trailing `/`), which also excludes + * everything inside it. + * + * This is not full glob syntax: `**`, `?`, character classes and `!` + * negation are not supported. + * + * @default - nothing is excluded + */ + readonly exclude?: string[]; + + /** + * Extra information to encode into the fingerprint (e.g. build instructions + * and other inputs). + * + * @default - no extra hash + */ + readonly extraHash?: string; + + /** + * Bundle the asset by executing a command in a Docker container or a + * custom bundling provider. + * + * The asset path will be mounted at `/asset-input`. The Docker + * container is responsible for putting content at `/asset-output`. + * The content at `/asset-output` will be zipped and used as the + * final asset. + * + * @default - uploaded as-is to the stack location without bundling + */ + readonly bundling?: BundlingOptions; + + /** + * 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 a container registry, 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 assetHashType?: AssetHashType; } export enum AssetType { @@ -46,6 +99,12 @@ export class TerraformAsset extends Construct { // file type of the asset, either AssetType.FILE, AssetType.DIRECTORY, AssetType.ARCHIVE public type: AssetType; + /** + * Internal staging helper for advanced features (bundling, exclusions, etc.) + * @private + */ + private staging?: AssetStaging; + /** * 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. @@ -58,6 +117,7 @@ export class TerraformAsset extends Construct { this.stack = TerraformStack.of(this); + // Resolve source path (relative to cdktf.json if relative, absolute otherwise) if (path.isAbsolute(config.path)) { this.sourcePath = config.path; } else { @@ -76,22 +136,60 @@ export class TerraformAsset extends Construct { } } - const stat = fs.statSync(this.sourcePath); - const inferredType = stat.isFile() ? AssetType.FILE : AssetType.DIRECTORY; - this.type = config.type ?? inferredType; - this.assetHash = - config.assetHash || - hashPath(this.sourcePath, { - canonical: !!this.node.tryGetContext(CANONICAL_ASSET_HASHES), - archive: this.type === AssetType.ARCHIVE, + // Check if advanced features are requested + const useAdvancedStaging = !!( + config.exclude || + config.extraHash || + config.bundling || + config.assetHashType + ); + + if (useAdvancedStaging) { + // Use AssetStaging for advanced features (bundling, exclusions, etc.) + this.staging = new AssetStaging(this, "__staging__", { + sourcePath: this.sourcePath, + exclude: config.exclude, + extraHash: config.extraHash, + bundling: config.bundling, + assetHash: config.assetHash, + assetHashType: config.assetHashType, }); - if (stat.isFile() && this.type !== AssetType.FILE) { - throw assetExpectsDirectory(id, config.path); - } + this.assetHash = this.staging.assetHash; - if (!stat.isFile() && this.type === AssetType.FILE) { - throw assetExpectsDirectory(id, config.path); + // Map AssetStaging packaging to TerraformAsset type + if (this.staging.packaging === FileAssetPackaging.FILE) { + this.type = this.staging.isArchive ? AssetType.ARCHIVE : AssetType.FILE; + } else { + // ZIP_DIRECTORY + this.type = AssetType.ARCHIVE; + } + + // Override with explicit type if provided + if (config.type !== undefined) { + this.validateType(id, config.path, config.type); + this.type = config.type; + } + } else { + // Use existing simple implementation (BACKWARDS COMPATIBLE) + const stat = fs.statSync(this.sourcePath); + const inferredType = stat.isFile() ? AssetType.FILE : AssetType.DIRECTORY; + this.type = config.type ?? inferredType; + this.assetHash = + config.assetHash || + hashPath(this.sourcePath, { + canonical: !!this.node.tryGetContext(CANONICAL_ASSET_HASHES), + archive: this.type === AssetType.ARCHIVE, + }); + + // Validation + if (stat.isFile() && this.type !== AssetType.FILE) { + throw assetExpectsDirectory(id, config.path); + } + + if (!stat.isFile() && this.type === AssetType.FILE) { + throw assetExpectsDirectory(id, config.path); + } } addCustomSynthesis(this, { @@ -99,6 +197,25 @@ export class TerraformAsset extends Construct { }); } + /** + * Reject a `type` that the staged asset cannot satisfy, matching the + * validation the non-staged path performs. A directory (or anything staged as + * a zip) can never be emitted as `AssetType.FILE`, and a single staged file + * can never be emitted as `AssetType.DIRECTORY`. + */ + private validateType(id: string, configPath: string, type: AssetType) { + const stagedAsDirectory = + this.staging!.packaging === FileAssetPackaging.ZIP_DIRECTORY; + + if (stagedAsDirectory && type === AssetType.FILE) { + throw assetExpectsDirectory(id, configPath); + } + + if (!stagedAsDirectory && type === AssetType.DIRECTORY) { + throw assetExpectsDirectory(id, configPath); + } + } + private get namedFolder(): string { return path.posix.join( ASSETS_DIRECTORY, @@ -152,17 +269,28 @@ export class TerraformAsset extends Construct { fs.mkdirSync(path.dirname(targetPath), { recursive: true }); } + // Use staged asset if available (from advanced features), otherwise use source + const sourceToUse = this.staging?.absoluteStagedPath ?? this.sourcePath; + switch (this.type) { case AssetType.FILE: - fs.copyFileSync(this.sourcePath, targetPath); + fs.copyFileSync(sourceToUse, targetPath); break; case AssetType.DIRECTORY: - copySync(this.sourcePath, targetPath); + copySync(sourceToUse, targetPath); break; case AssetType.ARCHIVE: - archiveSync(this.sourcePath, targetPath); + // A staged single file is copied as-is; only a directory can be zipped. + if ( + this.staging && + this.staging.packaging === FileAssetPackaging.FILE + ) { + fs.copyFileSync(sourceToUse, targetPath); + } else { + archiveSync(sourceToUse, targetPath); + } break; default: throw assetTypeNotImplemented(); diff --git a/packages/cdktn/src/testing/__tests__/matchers.test.ts b/packages/cdktn/src/testing/__tests__/matchers.test.ts index bced3ecf0..7a28c50f0 100644 --- a/packages/cdktn/src/testing/__tests__/matchers.test.ts +++ b/packages/cdktn/src/testing/__tests__/matchers.test.ts @@ -1,7 +1,11 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 import { Testing } from "../index"; -import { TestResource, DockerImage } from "../../../test/helper/resource"; +import { + TestResource, + DockerImage, + NullResource, +} from "../../../test/helper/resource"; import { toBeValidTerraform, toPlanSuccessfully, @@ -12,7 +16,7 @@ import { } from "../matchers"; import { TestDataSource } from "../../../test/helper/data-source"; import { TerraformStack } from "../../terraform-stack"; -import { DockerProvider } from "../../../test/helper/provider"; +import { DockerProvider, NullProvider } from "../../../test/helper/provider"; import * as fs from "fs"; import * as path from "path"; @@ -319,8 +323,8 @@ describe("matchers", () => { const app = Testing.app(); const stack = new TerraformStack(app, "test"); - new DockerProvider(stack, "provider", {}); - new DockerImage(stack, "test", { name: "test" }); + new NullProvider(stack, "provider"); + new NullResource(stack, "test"); const res = toPlanSuccessfully(Testing.fullSynth(stack)); @@ -334,8 +338,8 @@ describe("matchers", () => { const app = Testing.app(); const stack = new TerraformStack(app, "test"); - new DockerProvider(stack, "provider", {}); - new DockerImage(stack, "test", { name: "test" }); + new NullProvider(stack, "provider"); + new NullResource(stack, "test"); const result = Testing.fullSynth(stack); corruptSynthesizedStack(result); diff --git a/packages/cdktn/src/testing/matchers.ts b/packages/cdktn/src/testing/matchers.ts index feffea071..762c19e12 100644 --- a/packages/cdktn/src/testing/matchers.ts +++ b/packages/cdktn/src/testing/matchers.ts @@ -152,7 +152,7 @@ function getAssertElementWithProperties( try { stack = JSON.parse(stackContent) as SynthesizedStack; - } catch (e) { + } catch (_e) { throw invalidStack(functionName, stackContent); } diff --git a/packages/cdktn/test/app.test.ts b/packages/cdktn/test/app.test.ts index 7f374683f..5dbda1092 100644 --- a/packages/cdktn/test/app.test.ts +++ b/packages/cdktn/test/app.test.ts @@ -13,10 +13,9 @@ import { Fn, } from "../src"; import { FAIL_ON_CONSTRUCTS_OUTSIDE_OF_STACKS } from "../src/features"; - +import fs from "fs"; +import path from "path"; import { version } from "../package.json"; -import fs = require("fs"); -import path = require("path"); import { Aspects } from "../src/aspect"; import { IConstruct } from "constructs"; import { setupJest } from "../src/testing/adapters/jest"; diff --git a/packages/cdktn/test/asset-staging-regression.test.ts b/packages/cdktn/test/asset-staging-regression.test.ts new file mode 100644 index 000000000..35eeb9b7c --- /dev/null +++ b/packages/cdktn/test/asset-staging-regression.test.ts @@ -0,0 +1,405 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +// +// Regression tests for asset staging. Each case pins a behaviour that a +// previous implementation got wrong while still passing the rest of the suite: +// hash framing, exclusion handling, symlink fidelity, output-directory +// hygiene, and validation of user-supplied overrides. +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + AssetStaging, + AssetHashType, + TerraformAsset, + AssetType, + TerraformStack, + Testing, + DockerImage, + BundlingOutput, + BundlingFileAccess, +} from "../lib"; +import { hashPath } from "../lib/private/fs"; +import { CANONICAL_ASSET_HASHES } from "../lib/features"; + +const CDKTFJSON_PATH = path.join(__dirname, "fixtures", "app", "cdktf.json"); + +describe("asset staging regressions", () => { + let tempDir: string; + let outdir: string; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-staging-")); + outdir = fs.mkdtempSync(path.join(os.tmpdir(), "asset-outdir-")); + process.env.CDK_DOCKER = `${__dirname}/docker-stub.sh`; + }); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(outdir, { recursive: true, force: true }); + delete process.env.CDK_DOCKER; + }); + + const appWithOutdir = (ctx: Record = {}) => { + const app = Testing.app({ + outdir, + context: { cdktfJsonPath: path.resolve(CDKTFJSON_PATH), ...ctx }, + }); + return new TerraformStack(app, "S"); + }; + + function mkTree() { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "a.txt"), "x"); + fs.mkdirSync(path.join(dir, "sub")); + fs.writeFileSync(path.join(dir, "sub", "b.txt"), "y"); + fs.mkdirSync(path.join(dir, "emptydir")); + return dir; + } + + test("hashes a zip-packaged directory with archive framing", () => { + const dir = mkTree(); + const s = new AssetStaging( + appWithOutdir({ [CANONICAL_ASSET_HASHES]: true }), + "A", + { sourcePath: dir, assetHashType: AssetHashType.SOURCE }, + ); + expect(s.packaging).toBe("zip"); + expect(s.assetHash).toBe(hashPath(dir, { canonical: true, archive: true })); + expect(s.assetHash).not.toBe( + hashPath(dir, { canonical: true, archive: false }), + ); + }); + + test("an exclude pattern that matches nothing leaves the hash unchanged", () => { + const dir = mkTree(); + const st = appWithOutdir(); + const plain = new AssetStaging(st, "P", { sourcePath: dir }); + const noop = new AssetStaging(st, "N", { + sourcePath: dir, + exclude: ["zzz-matches-nothing"], + }); + expect(noop.assetHash).toBe(plain.assetHash); + }); + + test("rejects a custom assetHash that is not a safe file name", () => { + const f = path.join(tempDir, "f.txt"); + fs.writeFileSync(f, "hello"); + expect( + () => + new AssetStaging(appWithOutdir(), "A", { + sourcePath: f, + assetHash: "../../../../tmp/pwn", + }), + ).toThrow(/only contain letters, digits/); + // a sane custom hash still works + const ok = new AssetStaging(appWithOutdir(), "B", { + sourcePath: f, + assetHash: "v1.2.3-beta_4", + }); + expect(ok.assetHash).toBe("v1.2.3-beta_4"); + }); + + test("applies exclude patterns to bundled output", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "src.txt"), "x"); + const s = new AssetStaging(appWithOutdir(), "A", { + sourcePath: dir, + exclude: ["test1.txt"], + assetHashType: AssetHashType.OUTPUT, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["DOCKER_STUB_MULTIPLE_FILES"], + }, + }); + const staged = fs.readdirSync(s.absoluteStagedPath); + expect(staged).not.toContain("test1.txt"); + expect(staged).toContain("test2.txt"); + }); + + test("stages a symlink as a symlink rather than a copy", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "a.txt"), "x"); + fs.symlinkSync("a.txt", path.join(dir, "link.txt")); + const s = new AssetStaging(appWithOutdir(), "A", { + sourcePath: dir, + exclude: [], + }); + expect( + fs + .lstatSync(path.join(s.absoluteStagedPath, "link.txt")) + .isSymbolicLink(), + ).toBe(true); + }); + + test("stages a dangling symlink without failing", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "a.txt"), "x"); + fs.symlinkSync("/nonexistent/nope", path.join(dir, "broken.txt")); + const s = new AssetStaging(appWithOutdir(), "A", { + sourcePath: dir, + exclude: [], + }); + expect( + fs + .lstatSync(path.join(s.absoluteStagedPath, "broken.txt")) + .isSymbolicLink(), + ).toBe(true); + }); + + test("does not inline a symlinked directory", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.mkdirSync(path.join(dir, "real")); + fs.writeFileSync(path.join(dir, "real", "f.txt"), "x"); + fs.symlinkSync("real", path.join(dir, "alias")); + const s = new AssetStaging(appWithOutdir(), "A", { + sourcePath: dir, + exclude: [], + }); + expect( + fs.lstatSync(path.join(s.absoluteStagedPath, "alias")).isSymbolicLink(), + ).toBe(true); + }); + + test("extraHash still busts the hash when a salt is configured", () => { + const f = path.join(tempDir, "f.txt"); + fs.writeFileSync(f, "hello"); + const st = appWithOutdir({ "cdktn:assetHashSalt": "SALT" }); + const a = new AssetStaging(st, "A", { sourcePath: f, extraHash: "v1" }); + const b = new AssetStaging(st, "B", { sourcePath: f, extraHash: "v2" }); + expect(a.assetHash).not.toBe(b.assetHash); + // and the salt still matters + const unsalted = new AssetStaging(appWithOutdir(), "C", { + sourcePath: f, + extraHash: "v1", + }); + expect(unsalted.assetHash).not.toBe(a.assetHash); + }); + + test("leaves no scratch directories in the assets outdir", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "a.txt"), "x"); + const st = appWithOutdir(); + new AssetStaging(st, "A", { + sourcePath: dir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["DOCKER_STUB_SUCCESS"], + }, + }); + // local bundling path too + new AssetStaging(st, "B", { + sourcePath: dir, + extraHash: "local", + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["unused"], + local: { + tryBundle(o: string) { + fs.writeFileSync(path.join(o, "built.txt"), "l"); + return true; + }, + }, + }, + }); + const entries = fs.readdirSync(path.join(outdir, "assets")); + expect(entries.filter((e) => !e.startsWith("asset."))).toEqual([]); + }); + + test("keeps the file extension of bundled archive and single-file output", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "src.txt"), "x"); + const zip = new AssetStaging(appWithOutdir(), "A", { + sourcePath: dir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["DOCKER_STUB_SINGLE_ARCHIVE"], + outputType: BundlingOutput.ARCHIVED, + }, + }); + expect(path.basename(zip.absoluteStagedPath)).toMatch(/\.zip$/); + expect(fs.statSync(zip.absoluteStagedPath).isFile()).toBe(true); + + const single = new AssetStaging(appWithOutdir(), "B", { + sourcePath: dir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["DOCKER_STUB_SINGLE_FILE"], + outputType: BundlingOutput.SINGLE_FILE, + }, + }); + expect(path.basename(single.absoluteStagedPath)).toMatch(/\.txt$/); + }); + + test("rejects a FILE type override for a directory asset", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "src.txt"), "x"); + expect( + () => + new TerraformAsset(appWithOutdir(), "A", { + path: dir, + type: AssetType.FILE, + exclude: [], + }), + ).toThrow(/expects path to point to a directory|directory/i); + }); + + test("rejects a DIRECTORY type override for single-file bundling output", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "src.txt"), "x"); + expect( + () => + new TerraformAsset(appWithOutdir(), "A", { + path: dir, + type: AssetType.DIRECTORY, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["DOCKER_STUB_SINGLE_FILE"], + outputType: BundlingOutput.SINGLE_FILE, + }, + }), + ).toThrow(); + }); + + test("supports the documented exclude forms", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(path.join(dir, "node_modules")); + fs.writeFileSync(path.join(dir, "node_modules", "big.js"), "b"); + fs.mkdirSync(path.join(dir, "src", "nested"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src", "app.ts"), "a"); + fs.writeFileSync(path.join(dir, "src", "nested", "deep.log"), "d"); + fs.writeFileSync(path.join(dir, "debug.log"), "l"); + + const stagedFor = (exclude: string[], id: string) => { + const s = new AssetStaging(appWithOutdir(), id, { + sourcePath: dir, + exclude, + }); + const out: string[] = []; + const walk = (p: string, pre: string) => { + for (const e of fs.readdirSync(p)) { + const f = path.join(p, e); + const r = pre ? `${pre}/${e}` : e; + if (fs.statSync(f).isDirectory()) walk(f, r); + else out.push(r); + } + }; + walk(s.absoluteStagedPath, ""); + return out.sort(); + }; + + // *.ext matches at any depth + expect(stagedFor(["*.log"], "A")).toEqual([ + "node_modules/big.js", + "src/app.ts", + ]); + // directory, with and without trailing slash + expect(stagedFor(["node_modules"], "B")).toEqual( + stagedFor(["node_modules/"], "C"), + ); + // nested directory path + expect(stagedFor(["src/nested"], "D")).toEqual([ + "debug.log", + "node_modules/big.js", + "src/app.ts", + ]); + // no prefix bleed + expect(stagedFor(["src/nest"], "E")).toContain("src/nested/deep.log"); + }); + + test("stages into the App outdir", () => { + const f = path.join(tempDir, "f.txt"); + fs.writeFileSync(f, "hello"); + const s = new AssetStaging(appWithOutdir(), "A", { sourcePath: f }); + expect(s.absoluteStagedPath.startsWith(outdir)).toBe(true); + }); + + test("runs the bundler once for identical assets", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "a.txt"), "x"); + let runs = 0; + const local = { + tryBundle(o: string) { + runs++; + fs.writeFileSync(path.join(o, "out.txt"), "o"); + return true; + }, + }; + const st = appWithOutdir(); + const opts = { + sourcePath: dir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["x"], + local, + }, + }; + const a = new AssetStaging(st, "A", opts as any); + const b = new AssetStaging(st, "B", opts as any); + expect(a.absoluteStagedPath).toBe(b.absoluteStagedPath); + expect(runs).toBe(1); + }); + + test("omits --security-opt when it is not configured", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "a.txt"), "x"); + const stub = process.env.DOCKER_STUB_DIR; + expect(stub).toBeUndefined(); // sanity: default /tmp used below + // Assert via BIND_MOUNT argv recorded by the stub + const concat = "/tmp/docker-stub.input.concat"; + fs.rmSync(concat, { force: true }); + new AssetStaging(appWithOutdir(), "A", { + sourcePath: dir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["DOCKER_STUB_SUCCESS"], + }, + }); + expect(fs.readFileSync(concat, "utf8")).not.toContain("--security-opt"); + }); + + test("mounts BIND_MOUNT input read-only and output writable", () => { + const dir = path.join(tempDir, "d"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "a.txt"), "x"); + const concat = "/tmp/docker-stub.input.concat"; + fs.rmSync(concat, { force: true }); + new AssetStaging(appWithOutdir(), "A", { + sourcePath: dir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["DOCKER_STUB_SUCCESS"], + bundlingFileAccess: BundlingFileAccess.BIND_MOUNT, + }, + }); + const argv = fs.readFileSync(concat, "utf8"); + expect(argv).toMatch(/asset-input:ro/); + expect(argv).not.toMatch(/asset-output:[a-z,]*ro/); + }); + + test("plain TerraformAsset and staged asset agree on the hash", () => { + const dir = mkTree(); + // Testing.app always enables the canonical flag, so this covers the + // canonical scheme only. + const st = appWithOutdir(); + const plain = new TerraformAsset(st, "P", { path: dir }); + const staged = new TerraformAsset(st, "A", { path: dir, exclude: [] }); + // plain infers DIRECTORY, staged zips -> ARCHIVE, so the framing differs + expect(plain.assetHash).toBe( + hashPath(dir, { canonical: true, archive: false }), + ); + expect(staged.assetHash).toBe( + hashPath(dir, { canonical: true, archive: true }), + ); + }); +}); diff --git a/packages/cdktn/test/asset-staging.test.ts b/packages/cdktn/test/asset-staging.test.ts new file mode 100644 index 000000000..f37c25a9d --- /dev/null +++ b/packages/cdktn/test/asset-staging.test.ts @@ -0,0 +1,842 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { + AssetStaging, + AssetHashType, + FileAssetPackaging, + TerraformStack, + Testing, +} from "../lib"; +import { CANONICAL_ASSET_HASHES } from "../lib/features"; +import { hashPath } from "../lib/private/fs"; + +describe("AssetStaging", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-asset-test-")); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("basic functionality", () => { + test("can stage a single file", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + // Create a test file + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "Hello, World!"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testFile, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.assetHash).toHaveLength(32); // MD5 hash (unified with TerraformAsset) + expect(asset.assetHash).toMatch(/^[A-F0-9]{32}$/); // Uppercase hex + expect(asset.isArchive).toBe(false); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + expect(asset.sourcePath).toBe(testFile); + expect(asset.absoluteStagedPath).toBeDefined(); + }); + + test("can stage a directory", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + // Create a test directory with files + const testDir = path.join(tempDir, "test-dir"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file1.txt"), "Content 1"); + fs.writeFileSync(path.join(testDir, "file2.txt"), "Content 2"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.isArchive).toBe(true); + expect(asset.packaging).toBe(FileAssetPackaging.ZIP_DIRECTORY); + }); + + test("throws error for non-existent path", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: "/non/existent/path", + }); + }).toThrow("Cannot find asset at /non/existent/path"); + }); + }); + + describe("hashing", () => { + test("produces consistent hash for same content", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile1 = path.join(tempDir, "test1.txt"); + const testFile2 = path.join(tempDir, "test2.txt"); + fs.writeFileSync(testFile1, "Same content"); + fs.writeFileSync(testFile2, "Same content"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testFile1, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testFile2, + }); + + expect(asset1.assetHash).toBe(asset2.assetHash); + }); + + test("produces different hash for different content", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile1 = path.join(tempDir, "test1.txt"); + const testFile2 = path.join(tempDir, "test2.txt"); + fs.writeFileSync(testFile1, "Content A"); + fs.writeFileSync(testFile2, "Content B"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testFile1, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testFile2, + }); + + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + + test("supports custom hash", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "Content"); + + const customHash = "my-custom-hash-v1"; + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testFile, + assetHash: customHash, + assetHashType: AssetHashType.CUSTOM, + }); + + // Custom hash should be used verbatim (matches TerraformAsset behavior) + expect(asset.assetHash).toBe(customHash); + }); + + test("uses extraHash in hash calculation", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "Same content"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testFile, + extraHash: "extra-1", + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testFile, + extraHash: "extra-2", + }); + + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + + test("throws error when custom hash type without hash value", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "Content"); + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testFile, + assetHashType: AssetHashType.CUSTOM, + // assetHash is missing + }); + }).toThrow("assetHash must be specified when assetHashType is CUSTOM"); + }); + }); + + describe("exclusions", () => { + test("excludes files matching patterns", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + // Create directory with files to exclude + const testDir = path.join(tempDir, "test-dir"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "include.txt"), "Include me"); + fs.writeFileSync(path.join(testDir, "exclude.md"), "Exclude me"); + fs.writeFileSync(path.join(testDir, "README.md"), "Exclude me too"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testDir, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testDir, + exclude: ["*.md"], + }); + + // Hash should be different because asset2 excludes .md files + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + + test("excludes directories matching patterns", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "test-dir"); + fs.mkdirSync(testDir); + fs.mkdirSync(path.join(testDir, "node_modules")); + fs.writeFileSync(path.join(testDir, "index.js"), "code"); + fs.writeFileSync( + path.join(testDir, "node_modules", "dep.js"), + "dependency", + ); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testDir, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testDir, + exclude: ["node_modules"], + }); + + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + }); + + describe("symlinks", () => { + test("ignores symlinks by default", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "test-dir"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "real.txt"), "real content"); + + const linkPath = path.join(testDir, "link.txt"); + try { + fs.symlinkSync(path.join(testDir, "real.txt"), linkPath); + } catch (_e) { + // Skip test if symlinks are not supported (e.g., Windows without admin) + return; + } + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + }); + + expect(asset.assetHash).toBeDefined(); + }); + }); + + describe("directory hashing", () => { + test("hashes directories recursively", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "test-dir"); + fs.mkdirSync(testDir); + fs.mkdirSync(path.join(testDir, "subdir")); + fs.writeFileSync(path.join(testDir, "file1.txt"), "Content 1"); + fs.writeFileSync(path.join(testDir, "subdir", "file2.txt"), "Content 2"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.isArchive).toBe(true); + }); + + test("produces consistent hash for same directory structure", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + // Create two identical directory structures + const dir1 = path.join(tempDir, "dir1"); + const dir2 = path.join(tempDir, "dir2"); + + for (const dir of [dir1, dir2]) { + fs.mkdirSync(dir); + fs.mkdirSync(path.join(dir, "subdir")); + fs.writeFileSync(path.join(dir, "a.txt"), "A"); + fs.writeFileSync(path.join(dir, "subdir", "b.txt"), "B"); + } + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: dir1, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: dir2, + }); + + expect(asset1.assetHash).toBe(asset2.assetHash); + }); + }); + + describe("exclusion patterns", () => { + test("excludes markdown files", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const dir = path.join(tempDir, "testdir"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "index.js"), "console.log('hi')"); + fs.writeFileSync(path.join(dir, "README.md"), "# Docs"); + + const assetWithMd = new AssetStaging(stack, "WithMd", { + sourcePath: dir, + }); + + const assetNoMd = new AssetStaging(stack, "NoMd", { + sourcePath: dir, + exclude: ["*.md"], + }); + + // Hash should differ when excluding files + expect(assetNoMd.assetHash).not.toBe(assetWithMd.assetHash); + }); + + test("excludes directories", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const dir = path.join(tempDir, "testdir"); + fs.mkdirSync(dir); + fs.mkdirSync(path.join(dir, "node_modules")); + fs.writeFileSync(path.join(dir, "index.js"), "code"); + fs.writeFileSync(path.join(dir, "node_modules", "dep.js"), "dep"); + + const assetWithNodeModules = new AssetStaging(stack, "WithNodeModules", { + sourcePath: dir, + }); + + const assetNoNodeModules = new AssetStaging(stack, "NoNodeModules", { + sourcePath: dir, + exclude: ["node_modules"], + }); + + expect(assetNoNodeModules.assetHash).not.toBe( + assetWithNodeModules.assetHash, + ); + }); + }); + + describe("extra hash for cache busting", () => { + test("changes hash when extra hash changes", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "content"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testFile, + extraHash: "v1", + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testFile, + extraHash: "v2", + }); + + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + + test("same extra hash produces same hash", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "content"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testFile, + extraHash: "v1.0.0", + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testFile, + extraHash: "v1.0.0", + }); + + expect(asset1.assetHash).toBe(asset2.assetHash); + }); + }); + + describe("custom hash", () => { + test("normalizes custom hash to SHA256", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "content"); + + const customHash = "my-custom-version-v1.0.0"; + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testFile, + assetHash: customHash, + assetHashType: AssetHashType.CUSTOM, + }); + + // Custom hash should be used verbatim (matches TerraformAsset behavior) + expect(asset.assetHash).toBe(customHash); + }); + + test("preserves valid SHA256 hash", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "content"); + + const validHash = "a".repeat(64); + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testFile, + assetHash: validHash, + assetHashType: AssetHashType.CUSTOM, + }); + + expect(asset.assetHash).toBe(validHash); + }); + }); + + describe("archive detection", () => { + test("detects .zip as archive", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const zipFile = path.join(tempDir, "archive.zip"); + fs.writeFileSync(zipFile, "fake zip content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: zipFile, + }); + + expect(asset.isArchive).toBe(true); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + }); + + test("detects .tar.gz as archive", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const tarGzFile = path.join(tempDir, "archive.tar.gz"); + fs.writeFileSync(tarGzFile, "fake tar.gz content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: tarGzFile, + }); + + expect(asset.isArchive).toBe(true); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + }); + + test("detects .tgz as archive", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const tgzFile = path.join(tempDir, "archive.tgz"); + fs.writeFileSync(tgzFile, "fake tgz content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: tgzFile, + }); + + expect(asset.isArchive).toBe(true); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + }); + + test("detects .tar as archive", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const tarFile = path.join(tempDir, "archive.tar"); + fs.writeFileSync(tarFile, "fake tar content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: tarFile, + }); + + expect(asset.isArchive).toBe(true); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + }); + + test("detects non-archive file correctly", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const txtFile = path.join(tempDir, "document.txt"); + fs.writeFileSync(txtFile, "text content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: txtFile, + }); + + expect(asset.isArchive).toBe(false); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + }); + + test("detects .zip.txt as non-archive", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const txtFile = path.join(tempDir, "archive.zip.txt"); + fs.writeFileSync(txtFile, "text content, not an archive"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: txtFile, + }); + + expect(asset.isArchive).toBe(false); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + }); + + test("handles multiple extensions correctly", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const multiExtFile = path.join( + tempDir, + "artifact.da.vinci.monalisa.tar.gz", + ); + fs.writeFileSync(multiExtFile, "fake tar.gz"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: multiExtFile, + }); + + expect(asset.isArchive).toBe(true); + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + expect(asset.absoluteStagedPath).toContain(".tar.gz"); + }); + }); + + describe("asset reuse and caching", () => { + test("reuses staging for identical assets", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "Content"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testFile, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testFile, + }); + + expect(asset1.assetHash).toBe(asset2.assetHash); + expect(asset1.absoluteStagedPath).toBe(asset2.absoluteStagedPath); + }); + + test("preserves packaging when reusing from memory cache", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const zipFile = path.join(tempDir, "archive.zip"); + fs.writeFileSync(zipFile, "fake zip"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: zipFile, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: zipFile, + }); + + expect(asset1.packaging).toBe(FileAssetPackaging.FILE); + expect(asset1.isArchive).toBe(true); + expect(asset2.packaging).toBe(asset1.packaging); + expect(asset2.isArchive).toBe(asset1.isArchive); + }); + }); + + describe("symlink handling", () => { + test("follows symlink to directory", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + // Create a real directory + const realDir = path.join(tempDir, "real-dir"); + fs.mkdirSync(realDir); + fs.writeFileSync(path.join(realDir, "file.txt"), "content"); + + // Create a symlink + const symlinkDir = path.join(tempDir, "symlink-dir"); + try { + fs.symlinkSync(realDir, symlinkDir); + } catch (_e) { + // Skip test if symlinks are not supported + return; + } + + const assetFromReal = new AssetStaging(stack, "AssetReal", { + sourcePath: realDir, + }); + + const assetFromSymlink = new AssetStaging(stack, "AssetSymlink", { + sourcePath: symlinkDir, + }); + + // Should produce the same hash when following symlink + expect(assetFromSymlink.assetHash).toBe(assetFromReal.assetHash); + }); + }); + + describe("edge cases", () => { + test("handles empty directory", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const emptyDir = path.join(tempDir, "empty"); + fs.mkdirSync(emptyDir); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: emptyDir, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.packaging).toBe(FileAssetPackaging.ZIP_DIRECTORY); + }); + + test("handles deeply nested directories", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const deepDir = path.join(tempDir, "a", "b", "c", "d", "e"); + fs.mkdirSync(deepDir, { recursive: true }); + fs.writeFileSync(path.join(deepDir, "file.txt"), "deep"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: path.join(tempDir, "a"), + }); + + expect(asset.assetHash).toBeDefined(); + }); + + test("handles special characters in filenames", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const specialDir = path.join(tempDir, "special"); + fs.mkdirSync(specialDir); + fs.writeFileSync(path.join(specialDir, "file (1).txt"), "content"); + fs.writeFileSync(path.join(specialDir, "file [2].txt"), "content"); + fs.writeFileSync(path.join(specialDir, "file's.txt"), "content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: specialDir, + }); + + expect(asset.assetHash).toBeDefined(); + }); + + test("handles very long filenames", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const longNameDir = path.join(tempDir, "longname"); + fs.mkdirSync(longNameDir); + const longFileName = "a".repeat(200) + ".txt"; + fs.writeFileSync(path.join(longNameDir, longFileName), "content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: longNameDir, + }); + + expect(asset.assetHash).toBeDefined(); + }); + + test("handles binary files", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const binaryFile = path.join(tempDir, "binary.bin"); + const buffer = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]); + fs.writeFileSync(binaryFile, buffer); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: binaryFile, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.isArchive).toBe(false); + }); + }); + + describe("file permissions", () => { + test("handles executable files", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const scriptFile = path.join(tempDir, "script.sh"); + fs.writeFileSync(scriptFile, "#!/bin/bash\necho hello"); + try { + fs.chmodSync(scriptFile, 0o755); + } catch (_e) { + // Skip on Windows or if chmod fails + return; + } + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: scriptFile, + }); + + expect(asset.assetHash).toBeDefined(); + }); + }); + + describe("cross-platform behavior", () => { + test("handles Windows-style paths", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "Content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testFile, + }); + + // Hash should be consistent regardless of path separators + expect(asset.assetHash).toBeDefined(); + expect(asset.assetHash).toHaveLength(32); // MD5 hash (unified with TerraformAsset) + expect(asset.assetHash).toMatch(/^[A-F0-9]{32}$/); // Uppercase hex + }); + }); + + describe("asset output structure", () => { + test("staged path contains hash", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(testFile, "Content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testFile, + }); + + const stagedBasename = path.basename(asset.absoluteStagedPath); + expect(stagedBasename).toContain("asset."); + expect(stagedBasename).toContain(asset.assetHash); + }); + + test("archive files preserve extension in staged path", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const tarGzFile = path.join(tempDir, "archive.tar.gz"); + fs.writeFileSync(tarGzFile, "fake tar.gz"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: tarGzFile, + }); + + expect(asset.absoluteStagedPath).toMatch(/\.tar\.gz$/); + }); + + test("non-archive files preserve extension in staged path", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const txtFile = path.join(tempDir, "document.txt"); + fs.writeFileSync(txtFile, "content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: txtFile, + }); + + expect(asset.absoluteStagedPath).toMatch(/\.txt$/); + }); + }); + + describe("canonical hash feature flag", () => { + test("uses the canonical scheme when the flag is enabled", () => { + // GIVEN + const app = Testing.app({ + context: { + [CANONICAL_ASSET_HASHES]: "true", + }, + }); + const stack = new TerraformStack(app, "Stack"); + const sourceDir = path.join(tempDir, "source"); + fs.mkdirSync(sourceDir); + fs.writeFileSync(path.join(sourceDir, "file.txt"), "content"); + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: sourceDir, + }); + + // THEN - the exact canonical digest, framed as the archive that a + // directory asset is emitted as; not merely "some hash". + expect(staging.assetHash).toBe( + hashPath(sourceDir, { canonical: true, archive: true }), + ); + expect(staging.assetHash).not.toBe( + hashPath(sourceDir, { canonical: false, archive: true }), + ); + }); + + test("the canonical scheme distinguishes trees the legacy scheme cannot", () => { + // GIVEN two trees with identical file contents but different names, + // which the legacy content-concatenation hash cannot tell apart. + const app = Testing.app({ + context: { [CANONICAL_ASSET_HASHES]: "true" }, + }); + const stack = new TerraformStack(app, "Stack"); + + const a = path.join(tempDir, "a"); + fs.mkdirSync(a); + fs.writeFileSync(path.join(a, "one.txt"), "same"); + const b = path.join(tempDir, "b"); + fs.mkdirSync(b); + fs.writeFileSync(path.join(b, "two.txt"), "same"); + + // WHEN + const first = new AssetStaging(stack, "A", { sourcePath: a }); + const second = new AssetStaging(stack, "B", { sourcePath: b }); + + // THEN + expect(first.assetHash).not.toBe(second.assetHash); + expect(hashPath(a, { canonical: false })).toBe( + hashPath(b, { canonical: false }), + ); + }); + + test("uses feature flag constant not hardcoded string", () => { + // This test ensures we're using the constant from features.ts + // not the legacy "cdktn:canonicalAssetHashes" string + expect(CANONICAL_ASSET_HASHES).toBe("canonicalAssetHashes"); + expect(CANONICAL_ASSET_HASHES).not.toBe("cdktn:canonicalAssetHashes"); + }); + }); +}); diff --git a/packages/cdktn/test/assets-integration.test.ts b/packages/cdktn/test/assets-integration.test.ts new file mode 100644 index 000000000..2df8482cf --- /dev/null +++ b/packages/cdktn/test/assets-integration.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +import * as cdktn from "../lib"; + +describe("Assets Integration", () => { + test("exports are available from main package", () => { + expect(cdktn.AssetHashType).toBeDefined(); + expect(cdktn.FileAssetPackaging).toBeDefined(); + }); + + test("can use types for type checking", () => { + // This test verifies that the types compile correctly + const asset: cdktn.FileAssetSource = { + sourceHash: "abc123", + fileName: "test.zip", + packaging: cdktn.FileAssetPackaging.FILE, + }; + + const location: cdktn.FileAssetLocation = { + bucketName: "my-bucket", + objectKey: "test.zip", + httpUrl: "https://example.com/test.zip", + objectUrl: "s3://my-bucket/test.zip", + }; + + const dockerAsset: cdktn.DockerImageAssetSource = { + sourceHash: "def456", + directoryName: "./docker", + }; + + const dockerLocation: cdktn.DockerImageAssetLocation = { + imageUri: "registry.example.com/my-image:latest", + repositoryName: "my-image", + }; + + const options: cdktn.AssetOptions = { + assetHashType: cdktn.AssetHashType.SOURCE, + }; + + expect(asset).toBeDefined(); + expect(location).toBeDefined(); + expect(dockerAsset).toBeDefined(); + expect(dockerLocation).toBeDefined(); + expect(options).toBeDefined(); + }); + + test("IAsset interface can be implemented", () => { + class MyAsset implements cdktn.IAsset { + readonly assetHash: string = "test-hash"; + } + + const myAsset = new MyAsset(); + expect(myAsset.assetHash).toBe("test-hash"); + }); + + test("enums have correct values", () => { + expect(cdktn.AssetHashType.SOURCE).toBe("source"); + expect(cdktn.AssetHashType.OUTPUT).toBe("output"); + expect(cdktn.AssetHashType.CUSTOM).toBe("custom"); + expect(cdktn.FileAssetPackaging.FILE).toBe("file"); + expect(cdktn.FileAssetPackaging.ZIP_DIRECTORY).toBe("zip"); + }); +}); diff --git a/packages/cdktn/test/assets-types.test.ts b/packages/cdktn/test/assets-types.test.ts new file mode 100644 index 000000000..c223d4941 --- /dev/null +++ b/packages/cdktn/test/assets-types.test.ts @@ -0,0 +1,280 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +import { + AssetHashType, + FileAssetPackaging, + type FileAssetSource, + type DockerImageAssetSource, + type FileAssetLocation, + type DockerImageAssetLocation, + type AssetOptions, +} from "../lib"; + +describe("Assets Types", () => { + describe("AssetHashType", () => { + test("has expected values", () => { + expect(AssetHashType.SOURCE).toBe("source"); + expect(AssetHashType.OUTPUT).toBe("output"); + expect(AssetHashType.CUSTOM).toBe("custom"); + }); + }); + + describe("FileAssetPackaging", () => { + test("has expected values", () => { + expect(FileAssetPackaging.ZIP_DIRECTORY).toBe("zip"); + expect(FileAssetPackaging.FILE).toBe("file"); + }); + }); + + describe("FileAssetSource", () => { + test("can be created with required fields", () => { + const source: FileAssetSource = { + sourceHash: "abc123", + fileName: "path/to/asset.zip", + packaging: FileAssetPackaging.FILE, + }; + + expect(source.sourceHash).toBe("abc123"); + expect(source.fileName).toBe("path/to/asset.zip"); + expect(source.packaging).toBe(FileAssetPackaging.FILE); + }); + + test("can include optional fields", () => { + const source: FileAssetSource = { + sourceHash: "abc123", + fileName: "path/to/asset", + packaging: FileAssetPackaging.ZIP_DIRECTORY, + deployTime: true, + displayName: "My Asset", + }; + + expect(source.deployTime).toBe(true); + expect(source.displayName).toBe("My Asset"); + }); + }); + + describe("DockerImageAssetSource", () => { + test("can be created with required fields", () => { + const source: DockerImageAssetSource = { + sourceHash: "def456", + directoryName: "path/to/dockerfile/dir", + }; + + expect(source.sourceHash).toBe("def456"); + expect(source.directoryName).toBe("path/to/dockerfile/dir"); + }); + + test("can include docker build options", () => { + const source: DockerImageAssetSource = { + sourceHash: "def456", + directoryName: "path/to/dockerfile/dir", + dockerBuildArgs: { NODE_ENV: "production" }, + dockerBuildTarget: "production", + dockerFile: "Dockerfile.prod", + platform: "linux/amd64", + dockerCacheDisabled: false, + }; + + expect(source.dockerBuildArgs).toEqual({ NODE_ENV: "production" }); + expect(source.dockerBuildTarget).toBe("production"); + expect(source.dockerFile).toBe("Dockerfile.prod"); + expect(source.platform).toBe("linux/amd64"); + }); + + test("can include cache options", () => { + const source: DockerImageAssetSource = { + sourceHash: "def456", + directoryName: "path/to/dockerfile/dir", + dockerCacheFrom: [ + { type: "registry", params: { ref: "myrepo/cache:latest" } }, + ], + dockerCacheTo: { + type: "registry", + params: { ref: "myrepo/cache:latest", mode: "max" }, + }, + }; + + expect(source.dockerCacheFrom).toHaveLength(1); + expect(source.dockerCacheFrom![0].type).toBe("registry"); + expect(source.dockerCacheTo?.params?.mode).toBe("max"); + }); + }); + + describe("FileAssetLocation", () => { + test("can represent AWS S3 location", () => { + const location: FileAssetLocation = { + bucketName: "my-bucket", + objectKey: "assets/abc123.zip", + httpUrl: + "https://s3-us-east-1.amazonaws.com/my-bucket/assets/abc123.zip", + objectUrl: "s3://my-bucket/assets/abc123.zip", + }; + + expect(location.bucketName).toBe("my-bucket"); + expect(location.httpUrl).toContain("s3-us-east-1"); + expect(location.objectUrl).toContain("s3://"); + }); + + test("can represent Azure Blob Storage location", () => { + const location: FileAssetLocation = { + bucketName: "mycontainer", + objectKey: "assets/abc123.zip", + httpUrl: + "https://mystorageaccount.blob.core.windows.net/mycontainer/assets/abc123.zip", + objectUrl: "az://mycontainer/assets/abc123.zip", + }; + + expect(location.bucketName).toBe("mycontainer"); + expect(location.httpUrl).toContain("blob.core.windows.net"); + expect(location.objectUrl).toContain("az://"); + }); + + test("can represent GCS location", () => { + const location: FileAssetLocation = { + bucketName: "my-gcs-bucket", + objectKey: "assets/abc123.zip", + httpUrl: + "https://storage.googleapis.com/my-gcs-bucket/assets/abc123.zip", + objectUrl: "gs://my-gcs-bucket/assets/abc123.zip", + }; + + expect(location.bucketName).toBe("my-gcs-bucket"); + expect(location.httpUrl).toContain("storage.googleapis.com"); + expect(location.objectUrl).toContain("gs://"); + }); + }); + + describe("DockerImageAssetLocation", () => { + test("can represent AWS ECR location", () => { + const location: DockerImageAssetLocation = { + imageUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:abc123", + repositoryName: "my-repo", + imageTag: "abc123", + }; + + expect(location.imageUri).toContain("dkr.ecr"); + expect(location.repositoryName).toBe("my-repo"); + expect(location.imageTag).toBe("abc123"); + }); + + test("can represent Azure ACR location", () => { + const location: DockerImageAssetLocation = { + imageUri: "myregistry.azurecr.io/my-repo:abc123", + repositoryName: "my-repo", + imageTag: "abc123", + }; + + expect(location.imageUri).toContain("azurecr.io"); + expect(location.repositoryName).toBe("my-repo"); + }); + + test("can represent GCP Artifact Registry location", () => { + const location: DockerImageAssetLocation = { + imageUri: "us-docker.pkg.dev/my-project/my-repo/my-image:abc123", + repositoryName: "my-repo", + imageTag: "abc123", + }; + + expect(location.imageUri).toContain("pkg.dev"); + expect(location.repositoryName).toBe("my-repo"); + }); + }); + + describe("AssetOptions", () => { + test("can specify custom hash", () => { + const options: AssetOptions = { + assetHash: "my-custom-hash", + assetHashType: AssetHashType.CUSTOM, + }; + + expect(options.assetHash).toBe("my-custom-hash"); + expect(options.assetHashType).toBe(AssetHashType.CUSTOM); + }); + + test("can specify hash type without custom hash", () => { + const options: AssetOptions = { + assetHashType: AssetHashType.SOURCE, + }; + + expect(options.assetHashType).toBe(AssetHashType.SOURCE); + expect(options.assetHash).toBeUndefined(); + }); + }); + + describe("Multi-cloud FileAssetLocation examples", () => { + test("can represent AWS S3 location", () => { + const s3Location: FileAssetLocation = { + bucketName: "my-bucket", + objectKey: "assets/abc123.zip", + httpUrl: + "https://s3-us-east-1.amazonaws.com/my-bucket/assets/abc123.zip", + objectUrl: "s3://my-bucket/assets/abc123.zip", + }; + + expect(s3Location.bucketName).toBe("my-bucket"); + expect(s3Location.objectUrl).toContain("s3://"); + }); + + test("can represent Azure Blob Storage location", () => { + const azureLocation: FileAssetLocation = { + bucketName: "mycontainer", + objectKey: "assets/abc123.zip", + httpUrl: + "https://mystorageaccount.blob.core.windows.net/mycontainer/assets/abc123.zip", + objectUrl: "az://mycontainer/assets/abc123.zip", + }; + + expect(azureLocation.bucketName).toBe("mycontainer"); + expect(azureLocation.objectUrl).toContain("az://"); + }); + + test("can represent GCS location", () => { + const gcsLocation: FileAssetLocation = { + bucketName: "my-gcs-bucket", + objectKey: "assets/abc123.zip", + httpUrl: + "https://storage.googleapis.com/my-gcs-bucket/assets/abc123.zip", + objectUrl: "gs://my-gcs-bucket/assets/abc123.zip", + }; + + expect(gcsLocation.bucketName).toBe("my-gcs-bucket"); + expect(gcsLocation.objectUrl).toContain("gs://"); + }); + }); + + describe("Multi-cloud DockerImageAssetLocation examples", () => { + test("can represent AWS ECR location", () => { + const ecrLocation: DockerImageAssetLocation = { + imageUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:abc123", + repositoryName: "my-repo", + imageTag: "abc123", + }; + + expect(ecrLocation.imageUri).toContain("ecr.us-east-1"); + expect(ecrLocation.repositoryName).toBe("my-repo"); + }); + + test("can represent Azure ACR location", () => { + const acrLocation: DockerImageAssetLocation = { + imageUri: "myregistry.azurecr.io/my-repo:abc123", + repositoryName: "my-repo", + imageTag: "abc123", + }; + + expect(acrLocation.imageUri).toContain("azurecr.io"); + expect(acrLocation.repositoryName).toBe("my-repo"); + }); + + test("can represent GCP Artifact Registry location", () => { + const garLocation: DockerImageAssetLocation = { + imageUri: "us-docker.pkg.dev/my-project/my-repo/my-image:abc123", + repositoryName: "my-repo", + imageTag: "abc123", + }; + + expect(garLocation.imageUri).toContain("pkg.dev"); + expect(garLocation.repositoryName).toBe("my-repo"); + }); + }); +}); diff --git a/packages/cdktn/test/bundling.test.ts b/packages/cdktn/test/bundling.test.ts new file mode 100644 index 000000000..ce22e7110 --- /dev/null +++ b/packages/cdktn/test/bundling.test.ts @@ -0,0 +1,801 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +// Comprehensive bundling tests for CDKTN + +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { + BundlingOutput, + DockerImage, + DockerVolumeConsistency, + type ILocalBundling, + type BundlingOptions, +} from "../lib/bundling"; +import { + AssetStaging, + AssetHashType, + FileAssetPackaging, + TerraformStack, + Testing, +} from "../lib"; + +jest.mock("child_process"); + +// Mock local bundler for integration tests +class MockLocalBundler implements ILocalBundling { + constructor( + private shouldSucceed: boolean = true, + private outputContent: string = "bundled output", + ) {} + + tryBundle(outputDir: string, _options: BundlingOptions): boolean { + if (!this.shouldSucceed) { + return false; + } + + // Create output in the bundle directory + fs.writeFileSync(path.join(outputDir, "output.txt"), this.outputContent); + return true; + } +} + +describe("bundling", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("DockerImage", () => { + beforeEach(() => { + (spawnSync as jest.Mock).mockReturnValue({ + status: 0, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + }); + }); + + test("fromRegistry creates image reference", () => { + const image = DockerImage.fromRegistry("node:18"); + expect(image.image).toBe("node:18"); + }); + + test("fromBuild creates image with hash-based tag", () => { + const image = DockerImage.fromBuild("/path/to/context"); + expect(image.image).toMatch(/^cdktn-[a-f0-9]{64}$/); + expect(spawnSync).toHaveBeenCalledWith( + "docker", + expect.arrayContaining([ + "build", + "-t", + expect.any(String), + "/path/to/context", + ]), + expect.any(Object), + ); + }); + + test("run executes docker run with options", () => { + const image = DockerImage.fromRegistry("alpine"); + image.run({ + command: ["echo", "hello"], + environment: { TEST: "value" }, + user: "1000:1000", + }); + + expect(spawnSync).toHaveBeenCalledWith( + "docker", + expect.arrayContaining([ + "run", + "--rm", + "-u", + "1000:1000", + "--env", + "TEST=value", + "alpine", + "echo", + "hello", + ]), + expect.any(Object), + ); + }); + + test("run mounts volumes without imposing a consistency mode", () => { + const image = DockerImage.fromRegistry("alpine"); + image.run({ + volumes: [{ hostPath: "/host", containerPath: "/container" }], + }); + + // `consistency` is a macOS-only hint, so it is not forced onto mounts. + expect(spawnSync).toHaveBeenCalledWith( + "docker", + expect.arrayContaining(["-v", "/host:/container"]), + expect.any(Object), + ); + }); + + test("run applies readOnly and consistency to volume mounts", () => { + const image = DockerImage.fromRegistry("alpine"); + image.run({ + volumes: [ + { hostPath: "/ro", containerPath: "/in", readOnly: true }, + { + hostPath: "/cached", + containerPath: "/c", + consistency: DockerVolumeConsistency.CACHED, + }, + ], + }); + + expect(spawnSync).toHaveBeenLastCalledWith( + "docker", + expect.arrayContaining(["-v", "/ro:/in:ro", "-v", "/cached:/c:cached"]), + expect.any(Object), + ); + }); + }); + + describe("BundlingOutput", () => { + test("has expected enum values", () => { + expect(BundlingOutput.ARCHIVED).toBe("archived"); + expect(BundlingOutput.NOT_ARCHIVED).toBe("not-archived"); + expect(BundlingOutput.AUTO_DISCOVER).toBe("auto-discover"); + expect(BundlingOutput.SINGLE_FILE).toBe("single-file"); + }); + }); + + // Integration tests with AssetStaging + describe("Asset bundling integration", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-bundle-test-")); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("local bundling", () => { + test("uses local bundling when successful", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "index.js"), "console.log('hi')"); + + const bundler = new MockLocalBundler(true, "locally bundled"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("node:18"), + command: ["echo", "should not run"], + local: bundler, + }, + }); + + expect(asset.assetHash).toBeDefined(); + expect(fs.existsSync(asset.absoluteStagedPath)).toBe(true); + }); + + test("attempts docker when local bundling returns false", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "index.js"), "console.log('hi')"); + + const bundler = new MockLocalBundler(false); + + // Docker bundling will be attempted (may or may not work in test environment) + try { + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("node:18"), + command: ["echo", "docker would run"], + local: bundler, + }, + }); + expect(asset).toBeDefined(); + } catch (err) { + // If docker fails, that's expected in test environment + expect(err).toBeDefined(); + } + }); + + test("local bundler receives correct options", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + let receivedOptions: BundlingOptions | undefined; + + const customBundler: ILocalBundling = { + tryBundle(outputDir: string, options: BundlingOptions): boolean { + receivedOptions = options; + fs.writeFileSync(path.join(outputDir, "output.txt"), "bundled"); + return true; + }, + }; + + const bundlingOptions: BundlingOptions = { + image: DockerImage.fromRegistry("alpine"), + command: ["/bin/sh", "-c", "echo hello"], + environment: { + NODE_ENV: "production", + }, + user: "1000:1000", + workingDirectory: "/app", + }; + + new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + ...bundlingOptions, + local: customBundler, + }, + }); + + expect(receivedOptions).toBeDefined(); + expect(receivedOptions?.image.image).toBe("alpine"); + expect(receivedOptions?.environment?.NODE_ENV).toBe("production"); + }); + + test("requires directory for bundling", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testFile = path.join(tempDir, "file.txt"); + fs.writeFileSync(testFile, "content"); + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testFile, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + }, + }); + }).toThrow("Asset must be a directory when bundling"); + }); + }); + + describe("bundling output types", () => { + test("handles AUTO_DISCOVER output type with single file", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync(path.join(outputDir, "output.txt"), "bundled"); + return true; + }, + }; + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.AUTO_DISCOVER, + local: bundler, + }, + }); + + expect(asset.packaging).toBe(FileAssetPackaging.ZIP_DIRECTORY); + }); + + test("handles NOT_ARCHIVED output type", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync(path.join(outputDir, "file1.txt"), "content1"); + fs.writeFileSync(path.join(outputDir, "file2.txt"), "content2"); + return true; + }, + }; + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.NOT_ARCHIVED, + local: bundler, + }, + }); + + expect(asset.packaging).toBe(FileAssetPackaging.ZIP_DIRECTORY); + expect(asset.isArchive).toBe(false); + }); + + test("handles ARCHIVED output type with single archive", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync( + path.join(outputDir, "output.zip"), + "archive content", + ); + return true; + }, + }; + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.ARCHIVED, + local: bundler, + }, + }); + + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + expect(asset.isArchive).toBe(true); + }); + + test("throws error when ARCHIVED output has multiple files", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync(path.join(outputDir, "file1.txt"), "content1"); + fs.writeFileSync(path.join(outputDir, "file2.txt"), "content2"); + return true; + }, + }; + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.ARCHIVED, + local: bundler, + }, + }); + }).toThrow( + /expected BundlingOutput\.ARCHIVED but the bundling output directory.*contains 2 file\(s\)/, + ); + }); + + test("handles SINGLE_FILE output type", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync(path.join(outputDir, "output.txt"), "single file"); + return true; + }, + }; + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.SINGLE_FILE, + local: bundler, + }, + }); + + expect(asset.packaging).toBe(FileAssetPackaging.FILE); + expect(asset.isArchive).toBe(false); + }); + + test("throws error when SINGLE_FILE output has multiple files", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync(path.join(outputDir, "file1.txt"), "content1"); + fs.writeFileSync(path.join(outputDir, "file2.txt"), "content2"); + return true; + }, + }; + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.SINGLE_FILE, + local: bundler, + }, + }); + }).toThrow( + /expected BundlingOutput\.SINGLE_FILE but the bundling output directory.*contains 2 file\(s\)/, + ); + }); + + test("throws error when ARCHIVED expects archive but gets non-archive", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync( + path.join(outputDir, "output.txt"), + "not an archive", + ); + return true; + }, + }; + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.ARCHIVED, + local: bundler, + }, + }); + }).toThrow( + /expected BundlingOutput\.ARCHIVED but the bundling output directory.*contains 1 file\(s\)/, + ); + }); + + test("throws error when SINGLE_FILE gets archive file", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const bundler: ILocalBundling = { + tryBundle(outputDir: string): boolean { + fs.writeFileSync( + path.join(outputDir, "output.zip"), + "archive content", + ); + return true; + }, + }; + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "hello"], + outputType: BundlingOutput.SINGLE_FILE, + local: bundler, + }, + }); + }).toThrow( + /expected BundlingOutput\.SINGLE_FILE but the bundling output directory.*contains 1 file\(s\)/, + ); + }); + }); + + describe("bundling with hash types", () => { + test("SOURCE hash type works with bundling", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source1"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "index.js"), "console.log('v1')"); + + const asset = new AssetStaging(stack, "Asset1", { + sourcePath: testDir, + assetHashType: AssetHashType.SOURCE, + bundling: { + image: DockerImage.fromRegistry("node:18"), + command: ["echo", "bundle"], + local: new MockLocalBundler(true, "output"), + }, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.assetHash).toHaveLength(32); // MD5 hash (unified) + expect(asset.assetHash).toMatch(/^[A-F0-9]{32}$/); // Uppercase hex + expect(fs.existsSync(asset.absoluteStagedPath)).toBe(true); + }); + + test("supports OUTPUT hash type with bundling", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "input.txt"), "input"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testDir, + assetHashType: AssetHashType.OUTPUT, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "bundle"], + local: new MockLocalBundler(true, "output v1"), + }, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testDir, + assetHashType: AssetHashType.OUTPUT, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "bundle"], + local: new MockLocalBundler(true, "output v2"), + }, + }); + + // Hash should be different because output is different + expect(asset2.assetHash).not.toBe(asset1.assetHash); + }); + + test("uses SOURCE hash when OUTPUT specified without bundling", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + assetHashType: AssetHashType.OUTPUT, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.assetHash).toHaveLength(32); // MD5 hash (unified) + expect(asset.assetHash).toMatch(/^[A-F0-9]{32}$/); // Uppercase hex + }); + }); + + describe("bundling with custom hash", () => { + test("supports custom hash with bundling", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const customHash = "my-custom-v1"; + const asset = new AssetStaging(stack, "Asset", { + sourcePath: testDir, + assetHash: customHash, + assetHashType: AssetHashType.CUSTOM, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "bundle"], + local: new MockLocalBundler(), + }, + }); + + // Custom hash is used verbatim (unified behavior) + expect(asset.assetHash).toBe(customHash); + }); + }); + + describe("bundling error handling", () => { + test("cleans up temp directory on bundling failure", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const failingBundler: ILocalBundling = { + tryBundle(): boolean { + throw new Error("Bundling failed!"); + }, + }; + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "bundle"], + local: failingBundler, + }, + }); + }).toThrow("Bundling failed!"); + }); + + test("handles empty bundling output directory", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const emptyBundler: ILocalBundling = { + tryBundle(_outputDir: string): boolean { + return true; + }, + }; + + expect(() => { + new AssetStaging(stack, "Asset", { + sourcePath: testDir, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "bundle"], + local: emptyBundler, + }, + }); + }).toThrow(/bundling output directory.*is empty/); + }); + }); + + describe("bundling with extra hash", () => { + test("extra hash affects bundled asset hash", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const testDir = path.join(tempDir, "source"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file.txt"), "content"); + + const asset1 = new AssetStaging(stack, "Asset1", { + sourcePath: testDir, + extraHash: "v1", + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "bundle"], + local: new MockLocalBundler(), + }, + }); + + const asset2 = new AssetStaging(stack, "Asset2", { + sourcePath: testDir, + extraHash: "v2", + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: ["echo", "bundle"], + local: new MockLocalBundler(), + }, + }); + + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + }); + }); + + describe("DockerImage.fromBuild Dockerfile path validation", () => { + test("rejects absolute Dockerfile path", () => { + // GIVEN + const contextPath = "/some/context"; + + // WHEN/THEN + expect(() => + DockerImage.fromBuild(contextPath, { + file: "/absolute/path/Dockerfile", + }), + ).toThrow(/must be relative to context/); + }); + + test("rejects Dockerfile outside context with ../", () => { + // GIVEN + const contextPath = path.join(__dirname, "fixtures"); + + // WHEN/THEN + expect(() => + DockerImage.fromBuild(contextPath, { + file: "../Dockerfile", + }), + ).toThrow(/must be within the build context/); + }); + + test("rejects Dockerfile outside context with nested ../", () => { + // GIVEN + const contextPath = path.join(__dirname, "fixtures", "app"); + + // WHEN/THEN + expect(() => + DockerImage.fromBuild(contextPath, { + file: "../../outside/Dockerfile", + }), + ).toThrow(/must be within the build context/); + }); + + test("builds with a Dockerfile in a context subdirectory", () => { + // GIVEN + (spawnSync as jest.Mock).mockReturnValue({ + status: 0, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + }); + const contextPath = path.join(__dirname, "fixtures"); + + // WHEN + const image = DockerImage.fromBuild(contextPath, { + file: "app/cdktf.json", + }); + + // THEN - the build actually ran, with -f pointing at the resolved file + expect(image.image).toMatch(/^cdktn-[a-f0-9]{64}$/); + expect(spawnSync).toHaveBeenLastCalledWith( + "docker", + expect.arrayContaining([ + "build", + "-f", + path.join(contextPath, "app/cdktf.json"), + contextPath, + ]), + expect.any(Object), + ); + }); + + test("builds with a Dockerfile at the context root", () => { + // GIVEN + (spawnSync as jest.Mock).mockReturnValue({ + status: 0, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + }); + const contextPath = path.join(__dirname, "fixtures"); + + // WHEN + DockerImage.fromBuild(contextPath, { file: "cdktf.json" }); + + // THEN + expect(spawnSync).toHaveBeenLastCalledWith( + "docker", + expect.arrayContaining([ + "build", + "-f", + path.join(contextPath, "cdktf.json"), + contextPath, + ]), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/cdktn/test/docker-stub-cp.sh b/packages/cdktn/test/docker-stub-cp.sh new file mode 100755 index 000000000..23672540a --- /dev/null +++ b/packages/cdktn/test/docker-stub-cp.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +set -euo pipefail +# stub for the `docker` executable. it is used as CDK_DOCKER when executing unit +# tests in `staging.test.ts` This variant is specific for tests that use the +# docker copy method for files (VOLUME_COPY), instead of bind mounts. +# +# Output goes to $DOCKER_STUB_DIR so parallel jest workers cannot clobber each +# other's recorded invocations. + +stub_dir="${DOCKER_STUB_DIR:-/tmp}" +echo "$@" >> "${stub_dir}/docker-stub-cp.input.concat" +echo "$@" > "${stub_dir}/docker-stub-cp.input" + +# Emulate files produced by bundling. For `docker cp : ` +# the destination is the final argument; deriving it that way keeps the stub +# independent of where the caller placed its bundling directory. +if echo "$@" | grep -q "^cp " && echo "$@" | grep -q "/asset-output"; then + outdir="${!#}" + if [ -d "$outdir" ]; then + if grep -q "DOCKER_STUB_SINGLE_FILE_WITHOUT_EXT" \ + "${stub_dir}/docker-stub-cp.input.concat"; then + touch "${outdir}/test" # create a file without extension + else + touch "${outdir}/test.zip" + fi + fi +fi + +exit 0 diff --git a/packages/cdktn/test/docker-stub.sh b/packages/cdktn/test/docker-stub.sh new file mode 100755 index 000000000..99062182f --- /dev/null +++ b/packages/cdktn/test/docker-stub.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +set -euo pipefail + +# stub for the `docker` executable. it is used as CDK_DOCKER when executing unit +# tests in `staging.test.ts` It outputs the command line to +# `/tmp/docker-stub.input` and accepts one of several commands that impact its +# behavior. + +# Output goes to $DOCKER_STUB_DIR so parallel jest workers cannot clobber each +# other's recorded invocations. +stub_dir="${DOCKER_STUB_DIR:-/tmp}" +echo "$@" >> "${stub_dir}/docker-stub.input.concat" +echo "$@" > "${stub_dir}/docker-stub.input" + +if echo "$@" | grep "DOCKER_STUB_SUCCESS_NO_OUTPUT"; then + exit 0 +fi + +if echo "$@" | grep "DOCKER_STUB_FAIL"; then + echo "A HUGE FAILING DOCKER STUFF" + exit 1 +fi + +if echo "$@" | grep "DOCKER_STUB_SUCCESS"; then + outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1) + touch ${outdir}/test.txt + exit 0 +fi + +if echo "$@" | grep "DOCKER_STUB_MULTIPLE_FILES"; then + outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1) + touch ${outdir}/test1.txt + touch ${outdir}/test2.txt + exit 0 +fi + +if echo "$@" | grep "DOCKER_STUB_SINGLE_ARCHIVE"; then + outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1) + touch ${outdir}/test.zip + exit 0 +fi + +if echo "$@" | grep "DOCKER_STUB_SINGLE_FILE_WITHOUT_EXT"; then + outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1) + touch ${outdir}/test # create a file without extension + exit 0 +fi + +if echo "$@" | grep "DOCKER_STUB_SINGLE_FILE"; then + outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1) + touch ${outdir}/test.txt + exit 0 +fi + +echo "Docker mock only supports one of the following commands: DOCKER_STUB_SUCCESS_NO_OUTPUT,DOCKER_STUB_FAIL,DOCKER_STUB_SUCCESS,DOCKER_STUB_MULTIPLE_FILES,DOCKER_STUB_SINGLE_ARCHIVE,DOCKER_STUB_SINGLE_FILE,DOCKER_STUB_SINGLE_FILE_WITHOUT_EXT, got '$@'" +exit 1 diff --git a/packages/cdktn/test/fixtures/app/cdktf.json b/packages/cdktn/test/fixtures/app/cdktf.json new file mode 100644 index 000000000..197184f08 --- /dev/null +++ b/packages/cdktn/test/fixtures/app/cdktf.json @@ -0,0 +1,4 @@ +{ + "language": "typescript", + "app": "npx ts-node main.ts" +} diff --git a/packages/cdktn/test/fs/fixtures/test1/external-link.txt b/packages/cdktn/test/fs/fixtures/test1/external-link.txt new file mode 120000 index 000000000..76b900a50 --- /dev/null +++ b/packages/cdktn/test/fs/fixtures/test1/external-link.txt @@ -0,0 +1 @@ +/tmp/non-existent-target \ No newline at end of file diff --git a/packages/cdktn/test/fs/fixtures/test1/file1.txt b/packages/cdktn/test/fs/fixtures/test1/file1.txt new file mode 100644 index 000000000..ce0136250 --- /dev/null +++ b/packages/cdktn/test/fs/fixtures/test1/file1.txt @@ -0,0 +1 @@ +hello diff --git a/packages/cdktn/test/fs/fixtures/test1/local-link.txt b/packages/cdktn/test/fs/fixtures/test1/local-link.txt new file mode 120000 index 000000000..39cd5762d --- /dev/null +++ b/packages/cdktn/test/fs/fixtures/test1/local-link.txt @@ -0,0 +1 @@ +file1.txt \ No newline at end of file diff --git a/packages/cdktn/test/fs/fixtures/test1/subdir/file2.txt b/packages/cdktn/test/fs/fixtures/test1/subdir/file2.txt new file mode 100644 index 000000000..b3a1c798d --- /dev/null +++ b/packages/cdktn/test/fs/fixtures/test1/subdir/file2.txt @@ -0,0 +1 @@ +world in subdir diff --git a/packages/cdktn/test/helper/provider.ts b/packages/cdktn/test/helper/provider.ts index f8f6f849c..d4210e8c8 100644 --- a/packages/cdktn/test/helper/provider.ts +++ b/packages/cdktn/test/helper/provider.ts @@ -94,3 +94,27 @@ export class DockerProvider extends TerraformProvider { }; } } + +// Null provider for testing scenarios that require terraform plan +// (does not require any external services like Docker) +export class NullProvider extends TerraformProvider { + public static readonly tfResourceType: string = "null"; + public constructor(scope: Construct, id: string) { + super(scope, id, { + terraformResourceType: "null", + terraformGeneratorMetadata: { + providerName: "null", + providerVersionConstraint: "~> 3.0", + }, + terraformProviderSource: "hashicorp/null", + }); + } + + protected synthesizeAttributes(): { [name: string]: any } { + return {}; + } + + protected synthesizeHclAttributes(): { [name: string]: any } { + return {}; + } +} diff --git a/packages/cdktn/test/helper/resource.ts b/packages/cdktn/test/helper/resource.ts index f0f179d84..e74b8eed9 100644 --- a/packages/cdktn/test/helper/resource.ts +++ b/packages/cdktn/test/helper/resource.ts @@ -220,3 +220,21 @@ export class DockerImage extends TerraformResource { }; } } + +// Null resource for testing scenarios that require terraform plan +// (does not require any external services like Docker) +export class NullResource extends TerraformResource { + public static readonly tfResourceType: string = "null_resource"; + public constructor(scope: Construct, id: string) { + super(scope, id, { + terraformResourceType: "null_resource", + terraformGeneratorMetadata: { + providerName: "null", + }, + }); + } + + protected synthesizeAttributes(): { [name: string]: any } { + return {}; + } +} diff --git a/packages/cdktn/test/resource.test.ts b/packages/cdktn/test/resource.test.ts index b078b4b76..a9639db6b 100644 --- a/packages/cdktn/test/resource.test.ts +++ b/packages/cdktn/test/resource.test.ts @@ -476,7 +476,7 @@ it("moves resource to be in composition with foreach using list iterator", () => const synthedStack = JSON.parse(Testing.synth(stack)); expect(synthedStack.moved[0].from).toEqual("test_resource.simple"); expect(synthedStack.moved[0].to).toEqual( - `test_resource.simple-foreach[\"foo-one\"]`, + 'test_resource.simple-foreach["foo-one"]', ); expect(Object.keys(synthedStack.resource.test_resource)).toContain( "simple-foreach", @@ -523,7 +523,7 @@ it("moves resource to be in composition with foreach using complex iterator", () const synthedStack = JSON.parse(Testing.synth(stack)); expect(synthedStack.moved[0].from).toEqual("test_resource.simple"); expect(synthedStack.moved[0].to).toEqual( - `test_resource.simple-foreach[\"simple-foreach-one\"]`, + 'test_resource.simple-foreach["simple-foreach-one"]', ); expect(Object.keys(synthedStack.resource.test_resource)).toContain( "simple-foreach", diff --git a/packages/cdktn/test/staging.test.ts b/packages/cdktn/test/staging.test.ts new file mode 100644 index 000000000..7d988a0f8 --- /dev/null +++ b/packages/cdktn/test/staging.test.ts @@ -0,0 +1,1051 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +// Ported from AWS CDK and TerraConstructs + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + AnnotationMetadataEntryType, + App, + AssetHashType, + AssetStaging, + BundlingFileAccess, + BundlingOptions, + BundlingOutput, + DockerImage, + FileAssetPackaging, + TerraformStack, + Testing, +} from "../lib"; + +// Per-worker so parallel jest workers cannot overwrite each other's recordings. +const STUB_DIR = fs.mkdtempSync( + path.join( + os.tmpdir(), + `cdktn-docker-stub-${process.env.JEST_WORKER_ID ?? "0"}-`, + ), +); +process.env.DOCKER_STUB_DIR = STUB_DIR; + +const STUB_INPUT_FILE = path.join(STUB_DIR, "docker-stub.input"); +const STUB_INPUT_CONCAT_FILE = path.join(STUB_DIR, "docker-stub.input.concat"); + +const STUB_INPUT_CP_FILE = path.join(STUB_DIR, "docker-stub-cp.input"); +const STUB_INPUT_CP_CONCAT_FILE = path.join( + STUB_DIR, + "docker-stub-cp.input.concat", +); + +enum DockerStubCommand { + SUCCESS = "DOCKER_STUB_SUCCESS", + FAIL = "DOCKER_STUB_FAIL", + SUCCESS_NO_OUTPUT = "DOCKER_STUB_SUCCESS_NO_OUTPUT", + MULTIPLE_FILES = "DOCKER_STUB_MULTIPLE_FILES", + SINGLE_ARCHIVE = "DOCKER_STUB_SINGLE_ARCHIVE", + SINGLE_FILE = "DOCKER_STUB_SINGLE_FILE", + SINGLE_FILE_WITHOUT_EXT = "DOCKER_STUB_SINGLE_FILE_WITHOUT_EXT", + VOLUME_SINGLE_ARCHIVE = "DOCKER_STUB_VOLUME_SINGLE_ARCHIVE", +} + +const FIXTURE_TEST1_DIR = path.join(__dirname, "fs", "fixtures", "test1"); + +const CDKTFJSON_PATH = path.join(__dirname, "fixtures", "app", "cdktf.json"); +const TEST_STAGING_DIR = path.join( + __dirname, + "fixtures", + "app", + "cdktf.out", + "assets", +); +const TEST_OUTDIR = path.join(__dirname, "cdktf.out"); + +const userInfo = os.userInfo(); +const USER_ARG = `-u ${userInfo.uid}:${userInfo.gid}`; + +describe("staging", () => { + let stack: TerraformStack; + let app: App; + + beforeAll(() => { + // Use custom "docker" command for staging + process.env.CDK_DOCKER = `${__dirname}/docker-stub.sh`; + }); + + afterAll(() => { + delete process.env.CDK_DOCKER; + // clear the staging directory + fs.rmSync(TEST_STAGING_DIR, { recursive: true, force: true }); + }); + + beforeEach(() => { + if (fs.existsSync(TEST_OUTDIR)) { + fs.rmSync(TEST_OUTDIR, { recursive: true, force: true }); + } + app = Testing.app({ + outdir: TEST_OUTDIR, + context: { + cdktfJsonPath: path.resolve(CDKTFJSON_PATH), + }, + }); + stack = new TerraformStack(app, "TestStack"); + }); + + afterEach(() => { + if (fs.existsSync(STUB_INPUT_FILE)) { + fs.unlinkSync(STUB_INPUT_FILE); + } + if (fs.existsSync(STUB_INPUT_CONCAT_FILE)) { + fs.unlinkSync(STUB_INPUT_CONCAT_FILE); + } + // Clean staging output between tests + if (fs.existsSync(TEST_STAGING_DIR)) { + fs.rmSync(TEST_STAGING_DIR, { recursive: true, force: true }); + } + jest.restoreAllMocks(); + }); + + test("with bundling", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + const processStdOutWriteSpy = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + }, + }); + + // THEN + expect(readDockerStubInput()).toEqual( + `run --rm ${USER_ARG} -v /input:/asset-input:ro -v /output:/asset-output -w /asset-input alpine DOCKER_STUB_SUCCESS`, + ); + + // Shows a message before bundling + expect(processStdOutWriteSpy).toHaveBeenCalledWith( + "Bundling asset TestStack/Asset...\n", + ); + }); + + test("bundling throws when /asset-output is empty", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // THEN + expect( + () => + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS_NO_OUTPUT], + }, + }), + ).toThrow(/[Bb]undl.*output.*empty|[Bb]undl.*did not produce/); + + expect(readDockerStubInput()).toEqual( + `run --rm ${USER_ARG} -v /input:/asset-input:ro -v /output:/asset-output -w /asset-input alpine DOCKER_STUB_SUCCESS_NO_OUTPUT`, + ); + }); + + test("throws when bundling fails", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // THEN + expect( + () => + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("this-is-an-invalid-docker-image"), + command: [DockerStubCommand.FAIL], + }, + }), + ).toThrow(/[Ff]ailed.*bundl|docker.*exited/i); + + expect(readDockerStubInput()).toEqual( + `run --rm ${USER_ARG} -v /input:/asset-input:ro -v /output:/asset-output -w /asset-input this-is-an-invalid-docker-image DOCKER_STUB_FAIL`, + ); + }); + + test("bundling with docker security option", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + securityOpt: "no-new-privileges", + }, + }); + + // THEN + expect(readDockerStubInput()).toEqual( + `run --rm --security-opt no-new-privileges ${USER_ARG} -v /input:/asset-input:ro -v /output:/asset-output -w /asset-input alpine DOCKER_STUB_SUCCESS`, + ); + }); + + test("bundling with docker entrypoint", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + entrypoint: [DockerStubCommand.SUCCESS], + command: [DockerStubCommand.SUCCESS], + }, + }); + + // THEN + expect(readDockerStubInput()).toEqual( + `run --rm ${USER_ARG} -v /input:/asset-input:ro -v /output:/asset-output -w /asset-input --entrypoint DOCKER_STUB_SUCCESS alpine DOCKER_STUB_SUCCESS`, + ); + }); + + test("bundling that produces a single archive file is autodiscovered", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SINGLE_ARCHIVE], + }, + }); + + // THEN + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(true); + }); + + test("bundling that produces a single archive file with NOT_ARCHIVED", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SINGLE_ARCHIVE], + outputType: BundlingOutput.NOT_ARCHIVED, + }, + }); + + // THEN + expect(staging.packaging).toEqual(FileAssetPackaging.ZIP_DIRECTORY); + expect(staging.isArchive).toEqual(false); + }); + + test("throws with ARCHIVED and bundling that does not produce a single archive file", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + expect( + () => + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.MULTIPLE_FILES], + outputType: BundlingOutput.ARCHIVED, + }, + }), + ).toThrow(/ARCHIVED|SINGLE_FILE/); + }); + + test("bundling that produces a single file with SINGLE_FILE", () => { + // GIVEN + const directory = path.join(FIXTURE_TEST1_DIR, "subdir"); + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SINGLE_FILE], + outputType: BundlingOutput.SINGLE_FILE, + }, + }); + + // THEN + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(false); + }); + + test("bundling that produces a single file without extension with SINGLE_FILE", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SINGLE_FILE_WITHOUT_EXT], + outputType: BundlingOutput.SINGLE_FILE, + }, + }); + + // THEN + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(false); + }); + + test("with local bundling", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + let dir: string | undefined; + let opts: BundlingOptions | undefined; + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + local: { + tryBundle(outputDir: string, options: BundlingOptions): boolean { + dir = outputDir; + opts = options; + fs.writeFileSync(path.join(outputDir, "hello.txt"), "hello"); + return true; + }, + }, + }, + }); + + // THEN + expect(dir).toBeDefined(); + expect(opts?.command?.[0]).toEqual(DockerStubCommand.SUCCESS); + // Docker should NOT have been called + expect(fs.existsSync(STUB_INPUT_FILE)).toEqual(false); + + if (dir) { + fs.rmSync(path.join(dir, "hello.txt"), { recursive: true, force: true }); + } + }); + + test("bundling with BIND_MOUNT uses -v volumes", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + bundlingFileAccess: BundlingFileAccess.BIND_MOUNT, + }, + }); + + // THEN + const input = readDockerStubInput(); + // Should have -v bind mount flags + expect(input).toContain("-v /input:/asset-input:ro"); + expect(input).toContain("-v /output:/asset-output"); + // Should NOT have volume create commands + expect(input).not.toContain("volume create"); + }); + + test("BIND_MOUNT is the default bundlingFileAccess", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + // No bundlingFileAccess specified + }, + }); + + // THEN + const input = readDockerStubInput(); + expect(input).toContain("-v /input:/asset-input:ro"); + expect(input).toContain("-v /output:/asset-output"); + }); + + test("bundling with BIND_MOUNT passes environment variables", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + bundlingFileAccess: BundlingFileAccess.BIND_MOUNT, + environment: { NODE_ENV: "production" }, + }, + }); + + // THEN + expect(readDockerStubInput()).toContain("--env NODE_ENV=production"); + }); + + test("bundling with BIND_MOUNT passes network option", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + bundlingFileAccess: BundlingFileAccess.BIND_MOUNT, + network: "host", + }, + }); + + // THEN + expect(readDockerStubInput()).toContain("--network host"); + }); + + test("bundling with BIND_MOUNT passes platform option", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + bundlingFileAccess: BundlingFileAccess.BIND_MOUNT, + platform: "linux/amd64", + }, + }); + + // THEN + expect(readDockerStubInput()).toContain("--platform linux/amd64"); + }); + + test("bundling with BIND_MOUNT passes additional volumes", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SUCCESS], + bundlingFileAccess: BundlingFileAccess.BIND_MOUNT, + volumes: [{ hostPath: "/tmp/cache", containerPath: "/cache" }], + }, + }); + + // THEN + expect(readDockerStubInput()).toContain("/tmp/cache:/cache"); + }); +}); + +describe("staging with docker cp", () => { + let stack: TerraformStack; + let app: App; + + beforeAll(() => { + // Use custom "docker" command that handles VOLUME_COPY operations + process.env.CDK_DOCKER = `${__dirname}/docker-stub-cp.sh`; + }); + + afterAll(() => { + delete process.env.CDK_DOCKER; + // clear the staging directory + fs.rmSync(TEST_STAGING_DIR, { recursive: true, force: true }); + }); + + beforeEach(() => { + if (fs.existsSync(TEST_OUTDIR)) { + fs.rmSync(TEST_OUTDIR, { recursive: true, force: true }); + } + app = Testing.app({ + outdir: TEST_OUTDIR, + context: { + cdktfJsonPath: path.resolve(CDKTFJSON_PATH), + }, + }); + stack = new TerraformStack(app, "TestStack"); + }); + + afterEach(() => { + if (fs.existsSync(STUB_INPUT_CP_FILE)) { + fs.unlinkSync(STUB_INPUT_CP_FILE); + } + if (fs.existsSync(STUB_INPUT_CP_CONCAT_FILE)) { + fs.unlinkSync(STUB_INPUT_CP_CONCAT_FILE); + } + // Clean staging output between tests + if (fs.existsSync(TEST_STAGING_DIR)) { + fs.rmSync(TEST_STAGING_DIR, { recursive: true, force: true }); + } + jest.restoreAllMocks(); + }); + + test("bundling with docker image copy variant", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(true); + + const dockerCalls: string[] = readDockerStubInputConcat( + STUB_INPUT_CP_CONCAT_FILE, + ).split(/\r?\n/); + + expect(dockerCalls).toEqual( + expect.arrayContaining([ + expect.stringContaining("volume create assetInput"), + expect.stringContaining("volume create assetOutput"), + expect.stringMatching( + /run --name copyContainer.* -v .+:\/asset-input -v .+:\/asset-output public\.ecr\.aws\/docker\/library\/alpine:[\w.]+ sh -c mkdir -p \/asset-input && chown -R .* \/asset-output && chown -R .* \/asset-input/, + ), + expect.stringMatching( + /cp .*fs\/fixtures\/test1\/\. copyContainer.*:\/asset-input/, + ), + expect.stringMatching( + /run --rm -u .* --volumes-from copyContainer.* -w \/asset-input alpine DOCKER_STUB_VOLUME_SINGLE_ARCHIVE/, + ), + expect.stringMatching(/cp copyContainer.*:\/asset-output\/\. .*/), + expect.stringContaining("rm copyContainer"), + expect.stringContaining("volume rm assetInput"), + expect.stringContaining("volume rm assetOutput"), + ]), + ); + }); + + test("VOLUME_COPY issues volume create commands", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + expect(concat).toContain("volume create assetInput"); + expect(concat).toContain("volume create assetOutput"); + }); + + test("VOLUME_COPY cleans up volumes after bundling", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + expect(concat).toContain("volume rm assetInput"); + expect(concat).toContain("volume rm assetOutput"); + }); + + test("VOLUME_COPY cleans up helper container after bundling", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + expect(concat).toMatch(/rm copyContainer/); + }); + + test("VOLUME_COPY uses --volumes-from for the bundling container", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + expect(concat).toMatch(/--volumes-from copyContainer/); + }); + + test("VOLUME_COPY does not use -v bind mounts for source/output in bundling container", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const dockerCalls = readDockerStubInputConcat( + STUB_INPUT_CP_CONCAT_FILE, + ).split(/\r?\n/); + + // Find the bundling run (not the helper container run) + const bundlingRun = dockerCalls.find( + (line) => + line.includes("run --rm") && + line.includes("alpine DOCKER_STUB_VOLUME_SINGLE_ARCHIVE"), + ); + expect(bundlingRun).toBeDefined(); + // The bundling container should NOT have -v with the source directory + expect(bundlingRun).not.toMatch(/-v .*fixtures.*:\/asset-input/); + }); + + test("VOLUME_COPY copies source into input volume via docker cp", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + // Should have a docker cp from source to the copy container's /asset-input + expect(concat).toMatch( + /cp .*fs\/fixtures\/test1\/\. copyContainer.*:\/asset-input/, + ); + }); + + test("VOLUME_COPY copies output from output volume via docker cp", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + expect(concat).toMatch(/cp copyContainer.*:\/asset-output\/\. /); + }); + + test("bundling that produces a single file with docker image copy variant and hash type SOURCE", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SINGLE_FILE_WITHOUT_EXT], + outputType: BundlingOutput.SINGLE_FILE, + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + assetHashType: AssetHashType.SOURCE, + }); + + // THEN + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(false); + }); + + test("bundling that produces a single file with docker image copy variant and hash type CUSTOM", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.SINGLE_FILE_WITHOUT_EXT], + outputType: BundlingOutput.SINGLE_FILE, + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + assetHashType: AssetHashType.CUSTOM, + assetHash: "custom", + }); + + // THEN + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(false); + }); + + test("VOLUME_COPY passes user option to helper and bundling containers", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + user: "500:500", + }, + }); + + // THEN + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + // Helper container chown should use 500:500 + expect(concat).toMatch(/chown -R 500:500/); + // Bundling container should run as 500:500 + expect(concat).toMatch(/run --rm -u 500:500/); + }); + + test("VOLUME_COPY is not used when local bundling succeeds", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + local: { + tryBundle(outputDir: string): boolean { + fs.writeFileSync(path.join(outputDir, "local.txt"), "local"); + return true; + }, + }, + }, + }); + + // THEN - Docker should not have been called + expect(fs.existsSync(STUB_INPUT_CP_FILE)).toEqual(false); + }); + + describe("VOLUME_COPY host isolation", () => { + // Read-only *host* mounts are a BIND_MOUNT concern (covered in the + // BIND_MOUNT tests). VOLUME_COPY instead protects the host by never + // mounting host paths into the bundling container at all: the source is + // copied into a Docker volume first. + test("never bind-mounts a host path into the bundling container", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN + const dockerCalls = readDockerStubInputConcat( + STUB_INPUT_CP_CONCAT_FILE, + ).split(/\r?\n/); + + const bundlingRun = dockerCalls.find( + (line) => + line.includes("run --rm") && + line.includes("alpine DOCKER_STUB_VOLUME_SINGLE_ARCHIVE"), + ); + + expect(bundlingRun).toBeDefined(); + // Volumes come from the helper container, and no `-v host:container` + // mount is present. + expect(bundlingRun).toContain("--volumes-from"); + expect(bundlingRun).not.toMatch(/ -v \S/); + expect(bundlingRun).not.toContain(directory); + }); + }); + + describe("network option forwarding", () => { + test("VOLUME_COPY forwards network option to bundling container", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + network: "host", + }, + }); + + // THEN - The bundling container should use the network option + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + const dockerCalls = concat.split(/\r?\n/); + + // Find the bundling run (not the helper container run) + const bundlingRun = dockerCalls.find( + (line) => + line.includes("run --rm") && + line.includes("alpine DOCKER_STUB_VOLUME_SINGLE_ARCHIVE"), + ); + + expect(bundlingRun).toBeDefined(); + expect(bundlingRun).toContain("--network host"); + }); + }); + + describe("VOLUME_COPY cleanup resilience", () => { + test("cleanup commands are present in the Docker call sequence", () => { + // GIVEN + const directory = FIXTURE_TEST1_DIR; + + // WHEN - Run a normal VOLUME_COPY bundling operation + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN - Verify cleanup commands are executed + const concat = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE); + const dockerCalls = concat.split(/\r?\n/); + + // The sequence should include setup, bundling, AND cleanup + // Verify setup happened + expect(dockerCalls).toEqual( + expect.arrayContaining([ + expect.stringContaining("volume create assetInput"), + expect.stringContaining("volume create assetOutput"), + expect.stringMatching(/run --name copyContainer/), + ]), + ); + + // Verify bundling happened + expect(dockerCalls).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /run --rm.*--volumes-from copyContainer.*alpine DOCKER_STUB_VOLUME_SINGLE_ARCHIVE/, + ), + ]), + ); + + // Most importantly: verify cleanup happened AFTER bundling + expect(dockerCalls).toEqual( + expect.arrayContaining([ + expect.stringMatching(/rm copyContainer/), + expect.stringContaining("volume rm assetInput"), + expect.stringContaining("volume rm assetOutput"), + ]), + ); + + // Verify cleanup comes after bundling in the sequence + const bundlingIndex = dockerCalls.findIndex((cmd) => + cmd.includes("alpine DOCKER_STUB_VOLUME_SINGLE_ARCHIVE"), + ); + const cleanupIndex = dockerCalls.findIndex((cmd) => + cmd.includes("rm copyContainer"), + ); + + expect(bundlingIndex).toBeGreaterThanOrEqual(0); + expect(cleanupIndex).toBeGreaterThan(bundlingIndex); + }); + + test("every resource is still torn down when bundling fails", () => { + // GIVEN a docker stub whose bundling run fails, while cleanup succeeds + const directory = FIXTURE_TEST1_DIR; + const failingStub = path.join(STUB_DIR, "docker-stub-run-fail.sh"); + fs.writeFileSync( + failingStub, + [ + "#!/bin/bash", + `echo "$@" >> "${STUB_INPUT_CP_CONCAT_FILE}"`, + 'if echo "$@" | grep -q "run --rm"; then', + ' echo "bundling blew up" >&2', + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + const previousDocker = process.env.CDK_DOCKER; + process.env.CDK_DOCKER = failingStub; + + try { + // WHEN + expect( + () => + new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }), + ).toThrow(/bundling blew up|exited with/); + + // THEN - the helper container and both volumes are still removed + const dockerCalls = readDockerStubInputConcat( + STUB_INPUT_CP_CONCAT_FILE, + ).split(/\r?\n/); + expect(dockerCalls).toEqual( + expect.arrayContaining([ + expect.stringContaining("rm copyContainer"), + expect.stringContaining("volume rm assetInput"), + expect.stringContaining("volume rm assetOutput"), + ]), + ); + } finally { + process.env.CDK_DOCKER = previousDocker; + } + }); + + test("a failure removing one resource does not skip the others", () => { + // GIVEN a docker stub that fails only when removing the input volume, + // which previously aborted the rest of the teardown. + const directory = FIXTURE_TEST1_DIR; + const failingStub = path.join(STUB_DIR, "docker-stub-partial-fail.sh"); + fs.writeFileSync( + failingStub, + [ + "#!/bin/bash", + `echo "$@" >> "${STUB_INPUT_CP_CONCAT_FILE}"`, + 'if echo "$@" | grep -q "volume rm assetInput"; then', + ' echo "cannot remove volume in use" >&2', + " exit 1", + "fi", + 'if echo "$@" | grep -q "^cp .*asset-output"; then', + ' touch "${@: -1}/test.zip"', + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + const previousDocker = process.env.CDK_DOCKER; + process.env.CDK_DOCKER = failingStub; + + try { + // WHEN - bundling itself succeeds, so only cleanup misbehaves + const staging = new AssetStaging(stack, "Asset", { + sourcePath: directory, + bundling: { + image: DockerImage.fromRegistry("alpine"), + command: [DockerStubCommand.VOLUME_SINGLE_ARCHIVE], + bundlingFileAccess: BundlingFileAccess.VOLUME_COPY, + }, + }); + + // THEN - the failure is not fatal, and the output volume removal was + // still attempted despite the input volume removal failing. + expect(staging.assetHash).toBeDefined(); + const dockerCalls = readDockerStubInputConcat( + STUB_INPUT_CP_CONCAT_FILE, + ).split(/\r?\n/); + expect(dockerCalls).toEqual( + expect.arrayContaining([ + expect.stringContaining("volume rm assetInput"), + expect.stringContaining("volume rm assetOutput"), + ]), + ); + + // ...and the user is warned about what leaked. + const warnings = staging.node.metadata.filter( + (m) => m.type === AnnotationMetadataEntryType.WARN, + ); + expect(warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.stringContaining("clean up Docker resources"), + }), + ]), + ); + } finally { + process.env.CDK_DOCKER = previousDocker; + } + }); + }); +}); + +// Reads a docker stub and cleans the volume paths out of the stub. +function readAndCleanDockerStubInput(file: string) { + return fs + .readFileSync(file, "utf-8") + .trim() + .replace(/-v ([^:]+):\/asset-input/g, "-v /input:/asset-input") + .replace(/-v ([^:]+):\/asset-output/g, "-v /output:/asset-output"); +} + +// Last docker input since last teardown +function readDockerStubInput(file?: string) { + return readAndCleanDockerStubInput(file ?? STUB_INPUT_FILE); +} + +// Concatenated docker inputs since last teardown +function readDockerStubInputConcat(file?: string) { + return readAndCleanDockerStubInput(file ?? STUB_INPUT_CONCAT_FILE); +} diff --git a/packages/cdktn/test/terraform-asset.test.ts b/packages/cdktn/test/terraform-asset.test.ts new file mode 100644 index 000000000..0d5c30039 --- /dev/null +++ b/packages/cdktn/test/terraform-asset.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { Testing, TerraformStack } from "../lib"; +import { TerraformAsset, AssetType } from "../lib/terraform-asset"; + +describe("TerraformAsset Integration", () => { + let tempDir: string; + let testFile: string; + let testDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-test-")); + testFile = path.join(tempDir, "test.txt"); + testDir = path.join(tempDir, "testdir"); + + fs.writeFileSync(testFile, "test content"); + fs.mkdirSync(testDir); + fs.writeFileSync(path.join(testDir, "file1.txt"), "file 1 content"); + fs.writeFileSync(path.join(testDir, "file2.txt"), "file 2 content"); + fs.writeFileSync(path.join(testDir, "README.md"), "readme content"); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("Backwards Compatibility", () => { + test("works with simple file asset (no advanced features)", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testFile, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.type).toBe(AssetType.FILE); + expect(asset.path).toContain("assets"); + }); + + test("works with directory asset (no advanced features)", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testDir, + type: AssetType.DIRECTORY, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.type).toBe(AssetType.DIRECTORY); + }); + + test("works with archive type (no advanced features)", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testDir, + type: AssetType.ARCHIVE, + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.type).toBe(AssetType.ARCHIVE); + }); + + test("works with custom asset hash (no advanced features)", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testFile, + assetHash: "custom-hash-123", + }); + + expect(asset.assetHash).toBeDefined(); + // Custom hash is used as-is in simple mode + expect(asset.assetHash).toBe("custom-hash-123"); + }); + }); + + describe("New Features (AssetStaging Integration)", () => { + test("supports exclusion patterns", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testDir, + exclude: ["*.md"], + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.type).toBe(AssetType.ARCHIVE); + + // Hash should be different than without exclusions + const assetNoExclude = new TerraformAsset(stack, "Asset2", { + path: testDir, + }); + + expect(asset.assetHash).not.toBe(assetNoExclude.assetHash); + }); + + test("supports extra hash for cache busting", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset1 = new TerraformAsset(stack, "Asset1", { + path: testFile, + extraHash: "v1.0.0", + }); + + const asset2 = new TerraformAsset(stack, "Asset2", { + path: testFile, + extraHash: "v2.0.0", + }); + + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + + test("hash changes when excluding different files", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset1 = new TerraformAsset(stack, "Asset1", { + path: testDir, + exclude: ["*.md"], + }); + + const asset2 = new TerraformAsset(stack, "Asset2", { + path: testDir, + exclude: ["file1.txt"], + }); + + expect(asset1.assetHash).not.toBe(asset2.assetHash); + }); + + test("supports AssetHashType.SOURCE explicitly", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testFile, + assetHashType: 0, // AssetHashType.SOURCE + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.type).toBe(AssetType.FILE); + }); + + test("synthesizes correctly with advanced features", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + new TerraformAsset(stack, "Asset", { + path: testDir, + exclude: ["*.md"], + extraHash: "v1.0.0", + }); + + // Should not throw + expect(() => app.synth()).not.toThrow(); + }); + }); + + describe("Synthesis", () => { + test("stages asset to correct location during synth", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testFile, + }); + + const output = Testing.synth(stack); + expect(output).toBeDefined(); + + // The asset should be staged + expect(asset.path).toContain("assets"); + expect(asset.assetHash).toBeDefined(); + }); + + test("stages asset with exclusions correctly", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + new TerraformAsset(stack, "Asset", { + path: testDir, + exclude: ["*.md"], + }); + + const output = Testing.synth(stack); + expect(output).toBeDefined(); + }); + }); + + describe("Advanced Feature Combinations", () => { + test("combines exclusions with extra hash", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testDir, + exclude: ["*.md"], + extraHash: "build-v1.2.3", + }); + + expect(asset.assetHash).toBeDefined(); + expect(asset.type).toBe(AssetType.ARCHIVE); + + // Hash should differ from both: no exclusions, and no extra hash + const noExclude = new TerraformAsset(stack, "Asset2", { + path: testDir, + extraHash: "build-v1.2.3", + }); + const noExtra = new TerraformAsset(stack, "Asset3", { + path: testDir, + exclude: ["*.md"], + }); + + expect(asset.assetHash).not.toBe(noExclude.assetHash); + expect(asset.assetHash).not.toBe(noExtra.assetHash); + }); + + test("explicit type overrides inferred type with advanced features", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const asset = new TerraformAsset(stack, "Asset", { + path: testDir, + type: AssetType.DIRECTORY, // Explicit DIRECTORY + exclude: ["*.md"], // Advanced feature + }); + + expect(asset.type).toBe(AssetType.DIRECTORY); + expect(asset.assetHash).toBeDefined(); + }); + + test("custom hash with advanced features uses custom hash", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const customHash = "my-custom-hash"; + const asset = new TerraformAsset(stack, "Asset", { + path: testDir, + assetHash: customHash, + exclude: ["*.md"], // This triggers AssetStaging + }); + + // Custom hash should be used verbatim (unified behavior with AssetStaging) + expect(asset.assetHash).toBe(customHash); + }); + }); +}); diff --git a/packages/cdktn/test/tfExpression.test.ts b/packages/cdktn/test/tfExpression.test.ts index c994f255f..4c70e878f 100644 --- a/packages/cdktn/test/tfExpression.test.ts +++ b/packages/cdktn/test/tfExpression.test.ts @@ -95,7 +95,7 @@ describe("propertyAccess", () => { it("for map with an attribute name containing a colon", () => { expect( resolveExpression(propertyAccess(ref("local.map"), ["My:Key"])), - ).toEqual(`\${local.map[\"My:Key\"]}`); + ).toEqual('${local.map["My:Key"]}'); }); }); diff --git a/packages/cdktn/test/validations.test.ts b/packages/cdktn/test/validations.test.ts index 08fe80d56..e791c0db9 100644 --- a/packages/cdktn/test/validations.test.ts +++ b/packages/cdktn/test/validations.test.ts @@ -14,7 +14,6 @@ import { } from "../src/validations"; import { TestProvider } from "./helper/provider"; import { createTmpHelper } from "./helper/tmp"; -import { terraformBinaryName } from "../src/util"; const tmp = createTmpHelper(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92fd85d78..388f596b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,12 +1,11 @@ --- -lockfileVersion: '9.0' +lockfileVersion: "9.0" importers: - .: configDependencies: {} packageManagerDependencies: - '@pnpm/exe': + "@pnpm/exe": specifier: 11.5.2 version: 11.5.2 pnpm: @@ -14,183 +13,238 @@ importers: version: 11.5.2 packages: - - '@pnpm/exe@11.5.2': - resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==} + "@pnpm/exe@11.5.2": + resolution: + { + integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==, + } hasBin: true - '@pnpm/linux-arm64@11.5.2': - resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==} + "@pnpm/linux-arm64@11.5.2": + resolution: + { + integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==, + } cpu: [arm64] os: [linux] - '@pnpm/linux-x64@11.5.2': - resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==} + "@pnpm/linux-x64@11.5.2": + resolution: + { + integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==, + } cpu: [x64] os: [linux] - '@pnpm/linuxstatic-arm64@11.5.2': - resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==} + "@pnpm/linuxstatic-arm64@11.5.2": + resolution: + { + integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==, + } cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/linuxstatic-x64@11.5.2': - resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==} + "@pnpm/linuxstatic-x64@11.5.2": + resolution: + { + integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==, + } cpu: [x64] os: [linux] libc: [musl] - '@pnpm/macos-arm64@11.5.2': - resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==} + "@pnpm/macos-arm64@11.5.2": + resolution: + { + integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==, + } cpu: [arm64] os: [darwin] - '@pnpm/win-arm64@11.5.2': - resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==} + "@pnpm/win-arm64@11.5.2": + resolution: + { + integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==, + } cpu: [arm64] os: [win32] - '@pnpm/win-x64@11.5.2': - resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==} + "@pnpm/win-x64@11.5.2": + resolution: + { + integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==, + } cpu: [x64] os: [win32] - '@reflink/reflink-darwin-arm64@0.1.19': - resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} - engines: {node: '>= 10'} + "@reflink/reflink-darwin-arm64@0.1.19": + resolution: + { + integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==, + } + engines: { node: ">= 10" } cpu: [arm64] os: [darwin] - '@reflink/reflink-darwin-x64@0.1.19': - resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} - engines: {node: '>= 10'} + "@reflink/reflink-darwin-x64@0.1.19": + resolution: + { + integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==, + } + engines: { node: ">= 10" } cpu: [x64] os: [darwin] - '@reflink/reflink-linux-arm64-gnu@0.1.19': - resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} - engines: {node: '>= 10'} + "@reflink/reflink-linux-arm64-gnu@0.1.19": + resolution: + { + integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==, + } + engines: { node: ">= 10" } cpu: [arm64] os: [linux] libc: [glibc] - '@reflink/reflink-linux-arm64-musl@0.1.19': - resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} - engines: {node: '>= 10'} + "@reflink/reflink-linux-arm64-musl@0.1.19": + resolution: + { + integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==, + } + engines: { node: ">= 10" } cpu: [arm64] os: [linux] libc: [musl] - '@reflink/reflink-linux-x64-gnu@0.1.19': - resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} - engines: {node: '>= 10'} + "@reflink/reflink-linux-x64-gnu@0.1.19": + resolution: + { + integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==, + } + engines: { node: ">= 10" } cpu: [x64] os: [linux] libc: [glibc] - '@reflink/reflink-linux-x64-musl@0.1.19': - resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} - engines: {node: '>= 10'} + "@reflink/reflink-linux-x64-musl@0.1.19": + resolution: + { + integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==, + } + engines: { node: ">= 10" } cpu: [x64] os: [linux] libc: [musl] - '@reflink/reflink-win32-arm64-msvc@0.1.19': - resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} - engines: {node: '>= 10'} + "@reflink/reflink-win32-arm64-msvc@0.1.19": + resolution: + { + integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==, + } + engines: { node: ">= 10" } cpu: [arm64] os: [win32] - '@reflink/reflink-win32-x64-msvc@0.1.19': - resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} - engines: {node: '>= 10'} + "@reflink/reflink-win32-x64-msvc@0.1.19": + resolution: + { + integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==, + } + engines: { node: ">= 10" } cpu: [x64] os: [win32] - '@reflink/reflink@0.1.19': - resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} - engines: {node: '>= 10'} + "@reflink/reflink@0.1.19": + resolution: + { + integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==, + } + engines: { node: ">= 10" } detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} + resolution: + { + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, + } + engines: { node: ">=8" } pnpm@11.5.2: - resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==} - engines: {node: '>=22.13'} + resolution: + { + integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==, + } + engines: { node: ">=22.13" } hasBin: true snapshots: - - '@pnpm/exe@11.5.2': + "@pnpm/exe@11.5.2": dependencies: - '@reflink/reflink': 0.1.19 + "@reflink/reflink": 0.1.19 detect-libc: 2.1.2 optionalDependencies: - '@pnpm/linux-arm64': 11.5.2 - '@pnpm/linux-x64': 11.5.2 - '@pnpm/linuxstatic-arm64': 11.5.2 - '@pnpm/linuxstatic-x64': 11.5.2 - '@pnpm/macos-arm64': 11.5.2 - '@pnpm/win-arm64': 11.5.2 - '@pnpm/win-x64': 11.5.2 + "@pnpm/linux-arm64": 11.5.2 + "@pnpm/linux-x64": 11.5.2 + "@pnpm/linuxstatic-arm64": 11.5.2 + "@pnpm/linuxstatic-x64": 11.5.2 + "@pnpm/macos-arm64": 11.5.2 + "@pnpm/win-arm64": 11.5.2 + "@pnpm/win-x64": 11.5.2 - '@pnpm/linux-arm64@11.5.2': + "@pnpm/linux-arm64@11.5.2": optional: true - '@pnpm/linux-x64@11.5.2': + "@pnpm/linux-x64@11.5.2": optional: true - '@pnpm/linuxstatic-arm64@11.5.2': + "@pnpm/linuxstatic-arm64@11.5.2": optional: true - '@pnpm/linuxstatic-x64@11.5.2': + "@pnpm/linuxstatic-x64@11.5.2": optional: true - '@pnpm/macos-arm64@11.5.2': + "@pnpm/macos-arm64@11.5.2": optional: true - '@pnpm/win-arm64@11.5.2': + "@pnpm/win-arm64@11.5.2": optional: true - '@pnpm/win-x64@11.5.2': + "@pnpm/win-x64@11.5.2": optional: true - '@reflink/reflink-darwin-arm64@0.1.19': + "@reflink/reflink-darwin-arm64@0.1.19": optional: true - '@reflink/reflink-darwin-x64@0.1.19': + "@reflink/reflink-darwin-x64@0.1.19": optional: true - '@reflink/reflink-linux-arm64-gnu@0.1.19': + "@reflink/reflink-linux-arm64-gnu@0.1.19": optional: true - '@reflink/reflink-linux-arm64-musl@0.1.19': + "@reflink/reflink-linux-arm64-musl@0.1.19": optional: true - '@reflink/reflink-linux-x64-gnu@0.1.19': + "@reflink/reflink-linux-x64-gnu@0.1.19": optional: true - '@reflink/reflink-linux-x64-musl@0.1.19': + "@reflink/reflink-linux-x64-musl@0.1.19": optional: true - '@reflink/reflink-win32-arm64-msvc@0.1.19': + "@reflink/reflink-win32-arm64-msvc@0.1.19": optional: true - '@reflink/reflink-win32-x64-msvc@0.1.19': + "@reflink/reflink-win32-x64-msvc@0.1.19": optional: true - '@reflink/reflink@0.1.19': + "@reflink/reflink@0.1.19": optionalDependencies: - '@reflink/reflink-darwin-arm64': 0.1.19 - '@reflink/reflink-darwin-x64': 0.1.19 - '@reflink/reflink-linux-arm64-gnu': 0.1.19 - '@reflink/reflink-linux-arm64-musl': 0.1.19 - '@reflink/reflink-linux-x64-gnu': 0.1.19 - '@reflink/reflink-linux-x64-musl': 0.1.19 - '@reflink/reflink-win32-arm64-msvc': 0.1.19 - '@reflink/reflink-win32-x64-msvc': 0.1.19 + "@reflink/reflink-darwin-arm64": 0.1.19 + "@reflink/reflink-darwin-x64": 0.1.19 + "@reflink/reflink-linux-arm64-gnu": 0.1.19 + "@reflink/reflink-linux-arm64-musl": 0.1.19 + "@reflink/reflink-linux-x64-gnu": 0.1.19 + "@reflink/reflink-linux-x64-musl": 0.1.19 + "@reflink/reflink-win32-arm64-msvc": 0.1.19 + "@reflink/reflink-win32-x64-msvc": 0.1.19 detect-libc@2.1.2: {} @@ -215,13 +269,13 @@ importers: version: 9.39.4 '@nx/eslint': specifier: 22.7.5 - version: 22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) + version: 22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@nx/eslint-plugin': specifier: 22.7.5 version: 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.4.5))(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@nx/jest': specifier: 22.7.5 - version: 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) + version: 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@swc/core': specifier: ~1.15.5 version: 1.15.40(@swc/helpers@0.5.21) @@ -269,7 +323,7 @@ importers: version: 9.1.7 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) knip: specifier: ^6.16.1 version: 6.16.1 @@ -644,10 +698,10 @@ importers: version: link:../../../../packages/cdktn-cli jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) ts-node: specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) + version: 10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) typescript: specifier: ^5.0.0 version: 5.4.5 @@ -672,10 +726,10 @@ importers: version: link:../../../../packages/cdktn-cli jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) ts-node: specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) + version: 10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) typescript: specifier: ^5.0.0 version: 5.4.5 @@ -700,10 +754,10 @@ importers: version: link:../../../../packages/cdktn-cli jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) ts-node: specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) + version: 10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) typescript: specifier: ^5.0.0 version: 5.4.5 @@ -728,10 +782,10 @@ importers: version: link:../../../../packages/cdktn-cli jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) ts-node: specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) + version: 10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) typescript: specifier: ^5.0.0 version: 5.4.5 @@ -756,10 +810,10 @@ importers: version: link:../../../../packages/cdktn-cli jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) ts-node: specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) + version: 10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) typescript: specifier: ^5.0.0 version: 5.4.5 @@ -1552,7 +1606,7 @@ importers: version: 22.20.1 ts-node: specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) + version: 10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) typescript: specifier: ^5.0.0 version: 5.4.5 @@ -1879,12 +1933,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.28.6': resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} engines: {node: '>=6.9.0'} @@ -3669,9 +3717,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -7480,9 +7525,6 @@ packages: resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} engines: {node: '>=14'} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8284,11 +8326,6 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -9647,7 +9684,7 @@ snapshots: - typescript - verdaccio - '@nx/eslint@22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': + '@nx/eslint@22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': dependencies: '@nx/devkit': 22.7.5(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21))) '@nx/js': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) @@ -9656,7 +9693,7 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 optionalDependencies: - '@nx/jest': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) + '@nx/jest': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@zkochan/js-yaml': 0.0.7 transitivePeerDependencies: - '@babel/traverse' @@ -9667,7 +9704,7 @@ snapshots: - supports-color - verdaccio - '@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': + '@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': dependencies: '@jest/reporters': 30.3.0 '@jest/test-result': 30.3.0 @@ -9675,7 +9712,7 @@ snapshots: '@nx/js': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@phenomnomnominal/tsquery': 6.2.0(typescript@5.4.5) identity-obj-proxy: 3.0.0 - jest-config: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + jest-config: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) jest-resolve: 30.3.0 jest-util: 30.3.0 minimatch: 10.2.5 @@ -10147,7 +10184,7 @@ snapshots: '@types/cross-spawn@6.0.6': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.20.1 '@types/debug@4.1.13': dependencies: @@ -10163,12 +10200,12 @@ snapshots: '@types/follow-redirects@1.14.4': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.20.1 '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 18.19.130 + '@types/node': 22.20.1 '@types/fs-extra@8.1.5': dependencies: @@ -10203,10 +10240,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -10842,7 +10875,7 @@ snapshots: '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) @@ -12598,25 +12631,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): - dependencies: - '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.3 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-cli@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): dependencies: '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) @@ -12628,7 +12642,7 @@ snapshots: jest-config: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) jest-util: 30.3.0 jest-validate: 30.3.0 - yargs: 17.7.3 + yargs: 17.7.2 transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -12647,7 +12661,7 @@ snapshots: jest-config: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) jest-util: 30.3.0 jest-validate: 30.3.0 - yargs: 17.7.3 + yargs: 17.7.2 transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -12655,38 +12669,6 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): - dependencies: - '@babel/core': 7.29.0 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 18.19.130 - ts-node: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.0 @@ -12968,19 +12950,6 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): - dependencies: - '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): dependencies: '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) @@ -14953,6 +14922,7 @@ snapshots: yn: 3.1.1 optionalDependencies: '@swc/core': 1.15.40(@swc/helpers@0.5.21) + optional: true tsc-files@1.1.4(typescript@5.4.5): dependencies: @@ -15034,8 +15004,6 @@ snapshots: unbash@3.0.0: {} - undici-types@5.26.5: {} - undici-types@6.21.0: {} undici@8.7.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e32564870..e6a84805c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - 'packages/*' - 'packages/@cdktn/*' + - '!examples/**/cdktf.out/**/' - 'examples/**' - 'tools/generate-function-bindings' - 'tools/documentation-generation'