From e0b0406ae862c265e91b2cb28cf67dae258638b3 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Sun, 7 Jun 2026 02:59:53 +0200 Subject: [PATCH] Add --platform argument to `devcontainer up` --- src/spec-node/devContainers.ts | 34 ++++----------- src/spec-node/devContainersSpecCLI.ts | 4 +- src/spec-node/singleContainer.ts | 49 ++++++++++++++++++++- src/spec-node/utils.ts | 22 +++++++++- src/test/utils.test.ts | 62 ++++++++++++++++++++++++++- 5 files changed, 139 insertions(+), 32 deletions(-) diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index d647bb614..4a82d0872 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -8,9 +8,9 @@ import * as crypto from 'crypto'; import * as os from 'os'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; -import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability } from './utils'; +import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability, platformInfoFromBuildxPlatform } from './utils'; import { createNullLifecycleHook, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless'; -import { GoARCH, GoOS, getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; +import { getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; import { resolve } from './configContainer'; import { URI } from 'vscode-uri'; import { LogLevel, LogDimensions, toErrorText, createCombinedLog, createTerminalLog, Log, makeLog, LogFormat, createJSONLog, createPlainLog, LogHandler, replaceAllLog } from '../spec-utils/log'; @@ -177,31 +177,13 @@ export async function createDockerParams(options: ProvisionOptions, disposables: arch: mapNodeArchitectureToGOARCH(cliHost.arch), }; - const targetPlatformInfo = (() => { - if (common.buildxPlatform) { - const slash1 = common.buildxPlatform.indexOf('/'); - const slash2 = common.buildxPlatform.indexOf('/', slash1 + 1); - // `--platform linux/amd64/v3` `--platform linux/arm64/v8` - if (slash2 !== -1) { - return { - os: common.buildxPlatform.slice(0, slash1), - arch: common.buildxPlatform.slice(slash1 + 1, slash2), - variant: common.buildxPlatform.slice(slash2 + 1), - }; - } - // `--platform linux/amd64` and `--platform linux/arm64` - return { - os: common.buildxPlatform.slice(0, slash1), - arch: common.buildxPlatform.slice(slash1 + 1), - }; - } else { + const targetPlatformInfo = common.buildxPlatform ? + platformInfoFromBuildxPlatform(common.buildxPlatform) : + { // `--platform` omitted - return { - os: mapNodeOSToGOOS(cliHost.platform), - arch: mapNodeArchitectureToGOARCH(cliHost.arch), - }; - } - })(); + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + }; const buildKitVersion = options.useBuildKit === 'never' ? undefined : (await dockerBuildKitVersion({ cliHost, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 832e9603f..4bd84db3e 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -129,6 +129,7 @@ function provisionOptions(y: Argv) { 'cache-from': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, 'cache-to': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, '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 platform (e.g. linux/amd64). Used to resolve, pull and build the base image.' }, 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, 'skip-post-attach': { type: 'boolean', default: false, description: 'Do not run postAttachCommand.' }, @@ -213,6 +214,7 @@ async function provision({ 'cache-from': addCacheFrom, 'cache-to': addCacheTo, 'buildkit': buildkit, + 'platform': buildxPlatform, 'additional-features': additionalFeaturesJson, 'skip-feature-auto-mapping': skipFeatureAutoMapping, 'skip-post-attach': skipPostAttach, @@ -287,7 +289,7 @@ async function provision({ secretsP, additionalCacheFroms: addCacheFroms, useBuildKit: buildkit, - buildxPlatform: undefined, + buildxPlatform, buildxPush: false, additionalLabels: [], buildxOutput: undefined, diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 1c3669f74..2f43805c8 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ -import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, getDockerfilePath, getDockerContextPath, DockerResolverParameters, isDockerFileConfig, uriToWSLFsPath, WorkspaceConfiguration, getFolderImageName, inspectDockerImage, logUMask, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError, isBuildxCacheToInline } from './utils'; +import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, getDockerfilePath, getDockerContextPath, DockerResolverParameters, isDockerFileConfig, uriToWSLFsPath, WorkspaceConfiguration, getFolderImageName, inspectDockerImage, logUMask, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError, isBuildxCacheToInline, platformInfoFromBuildxPlatform } from './utils'; import { ContainerProperties, setupInContainer, ResolverProgress, ResolverParameters } from '../spec-common/injectHeadless'; import { ContainerError, toErrorText } from '../spec-common/errors'; import { ContainerDetails, listContainers, DockerCLIParameters, inspectContainers, dockerCLI, dockerPtyCLI, toPtyExecParameters, ImageDetails, toExecParameters, removeContainer } from '../spec-shutdown/dockerUtils'; @@ -21,6 +21,17 @@ export async function openDockerfileDevContainer(params: DockerResolverParameter const { common } = params; const { config } = configWithRaw; + // Image-based dev containers have no `build.options` to carry a platform, so when the --platform flag is + // not set, fall back to the platform from runArgs (e.g. ["--platform=linux/amd64"]) to resolve the image + // for the same platform it will run on. Only the resolve platform is affected here; runArgs already pins + // `docker run`. Dockerfile builds are left untouched (they specify platform via build.options or --platform). + if (!params.buildxPlatform && !isDockerFileConfig(config)) { + const runArgsPlatform = findPlatformArg(config.runArgs); + if (runArgsPlatform) { + params.targetPlatformInfo = platformInfoFromBuildxPlatform(runArgsPlatform); + } + } + let container: ContainerDetails | undefined; let containerProperties: ContainerProperties | undefined; @@ -285,6 +296,35 @@ export function findUserArg(runArgs: string[] = []) { return undefined; } +export function findPlatformArg(runArgs: string[] = []) { + for (let i = runArgs.length - 1; i >= 0; i--) { + const runArg = runArgs[i]; + if (runArg === '--platform' && i + 1 < runArgs.length) { + return runArgs[i + 1]; + } + if (runArg.startsWith('--platform=')) { + return runArg.slice(runArg.indexOf('=') + 1); + } + } + return undefined; +} + +export function removePlatformArg(runArgs: string[] = []) { + const result: string[] = []; + for (let i = 0; i < runArgs.length; i++) { + const runArg = runArgs[i]; + if (runArg === '--platform') { + i++; // Skip the following value as well. + continue; + } + if (runArg.startsWith('--platform=')) { + continue; + } + result.push(runArg); + } + return result; +} + export async function findExistingContainer(params: DockerResolverParameters, labels: string[]) { const { common } = params; let container = await findDevContainer(params, labels); @@ -359,6 +399,11 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const containerUserArgs = containerUser ? ['-u', containerUser] : []; + // The --platform flag (params.buildxPlatform) takes precedence over any --platform in runArgs. + const runArgs = params.buildxPlatform ? + ['--platform', params.buildxPlatform, ...removePlatformArg(config.runArgs)] : + (config.runArgs || []); + const featureArgs: string[] = []; if (mergedConfig.init) { featureArgs.push('--init'); @@ -407,7 +452,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t ...containerEnv, ...containerUserArgs, ...await getPodmanArgs(params, config, mergedConfig, imageDetails), - ...(config.runArgs || []), + ...runArgs, ...(await extraRunArgs(common, params, config) || []), ...featureArgs, ...entrypoint, diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 515294e7b..d14b2d661 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -8,7 +8,7 @@ import * as crypto from 'crypto'; import * as os from 'os'; import { ContainerError, toErrorText } from '../spec-common/errors'; -import { CLIHost, runCommandNoPty, runCommand, getLocalUsername, PlatformInfo } from '../spec-common/commonUtils'; +import { CLIHost, runCommandNoPty, runCommand, getLocalUsername, PlatformInfo, GoOS, GoARCH } from '../spec-common/commonUtils'; import { Log, LogLevel, makeLog, nullLog } from '../spec-utils/log'; import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, LifecycleCommand, ResolverParameters } from '../spec-common/injectHeadless'; @@ -241,6 +241,23 @@ export function isBuildKitImagePolicyError(err: any): boolean { || (errStderr && typeof errStderr === 'string' && (errStderr.includes(imagePolicyErrorString) || errStderr.includes(sourceDeniedString))); } +// Parses a buildx/docker platform string (e.g. `linux/amd64` or `linux/arm64/v8`) into PlatformInfo. +export function platformInfoFromBuildxPlatform(buildxPlatform: string): PlatformInfo { + const slash1 = buildxPlatform.indexOf('/'); + const slash2 = buildxPlatform.indexOf('/', slash1 + 1); + if (slash2 !== -1) { + return { + os: buildxPlatform.slice(0, slash1), + arch: buildxPlatform.slice(slash1 + 1, slash2), + variant: buildxPlatform.slice(slash2 + 1), + }; + } + return { + os: buildxPlatform.slice(0, slash1), + arch: buildxPlatform.slice(slash1 + 1), + }; +} + export async function inspectDockerImage(params: DockerResolverParameters | DockerCLIParameters, imageName: string, pullImageOnError: boolean) { try { return await inspectImage(params, imageName); @@ -256,7 +273,8 @@ export async function inspectDockerImage(params: DockerResolverParameters | Dock output.write(`Error fetching image details: ${inspectErr2?.message}`, LogLevel.Info); } try { - await retry(async () => dockerPtyCLI(params, 'pull', imageName), { maxRetries: 5, retryIntervalMilliseconds: 1000, output }); + const platformArgs = 'buildxPlatform' in params && params.buildxPlatform ? ['--platform', params.buildxPlatform] : []; + await retry(async () => dockerPtyCLI(params, 'pull', ...platformArgs, imageName), { maxRetries: 5, retryIntervalMilliseconds: 1000, output }); } catch (pullErr) { logErrorStdoutStderr(inspectErr, output); logErrorStdoutStderr(pullErr, output); diff --git a/src/test/utils.test.ts b/src/test/utils.test.ts index b266b9d2a..2304e79c7 100644 --- a/src/test/utils.test.ts +++ b/src/test/utils.test.ts @@ -4,7 +4,8 @@ import * as assert from 'assert'; -import { isBuildxCacheToInline } from '../spec-node/utils'; +import { isBuildxCacheToInline, platformInfoFromBuildxPlatform } from '../spec-node/utils'; +import { findPlatformArg, removePlatformArg } from '../spec-node/singleContainer'; describe('Utils', function () { describe('isBuildxCacheToInline', function () { @@ -26,4 +27,63 @@ describe('Utils', function () { assert.strictEqual(isBuildxCacheToInline('inline'), false); }); }); + + describe('platformInfoFromBuildxPlatform', function () { + it('parses os/arch without a variant', () => { + assert.deepStrictEqual(platformInfoFromBuildxPlatform('linux/amd64'), { os: 'linux', arch: 'amd64' }); + assert.deepStrictEqual(platformInfoFromBuildxPlatform('windows/amd64'), { os: 'windows', arch: 'amd64' }); + }); + + it('parses os/arch/variant', () => { + assert.deepStrictEqual(platformInfoFromBuildxPlatform('linux/arm64/v8'), { os: 'linux', arch: 'arm64', variant: 'v8' }); + assert.deepStrictEqual(platformInfoFromBuildxPlatform('linux/amd64/v3'), { os: 'linux', arch: 'amd64', variant: 'v3' }); + }); + }); + + describe('findPlatformArg', function () { + it('returns undefined when runArgs is missing or has no --platform', () => { + assert.strictEqual(findPlatformArg(), undefined); + assert.strictEqual(findPlatformArg([]), undefined); + assert.strictEqual(findPlatformArg(['--user=foo', '--rm']), undefined); + }); + + it('parses the --platform=value form', () => { + assert.strictEqual(findPlatformArg(['--platform=linux/amd64']), 'linux/amd64'); + }); + + it('parses the separate --platform value form', () => { + assert.strictEqual(findPlatformArg(['--rm', '--platform', 'linux/arm64/v8', '-it']), 'linux/arm64/v8'); + }); + + it('returns the last occurrence when --platform is repeated', () => { + assert.strictEqual(findPlatformArg(['--platform=linux/amd64', '--platform', 'linux/arm64']), 'linux/arm64'); + }); + + it('ignores a trailing --platform with no value', () => { + assert.strictEqual(findPlatformArg(['--foo', '--platform']), undefined); + }); + }); + + describe('removePlatformArg', function () { + it('returns an empty array for missing or empty runArgs', () => { + assert.deepStrictEqual(removePlatformArg(), []); + assert.deepStrictEqual(removePlatformArg([]), []); + }); + + it('leaves runArgs without --platform untouched', () => { + assert.deepStrictEqual(removePlatformArg(['--rm', '--user=foo']), ['--rm', '--user=foo']); + }); + + it('removes the --platform=value form', () => { + assert.deepStrictEqual(removePlatformArg(['--rm', '--platform=linux/amd64', '-it']), ['--rm', '-it']); + }); + + it('removes the separate --platform value form including its value', () => { + assert.deepStrictEqual(removePlatformArg(['--rm', '--platform', 'linux/arm64/v8', '-it']), ['--rm', '-it']); + }); + + it('removes all occurrences', () => { + assert.deepStrictEqual(removePlatformArg(['--platform=linux/amd64', '--rm', '--platform', 'linux/arm64']), ['--rm']); + }); + }); });