From f46bece802c102bdd85fd60985a5fc82ce0f6335 Mon Sep 17 00:00:00 2001 From: Patrick Stoeckle Date: Wed, 23 Sep 2026 12:14:30 +0200 Subject: [PATCH] add --build-output-folder Co-authored-by: GitHub Copilot/GPT-5.6 Sol --- src/spec-node/containerFeatures.ts | 48 +++++++++++++++++++++------ src/spec-node/devContainersSpecCLI.ts | 42 ++++++++++++++++++++--- src/spec-node/dockerCompose.ts | 24 +++++++++++--- src/spec-node/singleContainer.ts | 38 ++++++++++++++++----- src/test/cli.build.test.ts | 22 +++++++++++- 5 files changed, 145 insertions(+), 29 deletions(-) diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index eebb793f2..6d8232fd5 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -28,12 +28,22 @@ export const getSafeId = (str: string) => str .replace(/^[\d_]+/g, '_') .toUpperCase(); -export async function extendImage(params: DockerResolverParameters, config: SubstitutedConfig, imageName: string, additionalImageNames: string[], additionalFeatures: Record>, canAddLabelsToContainer: boolean) { +export interface PreparedBuildInfo { + dockerfile: string; + context: string; + target: string | undefined; + buildArgs: Record; + buildContexts: Record; + securityOpts: string[]; + imageNames: string[]; +} + +export async function extendImage(params: DockerResolverParameters, config: SubstitutedConfig, imageName: string, additionalImageNames: string[], additionalFeatures: Record>, canAddLabelsToContainer: boolean, buildOutputFolder?: string) { const { common } = params; const { cliHost, output } = common; const imageBuildInfo = await getImageBuildInfoFromImage(params, imageName, config.substitute); - const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageBuildInfo, undefined, additionalFeatures, canAddLabelsToContainer); + const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageBuildInfo, undefined, additionalFeatures, canAddLabelsToContainer, buildOutputFolder); if (!extendImageDetails?.featureBuildInfo) { // no feature extensions - return if (additionalImageNames.length) { @@ -53,10 +63,27 @@ export async function extendImage(params: DockerResolverParameters, config: Subs const { featureBuildInfo, featuresConfig } = extendImageDetails; // Got feature extensions -> build the image - const dockerfilePath = cliHost.path.join(featureBuildInfo.dstFolder, 'Dockerfile.extended'); + const dockerfilePath = cliHost.path.join(featureBuildInfo.dstFolder, buildOutputFolder ? 'Dockerfile' : 'Dockerfile.extended'); await cliHost.writeFile(dockerfilePath, Buffer.from(featureBuildInfo.dockerfilePrefixContent + featureBuildInfo.dockerfileContent)); const folderImageName = getFolderImageName(common); const updatedImageName = `${imageName.startsWith(folderImageName) ? imageName : folderImageName}-features`; + const outputImageNames = additionalImageNames.length > 0 ? additionalImageNames : [updatedImageName]; + if (buildOutputFolder) { + return { + updatedImageName: outputImageNames, + imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, config, featuresConfig), + imageDetails: async () => imageBuildInfo.imageDetails, + preparedBuild: { + dockerfile: dockerfilePath, + context: buildOutputFolder, + target: featureBuildInfo.overrideTarget, + buildArgs: featureBuildInfo.buildArgs, + buildContexts: featureBuildInfo.buildKitContexts, + securityOpts: featureBuildInfo.securityOpts, + imageNames: outputImageNames, + } satisfies PreparedBuildInfo, + }; + } const args: string[] = []; if (!params.buildKitVersion && @@ -132,16 +159,17 @@ export async function extendImage(params: DockerResolverParameters, config: Subs await dockerCLI(infoParams, ...args); } return { - updatedImageName: additionalImageNames.length > 0 ? additionalImageNames : [updatedImageName], + updatedImageName: outputImageNames, imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, config, featuresConfig), imageDetails: async () => imageBuildInfo.imageDetails, }; } -export async function getExtendImageBuildInfo(params: DockerResolverParameters, config: SubstitutedConfig, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined, additionalFeatures: Record>, canAddLabelsToContainer: boolean): Promise<{ featureBuildInfo?: ImageBuildOptions; featuresConfig?: FeaturesConfig; labels?: Record } | undefined> { +export async function getExtendImageBuildInfo(params: DockerResolverParameters, config: SubstitutedConfig, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined, additionalFeatures: Record>, canAddLabelsToContainer: boolean, buildOutputFolder?: string): Promise<{ featureBuildInfo?: ImageBuildOptions; featuresConfig?: FeaturesConfig; labels?: Record } | undefined> { // Creates the folder where the working files will be setup. - const dstFolder = await createFeaturesTempFolder(params.common); + const dstFolder = buildOutputFolder || await createFeaturesTempFolder(params.common); + await params.common.cliHost.mkdirp(dstFolder); // Processes the user's configuration. const platform = params.common.cliHost.platform; @@ -161,7 +189,7 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters, } // Generates the end configuration. - const featureBuildInfo = await getFeaturesBuildOptions(params, config, featuresConfig, baseName, imageBuildInfo, composeServiceUser); + const featureBuildInfo = await getFeaturesBuildOptions(params, config, featuresConfig, baseName, imageBuildInfo, composeServiceUser, !!buildOutputFolder); if (!featureBuildInfo) { return undefined; } @@ -224,7 +252,7 @@ function getOmitDevcontainerPropertyOverride(resolverParams: { omitConfigRemotEn return []; } -async function getFeaturesBuildOptions(params: DockerResolverParameters, devContainerConfig: SubstitutedConfig, featuresConfig: FeaturesConfig, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined): Promise { +async function getFeaturesBuildOptions(params: DockerResolverParameters, devContainerConfig: SubstitutedConfig, featuresConfig: FeaturesConfig, baseName: string, imageBuildInfo: ImageBuildInfo, composeServiceUser: string | undefined, prepareOnly: boolean): Promise { const { common } = params; const { cliHost, output } = common; const { dstFolder } = featuresConfig; @@ -241,7 +269,7 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont // TODO generate an image name that is specific to this dev container? const buildKitVersionParsed = params.buildKitVersion?.versionMatch ? parseVersion(params.buildKitVersion.versionMatch) : undefined; const minRequiredVersion = [0, 8, 0]; - const useBuildKitBuildContexts = buildKitVersionParsed ? !isEarlierVersion(buildKitVersionParsed, minRequiredVersion) : false; + const useBuildKitBuildContexts = prepareOnly || (buildKitVersionParsed ? !isEarlierVersion(buildKitVersionParsed, minRequiredVersion) : false); const buildContentImageName = 'dev_container_feature_content_temp'; const disableSELinuxLabels = useBuildKitBuildContexts && await isUsingSELinuxLabels(params); // Access Docker engine version @@ -378,7 +406,7 @@ async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise { + if (argv['build-output-folder'] && (argv.push || argv.output)) { + throw new Error('--build-output-folder cannot be used with --push or --output.'); + } if (argv['no-lockfile'] && argv['frozen-lockfile']) { throw new Error('--no-lockfile and --frozen-lockfile are mutually exclusive.'); } @@ -633,6 +637,7 @@ async function doBuild({ 'push': buildxPush, 'label': buildxLabel, 'output': buildxOutput, + 'build-output-folder': buildOutputFolderArg, 'cache-to': buildxCacheTo, 'additional-features': additionalFeaturesJson, 'skip-feature-auto-mapping': skipFeatureAutoMapping, @@ -654,6 +659,7 @@ async function doBuild({ }; try { const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); + const buildOutputFolder = buildOutputFolderArg ? path.resolve(process.cwd(), buildOutputFolderArg) : undefined; const configFile: URI | undefined = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const overrideConfigFile: URI | undefined = /* overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : */ undefined; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; @@ -703,6 +709,12 @@ async function doBuild({ const { common, dockerComposeCLI } = params; const { cliHost, env, output } = common; + if (buildOutputFolder) { + if (await cliHost.isFolder(buildOutputFolder) && (await cliHost.readDir(buildOutputFolder)).length) { + throw new ContainerError({ description: `Build output folder (${buildOutputFolder}) must be empty.` }); + } + await cliHost.mkdirp(buildOutputFolder); + } const workspace = workspaceFromPath(cliHost.path, workspaceFolder); const configPath = configFile ? configFile : workspace ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) @@ -715,6 +727,7 @@ async function doBuild({ const configWithRaw = configs.config; const { config } = configWithRaw; let imageNameResult: string[] = ['']; + let preparedBuild; if (buildxOutput && buildxPush) { throw new ContainerError({ description: '--push true cannot be used with --output.' }); @@ -732,7 +745,9 @@ async function doBuild({ if (isDockerFileConfig(config)) { // Build the base image and extend with features etc. - let { updatedImageName } = await buildNamedImageAndExtend(params, configWithRaw as SubstitutedConfig, additionalFeatures, false, imageNames); + const buildResult = await buildNamedImageAndExtend(params, configWithRaw as SubstitutedConfig, additionalFeatures, false, imageNames, buildOutputFolder); + const { updatedImageName } = buildResult; + preparedBuild = buildResult.preparedBuild; if (imageNames) { imageNameResult = imageNames; @@ -762,7 +777,7 @@ async function doBuild({ if (envFile) { composeGlobalArgs.push('--env-file', envFile); } - + const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); const projectName = await getProjectName(params, workspace, composeFiles, composeConfig); const services = Object.keys(composeConfig.services || {}); @@ -772,14 +787,21 @@ async function doBuild({ const versionPrefix = await readVersionPrefix(cliHost, composeFiles); const infoParams = { ...params, common: { ...params.common, output: makeLog(buildParams.output, LogLevel.Info) } }; - const { overrideImageName } = await buildAndExtendDockerCompose(configWithRaw as SubstitutedConfig, projectName, infoParams, composeFiles, envFile, composeGlobalArgs, [config.service], params.buildNoCache || false, params.common.persistedFolder, 'docker-compose.devcontainer.build', versionPrefix, additionalFeatures, false, addCacheFroms); + const buildResult = await buildAndExtendDockerCompose(configWithRaw as SubstitutedConfig, projectName, infoParams, composeFiles, envFile, composeGlobalArgs, [config.service], params.buildNoCache || false, buildOutputFolder || params.common.persistedFolder, 'docker-compose.devcontainer.build', versionPrefix, additionalFeatures, false, addCacheFroms, !!buildOutputFolder, buildOutputFolder); + const { overrideImageName } = buildResult; + preparedBuild = buildResult.preparedBuild; + if (preparedBuild && imageNames) { + preparedBuild.imageNames = imageNames; + } const service = composeConfig.services[config.service]; const originalImageName = overrideImageName || service.image || getDefaultImageName(await buildParams.dockerComposeCLI(), projectName, config.service); if (imageNames) { // Future improvement: Compose 2.6.0 (released 2022-05-30) added `tags` to the compose file. - if (params.isTTY) { + if (buildOutputFolder) { + // The requested names are recorded in build-output.json for the external builder. + } else if (params.isTTY) { await Promise.all(imageNames.map(imageName => dockerPtyCLI(params, 'tag', originalImageName, imageName))); } else { await Promise.all(imageNames.map(imageName => dockerCLI(params, 'tag', originalImageName, imageName))); @@ -795,7 +817,9 @@ async function doBuild({ } await inspectDockerImage(params, config.image, true); - const { updatedImageName } = await extendImage(params, configWithRaw, config.image, imageNames || [], additionalFeatures, false); + const buildResult = await extendImage(params, configWithRaw, config.image, imageNames || [], additionalFeatures, false, buildOutputFolder); + const { updatedImageName } = buildResult; + preparedBuild = buildResult.preparedBuild; if (imageNames) { imageNameResult = imageNames; @@ -804,9 +828,17 @@ async function doBuild({ } } + if (buildOutputFolder) { + if (!preparedBuild) { + throw new ContainerError({ description: 'Failed to prepare build output.' }); + } + await cliHost.writeFile(cliHost.path.join(buildOutputFolder, 'build-output.json'), Buffer.from(JSON.stringify(preparedBuild, undefined, 2) + '\n')); + } + return { outcome: 'success' as 'success', imageName: imageNameResult, + buildOutputFolder, ociAuthDiagnostics: params.common.ociAuthDiagnostics, dispose, }; diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 8e7750040..cf1ab0b2b 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -14,7 +14,7 @@ import { equalPaths, parseVersion, isEarlierVersion, CLIHost } from '../spec-com import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { DevContainerFromDockerComposeConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; import { Log, LogLevel, makeLog, terminalEscapeSequences } from '../spec-utils/log'; -import { getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; +import { getExtendImageBuildInfo, PreparedBuildInfo, updateRemoteUserUID } from './containerFeatures'; import { Mount, parseMount } from '../spec-configuration/containerFeaturesConfiguration'; import path from 'path'; import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageBuildInfoFromImage, getImageMetadataFromContainer, ImageBuildInfo, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; @@ -149,7 +149,7 @@ export function getBuildInfoForService(composeService: any, cliHostPath: typeof }; } -export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConfig, projectName: string, params: DockerResolverParameters, localComposeFiles: string[], envFile: string | undefined, composeGlobalArgs: string[], runServices: string[], noCache: boolean, overrideFilePath: string, overrideFilePrefix: string, versionPrefix: string, additionalFeatures: Record>, canAddLabelsToContainer: boolean, additionalCacheFroms?: string[], noBuild?: boolean) { +export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConfig, projectName: string, params: DockerResolverParameters, localComposeFiles: string[], envFile: string | undefined, composeGlobalArgs: string[], runServices: string[], noCache: boolean, overrideFilePath: string, overrideFilePrefix: string, versionPrefix: string, additionalFeatures: Record>, canAddLabelsToContainer: boolean, additionalCacheFroms?: string[], noBuild?: boolean, buildOutputFolder?: string) { const { common, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc } = params; const { cliHost, env, output } = common; @@ -190,10 +190,11 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf const version = parseVersion((await params.dockerComposeCLI()).version); const supportsAdditionalBuildContexts = params.cliVariant === CLIVariant.Docker && version && !isEarlierVersion(version, [2, 17, 0]); const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined }; - const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); + const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer, buildOutputFolder); let overrideImageName: string | undefined; let buildOverrideContent = ''; + let preparedBuild: PreparedBuildInfo | undefined; if (extendImageBuildInfo?.featureBuildInfo) { // Avoid retagging a previously pulled image. if (!serviceInfo.build) { @@ -212,8 +213,22 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf dockerfile = dockerfile.slice(syntaxMatch[0].length); } let finalDockerfileContent = `${featureBuildInfo.dockerfilePrefixContent}${dockerfile}\n${featureBuildInfo.dockerfileContent}`; - const finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, 'Dockerfile-with-features'); + const finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, buildOutputFolder ? 'Dockerfile' : 'Dockerfile-with-features'); await cliHost.writeFile(finalDockerfilePath, Buffer.from(finalDockerfileContent)); + if (buildOutputFolder) { + preparedBuild = { + dockerfile: finalDockerfilePath, + context: serviceInfo.build?.context || buildOutputFolder, + target: featureBuildInfo.overrideTarget, + buildArgs: { + ...(serviceInfo.build?.args || {}), + ...featureBuildInfo.buildArgs, + }, + buildContexts: featureBuildInfo.buildKitContexts, + securityOpts: featureBuildInfo.securityOpts, + imageNames: overrideImageName ? [overrideImageName] : serviceInfo.image ? [serviceInfo.image] : [], + }; + } buildOverrideContent += ` dockerfile: ${finalDockerfilePath}\n`; if (serviceInfo.build?.target) { // Replace target. (Only when set because it is only supported with Docker Compose file version 3.4 and later.) @@ -301,6 +316,7 @@ ${cacheFromOverrideContent} additionalComposeOverrideFiles, overrideImageName, labels: extendImageBuildInfo?.labels, + preparedBuild, }; } diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 362559c2e..4c431499d 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -10,7 +10,7 @@ import { ContainerError, toErrorText } from '../spec-common/errors'; import { ContainerDetails, listContainers, DockerCLIParameters, inspectContainers, dockerCLI, dockerPtyCLI, toPtyExecParameters, ImageDetails, toExecParameters, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { DevContainerConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig } from '../spec-configuration/configuration'; import { LogLevel, Log, makeLog } from '../spec-utils/log'; -import { extendImage, getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; +import { extendImage, getExtendImageBuildInfo, PreparedBuildInfo, updateRemoteUserUID } from './containerFeatures'; import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageMetadataFromContainer, ImageMetadataEntry, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { ensureDockerfileHasFinalStageName, generateMountCommand } from './dockerfileUtils'; @@ -110,18 +110,18 @@ async function setupContainer(container: ContainerDetails, params: DockerResolve function getDefaultName(config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, params: DockerResolverParameters) { return 'image' in config && config.image ? config.image : getFolderImageName(params.common); } -export async function buildNamedImageAndExtend(params: DockerResolverParameters, configWithRaw: SubstitutedConfig, additionalFeatures: Record>, canAddLabelsToContainer: boolean, argImageNames?: string[]): Promise<{ updatedImageName: string[]; imageMetadata: SubstitutedConfig; imageDetails: () => Promise; labels?: Record }> { +export async function buildNamedImageAndExtend(params: DockerResolverParameters, configWithRaw: SubstitutedConfig, additionalFeatures: Record>, canAddLabelsToContainer: boolean, argImageNames?: string[], buildOutputFolder?: string): Promise<{ updatedImageName: string[]; imageMetadata: SubstitutedConfig; imageDetails: () => Promise; labels?: Record; preparedBuild?: PreparedBuildInfo }> { const { config } = configWithRaw; const imageNames = argImageNames ?? [getDefaultName(config, params)]; params.common.progress(ResolverProgress.BuildingImage); if (isDockerFileConfig(config)) { - return await buildAndExtendImage(params, configWithRaw as SubstitutedConfig, imageNames, params.buildNoCache ?? false, additionalFeatures); + return await buildAndExtendImage(params, configWithRaw as SubstitutedConfig, imageNames, params.buildNoCache ?? false, additionalFeatures, buildOutputFolder); } // image-based dev container - extend - return await extendImage(params, configWithRaw, imageNames[0], argImageNames || [], additionalFeatures, canAddLabelsToContainer); + return await extendImage(params, configWithRaw, imageNames[0], argImageNames || [], additionalFeatures, canAddLabelsToContainer, buildOutputFolder); } -async function buildAndExtendImage(buildParams: DockerResolverParameters, configWithRaw: SubstitutedConfig, baseImageNames: string[], noCache: boolean, additionalFeatures: Record>) { +async function buildAndExtendImage(buildParams: DockerResolverParameters, configWithRaw: SubstitutedConfig, baseImageNames: string[], noCache: boolean, additionalFeatures: Record>, buildOutputFolder?: string) { const { cliHost, output } = buildParams.common; const { config } = configWithRaw; const dockerfileUri = getDockerfilePath(cliHost, config); @@ -147,7 +147,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config } const imageBuildInfo = await getImageBuildInfoFromDockerfile(buildParams, originalDockerfile, config.build?.args || {}, config.build?.target, configWithRaw.substitute); - const extendImageBuildInfo = await getExtendImageBuildInfo(buildParams, configWithRaw, baseName, imageBuildInfo, undefined, additionalFeatures, false); + const extendImageBuildInfo = await getExtendImageBuildInfo(buildParams, configWithRaw, baseName, imageBuildInfo, undefined, additionalFeatures, false, buildOutputFolder); let finalDockerfilePath = dockerfilePath; const additionalBuildArgs: string[] = []; @@ -159,7 +159,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config dockerfile = dockerfile.slice(syntaxMatch[0].length); } let finalDockerfileContent = `${featureBuildInfo.dockerfilePrefixContent}${dockerfile}\n${featureBuildInfo.dockerfileContent}`; - finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, 'Dockerfile-with-features'); + finalDockerfilePath = cliHost.path.join(featureBuildInfo?.dstFolder, buildOutputFolder ? 'Dockerfile' : 'Dockerfile-with-features'); await cliHost.writeFile(finalDockerfilePath, Buffer.from(finalDockerfileContent)); // track additional build args to include below @@ -189,7 +189,7 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config if (buildParams.buildxPush) { args.push('--push'); } else { - if (buildParams.buildxOutput) { + if (buildParams.buildxOutput) { args.push('--output', buildParams.buildxOutput); } else { args.push('--load'); // (short for --output=docker, i.e. load into normal 'docker images' collection) @@ -246,7 +246,27 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config args.push(...buildOptions); } args.push(...additionalBuildArgs); - args.push(await uriToWSLFsPath(getDockerContextPath(cliHost, config), cliHost)); + const context = await uriToWSLFsPath(getDockerContextPath(cliHost, config), cliHost); + args.push(context); + if (buildOutputFolder) { + return { + updatedImageName: baseImageNames, + imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, configWithRaw, extendImageBuildInfo?.featuresConfig), + imageDetails: () => inspectDockerImage(buildParams, baseImageNames[0], false), + preparedBuild: { + dockerfile: finalDockerfilePath, + context, + target, + buildArgs: { + ...(config.build?.args || {}), + ...(extendImageBuildInfo?.featureBuildInfo?.buildArgs || {}), + }, + buildContexts: extendImageBuildInfo?.featureBuildInfo?.buildKitContexts || {}, + securityOpts: extendImageBuildInfo?.featureBuildInfo?.securityOpts || [], + imageNames: baseImageNames, + } satisfies PreparedBuildInfo, + }; + } try { if (buildParams.isTTY) { const infoParams = { ...toPtyExecParameters(buildParams), output: makeLog(output, LogLevel.Info) }; diff --git a/src/test/cli.build.test.ts b/src/test/cli.build.test.ts index e2980ea19..70b49bfa5 100644 --- a/src/test/cli.build.test.ts +++ b/src/test/cli.build.test.ts @@ -26,6 +26,26 @@ describe('Dev Containers CLI', function () { }); describe('Command build', () => { + it('should prepare build output without building an image', async () => { + const testFolder = `${__dirname}/configs/dockerfile-with-features`; + const outputFolder = path.join(__dirname, 'tmp', 'build-output'); + const imageName = 'devcontainer-build-output-test'; + fs.rmSync(outputFolder, { recursive: true, force: true }); + await shellExec(`docker rmi -f ${imageName} || true`); + + const response = await shellExec(`${cli} build --workspace-folder ${testFolder} --image-name ${imageName} --buildkit=never --build-output-folder ${outputFolder}`); + const result = JSON.parse(response.stdout); + const buildOutput = JSON.parse(fs.readFileSync(path.join(outputFolder, 'build-output.json'), 'utf8')); + + assert.equal(result.outcome, 'success'); + assert.equal(result.buildOutputFolder, outputFolder); + assert.equal(fs.existsSync(path.join(outputFolder, 'Dockerfile')), true); + assert.equal(buildOutput.dockerfile, path.join(outputFolder, 'Dockerfile')); + assert.equal(buildOutput.imageNames[0], imageName); + assert.equal(buildOutput.buildContexts.dev_containers_feature_content_source, outputFolder); + const images = await shellExec(`docker images -q ${imageName}`); + assert.equal(images.stdout.trim(), ''); + }); it('should build successfully with valid image metadata --label property', async () => { const testFolder = `${__dirname}/configs/example`; @@ -53,7 +73,7 @@ describe('Dev Containers CLI', function () { await shellExec(`${cli} build --workspace-folder ${testFolder} --image-name demo:v1`); const tags = await shellExec(`docker images --format "{{.Tag}}" demo`); const imageTags = tags.stdout.trim().split('\n').filter(tag => tag !== ''); - assert.equal(imageTags.length, 1, 'There should be only one tag for demo:v1'); + assert.equal(imageTags.length, 1, 'There should be only one tag for demo:v1'); } catch (error) { assert.equal(error.code, 'ERR_ASSERTION', 'Should fail with ERR_ASSERTION'); }