diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 6ceed1951..e692eef47 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -23,6 +23,7 @@ import { Event } from '../spec-utils/event'; export interface ProvisionOptions { dockerPath: string | undefined; + runtimeArgs?: string[]; dockerComposePath: string | undefined; containerDataFolder: string | undefined; containerSystemDataFolder: string | undefined; @@ -206,6 +207,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, @@ -213,11 +215,12 @@ export async function createDockerParams(options: ProvisionOptions, disposables: targetPlatformInfo })); - 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, @@ -229,6 +232,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 832e9603f..8adaa1aaf 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'; @@ -46,6 +46,13 @@ import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './te import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; +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))?$/; @@ -354,6 +361,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.' }, @@ -402,6 +410,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, @@ -431,6 +440,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, @@ -787,6 +797,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.' }, @@ -848,6 +859,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, @@ -892,6 +904,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 e6cf6980f..e3522dcd0 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -109,6 +109,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 0531f6b87..7ab7c8b3c 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -49,6 +49,7 @@ export interface ContainerDetails { export interface DockerCLIParameters { cliHost: CLIHost; dockerCLI: string; + runtimeArgs?: string[]; dockerComposeCLI: () => Promise; env: NodeJS.ProcessEnv; output: Log; @@ -77,6 +78,7 @@ export interface PartialPtyExecParameters { interface DockerResolverParameters { dockerCLI: string; + runtimeArgs?: string[]; cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; @@ -423,13 +425,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, } : { @@ -443,14 +445,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);