Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/spec-common/commonUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export interface ExecFunction {
(params: ExecParameters): Promise<Exec>;
}

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;
Expand Down
2 changes: 1 addition & 1 deletion src/spec-node/containerFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`;
Expand Down
4 changes: 2 additions & 2 deletions src/spec-node/devContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -189,7 +189,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
arch: <GoARCH> common.buildxPlatform.slice(slash1 + 1),
};
} else {
// `--platform` omitted
// `--platform` omitted or multiple platforms
return {
os: mapNodeOSToGOOS(cliHost.platform),
arch: mapNodeArchitectureToGOARCH(cliHost.arch),
Expand Down
18 changes: 16 additions & 2 deletions src/spec-node/dockerfileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^(?<line>\s*FROM.*)/, 'gmi');
Expand Down Expand Up @@ -100,15 +101,28 @@ export function findUserStatement(dockerfile: Dockerfile, buildArgs: Record<stri
return undefined;
}

export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record<string, string>, target: string | undefined) {
export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record<string, string>, target: string | undefined, platformInfo: PlatformInfo) {
let stage: Stage | undefined = target ? dockerfile.stagesByLabel[target] : dockerfile.stages[dockerfile.stages.length - 1];
const seen = new Set<Stage>();
while (stage) {
if (seen.has(stage)) {
return undefined;
}
seen.add(stage);

if (stage.from.image.includes('$')) {
buildArgs = {
...buildArgs,
TARGETPLATFORM: platformInfo.variant ? `${platformInfo.arch}/${platformInfo.os}/${platformInfo.variant}` : `${platformInfo.arch}/${platformInfo.os}`,
TARGETOS: platformInfo.os,
TARGETARCH: platformInfo.arch,
};
if (platformInfo.variant) {
buildArgs = {
...buildArgs,
TARGETVARIANT: platformInfo.variant,
};
}
}
const image = replaceVariables(dockerfile, buildArgs, /* not available in FROM instruction */ {}, stage.from.image, dockerfile.preamble, dockerfile.preamble.instructions.length);
const nextStage = dockerfile.stagesByLabel[image];
if (!nextStage) {
Expand Down
42 changes: 39 additions & 3 deletions src/spec-node/imageMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

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';
Expand Down Expand Up @@ -394,16 +395,51 @@ export async function getImageBuildInfoFromImage(params: DockerResolverParameter
export async function getImageBuildInfoFromDockerfile(params: DockerResolverParameters | DockerCLIParameters, dockerfile: string, dockerBuildArgs: Record<string, string>, 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: <GoOS>platform.slice(0, slash1),
arch: <GoARCH>platform.slice(slash1 + 1, slash2),
variant: platform.slice(slash2 + 1),
};
}
// `--platform linux/amd64` and `--platform linux/arm64`
return {
os: <GoOS>platform.slice(0, slash1),
arch: <GoARCH>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<ImageDetails>, dockerfileText: string, dockerBuildArgs: Record<string, string>, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean): Promise<ImageBuildInfo> {
export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage: (imageName: string) => Promise<ImageDetails>, dockerfileText: string, dockerBuildArgs: Record<string, string>, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean, platformInfo: PlatformInfo, buildxPlatforms: PlatformInfo[]): Promise<ImageBuildInfo> {
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';
Expand Down
19 changes: 18 additions & 1 deletion src/spec-shutdown/dockerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
};
}
69 changes: 68 additions & 1 deletion src/test/cli.build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}`);
}
});
});
});
12 changes: 12 additions & 0 deletions src/test/cli.exec.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"build": {
"dockerfile": "Dockerfile",
"args": {
"VARIANT": "18-bookworm"
}
},
"features": {
"ghcr.io/devcontainers/feature-starter/hello:1": {
"greeting": "howdy"
}
}
}
Original file line number Diff line number Diff line change
@@ -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}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[registry."localhost:5000"]
http = true
insecure = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"build": {
"dockerfile": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/feature-starter/hello:1": {
"greeting": "howdy"
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Loading