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/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, 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); + 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): 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); + const images: string[] = []; + 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); + } + } + 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 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'; diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 10205c420..179b1466f 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CLIHost, runCommand, runCommandNoPty, ExecFunction, ExecParameters, Exec, PtyExecFunction, PtyExec, PtyExecParameters, plainExecAsPtyExec, PlatformInfo } from '../spec-common/commonUtils'; +import { CLIHost, runCommand, runCommandNoPty, ExecFunction, ExecParameters, Exec, PtyExecFunction, PtyExec, PtyExecParameters, plainExecAsPtyExec, PlatformInfo, GoARCH, GoOS } from '../spec-common/commonUtils'; import { toErrorText } from '../spec-common/errors'; import * as ptyType from 'node-pty'; import { Log, makeLog } from '../spec-utils/log'; @@ -426,3 +426,20 @@ export function toDockerImageName(name: string) { .replace(/[^a-z0-9\._-]+/g, '') .replace(/(\.[\._-]|_[\.-]|__[\._-]|-+[\._])[\._-]*/g, (_, a) => 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; + }; +} 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/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/); + }); + }); }); }); } diff --git a/src/test/configs/dockerfile-with-automatic-platform-args/.devcontainer.json b/src/test/configs/dockerfile-with-automatic-platform-args/.devcontainer.json new file mode 100644 index 000000000..c5c71d896 --- /dev/null +++ b/src/test/configs/dockerfile-with-automatic-platform-args/.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/Dockerfile b/src/test/configs/dockerfile-with-automatic-platform-args/Dockerfile new file mode 100644 index 000000000..4a2a53f61 --- /dev/null +++ b/src/test/configs/dockerfile-with-automatic-platform-args/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/config.toml b/src/test/configs/dockerfile-with-automatic-platform-args/config.toml new file mode 100644 index 000000000..e45c8cc2d --- /dev/null +++ b/src/test/configs/dockerfile-with-automatic-platform-args/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 diff --git a/src/test/dockerfileUtils.test.ts b/src/test/dockerfileUtils.test.ts index 979375b91..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); + }, 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'); + }); }); }); });