From d9d5d977ebb677af6e1cf5e6a6ab1fdc993d1d61 Mon Sep 17 00:00:00 2001 From: "Shawn Yuan (from Dev Box)" Date: Wed, 16 Sep 2026 15:54:47 +0800 Subject: [PATCH 1/3] Add optional runtime argument prefixes to CLI commands --- src/spec-node/devContainers.ts | 6 +- src/spec-node/devContainersSpecCLI.ts | 15 +- src/spec-node/utils.ts | 1 + src/spec-shutdown/dockerUtils.ts | 10 +- src/test/dockerUtils.test.ts | 250 +++++++++++++++++++++++++- 5 files changed, 274 insertions(+), 8 deletions(-) diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 2c93d651e..14cbd0525 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -24,6 +24,7 @@ import { Event } from '../spec-utils/event'; export interface ProvisionOptions { dockerPath: string | undefined; + runtimeArgs?: string[]; dockerComposePath: string | undefined; containerDataFolder: string | undefined; containerSystemDataFolder: string | undefined; @@ -213,6 +214,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: const buildKitVersion = options.useBuildKit === 'never' ? undefined : (await dockerBuildKitVersion({ cliHost, dockerCLI: dockerPath, + runtimeArgs: options.runtimeArgs, dockerComposeCLI, env: cliHost.env, output, @@ -221,11 +223,12 @@ export async function createDockerParams(options: ProvisionOptions, disposables: ociAuthDiagnostics: common.ociAuthDiagnostics, })); - const cliVariant = await lookupCLIVariant({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }); + const cliVariant = await lookupCLIVariant({ exec: cliHost.exec, cmd: dockerPath, args: options.runtimeArgs, env: cliHost.env, output }); const dockerEngineVer = await dockerEngineVersion({ cliHost, dockerCLI: dockerPath, + runtimeArgs: options.runtimeArgs, dockerComposeCLI, env: cliHost.env, output, @@ -238,6 +241,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: common, parsedAuthority, dockerCLI: dockerPath, + runtimeArgs: options.runtimeArgs, cliVariant, dockerComposeCLI: dockerComposeCLI, dockerEnv: cliHost.env, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 782232e11..5581015e3 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; -import yargs, { Argv } from 'yargs'; +import yargs, { Argv, Options } from 'yargs'; import textTable from 'text-table'; import * as jsonc from 'jsonc-parser'; @@ -48,6 +48,13 @@ import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; import { parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry'; +const runtimeArgOption = { + type: 'string', + nargs: 1, + coerce: (arg: string | string[]) => Array.isArray(arg) ? arg : [arg], + description: 'Argument to prepend to commands of the container runtime executable selected by --docker-path. Repeat --runtime-arg=VALUE for each argument.', +} satisfies Options; + const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/; @@ -390,6 +397,7 @@ async function doProvision(options: ProvisionOptions, providedIdLabels: string[] function setUpOptions(y: Argv) { return y.options({ 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'runtime-arg': runtimeArgOption, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, 'container-id': { type: 'string', required: true, description: 'Id of the container.' }, @@ -438,6 +446,7 @@ async function setUp(args: SetUpArgs) { async function doSetUp({ 'user-data-folder': persistedFolder, 'docker-path': dockerPath, + 'runtime-arg': runtimeArgs, 'container-data-folder': containerDataFolder, 'container-system-data-folder': containerSystemDataFolder, 'container-id': containerId, @@ -469,6 +478,7 @@ async function doSetUp({ const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const params = await createDockerParams({ dockerPath, + runtimeArgs, dockerComposePath: undefined, containerSessionDataFolder, containerDataFolder, @@ -832,6 +842,7 @@ function runUserCommandsOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'runtime-arg': runtimeArgOption, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, @@ -893,6 +904,7 @@ async function runUserCommands(args: RunUserCommandsArgs) { async function doRunUserCommands({ 'user-data-folder': persistedFolder, 'docker-path': dockerPath, + 'runtime-arg': runtimeArgs, 'docker-compose-path': dockerComposePath, 'container-data-folder': containerDataFolder, 'container-system-data-folder': containerSystemDataFolder, @@ -937,6 +949,7 @@ async function doRunUserCommands({ const params = await createDockerParams({ dockerPath, + runtimeArgs, dockerComposePath, containerDataFolder, containerSystemDataFolder, diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 9a62da347..4b7255a0e 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -110,6 +110,7 @@ export interface DockerResolverParameters { common: ResolverParameters; parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; + runtimeArgs?: string[]; cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 3a15aa6b7..d386945e5 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -50,6 +50,7 @@ export interface ContainerDetails { export interface DockerCLIParameters { cliHost: CLIHost; dockerCLI: string; + runtimeArgs?: string[]; dockerComposeCLI: () => Promise; env: NodeJS.ProcessEnv; output: Log; @@ -81,6 +82,7 @@ export interface PartialPtyExecParameters { interface DockerResolverParameters { dockerCLI: string; + runtimeArgs?: string[]; cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; @@ -427,13 +429,13 @@ export function toExecParameters(params: DockerCLIParameters | PartialExecParame return 'dockerEnv' in params ? { exec: params.common.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : [], + args: compose ? compose.args : params.runtimeArgs || [], env: params.dockerEnv, output: params.common.output, } : 'cliHost' in params ? { exec: params.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : [], + args: compose ? compose.args : params.runtimeArgs || [], env: params.env, output: params.output, } : { @@ -447,14 +449,14 @@ export function toPtyExecParameters(params: DockerCLIParameters | PartialPtyExec ptyExec: params.common.cliHost.ptyExec, exec: params.common.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : [], + args: compose ? compose.args : params.runtimeArgs || [], env: params.dockerEnv, output: params.common.output, } : 'cliHost' in params ? { ptyExec: params.cliHost.ptyExec, exec: params.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : [], + args: compose ? compose.args : params.runtimeArgs || [], env: params.env, output: params.output, } : { diff --git a/src/test/dockerUtils.test.ts b/src/test/dockerUtils.test.ts index c9329d7c2..1a54cbb84 100644 --- a/src/test/dockerUtils.test.ts +++ b/src/test/dockerUtils.test.ts @@ -3,10 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createPlainLog, LogLevel, makeLog } from '../spec-utils/log'; +import { createPlainLog, LogLevel, makeLog, nullLog } from '../spec-utils/log'; import { inspectImageInRegistry, qualifyImageName } from '../spec-node/utils'; import assert from 'assert'; -import { dockerCLI, listContainers, PartialExecParameters, removeContainer, toExecParameters } from '../spec-shutdown/dockerUtils'; +import { CLIVariant, DockerCLIParameters, dockerBuildKitVersion, dockerCLI, dockerEngineVersion, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, getEvents, inspectContainer, inspectImage, listContainers, lookupCLIVariant, PartialExecParameters, removeContainer, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; +import { CLIHost, ExecFunction, ExecParameters, PtyExecFunction } from '../spec-common/commonUtils'; +import { PassThrough } from 'stream'; +import { dockerComposeCLIConfig } from '../spec-node/dockerCompose'; +import { execFileSync } from 'child_process'; +import * as path from 'path'; +import { createDockerParams } from '../spec-node/devContainers'; +import { staticProvisionParams } from '../spec-node/featuresCLI/utils'; import { createCLIParams } from './testUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); @@ -64,6 +71,245 @@ describe('Docker utils', function () { }); }); +describe('Container inspect', () => { + const bindings = { '8080/tcp': [{ HostIp: '127.0.0.1', HostPort: '18080' }] }; + const container = { + Id: 'container', Created: '2026-09-11T00:00:00Z', Name: '/container', + State: { Status: 'running', StartedAt: '', FinishedAt: '' }, + Config: { Image: 'image', User: 'root', Env: ['PATH=/usr/bin'], Labels: { project: 'demo' } }, + Mounts: [{ Type: 'bind', Source: '/workspace', Destination: '/workspaces/demo' }], + NetworkSettings: { Networks: { bridge: { IPAddress: '172.17.0.2' } } }, + }; + + function inspectParams(results: object[]): PartialExecParameters { + return { + cmd: 'runtime', env: {}, output: nullLog, + exec: async () => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + return { + stdin: new PassThrough(), stdout, stderr, + exit: new Promise(resolve => setImmediate(() => { + stdout.end(Buffer.from(JSON.stringify(results))); + stderr.end(); + resolve({ code: 0, signal: null }); + })), + terminate: async () => {}, + }; + }, + }; + } + + it('preserves Docker nested port flattening', async () => { + const raw = { ...container, NetworkSettings: { ...container.NetworkSettings, Ports: bindings } }; + assert.deepStrictEqual(await inspectContainer(inspectParams([raw]), container.Id), { + ...raw, Ports: [{ IP: '127.0.0.1', PrivatePort: 8080, PublicPort: 18080, Type: 'tcp' }], + }); + }); + + it('preserves WSLC metadata without nested ports and ignores top-level mappings', async () => { + const raw = { ...container, Ports: bindings }; + assert.deepStrictEqual(await inspectContainer(inspectParams([raw]), container.Id), { + ...container, Ports: [], + }); + }); +}); + +describe('Runtime argument prefixes', () => { + const prefix = ['--context', 'context with spaces', '001', 'a=b', '"literal quotes"', '']; + + function recordingHost(response = '[]') { + const calls: ExecParameters[] = []; + const exec: ExecFunction = async params => { + calls.push(params); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + return { + stdin: new PassThrough(), stdout, stderr, + exit: new Promise(resolve => setImmediate(() => { + stdout.end(Buffer.from(response)); + stderr.end(); + resolve({ code: 0, signal: null }); + })), + terminate: async () => {}, + }; + }; + const ptyExec: PtyExecFunction = async params => { + calls.push(params); + return { + onData: () => ({ dispose() {} }), + resize() {}, + exit: Promise.resolve({ code: 0, signal: undefined }), + terminate: async () => {}, + }; + }; + const cliHost = { exec, ptyExec } as CLIHost; + const params: DockerCLIParameters = { + cliHost, dockerCLI: 'docker', runtimeArgs: prefix, + dockerComposeCLI: dockerComposeCLIConfig({ exec, env: {}, output: nullLog }, 'docker', 'docker-compose'), + env: {}, output: nullLog, + buildPlatformInfo: { os: 'linux', arch: 'amd64' }, + targetPlatformInfo: { os: 'linux', arch: 'amd64' }, + }; + const resolver = { + dockerCLI: params.dockerCLI, runtimeArgs: prefix, cliVariant: CLIVariant.Docker, + dockerComposeCLI: params.dockerComposeCLI, dockerEnv: params.env, + common: { cliHost, output: nullLog }, + }; + return { calls, params, resolver }; + } + + it('converts CLI and resolver parameters without changing partial arguments', () => { + const { params, resolver } = recordingHost(); + for (const input of [params, resolver]) { + assert.deepStrictEqual(toExecParameters(input).args, prefix); + assert.deepStrictEqual(toPtyExecParameters(input).args, prefix); + const exec = { ...toExecParameters(input), args: [...prefix, 'existing'] }; + const pty = { ...toPtyExecParameters(input), args: [...prefix, 'existing'] }; + assert.deepStrictEqual(toExecParameters(exec), exec); + assert.deepStrictEqual(toPtyExecParameters(pty), pty); + assert.deepStrictEqual(toExecParameters({ ...input, runtimeArgs: undefined }).args, []); + assert.deepStrictEqual(toPtyExecParameters({ ...input, runtimeArgs: [] }).args, []); + } + }); + + it('prefixes inspect, events, plain and PTY commands once without mutating arguments', async () => { + const { calls, params, resolver } = recordingHost(); + const original = prefix.slice(); + await inspectImage(params, 'image'); + await listContainers(resolver); + const events = await getEvents(resolver); + await events.exit; + await dockerCLI(toExecParameters(params), 'build', '.'); + await dockerPtyCLI(resolver, 'run', 'image'); + assert.deepStrictEqual(calls.map(call => call.args), [ + [...prefix, 'inspect', '--type', 'image', 'image'], + [...prefix, 'ps', '-q'], + [...prefix, 'events', '--format', '{{json .}}'], + [...prefix, 'build', '.'], + [...prefix, 'run', 'image'], + ]); + assert.deepStrictEqual(prefix, original); + }); + + it('prefixes container exec including native PTY and plain fallback', async () => { + const { calls, params, resolver } = recordingHost(); + const command = { cmd: 'printf', args: ['a b', ''], output: nullLog }; + await (await dockerExecFunction(params, 'container', 'user')(command)).exit; + const pty = await dockerPtyExecFunction(resolver, 'container', 'user', async () => ({} as T), false); + await (await pty(command)).exit; + const fallback = await dockerPtyExecFunction(resolver, 'container', 'user', async () => undefined, false); + await (await fallback(command)).exit; + assert.deepStrictEqual(calls.map(call => call.args), [ + [...prefix, 'exec', '-i', '-u', 'user', 'container', 'printf', 'a b', ''], + [...prefix, 'exec', '-i', '-t', '-u', 'user', 'container', 'printf', 'a b', ''], + [...prefix, 'exec', '-i', '-u', 'user', 'container', 'printf', 'a b', ''], + ]); + }); + + it('prefixes all Docker version probes', async () => { + const { calls, params } = recordingHost('Docker 28.0.0'); + assert.strictEqual((await dockerBuildKitVersion(params))?.versionMatch, '28.0.0'); + assert.strictEqual((await dockerEngineVersion(params))?.versionMatch, '28.0.0'); + assert.strictEqual(await lookupCLIVariant(toExecParameters(params)), CLIVariant.Docker); + assert.deepStrictEqual(calls.map(call => call.args), [ + [...prefix, 'buildx', 'version'], + [...prefix, 'version', '--format', '{{.Server.Version}}'], + [...prefix, '-v'], + ]); + }); + + it('propagates ProvisionOptions through createDockerParams and its probes', async function () { + this.timeout(20000); + // Node stands in for Docker; its inline script reports the exact received arguments. + const runtimeArgs = ['-e', 'process.stdout.write("podman " + JSON.stringify(process.argv.slice(1)))', '--', 'prefix value']; + const disposables: (() => Promise | undefined)[] = []; + try { + const params = await createDockerParams({ + ...staticProvisionParams, + dockerPath: process.execPath, runtimeArgs, + workspaceFolder: __dirname, persistedFolder: __dirname, + mountWorkspaceGitRoot: false, mountGitWorktreeCommonDir: false, + log: () => {}, logLevel: LogLevel.Error, + remoteEnv: {}, additionalLabels: [], + skipFeatureAutoMapping: false, skipPersistingCustomizationsFromFeatures: false, + dotfiles: {}, + }, disposables); + assert.deepStrictEqual(params.runtimeArgs, runtimeArgs); + assert.strictEqual(params.cliVariant, CLIVariant.Podman); + assert.strictEqual(params.buildKitVersion?.versionString, 'podman ["prefix value","buildx","version"]'); + assert.strictEqual(params.dockerEngineVersion?.versionString, 'podman ["prefix value","version","--format","{{.Server.Version}}"]'); + const result = await dockerCLI(params, 'ps'); + assert.strictEqual(result.stdout.toString(), 'podman ["prefix value","ps"]'); + } finally { + await Promise.all(disposables.map(dispose => dispose())); + } + }); +}); + +// Use the real CLI parser in a child process, replacing only command handlers so no Docker is needed. +describe('Runtime argument parsing', function () { + this.timeout(30000); + const cli = path.resolve(__dirname, '../spec-node/devContainersSpecCLI.ts'); + const project = path.resolve(__dirname, 'tsconfig.json'); + function parse(args: string[]) { + const script = ` + require(${JSON.stringify(require.resolve('ts-node'))}).register({ project: ${JSON.stringify(project)}, transpileOnly: true }); + const yargsPath = ${JSON.stringify(require.resolve('yargs'))}; + const original = require(yargsPath); + require.cache[yargsPath].exports = (...args) => { + const y = original(...args); + const command = y.command; + y.command = (...args) => { + if (typeof args[3] === 'function') { + args[3] = argv => process.stdout.write(JSON.stringify(argv)); + } + return command.apply(y, args); + }; + return y; + }; + process.argv = [process.execPath, ${JSON.stringify(cli)}, ...${JSON.stringify(args)}]; + require(${JSON.stringify(cli)}); + `; + return JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', stdio: 'pipe' })); + } + + for (const command of ['set-up', 'run-user-commands']) { + const args = [command, '--docker-path', 'custom-docker', '--container-id', 'container']; + it(`preserves repeated exact strings for ${command}`, () => { + const values = ['--session', 'a b', '001', 'a=b', '"quoted"', "'quoted'", '', '--', 'a\\b', '']; + assert.deepStrictEqual(parse([...args, ...values.map(value => `--runtime-arg=${value}`)])['runtime-arg'], values); + }); + + it(`normalizes a single empty argument and leaves an omitted option undefined for ${command}`, () => { + assert.deepStrictEqual(parse([...args, '--runtime-arg='])['runtime-arg'], ['']); + assert.strictEqual(parse(args)['runtime-arg'], undefined); + }); + } + + for (const command of ['up', 'build', 'read-configuration', 'exec', 'upgrade']) { + it(`rejects unsupported runtime arguments before the command handler for ${command}`, () => { + const args = [command, '--runtime-arg=--session']; + if (command === 'exec') { + args.push('printf', 'a b'); + } + assert.throws(() => parse(args), (error: { status: number; stdout: string; stderr: string }) => { + assert.strictEqual(error.status, 1); + assert.strictEqual(error.stdout, ''); + assert.match(error.stderr, /Unknown arguments?: runtime-arg/); + return true; + }); + }); + } + + it('preserves literal runtime-arg arguments after the public exec child command', () => { + const command = ['printf', '--runtime-arg=container-argument', '--runtime-arg', 'a b', '']; + const parsed = parse(['exec', '--container-id', 'container', ...command]); + assert.strictEqual(parsed['runtime-arg'], undefined); + assert.deepStrictEqual(parsed._, command); + }); +}); + async function testRemoveContainer(params: PartialExecParameters, nameOrId: string) { await removeContainer(params, nameOrId); const all = await listContainers(params, true); From 7251b0a866ff76b0ce19a8214b7b31a4b90fb2af Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 29 Oct 2026 17:22:03 +0100 Subject: [PATCH 2/3] Rename --runtime-arg to --docker-arg and support it on all commands Accept --docker-arg on every command that takes --docker-path (up, set-up, build, run-user-commands, read-configuration, exec and upgrade) for consistency with --docker-path. Prefix the Docker Compose plugin invocation (docker compose) but not a standalone docker-compose executable, and keep the arguments on Docker CLI parameters derived from resolver parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-node/configContainer.ts | 2 +- src/spec-node/devContainers.ts | 12 ++-- src/spec-node/devContainersSpecCLI.ts | 39 ++++++----- src/spec-node/dockerCompose.ts | 9 +-- src/spec-node/featuresCLI/utils.ts | 1 + src/spec-node/imageMetadata.ts | 2 +- src/spec-node/upgradeCommand.ts | 7 +- src/spec-node/utils.ts | 10 ++- src/spec-shutdown/dockerUtils.ts | 12 ++-- src/test/dockerUtils.test.ts | 97 ++++++++++++++++++--------- 10 files changed, 121 insertions(+), 70 deletions(-) diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index 3ee8873ee..c7a31be4d 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -60,7 +60,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu const { dockerCLI, dockerComposeCLI } = params; const { env } = common; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerArgs: params.dockerArgs, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; await ensureNoDisallowedFeatures(cliParams, config, additionalFeatures, idLabels); await runInitializeCommand({ ...params, common: { ...common, output: common.lifecycleHook.output } }, config.initializeCommand, common.lifecycleHook.onDidInput); diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 14cbd0525..11ab285c3 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -24,7 +24,7 @@ import { Event } from '../spec-utils/event'; export interface ProvisionOptions { dockerPath: string | undefined; - runtimeArgs?: string[]; + dockerArgs?: string[]; dockerComposePath: string | undefined; containerDataFolder: string | undefined; containerSystemDataFolder: string | undefined; @@ -178,7 +178,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: exec: cliHost.exec, env: cliHost.env, output: common.output, - }, dockerPath, dockerComposePath); + }, dockerPath, dockerComposePath, options.dockerArgs); const buildPlatformInfo = { os: mapNodeOSToGOOS(cliHost.platform), @@ -214,7 +214,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: const buildKitVersion = options.useBuildKit === 'never' ? undefined : (await dockerBuildKitVersion({ cliHost, dockerCLI: dockerPath, - runtimeArgs: options.runtimeArgs, + dockerArgs: options.dockerArgs, dockerComposeCLI, env: cliHost.env, output, @@ -223,12 +223,12 @@ export async function createDockerParams(options: ProvisionOptions, disposables: ociAuthDiagnostics: common.ociAuthDiagnostics, })); - const cliVariant = await lookupCLIVariant({ exec: cliHost.exec, cmd: dockerPath, args: options.runtimeArgs, env: cliHost.env, output }); + const cliVariant = await lookupCLIVariant({ exec: cliHost.exec, cmd: dockerPath, args: options.dockerArgs, env: cliHost.env, output }); const dockerEngineVer = await dockerEngineVersion({ cliHost, dockerCLI: dockerPath, - runtimeArgs: options.runtimeArgs, + dockerArgs: options.dockerArgs, dockerComposeCLI, env: cliHost.env, output, @@ -241,7 +241,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: common, parsedAuthority, dockerCLI: dockerPath, - runtimeArgs: options.runtimeArgs, + dockerArgs: options.dockerArgs, cliVariant, dockerComposeCLI: dockerComposeCLI, dockerEnv: cliHost.env, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 5581015e3..221f9e3fa 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; -import yargs, { Argv, Options } from 'yargs'; +import yargs, { Argv } from 'yargs'; import textTable from 'text-table'; import * as jsonc from 'jsonc-parser'; import { createDockerParams, createLog, launch, ProvisionOptions } from './devContainers'; -import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler } from './utils'; +import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler, dockerArgOption } from './utils'; import { URI } from 'vscode-uri'; import { ContainerError } from '../spec-common/errors'; import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; @@ -48,13 +48,6 @@ import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; import { parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry'; -const runtimeArgOption = { - type: 'string', - nargs: 1, - coerce: (arg: string | string[]) => Array.isArray(arg) ? arg : [arg], - description: 'Argument to prepend to commands of the container runtime executable selected by --docker-path. Repeat --runtime-arg=VALUE for each argument.', -} satisfies Options; - const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/; @@ -139,6 +132,7 @@ export function getAllowedCrossOriginAuthHosts(args: OciAuthArgs) { function provisionOptions(y: Argv) { return y.options({ 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-arg': dockerArgOption, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, @@ -224,6 +218,7 @@ function provisionHandler(args: ProvisionArgs) { async function provision({ 'user-data-folder': persistedFolder, 'docker-path': dockerPath, + 'docker-arg': dockerArgs, 'docker-compose-path': dockerComposePath, 'container-data-folder': containerDataFolder, 'container-system-data-folder': containerSystemDataFolder, @@ -287,6 +282,7 @@ async function provision({ const options: ProvisionOptions = { dockerPath, + dockerArgs, dockerComposePath, containerDataFolder, containerSystemDataFolder, @@ -397,7 +393,7 @@ async function doProvision(options: ProvisionOptions, providedIdLabels: string[] function setUpOptions(y: Argv) { return y.options({ 'docker-path': { type: 'string', description: 'Docker CLI path.' }, - 'runtime-arg': runtimeArgOption, + 'docker-arg': dockerArgOption, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, 'container-id': { type: 'string', required: true, description: 'Id of the container.' }, @@ -446,7 +442,7 @@ async function setUp(args: SetUpArgs) { async function doSetUp({ 'user-data-folder': persistedFolder, 'docker-path': dockerPath, - 'runtime-arg': runtimeArgs, + 'docker-arg': dockerArgs, 'container-data-folder': containerDataFolder, 'container-system-data-folder': containerSystemDataFolder, 'container-id': containerId, @@ -478,7 +474,7 @@ async function doSetUp({ const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const params = await createDockerParams({ dockerPath, - runtimeArgs, + dockerArgs, dockerComposePath: undefined, containerSessionDataFolder, containerDataFolder, @@ -574,6 +570,7 @@ function buildOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-arg': dockerArgOption, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If not provided, defaults to the current directory.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, @@ -630,6 +627,7 @@ async function build(args: BuildArgs) { async function doBuild({ 'user-data-folder': persistedFolder, 'docker-path': dockerPath, + 'docker-arg': dockerArgs, 'docker-compose-path': dockerComposePath, 'workspace-folder': workspaceFolderArg, config: configParam, @@ -670,6 +668,7 @@ async function doBuild({ const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; const params = await createDockerParams({ dockerPath, + dockerArgs, dockerComposePath, containerDataFolder: undefined, containerSystemDataFolder: undefined, @@ -730,7 +729,7 @@ async function doBuild({ throw new ContainerError({ description: '--push true cannot be used with --output.' }); } - const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: params.common.ociAuthDiagnostics }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerArgs: params.dockerArgs, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: params.common.ociAuthDiagnostics }; await ensureNoDisallowedFeatures(buildParams, config, additionalFeatures, undefined); // Support multiple use of `--image-name` @@ -842,7 +841,7 @@ function runUserCommandsOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, - 'runtime-arg': runtimeArgOption, + 'docker-arg': dockerArgOption, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, @@ -904,7 +903,7 @@ async function runUserCommands(args: RunUserCommandsArgs) { async function doRunUserCommands({ 'user-data-folder': persistedFolder, 'docker-path': dockerPath, - 'runtime-arg': runtimeArgs, + 'docker-arg': dockerArgs, 'docker-compose-path': dockerComposePath, 'container-data-folder': containerDataFolder, 'container-system-data-folder': containerSystemDataFolder, @@ -949,7 +948,7 @@ async function doRunUserCommands({ const params = await createDockerParams({ dockerPath, - runtimeArgs, + dockerArgs, dockerComposePath, containerDataFolder, containerSystemDataFolder, @@ -1052,6 +1051,7 @@ function readConfigurationOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-arg': dockerArgOption, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, @@ -1090,6 +1090,7 @@ function readConfigurationHandler(args: ReadConfigurationArgs) { async function readConfiguration({ // 'user-data-folder': persistedFolder, 'docker-path': dockerPath, + 'docker-arg': dockerArgs, 'docker-compose-path': dockerComposePath, 'workspace-folder': workspaceFolderArg, 'mount-workspace-git-root': mountWorkspaceGitRoot, @@ -1152,7 +1153,7 @@ async function readConfiguration({ exec: cliHost.exec, env: cliHost.env, output, - }, dockerCLI, dockerComposePath || 'docker-compose'); + }, dockerCLI, dockerComposePath || 'docker-compose', dockerArgs); const buildPlatformInfo = { os: mapNodeOSToGOOS(cliHost.platform), arch: mapNodeArchitectureToGOARCH(cliHost.arch), @@ -1160,6 +1161,7 @@ async function readConfiguration({ const params: DockerCLIParameters = { cliHost, dockerCLI, + dockerArgs, dockerComposeCLI, env: cliHost.env, output, @@ -1320,6 +1322,7 @@ function execOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-arg': dockerArgOption, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, @@ -1382,6 +1385,7 @@ async function exec(args: ExecArgs) { export async function doExec({ 'user-data-folder': persistedFolder, 'docker-path': dockerPath, + 'docker-arg': dockerArgs, 'docker-compose-path': dockerComposePath, 'container-data-folder': containerDataFolder, 'container-system-data-folder': containerSystemDataFolder, @@ -1415,6 +1419,7 @@ export async function doExec({ const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; const params = await createDockerParams({ dockerPath, + dockerArgs, dockerComposePath, containerDataFolder, containerSystemDataFolder, diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 8e7750040..410f7636d 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -27,7 +27,7 @@ const serviceLabel = 'com.docker.compose.service'; export async function openDockerComposeDevContainer(params: DockerResolverParameters, workspace: Workspace, config: SubstitutedConfig, idLabels: string[], additionalFeatures: Record>): Promise { const { common, dockerCLI, dockerComposeCLI } = params; const { cliHost, env, output } = common; - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerArgs: params.dockerArgs, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; return _openDockerComposeDevContainer(params, buildParams, workspace, config, getRemoteWorkspaceFolder(config.config), idLabels, additionalFeatures); } @@ -155,7 +155,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf const { cliHost, env, output } = common; const { config } = configWithRaw; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerArgs: params.dockerArgs, dockerComposeCLI: dockerComposeCLIFunc, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; const composeConfig = await readDockerComposeConfig(cliParams, localComposeFiles, envFile); const composeService = composeConfig.services[config.service]; @@ -701,7 +701,7 @@ async function useNewProjectName(params: DockerCLIParameters | DockerResolverPar } } -export function dockerComposeCLIConfig(params: Omit, dockerCLICmd: string, dockerComposeCLICmd: string) { +export function dockerComposeCLIConfig(params: Omit, dockerCLICmd: string, dockerComposeCLICmd: string, dockerArgs: string[] = []) { let result: Promise; return () => { return result || (result = (async () => { @@ -711,6 +711,7 @@ export function dockerComposeCLIConfig(params: Omit Array.isArray(arg) ? arg : [arg], + description: 'Global argument for the Docker CLI, inserted before the subcommand of every Docker CLI invocation (including the Docker Compose plugin, but not a separate --docker-compose-path executable). Repeat --docker-arg=VALUE for each argument.', +} satisfies Options; + export type BindMountConsistency = 'consistent' | 'cached' | 'delegated' | undefined; export type GPUAvailability = 'all' | 'detect' | 'none'; @@ -110,7 +118,7 @@ export interface DockerResolverParameters { common: ResolverParameters; parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; - runtimeArgs?: string[]; + dockerArgs?: string[]; cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index d386945e5..f5e6a05bf 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -50,7 +50,7 @@ export interface ContainerDetails { export interface DockerCLIParameters { cliHost: CLIHost; dockerCLI: string; - runtimeArgs?: string[]; + dockerArgs?: string[]; dockerComposeCLI: () => Promise; env: NodeJS.ProcessEnv; output: Log; @@ -82,7 +82,7 @@ export interface PartialPtyExecParameters { interface DockerResolverParameters { dockerCLI: string; - runtimeArgs?: string[]; + dockerArgs?: string[]; cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; @@ -429,13 +429,13 @@ export function toExecParameters(params: DockerCLIParameters | PartialExecParame return 'dockerEnv' in params ? { exec: params.common.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : params.runtimeArgs || [], + args: compose ? compose.args : params.dockerArgs || [], env: params.dockerEnv, output: params.common.output, } : 'cliHost' in params ? { exec: params.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : params.runtimeArgs || [], + args: compose ? compose.args : params.dockerArgs || [], env: params.env, output: params.output, } : { @@ -449,14 +449,14 @@ export function toPtyExecParameters(params: DockerCLIParameters | PartialPtyExec ptyExec: params.common.cliHost.ptyExec, exec: params.common.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : params.runtimeArgs || [], + args: compose ? compose.args : params.dockerArgs || [], env: params.dockerEnv, output: params.common.output, } : 'cliHost' in params ? { ptyExec: params.cliHost.ptyExec, exec: params.cliHost.exec, cmd: compose ? compose.cmd : params.dockerCLI, - args: compose ? compose.args : params.runtimeArgs || [], + args: compose ? compose.args : params.dockerArgs || [], env: params.env, output: params.output, } : { diff --git a/src/test/dockerUtils.test.ts b/src/test/dockerUtils.test.ts index 1a54cbb84..0c2991b7f 100644 --- a/src/test/dockerUtils.test.ts +++ b/src/test/dockerUtils.test.ts @@ -14,6 +14,7 @@ import { execFileSync } from 'child_process'; import * as path from 'path'; import { createDockerParams } from '../spec-node/devContainers'; import { staticProvisionParams } from '../spec-node/featuresCLI/utils'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; import { createCLIParams } from './testUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); @@ -115,7 +116,7 @@ describe('Container inspect', () => { }); }); -describe('Runtime argument prefixes', () => { +describe('Docker argument prefixes', () => { const prefix = ['--context', 'context with spaces', '001', 'a=b', '"literal quotes"', '']; function recordingHost(response = '[]') { @@ -145,14 +146,15 @@ describe('Runtime argument prefixes', () => { }; const cliHost = { exec, ptyExec } as CLIHost; const params: DockerCLIParameters = { - cliHost, dockerCLI: 'docker', runtimeArgs: prefix, + cliHost, dockerCLI: 'docker', dockerArgs: prefix, dockerComposeCLI: dockerComposeCLIConfig({ exec, env: {}, output: nullLog }, 'docker', 'docker-compose'), env: {}, output: nullLog, buildPlatformInfo: { os: 'linux', arch: 'amd64' }, targetPlatformInfo: { os: 'linux', arch: 'amd64' }, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const resolver = { - dockerCLI: params.dockerCLI, runtimeArgs: prefix, cliVariant: CLIVariant.Docker, + dockerCLI: params.dockerCLI, dockerArgs: prefix, cliVariant: CLIVariant.Docker, dockerComposeCLI: params.dockerComposeCLI, dockerEnv: params.env, common: { cliHost, output: nullLog }, }; @@ -168,8 +170,8 @@ describe('Runtime argument prefixes', () => { const pty = { ...toPtyExecParameters(input), args: [...prefix, 'existing'] }; assert.deepStrictEqual(toExecParameters(exec), exec); assert.deepStrictEqual(toPtyExecParameters(pty), pty); - assert.deepStrictEqual(toExecParameters({ ...input, runtimeArgs: undefined }).args, []); - assert.deepStrictEqual(toPtyExecParameters({ ...input, runtimeArgs: [] }).args, []); + assert.deepStrictEqual(toExecParameters({ ...input, dockerArgs: undefined }).args, []); + assert.deepStrictEqual(toPtyExecParameters({ ...input, dockerArgs: [] }).args, []); } }); @@ -219,15 +221,50 @@ describe('Runtime argument prefixes', () => { ]); }); + it('prefixes the Docker Compose plugin but not a standalone Docker Compose executable', async () => { + function composeHost(v2: boolean) { + const calls: ExecParameters[] = []; + const exec: ExecFunction = async params => { + calls.push(params); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const code = params.cmd === 'docker' && !v2 ? 1 : 0; + return { + stdin: new PassThrough(), stdout, stderr, + exit: new Promise(resolve => setImmediate(() => { + stdout.end(Buffer.from(code ? '' : '2.0.0')); + stderr.end(); + resolve({ code, signal: null }); + })), + terminate: async () => {}, + }; + }; + return { calls, compose: dockerComposeCLIConfig({ exec, env: {}, output: nullLog }, 'docker', 'docker-compose', prefix) }; + } + + const plugin = composeHost(true); + assert.deepStrictEqual(await plugin.compose(), { version: '2.0.0', cmd: 'docker', args: [...prefix, 'compose'] }); + assert.deepStrictEqual(plugin.calls.map(call => [call.cmd, ...call.args || []]), [ + ['docker', ...prefix, 'compose', 'version', '--short'], + ]); + + const standalone = composeHost(false); + assert.deepStrictEqual(await standalone.compose(), { version: '2.0.0', cmd: 'docker-compose', args: [] }); + assert.deepStrictEqual(standalone.calls.map(call => [call.cmd, ...call.args || []]), [ + ['docker', ...prefix, 'compose', 'version', '--short'], + ['docker-compose', 'version', '--short'], + ]); + }); + it('propagates ProvisionOptions through createDockerParams and its probes', async function () { this.timeout(20000); // Node stands in for Docker; its inline script reports the exact received arguments. - const runtimeArgs = ['-e', 'process.stdout.write("podman " + JSON.stringify(process.argv.slice(1)))', '--', 'prefix value']; + const dockerArgs = ['-e', 'process.stdout.write("podman " + JSON.stringify(process.argv.slice(1)))', '--', 'prefix value']; const disposables: (() => Promise | undefined)[] = []; try { const params = await createDockerParams({ ...staticProvisionParams, - dockerPath: process.execPath, runtimeArgs, + dockerPath: process.execPath, dockerArgs, workspaceFolder: __dirname, persistedFolder: __dirname, mountWorkspaceGitRoot: false, mountGitWorktreeCommonDir: false, log: () => {}, logLevel: LogLevel.Error, @@ -235,7 +272,7 @@ describe('Runtime argument prefixes', () => { skipFeatureAutoMapping: false, skipPersistingCustomizationsFromFeatures: false, dotfiles: {}, }, disposables); - assert.deepStrictEqual(params.runtimeArgs, runtimeArgs); + assert.deepStrictEqual(params.dockerArgs, dockerArgs); assert.strictEqual(params.cliVariant, CLIVariant.Podman); assert.strictEqual(params.buildKitVersion?.versionString, 'podman ["prefix value","buildx","version"]'); assert.strictEqual(params.dockerEngineVersion?.versionString, 'podman ["prefix value","version","--format","{{.Server.Version}}"]'); @@ -248,7 +285,7 @@ describe('Runtime argument prefixes', () => { }); // Use the real CLI parser in a child process, replacing only command handlers so no Docker is needed. -describe('Runtime argument parsing', function () { +describe('Docker argument parsing', function () { this.timeout(30000); const cli = path.resolve(__dirname, '../spec-node/devContainersSpecCLI.ts'); const project = path.resolve(__dirname, 'tsconfig.json'); @@ -274,38 +311,34 @@ describe('Runtime argument parsing', function () { return JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', stdio: 'pipe' })); } - for (const command of ['set-up', 'run-user-commands']) { - const args = [command, '--docker-path', 'custom-docker', '--container-id', 'container']; + const commandArgs: Record = { + 'up': ['up', '--docker-path', 'custom-docker', '--workspace-folder', __dirname], + 'set-up': ['set-up', '--docker-path', 'custom-docker', '--container-id', 'container'], + 'build': ['build', '--docker-path', 'custom-docker', '--workspace-folder', __dirname], + 'run-user-commands': ['run-user-commands', '--docker-path', 'custom-docker', '--container-id', 'container'], + 'read-configuration': ['read-configuration', '--docker-path', 'custom-docker', '--container-id', 'container'], + 'exec': ['exec', '--docker-path', 'custom-docker', '--container-id', 'container'], + 'upgrade': ['upgrade', '--docker-path', 'custom-docker', '--workspace-folder', __dirname], + }; + + for (const [command, args] of Object.entries(commandArgs)) { + const withChildCommand = (dockerArgs: string[]) => command === 'exec' ? [...args, ...dockerArgs, 'printf', 'a b'] : [...args, ...dockerArgs]; + it(`preserves repeated exact strings for ${command}`, () => { const values = ['--session', 'a b', '001', 'a=b', '"quoted"', "'quoted'", '', '--', 'a\\b', '']; - assert.deepStrictEqual(parse([...args, ...values.map(value => `--runtime-arg=${value}`)])['runtime-arg'], values); + assert.deepStrictEqual(parse(withChildCommand(values.map(value => `--docker-arg=${value}`)))['docker-arg'], values); }); it(`normalizes a single empty argument and leaves an omitted option undefined for ${command}`, () => { - assert.deepStrictEqual(parse([...args, '--runtime-arg='])['runtime-arg'], ['']); - assert.strictEqual(parse(args)['runtime-arg'], undefined); - }); - } - - for (const command of ['up', 'build', 'read-configuration', 'exec', 'upgrade']) { - it(`rejects unsupported runtime arguments before the command handler for ${command}`, () => { - const args = [command, '--runtime-arg=--session']; - if (command === 'exec') { - args.push('printf', 'a b'); - } - assert.throws(() => parse(args), (error: { status: number; stdout: string; stderr: string }) => { - assert.strictEqual(error.status, 1); - assert.strictEqual(error.stdout, ''); - assert.match(error.stderr, /Unknown arguments?: runtime-arg/); - return true; - }); + assert.deepStrictEqual(parse(withChildCommand(['--docker-arg=']))['docker-arg'], ['']); + assert.strictEqual(parse(withChildCommand([]))['docker-arg'], undefined); }); } - it('preserves literal runtime-arg arguments after the public exec child command', () => { - const command = ['printf', '--runtime-arg=container-argument', '--runtime-arg', 'a b', '']; + it('preserves literal docker-arg arguments after the public exec child command', () => { + const command = ['printf', '--docker-arg=container-argument', '--docker-arg', 'a b', '']; const parsed = parse(['exec', '--container-id', 'container', ...command]); - assert.strictEqual(parsed['runtime-arg'], undefined); + assert.strictEqual(parsed['docker-arg'], undefined); assert.deepStrictEqual(parsed._, command); }); }); From bef5b267cc1316f1f42d4759bef522dd5dfba38e Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 30 Oct 2026 14:44:57 +0100 Subject: [PATCH 3/3] Add --docker-arg integration tests and shorten exec log lines Run the built CLI with an unreachable DOCKER_HOST and the real endpoint passed with --docker-arg=-H so any Docker invocation that drops the arguments fails. Cover up, exec, set-up, run-user-commands, read-configuration, build and the Docker Compose plugin. The tests use execFile without a shell, so they also run on Windows. Also include the Docker arguments when rewriting container exec log lines to "Run in container:". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-shutdown/dockerUtils.ts | 4 +- src/test/cli.docker-arg.test.ts | 83 +++++++++++++++++++ .../.devcontainer/devcontainer.json | 6 ++ .../.devcontainer/docker-compose.yml | 6 ++ .../docker-arg-dockerfile/.devcontainer.json | 9 ++ .../configs/docker-arg-dockerfile/Dockerfile | 2 + src/test/dockerUtils.test.ts | 10 +++ 7 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/test/cli.docker-arg.test.ts create mode 100644 src/test/configs/docker-arg-compose/.devcontainer/devcontainer.json create mode 100644 src/test/configs/docker-arg-compose/.devcontainer/docker-compose.yml create mode 100644 src/test/configs/docker-arg-dockerfile/.devcontainer.json create mode 100644 src/test/configs/docker-arg-dockerfile/Dockerfile diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index f5e6a05bf..487093c81 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -357,7 +357,7 @@ export function dockerExecFunction(params: DockerCLIParameters | PartialExecPara args: (args || []).concat(execArgs), env, stdio: execParams.stdio, - output: replacingDockerExecLog(execParams.output, cmd, argsPrefix), + output: replacingDockerExecLog(execParams.output, cmd, (args || []).concat(argsPrefix)), }); }; } @@ -376,7 +376,7 @@ export async function dockerPtyExecFunction(params: PartialPtyExecParameters | D cmd, args: (args || []).concat(execArgs), env, - output: replacingDockerExecLog(execParams.output, cmd, argsPrefix), + output: replacingDockerExecLog(execParams.output, cmd, (args || []).concat(argsPrefix)), }); }; } diff --git a/src/test/cli.docker-arg.test.ts b/src/test/cli.docker-arg.test.ts new file mode 100644 index 000000000..760745380 --- /dev/null +++ b/src/test/cli.docker-arg.test.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as cp from 'child_process'; +import * as path from 'path'; +import { promisify } from 'util'; + +const execFile = promisify(cp.execFile); + +// The Docker CLI and its plugins prefer -H over DOCKER_HOST, so with an unreachable DOCKER_HOST only invocations that +// received the --docker-arg values reach the Docker engine. Runs the built CLI without a shell so this also runs on Windows. +describe('Dev Containers CLI --docker-arg', function () { + this.timeout('300s'); + + const cli = path.join(__dirname, '..', '..', 'devcontainer.js'); + const env = { ...process.env, DOCKER_HOST: 'tcp://127.0.0.1:9' }; + const dockerfileFolder = path.join(__dirname, 'configs', 'docker-arg-dockerfile'); + const composeFolder = path.join(__dirname, 'configs', 'docker-arg-compose'); + let dockerArgs: string[]; + + before(async () => { + const { stdout } = await execFile('docker', ['context', 'inspect', '--format', '{{.Endpoints.docker.Host}}']); + dockerArgs = ['-H', stdout.trim()].map(arg => `--docker-arg=${arg}`); + }); + + async function devcontainer(command: string, args: string[], withDockerArgs = true) { + const { stdout } = await execFile(process.execPath, [cli, command, ...(withDockerArgs ? dockerArgs : []), ...args], { env, maxBuffer: 100 * 1024 * 1024 }); + return stdout; + } + + function parseResult(stdout: string) { + return JSON.parse(stdout.trim().split(/\r?\n/).pop()!); + } + + it('cannot reach Docker without --docker-arg', async () => { + await assert.rejects(devcontainer('up', ['--workspace-folder', dockerfileFolder], false), (err: { stdout: string }) => { + assert.strictEqual(parseResult(err.stdout).outcome, 'error'); + return true; + }); + }); + + it('uses the Docker arguments for up, exec, set-up, run-user-commands and read-configuration', async () => { + const up = parseResult(await devcontainer('up', ['--workspace-folder', dockerfileFolder, '--remove-existing-container'])); + assert.strictEqual(up.outcome, 'success'); + try { + assert.strictEqual((await devcontainer('exec', ['--workspace-folder', dockerfileFolder, 'cat', '/tmp/docker-arg-marker'])).trim(), 'post-create'); + + const config = path.join(dockerfileFolder, '.devcontainer.json'); + for (const command of ['set-up', 'run-user-commands']) { + assert.strictEqual(parseResult(await devcontainer(command, ['--container-id', up.containerId, '--config', config])).outcome, 'success', command); + } + + // Only resolved when the container was found. + const read = parseResult(await devcontainer('read-configuration', ['--container-id', up.containerId, '--workspace-folder', dockerfileFolder])); + assert.strictEqual(read.configuration.remoteEnv.FROM_CONTAINER, 'from-image'); + } finally { + await execFile('docker', ['rm', '-f', up.containerId]); + } + }); + + it('uses the Docker arguments for build', async () => { + const imageName = `devcontainers-docker-arg-test:${Date.now()}`; + const build = parseResult(await devcontainer('build', ['--workspace-folder', dockerfileFolder, '--image-name', imageName])); + assert.strictEqual(build.outcome, 'success'); + await execFile('docker', ['rmi', '-f', imageName]); + }); + + it('uses the Docker arguments for the Docker Compose plugin', async () => { + const read = parseResult(await devcontainer('read-configuration', ['--workspace-folder', composeFolder, '--include-merged-configuration'])); + assert.ok(read.mergedConfiguration); + + const up = parseResult(await devcontainer('up', ['--workspace-folder', composeFolder, '--remove-existing-container'])); + assert.strictEqual(up.outcome, 'success'); + try { + assert.strictEqual((await devcontainer('exec', ['--workspace-folder', composeFolder, 'cat', '/tmp/docker-arg-marker'])).trim(), 'post-create'); + } finally { + await execFile('docker', ['compose', '--project-name', up.composeProjectName, 'down']); + } + }); +}); diff --git a/src/test/configs/docker-arg-compose/.devcontainer/devcontainer.json b/src/test/configs/docker-arg-compose/.devcontainer/devcontainer.json new file mode 100644 index 000000000..184a76ab4 --- /dev/null +++ b/src/test/configs/docker-arg-compose/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "postCreateCommand": "echo post-create > /tmp/docker-arg-marker" +} diff --git a/src/test/configs/docker-arg-compose/.devcontainer/docker-compose.yml b/src/test/configs/docker-arg-compose/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..518022758 --- /dev/null +++ b/src/test/configs/docker-arg-compose/.devcontainer/docker-compose.yml @@ -0,0 +1,6 @@ +services: + app: + image: alpine:latest + volumes: + - ..:/workspace:cached + command: sleep infinity diff --git a/src/test/configs/docker-arg-dockerfile/.devcontainer.json b/src/test/configs/docker-arg-dockerfile/.devcontainer.json new file mode 100644 index 000000000..314bad33f --- /dev/null +++ b/src/test/configs/docker-arg-dockerfile/.devcontainer.json @@ -0,0 +1,9 @@ +{ + "build": { + "dockerfile": "Dockerfile" + }, + "postCreateCommand": "echo post-create > /tmp/docker-arg-marker", + "remoteEnv": { + "FROM_CONTAINER": "${containerEnv:DOCKER_ARG_TEST}" + } +} diff --git a/src/test/configs/docker-arg-dockerfile/Dockerfile b/src/test/configs/docker-arg-dockerfile/Dockerfile new file mode 100644 index 000000000..468d30745 --- /dev/null +++ b/src/test/configs/docker-arg-dockerfile/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +ENV DOCKER_ARG_TEST=from-image diff --git a/src/test/dockerUtils.test.ts b/src/test/dockerUtils.test.ts index 0c2991b7f..af27206f1 100644 --- a/src/test/dockerUtils.test.ts +++ b/src/test/dockerUtils.test.ts @@ -209,6 +209,16 @@ describe('Docker argument prefixes', () => { ]); }); + it('shortens the container exec log line including the prefix', async () => { + const { calls, params } = recordingHost(); + const lines: string[] = []; + const log = makeLog(createPlainLog(text => lines.push(text), () => LogLevel.Trace)); + await (await dockerExecFunction(params, 'container', 'user')({ cmd: 'printf', args: ['a b'], output: log })).exit; + calls[0].output.write(`Run: ${calls[0].cmd} ${calls[0].args!.join(' ')}`); + assert.strictEqual(lines.length, 1); + assert.match(lines[0], /Run in container: printf a b\s*$/); + }); + it('prefixes all Docker version probes', async () => { const { calls, params } = recordingHost('Docker 28.0.0'); assert.strictEqual((await dockerBuildKitVersion(params))?.versionMatch, '28.0.0');