From adee304186af8a1e4a74963e1f6e17882803c4f7 Mon Sep 17 00:00:00 2001 From: tky2240 Date: Sun, 31 Aug 2025 16:59:38 +0000 Subject: [PATCH 1/8] add dockerfiles for test --- .../.devcontainer.json | 13 ++++++++++ .../Dockerfile | 26 +++++++++++++++++++ .../config.toml | 3 +++ .../.devcontainer.json | 10 +++++++ .../Dockerfile | 15 +++++++++++ 5 files changed, 67 insertions(+) create mode 100644 src/test/configs/dockerfile-with-automatic-platform-args copy/.devcontainer.json create mode 100644 src/test/configs/dockerfile-with-automatic-platform-args copy/Dockerfile create mode 100644 src/test/configs/dockerfile-with-automatic-platform-args copy/config.toml create mode 100644 src/test/configs/dockerfile-with-inconsistent-base-image/.devcontainer.json create mode 100644 src/test/configs/dockerfile-with-inconsistent-base-image/Dockerfile diff --git a/src/test/configs/dockerfile-with-automatic-platform-args copy/.devcontainer.json b/src/test/configs/dockerfile-with-automatic-platform-args copy/.devcontainer.json new file mode 100644 index 000000000..c5c71d896 --- /dev/null +++ b/src/test/configs/dockerfile-with-automatic-platform-args copy/.devcontainer.json @@ -0,0 +1,13 @@ +{ + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "18-bookworm" + } + }, + "features": { + "ghcr.io/devcontainers/feature-starter/hello:1": { + "greeting": "howdy" + } + } +} diff --git a/src/test/configs/dockerfile-with-automatic-platform-args copy/Dockerfile b/src/test/configs/dockerfile-with-automatic-platform-args copy/Dockerfile new file mode 100644 index 000000000..4a2a53f61 --- /dev/null +++ b/src/test/configs/dockerfile-with-automatic-platform-args copy/Dockerfile @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +ARG VARIANT="16-bullseye" +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH +ARG TARGETVARIANT + +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} AS base + +FROM --platform=amd64 base AS amd64-base +LABEL Architecture="amd64" + +FROM --platform=arm64 base AS arm64-base +LABEL Architecture="arm64" + +FROM ${TARGETARCH}-base AS final +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH +ARG TARGETVARIANT + +LABEL TargetPlatform="${TARGETPLATFORM}" +LABEL TargetOS="${TARGETOS}" +LABEL TargetArch="${TARGETARCH}" +LABEL TargetVariant="${TARGETVARIANT}" \ No newline at end of file diff --git a/src/test/configs/dockerfile-with-automatic-platform-args copy/config.toml b/src/test/configs/dockerfile-with-automatic-platform-args copy/config.toml new file mode 100644 index 000000000..e45c8cc2d --- /dev/null +++ b/src/test/configs/dockerfile-with-automatic-platform-args copy/config.toml @@ -0,0 +1,3 @@ +[registry."localhost:5000"] +http = true +insecure = true \ No newline at end of file diff --git a/src/test/configs/dockerfile-with-inconsistent-base-image/.devcontainer.json b/src/test/configs/dockerfile-with-inconsistent-base-image/.devcontainer.json new file mode 100644 index 000000000..7a9429e97 --- /dev/null +++ b/src/test/configs/dockerfile-with-inconsistent-base-image/.devcontainer.json @@ -0,0 +1,10 @@ +{ + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/feature-starter/hello:1": { + "greeting": "howdy" + } + } +} diff --git a/src/test/configs/dockerfile-with-inconsistent-base-image/Dockerfile b/src/test/configs/dockerfile-with-inconsistent-base-image/Dockerfile new file mode 100644 index 000000000..f8a66d453 --- /dev/null +++ b/src/test/configs/dockerfile-with-inconsistent-base-image/Dockerfile @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +ARG TARGETARCH + +FROM mcr.microsoft.com/devcontainers/typescript-node:1-16-bullseye AS base-1 + +FROM mcr.microsoft.com/devcontainers/typescript-node:1-18-bookworm AS base-2 + +FROM --platform=amd64 base-1 AS amd64-base +LABEL Architecture="amd64" + +FROM --platform=arm64 base-2 AS arm64-base +LABEL Architecture="arm64" + +FROM ${TARGETARCH}-base AS final From 8e24e48bfb52f2341d652197799c4d043aa73bb2 Mon Sep 17 00:00:00 2001 From: tky2240 Date: Sun, 31 Aug 2025 17:00:03 +0000 Subject: [PATCH 2/8] add unknow os and arch type for manifest --- src/spec-common/commonUtils.ts | 4 ++-- src/spec-node/imageMetadata.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/spec-common/commonUtils.ts b/src/spec-common/commonUtils.ts index c74b3c67f..952211146 100644 --- a/src/spec-common/commonUtils.ts +++ b/src/spec-common/commonUtils.ts @@ -41,8 +41,8 @@ export interface ExecFunction { (params: ExecParameters): Promise; } -export type GoOS = { [OS in NodeJS.Platform]: OS extends 'win32' ? 'windows' : OS; }[NodeJS.Platform]; -export type GoARCH = { [ARCH in NodeJS.Architecture]: ARCH extends 'x64' ? 'amd64' : ARCH; }[NodeJS.Architecture]; +export type GoOS = { [OS in NodeJS.Platform]: OS extends 'win32' ? 'windows' : OS; }[NodeJS.Platform] | 'unknown'; +export type GoARCH = { [ARCH in NodeJS.Architecture]: ARCH extends 'x64' ? 'amd64' : ARCH; }[NodeJS.Architecture] | 'unknown'; export interface PlatformInfo { os: GoOS; diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index cb3cc5fa6..834b1db31 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { PlatformInfo } from '../spec-common/commonUtils'; import { ContainerError } from '../spec-common/errors'; import { LifecycleCommand, LifecycleHooksInstallMap } from '../spec-common/injectHeadless'; import { DevContainerConfig, DevContainerConfigCommand, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostGPURequirements, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration'; @@ -394,16 +395,20 @@ export async function getImageBuildInfoFromImage(params: DockerResolverParameter export async function getImageBuildInfoFromDockerfile(params: DockerResolverParameters | DockerCLIParameters, dockerfile: string, dockerBuildArgs: Record, targetStage: string | undefined, substitute: SubstituteConfig) { const { output } = 'output' in params ? params : params.common; const omitSyntaxDirective = 'common' in params ? !!params.common.omitSyntaxDirective : false; - return internalGetImageBuildInfoFromDockerfile(imageName => inspectDockerImage(params, imageName, true), dockerfile, dockerBuildArgs, targetStage, substitute, output, omitSyntaxDirective); + return internalGetImageBuildInfoFromDockerfile(imageName => inspectDockerImage(params, imageName, true), dockerfile, dockerBuildArgs, targetStage, substitute, output, omitSyntaxDirective, params.platformInfo); } -export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage: (imageName: string) => Promise, dockerfileText: string, dockerBuildArgs: Record, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean): Promise { +export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage: (imageName: string) => Promise, dockerfileText: string, dockerBuildArgs: Record, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean, platformInfo: PlatformInfo): Promise { const dockerfile = extractDockerfile(dockerfileText); if (dockerfile.preamble.directives.syntax && omitSyntaxDirective) { output.write(`Omitting syntax directive '${dockerfile.preamble.directives.syntax}' from Dockerfile.`, LogLevel.Trace); delete dockerfile.preamble.directives.syntax; } - const baseImage = findBaseImage(dockerfile, dockerBuildArgs, targetStage); + const baseImage = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platformInfo); + const dummyBaseImage = findBaseImage(dockerfile, dockerBuildArgs, targetStage, { os: 'unknown', arch: 'unknown' }); + if (baseImage !== dummyBaseImage) { + throw new Error(`Inconsistent base image used for multi-platform builds. Please check your Dockerfile.`); + } const imageDetails = baseImage && await inspectDockerImage(baseImage) || undefined; const dockerfileUser = findUserStatement(dockerfile, dockerBuildArgs, envListToObj(imageDetails?.Config.Env), targetStage); const user = dockerfileUser || imageDetails?.Config.User || 'root'; From 345bcb88646489448e35251fb16419de0df9aa6b Mon Sep 17 00:00:00 2001 From: tky2240 Date: Sun, 31 Aug 2025 17:00:34 +0000 Subject: [PATCH 3/8] partial support multi-platform build --- src/spec-node/containerFeatures.ts | 2 +- src/spec-node/devContainers.ts | 4 ++-- src/spec-node/dockerfileUtils.ts | 18 ++++++++++++++++-- src/spec-shutdown/dockerUtils.ts | 19 ++++++++++++++++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index c7e42a56a..804853a21 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -265,7 +265,7 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; const omitSyntaxDirective = common.omitSyntaxDirective; // Can be removed when https://github.com/moby/buildkit/issues/4556 is fixed const dockerfilePrefixContent = `${omitSyntaxDirective ? '' : - useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.4' : + useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.11' : syntax ? `# syntax=${syntax}` : ''} ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder `; diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 493d0dc22..af19658fd 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -172,7 +172,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: }, dockerPath, dockerComposePath); const platformInfo = (() => { - if (common.buildxPlatform) { + if (common.buildxPlatform && common.buildxPlatform.split(',').length === 1) { const slash1 = common.buildxPlatform.indexOf('/'); const slash2 = common.buildxPlatform.indexOf('/', slash1 + 1); // `--platform linux/amd64/v3` `--platform linux/arm64/v8` @@ -189,7 +189,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: arch: common.buildxPlatform.slice(slash1 + 1), }; } else { - // `--platform` omitted + // `--platform` omitted or multiple platforms return { os: mapNodeOSToGOOS(cliHost.platform), arch: mapNodeArchitectureToGOARCH(cliHost.arch), diff --git a/src/spec-node/dockerfileUtils.ts b/src/spec-node/dockerfileUtils.ts index 2a07023aa..61d2ee688 100644 --- a/src/spec-node/dockerfileUtils.ts +++ b/src/spec-node/dockerfileUtils.ts @@ -5,6 +5,7 @@ import * as semver from 'semver'; import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; +import { PlatformInfo } from '../spec-common/commonUtils'; const findFromLines = new RegExp(/^(?\s*FROM.*)/, 'gmi'); @@ -100,7 +101,7 @@ export function findUserStatement(dockerfile: Dockerfile, buildArgs: Record, target: string | undefined) { +export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record, target: string | undefined, platformInfo: PlatformInfo) { let stage: Stage | undefined = target ? dockerfile.stagesByLabel[target] : dockerfile.stages[dockerfile.stages.length - 1]; const seen = new Set(); while (stage) { @@ -108,7 +109,20 @@ export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record a.substr(0, a.length - 1)); } + +export interface ManifestDetail { + readonly schemaVersion: number; + readonly mediaType: string; + readonly manifests: readonly Manifest[]; +} + +export interface Manifest { + readonly mediaType: string; + readonly size: number; + readonly digest: string; + readonly platform: { + readonly architecture: GoARCH; + readonly os: GoOS; + readonly variant?: string; + }; +} From a0e609045317ddded21aa91c7891cce52da714b6 Mon Sep 17 00:00:00 2001 From: tky2240 Date: Sun, 31 Aug 2025 17:00:41 +0000 Subject: [PATCH 4/8] add tests --- src/test/cli.build.test.ts | 69 +++++++++++++++++++++++++++++++- src/test/dockerfileUtils.test.ts | 56 +++++++++++++++++++------- 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/src/test/cli.build.test.ts b/src/test/cli.build.test.ts index 0dfae0427..3d1e2f60a 100644 --- a/src/test/cli.build.test.ts +++ b/src/test/cli.build.test.ts @@ -8,7 +8,7 @@ import * as assert from 'assert'; import * as path from 'path'; import * as os from 'os'; import { buildKitOptions, shellExec } from './testUtils'; -import { ImageDetails } from '../spec-shutdown/dockerUtils'; +import { ImageDetails, ManifestDetail } from '../spec-shutdown/dockerUtils'; import { envListToObj } from '../spec-node/utils'; const pkg = require('../../package.json'); @@ -433,5 +433,72 @@ describe('Dev Containers CLI', function () { const details = JSON.parse((await shellExec(`docker inspect ${response.imageName}`)).stdout)[0] as ImageDetails; assert.strictEqual(details.Config.Labels?.test_build_options, 'success'); }); + + it(`should build successfully with platform args container builder`, async () => { + const builderName = 'test-container-builder'; + const registryName = 'test-registry'; + const imageName = `localhost:5000/test:latest`; + try { + await shellExec(`docker run -d --name ${registryName} -p 5000:5000 registry`); + const testFolder = `${__dirname}/configs/dockerfile-with-automatic-platform-args`; + await shellExec(`docker buildx create --name ${builderName} --driver docker-container --use --driver-opt network=host --config ${testFolder}/config.toml`); + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --log-level trace --platform linux/arm64,linux/amd64 --push --image-name ${imageName}`); + console.log(res.stdout); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + const details = JSON.parse((await shellExec(`docker manifest inspect --insecure ${imageName}`)).stdout) as ManifestDetail; + + const osSet = new Set(details.manifests.map(manifest => manifest.platform.os)); + assert.ok(osSet.has('linux'), 'Expected linux OS to be present'); + + const archSet = new Set(details.manifests.map(manifest => manifest.platform.architecture)); + assert.ok(archSet.has('arm64'), 'Expected linux/arm64 architecture to be present'); + assert.ok(archSet.has('amd64'), 'Expected linux/amd64 architecture to be present'); + + const amd64Manifest = details.manifests.find(manifest => manifest.platform.architecture === 'amd64'); + assert.ok(amd64Manifest, 'Expected linux/amd64 manifest to be present'); + + await shellExec(`docker pull ${imageName}@${amd64Manifest.digest}`); + const amd64Details = JSON.parse((await shellExec(`docker inspect ${imageName}@${amd64Manifest.digest}`)).stdout)[0] as ImageDetails; + assert.strictEqual(amd64Details.Config.Labels?.Architecture, 'amd64'); + assert.strictEqual(amd64Details.Config.Labels?.TargetPlatform, 'linux/amd64'); + assert.strictEqual(amd64Details.Config.Labels?.TargetOS, 'linux'); + assert.strictEqual(amd64Details.Config.Labels?.TargetArch, 'amd64'); + assert.strictEqual(amd64Details.Config.Labels?.TargetVariant, ''); + + const arm64Manifest = details.manifests.find(manifest => manifest.platform.architecture === 'arm64'); + assert.ok(arm64Manifest, 'Expected linux/arm64 manifest to be present'); + + await shellExec(`docker pull ${imageName}@${arm64Manifest.digest}`); + const arm64Details = JSON.parse((await shellExec(`docker inspect ${imageName}@${arm64Manifest.digest}`)).stdout)[0] as ImageDetails; + assert.strictEqual(arm64Details.Config.Labels?.Architecture, 'arm64'); + assert.strictEqual(arm64Details.Config.Labels?.TargetPlatform, 'linux/arm64'); + assert.strictEqual(arm64Details.Config.Labels?.TargetOS, 'linux'); + assert.strictEqual(arm64Details.Config.Labels?.TargetArch, 'arm64'); + assert.strictEqual(arm64Details.Config.Labels?.TargetVariant, ''); + + } finally { + await shellExec(`docker rm -f ${registryName}`); + await shellExec(`docker buildx rm ${builderName}`); + } + }); + it(`should fail with inconsistent base images`, async () => { + const builderName = 'test-container-builder'; + try { + await shellExec(`docker buildx create --name ${builderName} --driver docker-container --use`); + const testFolder = `${__dirname}/configs/dockerfile-with-inconsistent-base-image`; + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --log-level trace --platform linux/arm64,linux/amd64`); + console.log(res.stdout); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + } 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, /Inconsistent base image used for multi-platform builds. Please check your Dockerfile./); + } finally { + await shellExec(`docker buildx rm ${builderName}`); + } + }); }); }); diff --git a/src/test/dockerfileUtils.test.ts b/src/test/dockerfileUtils.test.ts index 979375b91..ce92c8aa8 100644 --- a/src/test/dockerfileUtils.test.ts +++ b/src/test/dockerfileUtils.test.ts @@ -178,7 +178,7 @@ FROM ubuntu:latest as dev const info = await internalGetImageBuildInfoFromDockerfile(async (imageName) => { assert.strictEqual(imageName, 'ubuntu:latest'); return details; - }, dockerfile, {}, undefined, testSubstitute, nullLog, false); + }, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }); assert.strictEqual(info.user, 'imageUser'); assert.strictEqual(info.metadata.config.length, 1); assert.strictEqual(info.metadata.config[0].id, 'testid-substituted'); @@ -206,7 +206,7 @@ USER dockerfileUserB const info = await internalGetImageBuildInfoFromDockerfile(async (imageName) => { assert.strictEqual(imageName, 'ubuntu:latest'); return details; - }, dockerfile, {}, undefined, testSubstitute, nullLog, false); + }, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }); assert.strictEqual(info.user, 'dockerfileUserB'); assert.strictEqual(info.metadata.config.length, 0); assert.strictEqual(info.metadata.raw.length, 0); @@ -220,7 +220,7 @@ describe('findBaseImage', () => { USER user1 `; const extracted = extractDockerfile(dockerfile); - const image = findBaseImage(extracted, {}, undefined); + const image = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'image1'); }); @@ -231,7 +231,7 @@ ARG IMAGE_USER=user2 USER $IMAGE_USER `; const extracted = extractDockerfile(dockerfile); - const image = findBaseImage(extracted, {}, undefined); + const image = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'image2'); }); @@ -244,7 +244,7 @@ USER $IMAGE_USER const extracted = extractDockerfile(dockerfile); const image = findBaseImage(extracted, { 'BASE_IMAGE': 'image3' - }, undefined); + }, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'image3'); }); @@ -256,7 +256,7 @@ FROM image3 as stage3 FROM image4 as stage4 `; const extracted = extractDockerfile(dockerfile); - const image = findBaseImage(extracted, {}, 'stage2'); + const image = findBaseImage(extracted, {}, 'stage2', { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'image3'); }); @@ -268,7 +268,7 @@ FROM "\${BASE_IMAGE}" `; const extracted = extractDockerfile(dockerfile); assert.strictEqual(extracted.stages.length, 1); - const image = findBaseImage(extracted, {}, undefined); + const image = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'ubuntu:latest'); }); @@ -282,7 +282,7 @@ FROM \${cloud:+mcr.microsoft.com/}azure-cli:latest assert.strictEqual(extracted.stages.length, 1); const image = findBaseImage(extracted, { 'cloud': 'true' - }, undefined); + }, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'mcr.microsoft.com/azure-cli:latest'); }); @@ -293,7 +293,7 @@ FROM \${cloud:+mcr.microsoft.com/}azure-cli:latest `; const extracted = extractDockerfile(dockerfile); assert.strictEqual(extracted.stages.length, 1); - const image = findBaseImage(extracted, {}, undefined); + const image = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'azure-cli:latest'); }); @@ -306,7 +306,7 @@ FROM \${cloud:-mcr.microsoft.com/}azure-cli:latest assert.strictEqual(extracted.stages.length, 1); const image = findBaseImage(extracted, { 'cloud': 'ghcr.io/' - }, undefined); + }, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'ghcr.io/azure-cli:latest'); }); @@ -317,7 +317,7 @@ FROM \${cloud:-mcr.microsoft.com/}azure-cli:latest `; const extracted = extractDockerfile(dockerfile); assert.strictEqual(extracted.stages.length, 1); - const image = findBaseImage(extracted, {}, undefined); + const image = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'mcr.microsoft.com/azure-cli:latest'); }); @@ -334,7 +334,8 @@ FROM \${cloud:+"mcr.microsoft.com/"}azure-cli:latest" { cloud: 'true', }, - undefined + undefined, + { os: 'linux', arch: 'amd64' } ); assert.strictEqual(image, 'mcr.microsoft.com/azure-cli:latest'); }); @@ -347,7 +348,7 @@ FROM "\${cloud:+"mcr.microsoft.com/"}azure-cli:latest" const extracted = extractDockerfile(dockerfile); assert.strictEqual(extracted.stages.length, 1); - const image = findBaseImage(extracted, {}, undefined); + const image = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'azure-cli:latest'); }); @@ -364,7 +365,8 @@ FROM "\${cloud:-"mcr.microsoft.com/"}azure-cli:latest" { cloud: 'ghcr.io/', }, - undefined + undefined, + { os: 'linux', arch: 'amd64' } ); assert.strictEqual(image, 'ghcr.io/azure-cli:latest'); }); @@ -377,9 +379,33 @@ FROM \${cloud:-"mcr.microsoft.com/"}azure-cli:latest as label const extracted = extractDockerfile(dockerfile); assert.strictEqual(extracted.stages.length, 1); - const image = findBaseImage(extracted, {}, undefined); + const image = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); assert.strictEqual(image, 'mcr.microsoft.com/azure-cli:latest'); }); + + it('Multi-platform build', async () => { + const dockerfile = ` +ARG TARGETARCH +FROM image1 AS base-1 + +FROM image2 AS base-2 + +FROM --platform=amd64 base-1 AS amd64-base +LABEL Architecture="amd64" + +FROM --platform=arm64 base-2 AS arm64-base +LABEL Architecture="arm64" + +FROM \${TARGETARCH}-base AS final +`; + + const extracted = extractDockerfile(dockerfile); + assert.strictEqual(extracted.stages.length, 5); + const amd64BaseImage = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'amd64' }); + assert.strictEqual(amd64BaseImage, 'image1'); + const arm64BaseImage = findBaseImage(extracted, {}, undefined, { os: 'linux', arch: 'arm64' }); + assert.strictEqual(arm64BaseImage, 'image2'); + }); }); }); }); From 01d301b61e3d5786da5457330b89b016f4c22ed0 Mon Sep 17 00:00:00 2001 From: tky2240 Date: Sun, 31 Aug 2025 18:31:24 +0000 Subject: [PATCH 5/8] move to correct directory --- .../.devcontainer.json | 0 .../Dockerfile | 0 .../config.toml | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/test/configs/{dockerfile-with-automatic-platform-args copy => dockerfile-with-automatic-platform-args}/.devcontainer.json (100%) rename src/test/configs/{dockerfile-with-automatic-platform-args copy => dockerfile-with-automatic-platform-args}/Dockerfile (100%) rename src/test/configs/{dockerfile-with-automatic-platform-args copy => dockerfile-with-automatic-platform-args}/config.toml (100%) diff --git a/src/test/configs/dockerfile-with-automatic-platform-args copy/.devcontainer.json b/src/test/configs/dockerfile-with-automatic-platform-args/.devcontainer.json similarity index 100% rename from src/test/configs/dockerfile-with-automatic-platform-args copy/.devcontainer.json rename to src/test/configs/dockerfile-with-automatic-platform-args/.devcontainer.json diff --git a/src/test/configs/dockerfile-with-automatic-platform-args copy/Dockerfile b/src/test/configs/dockerfile-with-automatic-platform-args/Dockerfile similarity index 100% rename from src/test/configs/dockerfile-with-automatic-platform-args copy/Dockerfile rename to src/test/configs/dockerfile-with-automatic-platform-args/Dockerfile diff --git a/src/test/configs/dockerfile-with-automatic-platform-args copy/config.toml b/src/test/configs/dockerfile-with-automatic-platform-args/config.toml similarity index 100% rename from src/test/configs/dockerfile-with-automatic-platform-args copy/config.toml rename to src/test/configs/dockerfile-with-automatic-platform-args/config.toml From 8ebbbb509b243c728b00aa4f27d9eee4d298a37e Mon Sep 17 00:00:00 2001 From: tky2240 Date: Sun, 31 Aug 2025 19:01:17 +0000 Subject: [PATCH 6/8] fix checking inconsistence --- src/spec-node/imageMetadata.ts | 38 ++++++++++++++++++++++++++------ src/test/dockerfileUtils.test.ts | 4 ++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index 834b1db31..eb287e219 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PlatformInfo } from '../spec-common/commonUtils'; +import { GoARCH, GoOS, PlatformInfo } from '../spec-common/commonUtils'; import { ContainerError } from '../spec-common/errors'; import { LifecycleCommand, LifecycleHooksInstallMap } from '../spec-common/injectHeadless'; import { DevContainerConfig, DevContainerConfigCommand, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostGPURequirements, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration'; @@ -395,21 +395,45 @@ export async function getImageBuildInfoFromImage(params: DockerResolverParameter export async function getImageBuildInfoFromDockerfile(params: DockerResolverParameters | DockerCLIParameters, dockerfile: string, dockerBuildArgs: Record, targetStage: string | undefined, substitute: SubstituteConfig) { const { output } = 'output' in params ? params : params.common; const omitSyntaxDirective = 'common' in params ? !!params.common.omitSyntaxDirective : false; - return internalGetImageBuildInfoFromDockerfile(imageName => inspectDockerImage(params, imageName, true), dockerfile, dockerBuildArgs, targetStage, substitute, output, omitSyntaxDirective, params.platformInfo); + const buildxPlatform = 'common' in params ? params.common.buildxPlatform : undefined; + const buildxPlatforms = buildxPlatform?.split(',').map(platform => { + const slash1 = platform.indexOf('/'); + const slash2 = platform.indexOf('/', slash1 + 1); + // `--platform linux/amd64/v3` `--platform linux/arm64/v8` + if (slash2 !== -1) { + return { + os: platform.slice(0, slash1), + arch: platform.slice(slash1 + 1, slash2), + variant: platform.slice(slash2 + 1), + }; + } + // `--platform linux/amd64` and `--platform linux/arm64` + return { + os: platform.slice(0, slash1), + arch: platform.slice(slash1 + 1), + }; + }) ?? [] satisfies PlatformInfo[]; + return internalGetImageBuildInfoFromDockerfile(imageName => inspectDockerImage(params, imageName, true), dockerfile, dockerBuildArgs, targetStage, substitute, output, omitSyntaxDirective, params.platformInfo, buildxPlatforms); } -export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage: (imageName: string) => Promise, dockerfileText: string, dockerBuildArgs: Record, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean, platformInfo: PlatformInfo): Promise { +export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage: (imageName: string) => Promise, dockerfileText: string, dockerBuildArgs: Record, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean, platformInfo: PlatformInfo, buildxPlatforms: PlatformInfo[]): Promise { const dockerfile = extractDockerfile(dockerfileText); if (dockerfile.preamble.directives.syntax && omitSyntaxDirective) { output.write(`Omitting syntax directive '${dockerfile.preamble.directives.syntax}' from Dockerfile.`, LogLevel.Trace); delete dockerfile.preamble.directives.syntax; } - const baseImage = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platformInfo); - const dummyBaseImage = findBaseImage(dockerfile, dockerBuildArgs, targetStage, { os: 'unknown', arch: 'unknown' }); - if (baseImage !== dummyBaseImage) { + const images: string[] = []; + for (const platform of buildxPlatforms) { + const image = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platform); + if (image) { + images.push(image); + } + } + if (images.length !== 0 && !images.every(image => image === images[0])) { throw new Error(`Inconsistent base image used for multi-platform builds. Please check your Dockerfile.`); } - const imageDetails = baseImage && await inspectDockerImage(baseImage) || undefined; + const image = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platformInfo); + const imageDetails = image && await inspectDockerImage(image) || undefined; const dockerfileUser = findUserStatement(dockerfile, dockerBuildArgs, envListToObj(imageDetails?.Config.Env), targetStage); const user = dockerfileUser || imageDetails?.Config.User || 'root'; const metadata = imageDetails ? getImageMetadata(imageDetails, substitute, output) : { config: [], raw: [], substitute }; diff --git a/src/test/dockerfileUtils.test.ts b/src/test/dockerfileUtils.test.ts index ce92c8aa8..47c5eb538 100644 --- a/src/test/dockerfileUtils.test.ts +++ b/src/test/dockerfileUtils.test.ts @@ -178,7 +178,7 @@ FROM ubuntu:latest as dev const info = await internalGetImageBuildInfoFromDockerfile(async (imageName) => { assert.strictEqual(imageName, 'ubuntu:latest'); return details; - }, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }); + }, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }, []); assert.strictEqual(info.user, 'imageUser'); assert.strictEqual(info.metadata.config.length, 1); assert.strictEqual(info.metadata.config[0].id, 'testid-substituted'); @@ -206,7 +206,7 @@ USER dockerfileUserB const info = await internalGetImageBuildInfoFromDockerfile(async (imageName) => { assert.strictEqual(imageName, 'ubuntu:latest'); return details; - }, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }); + }, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }, []); assert.strictEqual(info.user, 'dockerfileUserB'); assert.strictEqual(info.metadata.config.length, 0); assert.strictEqual(info.metadata.raw.length, 0); From cc43c4f10469a9eaf116fa084c9f54c0fa4e9b0e Mon Sep 17 00:00:00 2001 From: tky2240 Date: Sun, 31 Aug 2025 19:01:32 +0000 Subject: [PATCH 7/8] add exec test --- src/test/cli.exec.base.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/test/cli.exec.base.ts b/src/test/cli.exec.base.ts index 10e876595..9a7a24fc2 100644 --- a/src/test/cli.exec.base.ts +++ b/src/test/cli.exec.base.ts @@ -406,6 +406,18 @@ export function describeTests2({ text, options }: BuildKitOption) { await shellExec(`docker rm -f ${response.containerId}`); }); + + describe(`with valid (Dockerfile) multi-platform build config containing features [${text}]`, () => { + let containerId: string | null = null; + const testFolder = `${__dirname}/configs/dockerfile-with-automatic-platform-args`; + beforeEach(async () => containerId = (await devContainerUp(cli, testFolder, options)).containerId); + afterEach(async () => await devContainerDown({ containerId })); + it('should have access to installed features (hello)', async () => { + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} hello`); + assert.strictEqual(res.error, null); + assert.match(res.stdout, /howdy, node/); + }); + }); }); }); } From 7e1349eac24dc28649d14234bed372c200fcb9fe Mon Sep 17 00:00:00 2001 From: tky2240 Date: Mon, 1 Sep 2025 13:43:50 +0000 Subject: [PATCH 8/8] consider cases that not match host platform and target platform --- src/spec-node/imageMetadata.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index eb287e219..ae83def33 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -423,8 +423,15 @@ export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage delete dockerfile.preamble.directives.syntax; } const images: string[] = []; - for (const platform of buildxPlatforms) { - const image = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platform); + if (buildxPlatforms.length > 0) { + for (const platform of buildxPlatforms) { + const image = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platform); + if (image) { + images.push(image); + } + } + } else { + const image = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platformInfo); if (image) { images.push(image); } @@ -432,8 +439,8 @@ export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage if (images.length !== 0 && !images.every(image => image === images[0])) { throw new Error(`Inconsistent base image used for multi-platform builds. Please check your Dockerfile.`); } - const image = findBaseImage(dockerfile, dockerBuildArgs, targetStage, platformInfo); - const imageDetails = image && await inspectDockerImage(image) || undefined; + const baseImage = images.at(0); + const imageDetails = baseImage && await inspectDockerImage(baseImage) || undefined; const dockerfileUser = findUserStatement(dockerfile, dockerBuildArgs, envListToObj(imageDetails?.Config.Env), targetStage); const user = dockerfileUser || imageDetails?.Config.User || 'root'; const metadata = imageDetails ? getImageMetadata(imageDetails, substitute, output) : { config: [], raw: [], substitute };