diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index a7a616e8b..630b7785d 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -56,6 +56,8 @@ export interface ResolverParameters { backgroundTasks: (Promise | (() => Promise))[]; persistedFolder: string; // A path where config can be persisted and restored at a later time. Should default to tmpdir() folder if not provided. remoteEnv: Record; + buildxPlatform: string | undefined; + buildxPush: boolean; } export interface PostCreate { diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 9d02a035a..672a617fe 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -46,6 +46,8 @@ export interface ProvisionOptions { remoteEnv: Record; additionalCacheFroms: string[]; useBuildKit: 'auto' | 'never'; + buildxPlatform: string | undefined; + buildxPush: boolean; } export async function launch(options: ProvisionOptions, disposables: (() => Promise | undefined)[]) { @@ -113,6 +115,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: backgroundTasks: [], persistedFolder: persistedFolder || await cliHost.tmpdir(), // Fallback to tmpDir(), even though that isn't 'persistent' remoteEnv, + buildxPlatform: options.buildxPlatform, + buildxPush: options.buildxPush, }; const dockerPath = options.dockerPath || 'docker'; @@ -148,6 +152,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: additionalCacheFroms: options.additionalCacheFroms, buildKitVersion, isTTY: process.stdin.isTTY || options.logFormat === 'json', + buildxPlatform: common.buildxPlatform, + buildxPush: common.buildxPush, }; } diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index a1f9f08f9..26acbbddb 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -184,6 +184,8 @@ async function provision({ remoteEnv: keyValuesToRecord(addRemoteEnvs), additionalCacheFroms: addCacheFroms, useBuildKit: buildkit, + buildxPlatform: undefined, + buildxPush: false, }; const result = await doProvision(options); @@ -241,6 +243,8 @@ function buildOptions(y: Argv) { 'image-name': { type: 'string', description: 'Image name.' }, 'cache-from': { type: 'string', description: 'Additional image to use as potential layer cache' }, 'buildkit': { choices: ['auto' as 'auto', 'never' as 'never'], default: 'auto' as 'auto', description: 'Control whether BuildKit should be used' }, + 'platform': { type: 'string', description: 'Set target platforms.' }, + 'push': { type: 'boolean', default: false, description: 'Push to a container registry.' }, }); } @@ -269,6 +273,8 @@ async function doBuild({ 'image-name': argImageName, 'cache-from': addCacheFrom, 'buildkit': buildkit, + 'platform': buildxPlatform, + 'push': buildxPush, }: BuildArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -305,7 +311,9 @@ async function doBuild({ updateRemoteUserUIDDefault: 'never', remoteEnv: {}, additionalCacheFroms: addCacheFroms, - useBuildKit: buildkit + useBuildKit: buildkit, + buildxPlatform, + buildxPush, }, disposables); const { common, dockerCLI, dockerComposeCLI } = params; @@ -325,16 +333,22 @@ async function doBuild({ if (isDockerFileConfig(config)) { // Build the base image and extend with features etc. - const { updatedImageName } = await buildNamedImageAndExtend(params, config); + const { updatedImageName } = await buildNamedImageAndExtend(params, config, argImageName); if (argImageName) { - await dockerPtyCLI(params, 'tag', updatedImageName, argImageName); + if (!buildxPush) { + await dockerPtyCLI(params, 'tag', updatedImageName, argImageName); + } imageNameResult = argImageName; } else { imageNameResult = updatedImageName; } } else if ('dockerComposeFile' in config) { + if (buildxPlatform || buildxPush) { + throw new ContainerError({ description: '--platform or --push not supported.' }); + } + const cwdEnvFile = cliHost.path.join(cliHost.cwd, '.env'); const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await cliHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; const composeFiles = await getDockerComposeFilePaths(cliHost, config, cliHost.env, workspaceFolder); @@ -371,6 +385,9 @@ async function doBuild({ await dockerPtyCLI(params, 'pull', config.image); const { updatedImageName } = await extendImage(params, config, config.image, 'image' in config); + if (buildxPlatform || buildxPush) { + throw new ContainerError({ description: '--platform or --push require dockerfilePath.' }); + } if (argImageName) { await dockerPtyCLI(params, 'tag', updatedImageName, argImageName); imageNameResult = argImageName; @@ -509,7 +526,9 @@ async function doRunUserCommands({ updateRemoteUserUIDDefault: 'never', remoteEnv: keyValuesToRecord(addRemoteEnvs), additionalCacheFroms: [], - useBuildKit: 'auto' + useBuildKit: 'auto', + buildxPlatform: undefined, + buildxPush: false, }, disposables); const { common } = params; @@ -753,7 +772,9 @@ async function doExec({ updateRemoteUserUIDDefault: 'never', remoteEnv: keyValuesToRecord(addRemoteEnvs), additionalCacheFroms: [], - useBuildKit: 'auto' + useBuildKit: 'auto', + buildxPlatform: undefined, + buildxPush: false, }, disposables); const { common } = params; diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 08402af29..1c0e6477b 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -100,8 +100,11 @@ async function setupContainer(container: ContainerDetails, params: DockerResolve }; } -export async function buildNamedImageAndExtend(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig) { - const imageName = 'image' in config ? config.image : getFolderImageName(params.common); +function getDefaultName(config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, params: DockerResolverParameters) { + return 'image' in config ? config.image : getFolderImageName(params.common); +} +export async function buildNamedImageAndExtend(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, argImageName?: string) { + const imageName = argImageName ?? getDefaultName(config, params); params.common.progress(ResolverProgress.BuildingImage); if (isDockerFileConfig(config)) { return await buildAndExtendImage(params, config, imageName, params.buildNoCache ?? false); @@ -109,6 +112,7 @@ export async function buildNamedImageAndExtend(params: DockerResolverParameters, // image-based dev container - extend return await extendImage(params, config, imageName, 'image' in config); } + async function buildAndExtendImage(buildParams: DockerResolverParameters, config: DevContainerFromDockerfileConfig, baseImageName: string, noCache: boolean) { const { cliHost, output } = buildParams.common; const dockerfileUri = getDockerfilePath(cliHost, config); @@ -158,15 +162,26 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config } const args: string[] = []; + if (!buildParams.buildKitVersion && + (buildParams.buildxPlatform || buildParams.buildxPush)) { + throw new ContainerError({ description: '--platform or --push require BuildKit enabled.', data: { fileWithError: dockerfilePath } }); + } if (buildParams.buildKitVersion) { - args.push('buildx', 'build', - '--load', // (short for --output=docker, i.e. load into normal 'docker images' collection) - '--build-arg', 'BUILDKIT_INLINE_CACHE=1', // ensure cache manifest is included in the image - ); + args.push('buildx', 'build'); + if (buildParams.buildxPlatform) { + args.push('--platform', buildParams.buildxPlatform); + } + if (buildParams.buildxPush) { + args.push('--push'); + } else { + args.push('--load'); // (short for --output=docker, i.e. load into normal 'docker images' collection) + } + args.push('--build-arg', 'BUILDKIT_INLINE_CACHE=1'); } else { args.push('build'); } args.push('-f', finalDockerfilePath, '-t', baseImageName); + const target = config.build?.target; if (target) { args.push('--target', target); diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index ea998f1a6..abcced851 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -70,6 +70,8 @@ export interface DockerResolverParameters { additionalCacheFroms: string[]; buildKitVersion: string | null; isTTY: boolean; + buildxPlatform: string | undefined; + buildxPush: boolean; } export interface ResolverResult { diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index c4f4485a0..530ff33bb 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -135,7 +135,69 @@ describe('Dev Containers CLI', function () { assert.equal(success, false, 'expect non-successful call'); }); + it('should succeed with supported --platform', async () => { + const testFolder = `${__dirname}/configs/dockerfile-with-target`; + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --platform linux/amd64`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + }); + + it('should fail --platform without dockerfile', async () => { + let success = false; + const testFolder = `${__dirname}/configs/image`; + try { + await shellExec(`${cli} build --workspace-folder ${testFolder} --platform linux/amd64`); + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + const res = JSON.parse(error.stdout); + assert.equal(res.outcome, 'error'); + assert.match(res.message, /require dockerfilePath/); + } + assert.equal(success, false, 'expect non-successful call'); + }); + + it('should fail with unsupported --platform', async () => { + let success = false; + const testFolder = `${__dirname}/configs/dockerfile-with-target`; + try { + await shellExec(`${cli} build --workspace-folder ${testFolder} --platform fake/platform`); + success = true; + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + const res = JSON.parse(error.stdout); + assert.equal(res.outcome, 'error'); + assert.match(res.message, /Command failed/); + } + assert.equal(success, false, 'expect non-successful call'); + }); + + it('should fail with BuildKit never and --platform', async () => { + let success = false; + const testFolder = `${__dirname}/configs/dockerfile-with-target`; + try { + await shellExec(`${cli} build --workspace-folder ${testFolder} --buildkit=never --platform linux/amd64`); + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + const res = JSON.parse(error.stdout); + assert.equal(res.outcome, 'error'); + assert.match(res.message, /require BuildKit enabled/); + } + assert.equal(success, false, 'expect non-successful call'); + }); + it('should fail with docker-compose and --platform not supported', async () => { + let success = false; + const testFolder = `${__dirname}/configs/compose-image-with-features`; + try { + await shellExec(`${cli} build --workspace-folder ${testFolder} --platform linux/amd64`); + } catch (error) { + assert.equal(error.error.code, 1, 'Should fail with exit code 1'); + const res = JSON.parse(error.stdout); + assert.equal(res.outcome, 'error'); + assert.match(res.message, /not supported/); + } + assert.equal(success, false, 'expect non-successful call'); + }); }); describe('Command up', () => {