From 42984667390d59fc475a0a2861ed3e68e9254b4b Mon Sep 17 00:00:00 2001 From: Chris Gatt Date: Fri, 10 Jan 2025 19:11:50 +1100 Subject: [PATCH 1/5] feat(DockerImage): allow mounting existing or volume copy volumes --- .../aws-lambda-nodejs/lib/bundling.ts | 6 +- .../aws-cdk-lib/core/lib/asset-staging.ts | 32 +- packages/aws-cdk-lib/core/lib/bundling.ts | 189 +++++--- .../core/lib/private/asset-staging.ts | 238 ---------- .../aws-cdk-lib/core/lib/private/bundling.ts | 241 ++++++++++ .../aws-cdk-lib/core/test/bundling.test.ts | 145 +++++- .../core/test/private/asset-staging.test.ts | 116 ----- .../core/test/private/bundling.test.ts | 435 ++++++++++++++++++ .../aws-cdk-lib/core/test/staging.test.ts | 51 +- 9 files changed, 1005 insertions(+), 448 deletions(-) delete mode 100644 packages/aws-cdk-lib/core/lib/private/asset-staging.ts create mode 100644 packages/aws-cdk-lib/core/lib/private/bundling.ts delete mode 100644 packages/aws-cdk-lib/core/test/private/asset-staging.test.ts create mode 100644 packages/aws-cdk-lib/core/test/private/bundling.test.ts diff --git a/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts b/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts index 75333cd9b177b..d7b9a83e653c1 100644 --- a/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts +++ b/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts @@ -4,7 +4,9 @@ import { IConstruct } from 'constructs'; import { PackageInstallation } from './package-installation'; import { LockFile, PackageManager } from './package-manager'; import { BundlingOptions, OutputFormat, SourceMapMode } from './types'; -import { exec, extractDependencies, findUp, getTsconfigCompilerOptions, isSdkV2Runtime } from './util'; +import { + exec, extractDependencies, findUp, getTsconfigCompilerOptions, isSdkV2Runtime, +} from './util'; import { Architecture, AssetCode, Code, Runtime } from '../../aws-lambda'; import * as cdk from '../../core'; import { LAMBDA_NODEJS_SDK_V3_EXCLUDE_SMITHY_PACKAGES } from '../../cx-api'; @@ -83,7 +85,7 @@ export class Bundling implements cdk.BundlingOptions { public readonly image: cdk.DockerImage; public readonly entrypoint?: string[]; public readonly command: string[]; - public readonly volumes?: cdk.DockerVolume[]; + public readonly volumes?: (cdk.DockerVolume | cdk.VolumeCopyDockerVolume | cdk.ExistingDockerVolume)[]; public readonly volumesFrom?: string[]; public readonly environment?: { [key: string]: string }; public readonly workingDirectory: string; diff --git a/packages/aws-cdk-lib/core/lib/asset-staging.ts b/packages/aws-cdk-lib/core/lib/asset-staging.ts index 49e8c292805dd..d6903d4be4b22 100644 --- a/packages/aws-cdk-lib/core/lib/asset-staging.ts +++ b/packages/aws-cdk-lib/core/lib/asset-staging.ts @@ -3,11 +3,10 @@ import * as path from 'path'; import { Construct } from 'constructs'; import * as fs from 'fs-extra'; import { AssetHashType, AssetOptions, FileAssetPackaging } from './assets'; -import { BundlingFileAccess, BundlingOptions, BundlingOutput } from './bundling'; +import { BundlingFileAccess, BundlingOptions, BundlingOutput, DockerVolumeType } from './bundling'; import { FileSystem, FingerprintOptions } from './fs'; import { clearLargeFileFingerprintCache } from './fs/fingerprint'; import { Names } from './names'; -import { AssetBundlingVolumeCopy, AssetBundlingBindMount } from './private/asset-staging'; import { Cache } from './private/cache'; import { Stack } from './stack'; import { Stage } from './stage'; @@ -452,17 +451,42 @@ export class AssetStaging extends Construct { sourcePath: this.sourcePath, bundleDir, ...options, + volumes: [...(options.volumes ?? [])], }; + // Add the asset input and output volumes based on BundlingFileAccess setting switch (options.bundlingFileAccess) { case BundlingFileAccess.VOLUME_COPY: - new AssetBundlingVolumeCopy(assetStagingOptions).run(); + assetStagingOptions.volumes.push({ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + hostInputPath: assetStagingOptions.sourcePath, + containerPath: AssetStaging.BUNDLING_INPUT_DIR, + }, + { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + hostOutputPath: assetStagingOptions.bundleDir, + containerPath: AssetStaging.BUNDLING_OUTPUT_DIR, + }); break; case BundlingFileAccess.BIND_MOUNT: default: - new AssetBundlingBindMount(assetStagingOptions).run(); + assetStagingOptions.volumes.push({ + hostPath: assetStagingOptions.sourcePath, + containerPath: AssetStaging.BUNDLING_INPUT_DIR, + }, + { + hostPath: assetStagingOptions.bundleDir, + containerPath: AssetStaging.BUNDLING_OUTPUT_DIR, + }); break; } + + assetStagingOptions.image.run({ + workingDirectory: + assetStagingOptions.workingDirectory ?? AssetStaging.BUNDLING_INPUT_DIR, + securityOpt: assetStagingOptions.securityOpt ?? '', + ...assetStagingOptions, + }); } } catch (err) { // When bundling fails, keep the bundle output for diagnosability, but diff --git a/packages/aws-cdk-lib/core/lib/bundling.ts b/packages/aws-cdk-lib/core/lib/bundling.ts index 3b61b17a195ef..88bf037c47561 100644 --- a/packages/aws-cdk-lib/core/lib/bundling.ts +++ b/packages/aws-cdk-lib/core/lib/bundling.ts @@ -1,9 +1,8 @@ -import { spawnSync } from 'child_process'; import * as crypto from 'crypto'; import { isAbsolute, join } from 'path'; import { DockerCacheOption } from './assets'; import { FileSystem } from './fs'; -import { dockerExec } from './private/asset-staging'; +import { dockerExec, DockerVolumeHelper } from './private/bundling'; import { quiet, reset } from './private/jsii-deprecated'; /** @@ -61,7 +60,7 @@ export interface BundlingOptions { * * @default - no additional volumes are mounted */ - readonly volumes?: DockerVolume[]; + readonly volumes?: (DockerVolume | VolumeCopyDockerVolume | ExistingDockerVolume)[]; /** * Where to mount the specified volumes from @@ -255,7 +254,6 @@ export class BundlingDockerImage { * Runs a Docker image */ public run(options: DockerRunOptions = {}) { - const volumes = options.volumes || []; const environment = options.environment || {}; const entrypoint = options.entrypoint?.[0] || null; const command = [ @@ -267,36 +265,39 @@ export class BundlingDockerImage { : [], ]; - const dockerArgs: string[] = [ - '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 - ? flatten(options.volumesFrom.map(v => ['--volumes-from', v])) - : [], - ...flatten(volumes.map(v => ['-v', `${v.hostPath}:${v.containerPath}:${isSeLinux() ? 'z,' : ''}${v.consistency ?? DockerVolumeConsistency.DELEGATED}`])), - ...flatten(Object.entries(environment).map(([k, v]) => ['--env', `${k}=${v}`])), - ...options.workingDirectory - ? ['-w', options.workingDirectory] - : [], - ...entrypoint - ? ['--entrypoint', entrypoint] - : [], - this.image, - ...command, - ]; + const volumeHelper = new DockerVolumeHelper(options); - dockerExec(dockerArgs); + try { + const dockerArgs: string[] = [ + 'run', '--rm', + ...options.securityOpt + ? ['--security-opt', options.securityOpt] + : [], + ...options.network + ? ['--network', options.network] + : [], + ...options.platform + ? ['--platform', options.platform] + : [], + ...volumeHelper.user + ? ['-u', volumeHelper.user] + : [], + ...volumeHelper.volumeCommands, + ...flatten(Object.entries(environment).map(([k, v]) => ['--env', `${k}=${v}`])), + ...options.workingDirectory + ? ['-w', options.workingDirectory] + : [], + ...entrypoint + ? ['--entrypoint', entrypoint] + : [], + this.image, + ...command, + ]; + + dockerExec(dockerArgs); + } finally { + volumeHelper.cleanup(); + } } /** @@ -461,19 +462,62 @@ export class DockerImage extends BundlingDockerImage { } /** - * A Docker volume + * The access mechanism used to make this volume available to the bundling container */ -export interface DockerVolume { +export enum DockerVolumeType { /** - * The path to the file or directory on the host machine + * 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. */ - readonly hostPath: string; + 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', + + /** + * The volume already exists and will not be created or destroyed by this class + */ + EXISTING = 'EXISTING', +} +/** + * Common properties for all Docker volume types. + */ +export interface DockerVolumeBase { /** - * The path where the file or directory is mounted in the container + * The path inside the container where the volume is mounted. + * This property is required for all volume types. */ readonly containerPath: string; + /** + * `--volume` options to use when mounting this volume to the docker container. + * + * @default - 'z' option is used for selinux bind mounts + */ + readonly opts?: string[]; +} + +/** + * Configuration for a `BIND_MOUNT` type Docker volume. + */ +export interface DockerVolume extends DockerVolumeBase { + /** + * The type of the Docker volume. + * + * @default DockerVolumeType.BIND_MOUNT + */ + readonly dockerVolumeType?: DockerVolumeType.BIND_MOUNT; + + /** + * The path on the host machine to be mounted as a bind mount. + * This property is required for `BIND_MOUNT` volumes. + */ + readonly hostPath: string; + /** * Mount consistency. Only applicable for macOS * @@ -483,6 +527,48 @@ export interface DockerVolume { readonly consistency?: DockerVolumeConsistency; } +/** + * Configuration for a `VOLUME_COPY` type Docker volume. + */ +export interface VolumeCopyDockerVolume extends DockerVolumeBase { + /** + * The type of the Docker volume. + * + * @default DockerVolumeType.BIND_MOUNT + */ + readonly dockerVolumeType: DockerVolumeType.VOLUME_COPY; + + /** + * The path on the host machine to be used as the input for the volume. + * @default - Does not copy from the host machine + */ + readonly hostInputPath?: string; + + /** + * The path on the host machine where the output from the volume will be written. + * @default - Does not copy to the host machine + */ + readonly hostOutputPath?: string; +} + +/** + * Configuration for an `EXISTING` type Docker volume. + */ +export interface ExistingDockerVolume extends DockerVolumeBase { + /** + * The type of the Docker volume. + * + * @default DockerVolumeType.BIND_MOUNT + */ + readonly dockerVolumeType: DockerVolumeType.EXISTING; + + /** + * The name of the existing volume. + * This property is required for `EXISTING` volumes. + */ + readonly volumeName: string; +} + /** * Supported Docker volume consistency types. Only valid on macOS due to the way file storage works on Mac */ @@ -524,7 +610,7 @@ export interface DockerRunOptions { * * @default - no volumes are mounted */ - readonly volumes?: DockerVolume[]; + readonly volumes?: (DockerVolume | VolumeCopyDockerVolume | ExistingDockerVolume)[]; /** * Where to mount the specified volumes from @@ -640,28 +726,3 @@ export interface DockerBuildOptions { function flatten(x: string[][]) { return Array.prototype.concat([], ...x); } - -function isSeLinux(): boolean { - if (process.platform != 'linux') { - return false; - } - const prog = 'selinuxenabled'; - const proc = spawnSync(prog, [], { - stdio: [ // show selinux status output - 'pipe', // get value of stdio - process.stderr, // redirect stdout to stderr - 'inherit', // inherit stderr - ], - }); - if (proc.error) { - // selinuxenabled not a valid command, therefore not enabled - return false; - } - if (proc.status == 0) { - // selinux enabled - return true; - } else { - // selinux not enabled - return false; - } -} diff --git a/packages/aws-cdk-lib/core/lib/private/asset-staging.ts b/packages/aws-cdk-lib/core/lib/private/asset-staging.ts deleted file mode 100644 index 80b2ce7690b1f..0000000000000 --- a/packages/aws-cdk-lib/core/lib/private/asset-staging.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { spawnSync, SpawnSyncOptions } from 'child_process'; -import * as crypto from 'crypto'; -import * as os from 'os'; -import { AssetStaging } from '../asset-staging'; -import { BundlingOptions } from '../bundling'; - -/** - * 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; -} - -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; - } -} - -/** - * 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, - }, - { - hostPath: this.options.bundleDir, - containerPath: AssetStaging.BUNDLING_OUTPUT_DIR, - }, - ...(this.options.volumes ?? []), - ], - network: this.options.network, - }); - } -} - -/** - * 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]); - } - - /** - * Removes volumes for asset input and output - */ - private cleanVolumes() { - dockerExec(['volume', 'rm', this.inputVolumeName]); - dockerExec(['volume', 'rm', 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}`, - 'public.ecr.aws/docker/library/alpine', - '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]); - } - - /** - * 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 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(); - 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 ?? []), - ], - }); - - this.copyOutputTo(this.options.bundleDir); - this.cleanHelperContainer(); - this.cleanVolumes(); - } -} - -export function dockerExec(args: string[], options?: SpawnSyncOptions) { - const prog = process.env.CDK_DOCKER ?? 'docker'; - const proc = spawnSync(prog, args, options ?? { - encoding: 'utf-8', - stdio: [ // show Docker output - 'ignore', // ignore stdio - process.stderr, // redirect stdout to stderr - 'inherit', // inherit stderr - ], - }); - - 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(' '); - - 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 Error([ - `${prog} exited with ${reason}`, - ...prependLines('--> STDOUT: ', proc.stdout ) ?? [], - ...prependLines('--> STDERR: ', proc.stderr ) ?? [], - `--> Command: ${command}`, - ].join('\n')); - } - - return proc; -} diff --git a/packages/aws-cdk-lib/core/lib/private/bundling.ts b/packages/aws-cdk-lib/core/lib/private/bundling.ts new file mode 100644 index 0000000000000..c515725c99f25 --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/private/bundling.ts @@ -0,0 +1,241 @@ +import { spawnSync, SpawnSyncOptions } from 'child_process'; +import * as crypto from 'crypto'; +import * as os from 'os'; +import { + DockerRunOptions, + DockerVolume, + DockerVolumeConsistency, + DockerVolumeType, + ExistingDockerVolume, + VolumeCopyDockerVolume, + type DockerVolumeBase, +} from '../bundling'; + +export function isBindMountDockerVolume(volume: DockerVolumeBase): volume is DockerVolume { + return ('dockerVolumeType' in volume && volume.dockerVolumeType === DockerVolumeType.BIND_MOUNT) + || (!('dockerVolumeType' in volume) || volume.dockerVolumeType === undefined) && 'hostPath' in volume; +} + +export function isVolumeCopyDockerVolume(volume: DockerVolumeBase): volume is VolumeCopyDockerVolume { + return 'dockerVolumeType' in volume && volume.dockerVolumeType === DockerVolumeType.VOLUME_COPY; +} + +export function isExistingDockerVolume(volume: DockerVolumeBase): volume is ExistingDockerVolume { + return 'dockerVolumeType' in volume && volume.dockerVolumeType === DockerVolumeType.EXISTING; +} + +export interface DockerVolumes { + bindMountVolumes: DockerVolume[]; + existingVolumes: ExistingDockerVolume[]; + volumeCopyVolumes: VolumeCopyDockerVolume[]; +}; + +/** + * Runs a helper container that holds copy volumes and does some preparation tasks. + * `DockerVolumeHelper.cleanup()` needs to be called to ensure containers + * and volumes aren't left on the host. + */ +export class DockerVolumeHelper { + readonly volumeCommands: string[]; + readonly user?: string; + readonly containerName?: string; + readonly volumes: DockerVolumes; + + constructor(readonly options: DockerRunOptions) { + this.volumes = { + bindMountVolumes: [], + existingVolumes: [], + volumeCopyVolumes: [], + }; + for (let volume of this.options.volumes ?? []) { + if (isBindMountDockerVolume(volume)) { + this.volumes.bindMountVolumes.push(volume); + } else if (isVolumeCopyDockerVolume(volume)) { + this.volumes.volumeCopyVolumes.push(volume); + } else if (isExistingDockerVolume(volume)) { + this.volumes.existingVolumes.push(volume); + } + } + if (this.volumes.volumeCopyVolumes.length > 0) { + // TODO handle user properly + this.user = this.determineUser(); + this.containerName = `copyContainer${crypto.randomBytes(12).toString('hex')}`; + const copyVolumeCommands = this.volumes.volumeCopyVolumes.flatMap(volume => [ + '-v', + // leading ':' is required for anonymous volume with opts + volume.opts ? `:${volume.containerPath}:${volume.opts.join(',')}` : `${volume.containerPath}`, + ]); + const chownCommands = this.volumes.volumeCopyVolumes + .map(volume => `mkdir -p ${volume.containerPath} && chown -R ${this.user} ${volume.containerPath}`); + dockerExec([ + 'run', + '--name', + this.containerName, + ...copyVolumeCommands, + 'public.ecr.aws/docker/library/alpine', + 'sh', + '-c', + chownCommands.join(' && '), + ]); + try { + this.copyInputVolumes(); + } catch (e) { + // If copy in fails, cleanup before re-throwing + dockerExec(['rm', '-v', this.containerName]); + throw e; + } + } else { + this.user = this.options.user; + } + this.volumeCommands = this.buildVolumeCommands(); + }; + + /** + * removes the Docker helper container and copy volumes + */ + public cleanup() { + if (this.containerName) { + try { + this.copyOutputVolumes(); + } finally { + dockerExec(['rm', '-v', this.containerName]); + } + } + } + + private buildVolumeCommands(): string[] { + const volumeCommands = []; + for (let volume of this.volumes.bindMountVolumes) { + const bindMountDefaultOpts = isSeLinux() ? ['z'] : []; + const opts = [ + ...(volume.opts ?? bindMountDefaultOpts), + volume.consistency ?? DockerVolumeConsistency.DELEGATED, + ].join(','); + volumeCommands.push('-v', [`${volume.hostPath}`, `${volume.containerPath}`, opts].join(':')); + } + for (let volume of this.volumes.existingVolumes) { + const opts = (volume.opts ?? []).join(','); + volumeCommands.push('-v', [`${volume.volumeName}`, `${volume.containerPath}`, opts].join(':')); + } + volumeCommands.push(...this.options.volumesFrom?.flatMap(containerName => ['--volumes-from', containerName]) ?? []); + if (this.containerName) { + volumeCommands.push('--volumes-from', this.containerName); + } + return volumeCommands; + } + + /** + * Determines a useful default user if not given otherwise + */ + private determineUser(): string { + let user; + 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; + } + + /** + * 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 copyInputVolumes() { + for (let volume of this.volumes.volumeCopyVolumes) { + if (volume.hostInputPath) { + dockerExec([ + 'cp', + `${volume.hostInputPath}/.`, + `${this.containerName}:${volume.containerPath}`, + ]); + } + } + } + + /** + * copy files from the 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 copyOutputVolumes() { + for (let volume of this.volumes.volumeCopyVolumes) { + if (volume.hostOutputPath) { + dockerExec([ + 'cp', + `${this.containerName}:${volume.containerPath}/.`, + `${volume.hostOutputPath}`, + ]); + } + } + } +} + +export function dockerExec(args: string[], options?: SpawnSyncOptions) { + const prog = process.env.CDK_DOCKER ?? 'docker'; + const proc = spawnSync(prog, args, options ?? { + encoding: 'utf-8', + stdio: [ // show Docker output + 'ignore', // ignore stdio + process.stderr, // redirect stdout to stderr + 'inherit', // inherit stderr + ], + }); + + 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(' '); + + 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 Error([ + `${prog} exited with ${reason}`, + ...prependLines('--> STDOUT: ', proc.stdout ) ?? [], + ...prependLines('--> STDERR: ', proc.stderr ) ?? [], + `--> Command: ${command}`, + ].join('\n')); + } + + return proc; +} + +function isSeLinux(): boolean { + if (process.platform != 'linux') { + return false; + } + const prog = 'selinuxenabled'; + const proc = spawnSync(prog, [], { + stdio: [ // show selinux status output + 'pipe', // get value of stdio + process.stderr, // redirect stdout to stderr + 'inherit', // inherit stderr + ], + }); + if (proc.error) { + // selinuxenabled not a valid command, therefore not enabled + return false; + } + if (proc.status == 0) { + // selinux enabled + return true; + } else { + // selinux not enabled + return false; + } +} diff --git a/packages/aws-cdk-lib/core/test/bundling.test.ts b/packages/aws-cdk-lib/core/test/bundling.test.ts index ea74e92998637..993a467383db1 100644 --- a/packages/aws-cdk-lib/core/test/bundling.test.ts +++ b/packages/aws-cdk-lib/core/test/bundling.test.ts @@ -2,7 +2,8 @@ import * as child_process from 'child_process'; import * as crypto from 'crypto'; import * as path from 'path'; import * as sinon from 'sinon'; -import { DockerBuildSecret, DockerImage, FileSystem } from '../lib'; +import { DockerBuildSecret, DockerImage, DockerVolumeType, FileSystem } from '../lib'; +import * as bundlingUtils from '../lib/private/bundling'; const dockerCmd = process.env.CDK_DOCKER ?? 'docker'; @@ -583,9 +584,9 @@ describe('bundling', () => { expect(spawnSyncStub.calledWith(dockerCmd, [ 'run', '--rm', '-u', 'user:group', + '-v', '/host-path:/container-path:delegated', '--volumes-from', 'foo', '--volumes-from', 'bar', - '-v', '/host-path:/container-path:delegated', '-w', '/working-directory', 'alpine', 'cool', 'command', @@ -722,4 +723,144 @@ describe('bundling', () => { // THEN expect(fromSrc).toEqual('src=path.json'); }); + + test('can handle all volume types with opts', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + const spawnSyncStub = sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + // WHEN + const image = DockerImage.fromRegistry('alpine'); + image.run({ + command: ['cool', 'command'], + volumes: [{ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/1', + hostInputPath: '/host/input/1', + }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/2', + hostPath: '/host/path/2', + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/3', + volumeName: 'container-volume-3', + }, { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/4', + hostInputPath: '/host/input/4', + opts: ['opt1', 'opt2'], + }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/5', + hostPath: '/host/path/5', + opts: ['opt1', 'opt2'], + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/6', + volumeName: 'container-volume-6', + opts: ['opt1', 'opt2'], + }], + volumesFrom: [ + 'volumes-from-container-1', + 'volumes-from-container-2', + ], + workingDirectory: '/working-directory', + user: 'user:group', + }); + + // copy container volume opts are correct + expect(spawnSyncStub.calledWith(dockerCmd, sinon.match([ + 'run', + '--name', sinon.match(/copyContainer.*/g), + '-v', '/container/path/1', + '-v', ':/container/path/4:opt1,opt2', + 'public.ecr.aws/docker/library/alpine', + 'sh', + '-c', + [ + 'mkdir -p /container/path/1 && chown -R user:group /container/path/1', + 'mkdir -p /container/path/4 && chown -R user:group /container/path/4', + ].join(' && '), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // main image run volume commands are correct + expect(spawnSyncStub.calledWith(dockerCmd, sinon.match([ + 'run', '--rm', + '-u', 'user:group', + '-v', '/host/path/2:/container/path/2:delegated', + '-v', '/host/path/5:/container/path/5:opt1,opt2,delegated', + '-v', 'container-volume-3:/container/path/3:', + '-v', 'container-volume-6:/container/path/6:opt1,opt2', + '--volumes-from', 'volumes-from-container-1', + '--volumes-from', 'volumes-from-container-2', + '--volumes-from', sinon.match(/copyContainer.*/g), + '-w', '/working-directory', + 'alpine', 'cool', 'command', + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + }); + + test('cleans up volume helper', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + const cleanupStub = sinon.stub(); + class MockDockerVolumeHelper { + readonly volumeCommands: string[] = []; + public cleanup = cleanupStub; + } + const dockerVolumeHelperStub = sinon.stub(bundlingUtils, 'DockerVolumeHelper').callsFake(() => new MockDockerVolumeHelper()); + + // WHEN + const image = DockerImage.fromRegistry('alpine'); + image.run(); + + // THEN + expect(dockerVolumeHelperStub.calledOnce).toBe(true); + expect(cleanupStub.calledOnce).toBe(true); + }); + + test('cleans up volume helper when run fails', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + sinon.stub(child_process, 'spawnSync').returns({ + status: -1, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + const cleanupStub = sinon.stub(); + class MockDockerVolumeHelper { + readonly volumeCommands: string[] = []; + public cleanup = cleanupStub; + } + const dockerVolumeHelperStub = sinon.stub(bundlingUtils, 'DockerVolumeHelper').callsFake(() => new MockDockerVolumeHelper()); + + // WHEN + const image = DockerImage.fromRegistry('alpine'); + + // THEN + expect(() => image.run()).toThrow(/exited with status -1/); + + expect(dockerVolumeHelperStub.calledOnce).toBe(true); + expect(cleanupStub.calledOnce).toBe(true); + }); }); diff --git a/packages/aws-cdk-lib/core/test/private/asset-staging.test.ts b/packages/aws-cdk-lib/core/test/private/asset-staging.test.ts deleted file mode 100644 index 384e83dc2fc3b..0000000000000 --- a/packages/aws-cdk-lib/core/test/private/asset-staging.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import * as child_process from 'child_process'; -import * as sinon from 'sinon'; -import { AssetStaging, DockerImage } from '../../lib'; -import { AssetBundlingBindMount, AssetBundlingVolumeCopy } from '../../lib/private/asset-staging'; - -const DOCKER_CMD = process.env.CDK_DOCKER ?? 'docker'; - -describe('bundling', () => { - afterEach(() => { - sinon.restore(); - }); - - test('AssetBundlingVolumeCopy bundles with volume copy ', () => { - // GIVEN - sinon.stub(process, 'platform').value('darwin'); - const spawnSyncStub = sinon.stub(child_process, 'spawnSync').returns({ - status: 0, - stderr: Buffer.from('stderr'), - stdout: Buffer.from('stdout'), - pid: 123, - output: ['stdout', 'stderr'], - signal: null, - }); - const options = { - sourcePath: '/tmp/source', - bundleDir: '/tmp/output', - image: DockerImage.fromRegistry('public.ecr.aws/docker/library/alpine'), - user: '1000', - }; - const helper = new AssetBundlingVolumeCopy(options); - helper.run(); - - // volume Creation - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'volume', 'create', sinon.match(/assetInput.*/g), - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'volume', 'create', sinon.match(/assetOutput.*/g), - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - // volume removal - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'volume', 'rm', sinon.match(/assetInput.*/g), - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'volume', 'rm', sinon.match(/assetOutput.*/g), - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - // prepare copy container - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'run', - '--name', sinon.match(/copyContainer.*/g), - '-v', sinon.match(/assetInput.*/g), - '-v', sinon.match(/assetOutput.*/g), - 'public.ecr.aws/docker/library/alpine', - 'sh', - '-c', - `mkdir -p ${AssetStaging.BUNDLING_INPUT_DIR} && chown -R ${options.user} ${AssetStaging.BUNDLING_OUTPUT_DIR} && chown -R ${options.user} ${AssetStaging.BUNDLING_INPUT_DIR}`, - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - // delete copy container - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'rm', sinon.match(/copyContainer.*/g), - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - // copy files to copy container - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'cp', `${options.sourcePath}/.`, `${helper.copyContainerName}:${AssetStaging.BUNDLING_INPUT_DIR}`, - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - // copy files from copy container to host - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ - 'cp', `${helper.copyContainerName}:${AssetStaging.BUNDLING_OUTPUT_DIR}/.`, options.bundleDir, - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - // actual docker run - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match.array.contains([ - 'run', '--rm', - '--volumes-from', helper.copyContainerName, - 'public.ecr.aws/docker/library/alpine', - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - - }); - - test('AssetBundlingBindMount bundles with bind mount ', () => { - // GIVEN - sinon.stub(process, 'platform').value('darwin'); - const spawnSyncStub = sinon.stub(child_process, 'spawnSync').returns({ - status: 0, - stderr: Buffer.from('stderr'), - stdout: Buffer.from('stdout'), - pid: 123, - output: ['stdout', 'stderr'], - signal: null, - }); - const options = { - sourcePath: '/tmp/source', - bundleDir: '/tmp/output', - image: DockerImage.fromRegistry('public.ecr.aws/docker/library/alpine'), - user: '1000', - network: 'host', - }; - const helper = new AssetBundlingBindMount(options); - helper.run(); - - // actual docker run with bind mount is called - expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match.array.contains([ - 'run', '--rm', - '--network', 'host', - '-v', - 'public.ecr.aws/docker/library/alpine', - ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); - }); -}); diff --git a/packages/aws-cdk-lib/core/test/private/bundling.test.ts b/packages/aws-cdk-lib/core/test/private/bundling.test.ts new file mode 100644 index 0000000000000..757b9160e7acc --- /dev/null +++ b/packages/aws-cdk-lib/core/test/private/bundling.test.ts @@ -0,0 +1,435 @@ +import * as child_process from 'child_process'; +import * as sinon from 'sinon'; +import { DockerVolume, DockerVolumeConsistency, DockerVolumeType, type DockerRunOptions, type ExistingDockerVolume, type VolumeCopyDockerVolume } from '../../lib/bundling'; +import { DockerVolumeHelper, isBindMountDockerVolume, isExistingDockerVolume, isVolumeCopyDockerVolume } from '../../lib/private/bundling'; + +const DOCKER_CMD = process.env.CDK_DOCKER ?? 'docker'; + +const DEFAULT_DOCKER_VOLUME: DockerVolume = { + containerPath: '/container/path', + hostPath: '/host/path', + consistency: DockerVolumeConsistency.CONSISTENT, +}; + +const BIND_MOUNT_DOCKER_VOLUME: DockerVolume = { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path', + hostPath: '/host/path', + consistency: DockerVolumeConsistency.CONSISTENT, +}; + +const VOLUME_COPY_DOCKER_VOLUME: VolumeCopyDockerVolume = { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path', + hostInputPath: '/host/input', + hostOutputPath: '/host/output', +}; + +const EXISTING_DOCKER_VOLUME: ExistingDockerVolume = { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path', + volumeName: 'existing-volume', +}; +describe('bundling utils', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('docker volume type helpers', () => { + test.each([ + [isBindMountDockerVolume, 'dockerVolumeType: undefined', true, DEFAULT_DOCKER_VOLUME], + [isBindMountDockerVolume, 'DockerVolume (Bind Mount)', true, BIND_MOUNT_DOCKER_VOLUME], + [isBindMountDockerVolume, 'VolumeCopyDockerVolume', false, VOLUME_COPY_DOCKER_VOLUME], + [isBindMountDockerVolume, 'ExistingDockerVolume', false, EXISTING_DOCKER_VOLUME], + [isVolumeCopyDockerVolume, 'dockerVolumeType: undefined', false, DEFAULT_DOCKER_VOLUME], + [isVolumeCopyDockerVolume, 'DockerVolume (Bind Mount)', false, BIND_MOUNT_DOCKER_VOLUME], + [isVolumeCopyDockerVolume, 'VolumeCopyDockerVolume', true, VOLUME_COPY_DOCKER_VOLUME], + [isVolumeCopyDockerVolume, 'ExistingDockerVolume', false, EXISTING_DOCKER_VOLUME], + [isExistingDockerVolume, 'dockerVolumeType: undefined', false, DEFAULT_DOCKER_VOLUME], + [isExistingDockerVolume, 'DockerVolume (Bind Mount)', false, BIND_MOUNT_DOCKER_VOLUME], + [isExistingDockerVolume, 'VolumeCopyDockerVolume', false, VOLUME_COPY_DOCKER_VOLUME], + [isExistingDockerVolume, 'ExistingDockerVolume', true, EXISTING_DOCKER_VOLUME], + ])( + '%p evaluates %p to %p', + (typeCheckFunction, _, result, object) => { + expect(typeCheckFunction(object)).toBe(result); + }, + ); + }); + + describe('DockerVolumeHelper', () => { + let spawnSyncStub: sinon.SinonStub; + + beforeEach(() => { + spawnSyncStub = sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + test('minimal options', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + + // WHEN + const helper = new DockerVolumeHelper({}); + + helper.cleanup(); + + // THEN + expect(spawnSyncStub.notCalled).toBe(true); + expect(helper.volumeCommands).toMatchObject([]); + }); + + test('prepares and cleans up copy container for VolumeCopyDockerVolumes', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + const options: DockerRunOptions = { + user: '1000', + volumes: [{ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/1', + hostInputPath: '/host/input/1', + }, { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/2', + hostOutputPath: '/host/output/2', + }, { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/3', + hostInputPath: '/host/input/3', + hostOutputPath: '/host/output/3', + }], + }; + + // WHEN + const helper = new DockerVolumeHelper(options); + + helper.cleanup(); + + // THEN + // prepare copy container + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'run', + '--name', sinon.match(/copyContainer.*/g), + '-v', sinon.match(/\/container\/path\/1/g), + '-v', sinon.match(/\/container\/path\/2/g), + '-v', sinon.match(/\/container\/path\/3/g), + 'public.ecr.aws/docker/library/alpine', + 'sh', + '-c', + [ + `mkdir -p /container/path/1 && chown -R ${options.user} /container/path/1`, + `mkdir -p /container/path/2 && chown -R ${options.user} /container/path/2`, + `mkdir -p /container/path/3 && chown -R ${options.user} /container/path/3`, + ].join(' && '), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // delete copy container + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'rm', '-v', sinon.match(/copyContainer.*/g), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // copy files to copy container volume 1 + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'cp', '/host/input/1/.', `${helper.containerName}:/container/path/1`, + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // copy files from copy container volume 2 to host + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'cp', `${helper.containerName}:/container/path/2/.`, '/host/output/2', + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // copy files to copy container volume 3 + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'cp', '/host/input/3/.', `${helper.containerName}:/container/path/3`, + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // copy files from copy container volume 3 to host + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'cp', `${helper.containerName}:/container/path/3/.`, '/host/output/3', + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // returned volumes command is correct + expect(helper.volumeCommands).toMatchObject([ + '--volumes-from', + expect.stringMatching(/copyContainer.*/), + ]); + }); + + test('correctly builds volumes command with opts', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + const options: DockerRunOptions = { + user: '1000', + volumes: [{ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/1', + hostInputPath: '/host/input/1', + }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/2', + hostPath: '/host/path/2', + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/3', + volumeName: 'container-volume-3', + }, { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/4', + hostInputPath: '/host/input/4', + opts: ['opt1', 'opt2'], + }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/5', + hostPath: '/host/path/5', + opts: ['opt1', 'opt2'], + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/6', + volumeName: 'container-volume-6', + opts: ['opt1', 'opt2'], + }], + volumesFrom: [ + 'volumes-from-container-1', + 'volumes-from-container-2', + ], + }; + + // WHEN + const helper = new DockerVolumeHelper(options); + + // THEN + // Copy volume opts are correct + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'run', + '--name', sinon.match(/copyContainer.*/g), + '-v', '/container/path/1', + '-v', ':/container/path/4:opt1,opt2', + 'public.ecr.aws/docker/library/alpine', + 'sh', + '-c', + [ + `mkdir -p /container/path/1 && chown -R ${options.user} /container/path/1`, + `mkdir -p /container/path/4 && chown -R ${options.user} /container/path/4`, + ].join(' && '), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // returned volumes command is correct + expect(helper.volumeCommands).toMatchObject([ + '-v', + '/host/path/2:/container/path/2:delegated', + '-v', + '/host/path/5:/container/path/5:opt1,opt2,delegated', + '-v', + 'container-volume-3:/container/path/3:', + '-v', + 'container-volume-6:/container/path/6:opt1,opt2', + '--volumes-from', + 'volumes-from-container-1', + '--volumes-from', + 'volumes-from-container-2', + '--volumes-from', + expect.stringMatching(/copyContainer.*/), + ]); + }); + + test('correctly includes selinux opt', () => { + // GIVEN + sinon.stub(process, 'platform').value('linux'); + const options: DockerRunOptions = { + user: '1000', + volumes: [{ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/1', + hostInputPath: '/host/input/1', + }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/2', + hostPath: '/host/path/2', + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/3', + volumeName: 'container-volume-3', + }, { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/4', + hostInputPath: '/host/input/4', + opts: ['opt1', 'opt2'], + }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/5', + hostPath: '/host/path/5', + opts: ['opt1', 'opt2'], + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/6', + volumeName: 'container-volume-6', + opts: ['opt1', 'opt2'], + }], + volumesFrom: [ + 'volumes-from-container-1', + 'volumes-from-container-2', + ], + }; + + // WHEN + const helper = new DockerVolumeHelper(options); + + // THEN + // Copy volume opts are correct + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'run', + '--name', sinon.match(/copyContainer.*/g), + '-v', '/container/path/1', + '-v', ':/container/path/4:opt1,opt2', + 'public.ecr.aws/docker/library/alpine', + 'sh', + '-c', + [ + `mkdir -p /container/path/1 && chown -R ${options.user} /container/path/1`, + `mkdir -p /container/path/4 && chown -R ${options.user} /container/path/4`, + ].join(' && '), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // returned volumes command is correct + expect(helper.volumeCommands).toMatchObject([ + '-v', + '/host/path/2:/container/path/2:z,delegated', + '-v', + '/host/path/5:/container/path/5:opt1,opt2,delegated', // selinux flag is not added automatically when custom opts passed + '-v', + 'container-volume-3:/container/path/3:', + '-v', + 'container-volume-6:/container/path/6:opt1,opt2', + '--volumes-from', + 'volumes-from-container-1', + '--volumes-from', + 'volumes-from-container-2', + '--volumes-from', + expect.stringMatching(/copyContainer.*/), + ]); + }); + + test('cleans up copy container when input copy fails', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + spawnSyncStub.withArgs(DOCKER_CMD, sinon.match.array.startsWith(['cp'])).returns({ + status: -1, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + const options: DockerRunOptions = { + user: '1000', + volumes: [{ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/1', + hostInputPath: '/host/input/1', + }], + }; + + // WHEN + expect(() => new DockerVolumeHelper(options)).toThrow(); + + // THEN + // delete copy container + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'rm', '-v', sinon.match(/copyContainer.*/g), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + }); + + test('cleans up copy container when output copy fails', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + spawnSyncStub.withArgs(DOCKER_CMD, sinon.match.array.startsWith(['cp'])).returns({ + status: -1, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + const options: DockerRunOptions = { + user: '1000', + volumes: [{ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/1', + hostOutputPath: '/host/output/1', + }], + }; + + // WHEN + const helper = new DockerVolumeHelper(options); + expect(() => helper.cleanup()).toThrow(); + + // THEN + // delete copy container + expect(spawnSyncStub.calledWith(DOCKER_CMD, sinon.match([ + 'rm', '-v', sinon.match(/copyContainer.*/g), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + }); + + test('does not spawn copy container with no copy volumes', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + const options: DockerRunOptions = { + user: '1000', + volumes: [{ + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/2', + hostPath: '/host/path/2', + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/3', + volumeName: 'container-volume-3', + }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, + containerPath: '/container/path/5', + hostPath: '/host/path/5', + opts: ['opt1', 'opt2'], + }, { + dockerVolumeType: DockerVolumeType.EXISTING, + containerPath: '/container/path/6', + volumeName: 'container-volume-6', + opts: ['opt1', 'opt2'], + }], + volumesFrom: [ + 'volumes-from-container-1', + 'volumes-from-container-2', + ], + }; + + // WHEN + const helper = new DockerVolumeHelper(options); + + // THEN + expect(spawnSyncStub.neverCalledWith(DOCKER_CMD)).toBe(true); + // returned volumes command is correct + expect(helper.volumeCommands).toMatchObject([ + '-v', + '/host/path/2:/container/path/2:delegated', + '-v', + '/host/path/5:/container/path/5:opt1,opt2,delegated', // selinux flag is not added automatically when custom opts passed + '-v', + 'container-volume-3:/container/path/3:', + '-v', + 'container-volume-6:/container/path/6:opt1,opt2', + '--volumes-from', + 'volumes-from-container-1', + '--volumes-from', + 'volumes-from-container-2', + ]); + }); + }); +}); diff --git a/packages/aws-cdk-lib/core/test/staging.test.ts b/packages/aws-cdk-lib/core/test/staging.test.ts index e7fae0974a940..660f291e6898a 100644 --- a/packages/aws-cdk-lib/core/test/staging.test.ts +++ b/packages/aws-cdk-lib/core/test/staging.test.ts @@ -5,7 +5,18 @@ import * as fs from 'fs-extra'; import * as sinon from 'sinon'; import { FileAssetPackaging } from '../../cloud-assembly-schema'; import * as cxapi from '../../cx-api'; -import { App, AssetHashType, AssetStaging, DockerImage, BundlingOptions, BundlingOutput, FileSystem, Stack, Stage, BundlingFileAccess } from '../lib'; +import { + App, + AssetHashType, + AssetStaging, + BundlingFileAccess, + BundlingOptions, + BundlingOutput, + DockerImage, + FileSystem, + Stack, + Stage, +} from '../lib'; const STUB_INPUT_FILE = '/tmp/docker-stub.input'; const STUB_INPUT_CONCAT_FILE = '/tmp/docker-stub.input.concat'; @@ -311,7 +322,7 @@ describe('staging', () => { const assembly = app.synth(); expect( readDockerStubInput()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(fs.readdirSync(assembly.directory)).toEqual([ 'asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4', @@ -390,7 +401,7 @@ describe('staging', () => { // We're testing that docker was run exactly once even though there are two bundling assets. expect( readDockerStubInputConcat()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -435,7 +446,7 @@ describe('staging', () => { // and that the hash is based on the output expect( readDockerStubInputConcat()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -483,8 +494,8 @@ describe('staging', () => { // operating on the same source asset. expect( readDockerStubInputConcat()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS\n` + - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env UNIQUE_ENV_VAR=SOMEVALUE -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS\n' + + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env UNIQUE_ENV_VAR=SOMEVALUE -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -532,7 +543,7 @@ describe('staging', () => { // We're testing that docker was run once, only for the first Asset, since the only difference is the token. expect( readDockerStubInputConcat()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -663,7 +674,7 @@ describe('staging', () => { expect( readDockerStubInputConcat()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(appAssembly.directory).toEqual(app2Assembly.directory); @@ -725,7 +736,7 @@ describe('staging', () => { expect( readDockerStubInputConcat()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_EXTRA_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_EXTRA_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(appAssembly.directory).toEqual(app2Assembly.directory); @@ -755,7 +766,7 @@ describe('staging', () => { expect( readDockerStubInput()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS_NO_OUTPUT`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS_NO_OUTPUT', ); }); @@ -778,7 +789,7 @@ describe('staging', () => { // THEN expect( readDockerStubInput()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); }); @@ -803,7 +814,7 @@ describe('staging', () => { // THEN expect( readDockerStubInput()).toEqual( - `run --rm --security-opt no-new-privileges ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm --security-opt no-new-privileges -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); }); @@ -828,7 +839,7 @@ describe('staging', () => { // THEN expect( readDockerStubInput()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input --entrypoint DOCKER_STUB_SUCCESS alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input --entrypoint DOCKER_STUB_SUCCESS alpine DOCKER_STUB_SUCCESS', ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); }); @@ -946,7 +957,7 @@ describe('staging', () => { })).toThrow(/Failed to bundle asset stack\/Asset/); expect( readDockerStubInput()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input this-is-an-invalid-docker-image DOCKER_STUB_FAIL`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input this-is-an-invalid-docker-image DOCKER_STUB_FAIL', ); }); @@ -1237,7 +1248,7 @@ describe('staging', () => { expect( readDockerStubInput()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); // hash of MyStack/Asset }); @@ -1261,7 +1272,7 @@ describe('staging', () => { expect( readDockerStubInput()).toEqual( - `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, + 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); // hash of MyStack/Asset }); @@ -1569,15 +1580,11 @@ describe('staging with docker cp', () => { 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 /input:/asset-input -v /output:/asset-output public.ecr.aws/docker/library/alpine sh -c mkdir -p /asset-input && chown -R .* /asset-output && chown -R .* /asset-input'), + expect.stringMatching('run --name copyContainer.* -v /asset-input -v /asset-output public.ecr.aws/docker/library/alpine sh -c mkdir -p /asset-input && chown -R 1000:1000 /asset-input && mkdir -p /asset-output && chown -R 1000:1000 /asset-output'), 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'), + expect.stringContaining('rm -v copyContainer'), ])); }); From 9387c9dbb7000ea796c316a00cc19248c4eeb6a4 Mon Sep 17 00:00:00 2001 From: Chris Gatt Date: Fri, 10 Jan 2025 19:48:31 +1100 Subject: [PATCH 2/5] refactor(asset-staging): explicitly set bind mount volume type --- packages/aws-cdk-lib/core/lib/asset-staging.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/aws-cdk-lib/core/lib/asset-staging.ts b/packages/aws-cdk-lib/core/lib/asset-staging.ts index d6903d4be4b22..ac441b4ab87ff 100644 --- a/packages/aws-cdk-lib/core/lib/asset-staging.ts +++ b/packages/aws-cdk-lib/core/lib/asset-staging.ts @@ -471,10 +471,12 @@ export class AssetStaging extends Construct { case BundlingFileAccess.BIND_MOUNT: default: assetStagingOptions.volumes.push({ + dockerVolumeType: DockerVolumeType.BIND_MOUNT, hostPath: assetStagingOptions.sourcePath, containerPath: AssetStaging.BUNDLING_INPUT_DIR, }, { + dockerVolumeType: DockerVolumeType.BIND_MOUNT, hostPath: assetStagingOptions.bundleDir, containerPath: AssetStaging.BUNDLING_OUTPUT_DIR, }); From eeeaaef33f21c76ffaf97cacf1522b1544a54786 Mon Sep 17 00:00:00 2001 From: Chris Gatt Date: Fri, 10 Jan 2025 20:09:13 +1100 Subject: [PATCH 3/5] test(staging): use regex for user ID to pass in CI --- packages/aws-cdk-lib/core/test/staging.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aws-cdk-lib/core/test/staging.test.ts b/packages/aws-cdk-lib/core/test/staging.test.ts index 660f291e6898a..c7a1a0cf6ccb2 100644 --- a/packages/aws-cdk-lib/core/test/staging.test.ts +++ b/packages/aws-cdk-lib/core/test/staging.test.ts @@ -1580,7 +1580,7 @@ describe('staging with docker cp', () => { expect(staging.isArchive).toEqual(true); const dockerCalls: string[] = readDockerStubInputConcat(STUB_INPUT_CP_CONCAT_FILE).split(/\r?\n/); expect(dockerCalls).toEqual(expect.arrayContaining([ - expect.stringMatching('run --name copyContainer.* -v /asset-input -v /asset-output public.ecr.aws/docker/library/alpine sh -c mkdir -p /asset-input && chown -R 1000:1000 /asset-input && mkdir -p /asset-output && chown -R 1000:1000 /asset-output'), + expect.stringMatching('run --name copyContainer.* -v /asset-input -v /asset-output public.ecr.aws/docker/library/alpine sh -c mkdir -p /asset-input && chown -R .* /asset-input && mkdir -p /asset-output && chown -R .* /asset-output'), 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/\. .*'), From c36ec026587a66c1d236067a90ca243011f09491 Mon Sep 17 00:00:00 2001 From: Chris Gatt Date: Mon, 13 Jan 2025 11:03:24 +1100 Subject: [PATCH 4/5] update alpha package tests --- packages/@aws-cdk/aws-lambda-go-alpha/lib/bundling.ts | 2 +- packages/@aws-cdk/aws-lambda-python-alpha/lib/bundling.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/aws-lambda-go-alpha/lib/bundling.ts b/packages/@aws-cdk/aws-lambda-go-alpha/lib/bundling.ts index 60524d8f20bde..1a5eea80438e3 100644 --- a/packages/@aws-cdk/aws-lambda-go-alpha/lib/bundling.ts +++ b/packages/@aws-cdk/aws-lambda-go-alpha/lib/bundling.ts @@ -108,7 +108,7 @@ export class Bundling implements cdk.BundlingOptions { public readonly environment?: { [key: string]: string }; public readonly local?: cdk.ILocalBundling; public readonly entrypoint?: string[]; - public readonly volumes?: cdk.DockerVolume[]; + public readonly volumes?: (cdk.DockerVolume | cdk.VolumeCopyDockerVolume | cdk.ExistingDockerVolume)[]; public readonly volumesFrom?: string[]; public readonly workingDirectory?: string; public readonly user?: string; diff --git a/packages/@aws-cdk/aws-lambda-python-alpha/lib/bundling.ts b/packages/@aws-cdk/aws-lambda-python-alpha/lib/bundling.ts index f29dd5ada3237..5c8e56ca60db6 100644 --- a/packages/@aws-cdk/aws-lambda-python-alpha/lib/bundling.ts +++ b/packages/@aws-cdk/aws-lambda-python-alpha/lib/bundling.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { Architecture, AssetCode, Code, Runtime } from 'aws-cdk-lib/aws-lambda'; -import { AssetStaging, BundlingFileAccess, BundlingOptions as CdkBundlingOptions, DockerImage, DockerVolume } from 'aws-cdk-lib/core'; +import { AssetStaging, BundlingFileAccess, BundlingOptions as CdkBundlingOptions, DockerImage, DockerVolume, type ExistingDockerVolume, type VolumeCopyDockerVolume } from 'aws-cdk-lib/core'; import { Packaging, DependenciesFile } from './packaging'; import { BundlingOptions, ICommandHooks } from './types'; @@ -65,7 +65,7 @@ export class Bundling implements CdkBundlingOptions { public readonly image: DockerImage; public readonly entrypoint?: string[]; public readonly command: string[]; - public readonly volumes?: DockerVolume[]; + public readonly volumes?: (DockerVolume | VolumeCopyDockerVolume | ExistingDockerVolume)[]; public readonly volumesFrom?: string[]; public readonly environment?: { [key: string]: string }; public readonly workingDirectory?: string; From 93c97286d48e7182831808f945fa064a0aa0107d Mon Sep 17 00:00:00 2001 From: Chris Gatt Date: Mon, 13 Jan 2025 14:23:52 +1100 Subject: [PATCH 5/5] move user determination behaviour back to assetStaging --- .../aws-cdk-lib/core/lib/asset-staging.ts | 23 +++++--- packages/aws-cdk-lib/core/lib/bundling.ts | 4 +- .../aws-cdk-lib/core/lib/private/bundling.ts | 33 +++--------- .../aws-cdk-lib/core/test/bundling.test.ts | 53 +++++++++++++++++++ .../aws-cdk-lib/core/test/staging.test.ts | 52 ++++++++---------- 5 files changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/aws-cdk-lib/core/lib/asset-staging.ts b/packages/aws-cdk-lib/core/lib/asset-staging.ts index ac441b4ab87ff..abaf53650376e 100644 --- a/packages/aws-cdk-lib/core/lib/asset-staging.ts +++ b/packages/aws-cdk-lib/core/lib/asset-staging.ts @@ -1,4 +1,5 @@ import * as crypto from 'crypto'; +import * as os from 'os'; import * as path from 'path'; import { Construct } from 'constructs'; import * as fs from 'fs-extra'; @@ -451,7 +452,11 @@ export class AssetStaging extends Construct { sourcePath: this.sourcePath, bundleDir, ...options, + securityOpt: options.securityOpt ?? '', + user: options.user ?? this.determineUser(), volumes: [...(options.volumes ?? [])], + workingDirectory: + options.workingDirectory ?? AssetStaging.BUNDLING_INPUT_DIR, }; // Add the asset input and output volumes based on BundlingFileAccess setting @@ -483,12 +488,7 @@ export class AssetStaging extends Construct { break; } - assetStagingOptions.image.run({ - workingDirectory: - assetStagingOptions.workingDirectory ?? AssetStaging.BUNDLING_INPUT_DIR, - securityOpt: assetStagingOptions.securityOpt ?? '', - ...assetStagingOptions, - }); + assetStagingOptions.image.run(assetStagingOptions); } } catch (err) { // When bundling fails, keep the bundle output for diagnosability, but @@ -552,6 +552,17 @@ export class AssetStaging extends Construct { } return targetPath; } + + /** + * Determines a useful default user if not given otherwise + */ + private determineUser(): string { + // Default to current user + const userInfo = os.userInfo(); + return userInfo.uid !== -1 // uid is -1 on Windows + ? `${userInfo.uid}:${userInfo.gid}` + : '1000:1000'; + } } function renderAssetFilename(assetHash: string, extension = '') { diff --git a/packages/aws-cdk-lib/core/lib/bundling.ts b/packages/aws-cdk-lib/core/lib/bundling.ts index 88bf037c47561..653b88c97e11c 100644 --- a/packages/aws-cdk-lib/core/lib/bundling.ts +++ b/packages/aws-cdk-lib/core/lib/bundling.ts @@ -279,8 +279,8 @@ export class BundlingDockerImage { ...options.platform ? ['--platform', options.platform] : [], - ...volumeHelper.user - ? ['-u', volumeHelper.user] + ...options.user + ? ['-u', options.user] : [], ...volumeHelper.volumeCommands, ...flatten(Object.entries(environment).map(([k, v]) => ['--env', `${k}=${v}`])), diff --git a/packages/aws-cdk-lib/core/lib/private/bundling.ts b/packages/aws-cdk-lib/core/lib/private/bundling.ts index c515725c99f25..777c3066e77e9 100644 --- a/packages/aws-cdk-lib/core/lib/private/bundling.ts +++ b/packages/aws-cdk-lib/core/lib/private/bundling.ts @@ -1,6 +1,5 @@ import { spawnSync, SpawnSyncOptions } from 'child_process'; import * as crypto from 'crypto'; -import * as os from 'os'; import { DockerRunOptions, DockerVolume, @@ -37,7 +36,6 @@ export interface DockerVolumes { */ export class DockerVolumeHelper { readonly volumeCommands: string[]; - readonly user?: string; readonly containerName?: string; readonly volumes: DockerVolumes; @@ -57,16 +55,17 @@ export class DockerVolumeHelper { } } if (this.volumes.volumeCopyVolumes.length > 0) { - // TODO handle user properly - this.user = this.determineUser(); this.containerName = `copyContainer${crypto.randomBytes(12).toString('hex')}`; const copyVolumeCommands = this.volumes.volumeCopyVolumes.flatMap(volume => [ '-v', // leading ':' is required for anonymous volume with opts volume.opts ? `:${volume.containerPath}:${volume.opts.join(',')}` : `${volume.containerPath}`, ]); - const chownCommands = this.volumes.volumeCopyVolumes - .map(volume => `mkdir -p ${volume.containerPath} && chown -R ${this.user} ${volume.containerPath}`); + const directoryCommands: string[] = []; + for (let volume of this.volumes.volumeCopyVolumes) { + directoryCommands.push(`mkdir -p ${volume.containerPath}`); + if (options.user) directoryCommands.push(`chown -R ${options.user} ${volume.containerPath}`); + } dockerExec([ 'run', '--name', @@ -75,7 +74,7 @@ export class DockerVolumeHelper { 'public.ecr.aws/docker/library/alpine', 'sh', '-c', - chownCommands.join(' && '), + directoryCommands.join(' && '), ]); try { this.copyInputVolumes(); @@ -84,8 +83,6 @@ export class DockerVolumeHelper { dockerExec(['rm', '-v', this.containerName]); throw e; } - } else { - this.user = this.options.user; } this.volumeCommands = this.buildVolumeCommands(); }; @@ -124,24 +121,6 @@ export class DockerVolumeHelper { return volumeCommands; } - /** - * Determines a useful default user if not given otherwise - */ - private determineUser(): string { - let user; - 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; - } - /** * 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 diff --git a/packages/aws-cdk-lib/core/test/bundling.test.ts b/packages/aws-cdk-lib/core/test/bundling.test.ts index 993a467383db1..a7fd47b6d4cad 100644 --- a/packages/aws-cdk-lib/core/test/bundling.test.ts +++ b/packages/aws-cdk-lib/core/test/bundling.test.ts @@ -807,6 +807,59 @@ describe('bundling', () => { ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); }); + test('can handle copy volume without user', () => { + // GIVEN + sinon.stub(process, 'platform').value('darwin'); + const spawnSyncStub = sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + // WHEN + const image = DockerImage.fromRegistry('alpine'); + image.run({ + command: ['cool', 'command'], + volumes: [{ + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/1', + hostInputPath: '/host/input/1', + }, { + dockerVolumeType: DockerVolumeType.VOLUME_COPY, + containerPath: '/container/path/2', + hostInputPath: '/host/input/2', + opts: ['opt1', 'opt2'], + }], + workingDirectory: '/working-directory', + }); + + // copy container volume opts are correct + expect(spawnSyncStub.calledWith(dockerCmd, sinon.match([ + 'run', + '--name', sinon.match(/copyContainer.*/g), + '-v', '/container/path/1', + '-v', ':/container/path/2:opt1,opt2', + 'public.ecr.aws/docker/library/alpine', + 'sh', + '-c', + [ + 'mkdir -p /container/path/1', + 'mkdir -p /container/path/2', + ].join(' && '), + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + + // main image run volume commands are correct + expect(spawnSyncStub.calledWith(dockerCmd, sinon.match([ + 'run', '--rm', + '--volumes-from', sinon.match(/copyContainer.*/g), + '-w', '/working-directory', + 'alpine', 'cool', 'command', + ]), { encoding: 'utf-8', stdio: ['ignore', process.stderr, 'inherit'] })).toEqual(true); + }); + test('cleans up volume helper', () => { // GIVEN sinon.stub(process, 'platform').value('darwin'); diff --git a/packages/aws-cdk-lib/core/test/staging.test.ts b/packages/aws-cdk-lib/core/test/staging.test.ts index c7a1a0cf6ccb2..326be72889e43 100644 --- a/packages/aws-cdk-lib/core/test/staging.test.ts +++ b/packages/aws-cdk-lib/core/test/staging.test.ts @@ -5,18 +5,7 @@ import * as fs from 'fs-extra'; import * as sinon from 'sinon'; import { FileAssetPackaging } from '../../cloud-assembly-schema'; import * as cxapi from '../../cx-api'; -import { - App, - AssetHashType, - AssetStaging, - BundlingFileAccess, - BundlingOptions, - BundlingOutput, - DockerImage, - FileSystem, - Stack, - Stage, -} from '../lib'; +import { App, AssetHashType, AssetStaging, DockerImage, BundlingOptions, BundlingOutput, FileSystem, Stack, Stage, BundlingFileAccess } from '../lib'; const STUB_INPUT_FILE = '/tmp/docker-stub.input'; const STUB_INPUT_CONCAT_FILE = '/tmp/docker-stub.input.concat'; @@ -322,7 +311,7 @@ describe('staging', () => { const assembly = app.synth(); expect( readDockerStubInput()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(fs.readdirSync(assembly.directory)).toEqual([ 'asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4', @@ -401,7 +390,7 @@ describe('staging', () => { // We're testing that docker was run exactly once even though there are two bundling assets. expect( readDockerStubInputConcat()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -446,7 +435,7 @@ describe('staging', () => { // and that the hash is based on the output expect( readDockerStubInputConcat()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -494,8 +483,8 @@ describe('staging', () => { // operating on the same source asset. expect( readDockerStubInputConcat()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS\n' + - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env UNIQUE_ENV_VAR=SOMEVALUE -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS\n` + + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env UNIQUE_ENV_VAR=SOMEVALUE -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -543,7 +532,7 @@ describe('staging', () => { // We're testing that docker was run once, only for the first Asset, since the only difference is the token. expect( readDockerStubInputConcat()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(fs.readdirSync(assembly.directory)).toEqual([ @@ -674,7 +663,7 @@ describe('staging', () => { expect( readDockerStubInputConcat()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(appAssembly.directory).toEqual(app2Assembly.directory); @@ -736,7 +725,7 @@ describe('staging', () => { expect( readDockerStubInputConcat()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_EXTRA_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated --env PIP_EXTRA_INDEX_URL=https://aws:MY_SECRET_TOKEN@your-code-repo.d.codeartifact.us-west-2.amazonaws.com/pypi/python/simple/ -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(appAssembly.directory).toEqual(app2Assembly.directory); @@ -766,7 +755,7 @@ describe('staging', () => { expect( readDockerStubInput()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS_NO_OUTPUT', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS_NO_OUTPUT`, ); }); @@ -789,7 +778,7 @@ describe('staging', () => { // THEN expect( readDockerStubInput()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); }); @@ -814,7 +803,7 @@ describe('staging', () => { // THEN expect( readDockerStubInput()).toEqual( - 'run --rm --security-opt no-new-privileges -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm --security-opt no-new-privileges ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); }); @@ -839,7 +828,7 @@ describe('staging', () => { // THEN expect( readDockerStubInput()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input --entrypoint DOCKER_STUB_SUCCESS alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input --entrypoint DOCKER_STUB_SUCCESS alpine DOCKER_STUB_SUCCESS`, ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); }); @@ -957,7 +946,7 @@ describe('staging', () => { })).toThrow(/Failed to bundle asset stack\/Asset/); expect( readDockerStubInput()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input this-is-an-invalid-docker-image DOCKER_STUB_FAIL', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input this-is-an-invalid-docker-image DOCKER_STUB_FAIL`, ); }); @@ -1248,7 +1237,7 @@ describe('staging', () => { expect( readDockerStubInput()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); // hash of MyStack/Asset }); @@ -1272,7 +1261,7 @@ describe('staging', () => { expect( readDockerStubInput()).toEqual( - 'run --rm -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS', + `run --rm ${USER_ARG} -v /input:/asset-input:delegated -v /output:/asset-output:delegated -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(asset.assetHash).toEqual('33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); // hash of MyStack/Asset }); @@ -1656,11 +1645,12 @@ describe('staging with docker cp', () => { // Reads a docker stub and cleans the volume paths out of the stub. function readAndCleanDockerStubInput(file: string) { - return fs - .readFileSync(file, 'utf-8') + const commands = fs + .readFileSync(file, 'utf-8'); + return commands .trim() - .replace(/-v ([^:]+):\/asset-input/g, '-v /input:/asset-input') - .replace(/-v ([^:]+):\/asset-output/g, '-v /output:/asset-output'); + .replace(/-v ([^:\n]+):\/asset-input/g, '-v /input:/asset-input') + .replace(/-v ([^:\n]+):\/asset-output/g, '-v /output:/asset-output'); } // Last docker input since last teardown