diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 5ac9743d1..e16642aee 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -18,7 +18,7 @@ permissions: jobs: tests-matrix: name: Tests Matrix (Windows) - runs-on: windows-latest + runs-on: windows-2022 timeout-minutes: 15 strategy: fail-fast: false diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index d8912967c..b3d2dff88 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { DevContainerConfig } from '../spec-configuration/configuration'; -import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; +import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters, CLIVariant } from '../spec-shutdown/dockerUtils'; import { LogLevel, makeLog } from '../spec-utils/log'; import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; import { readLocalFile } from '../spec-utils/pfs'; @@ -364,7 +364,7 @@ async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise[]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port])); - const cwdMount = workspaceMount ? ['--mount', workspaceMount] : []; - const additionalMount = additionalMountString ? ['--mount', additionalMountString] : []; + const cwdMount = workspaceMount ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; + const additionalMount = additionalMountString ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; const envObj = mergedConfig.containerEnv || {}; const containerEnv = Object.keys(envObj) @@ -360,24 +360,30 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const containerUserArgs = containerUser ? ['-u', containerUser] : []; const featureArgs: string[] = []; - if (mergedConfig.init) { + // wslc does not support --init, --privileged, --cap-add, or --security-opt + if (mergedConfig.init && params.cliVariant !== CLIVariant.Wslc) { featureArgs.push('--init'); } - if (mergedConfig.privileged) { + if (mergedConfig.privileged && params.cliVariant !== CLIVariant.Wslc) { featureArgs.push('--privileged'); } - for (const cap of mergedConfig.capAdd || []) { - featureArgs.push('--cap-add', cap); - } - for (const securityOpt of mergedConfig.securityOpt || []) { - featureArgs.push('--security-opt', securityOpt); + if (params.cliVariant !== CLIVariant.Wslc) { + for (const cap of mergedConfig.capAdd || []) { + featureArgs.push('--cap-add', cap); + } + for (const securityOpt of mergedConfig.securityOpt || []) { + featureArgs.push('--security-opt', securityOpt); + } } const featureMounts = ([] as string[]).concat( ...[ ...mergedConfig.mounts || [], ...params.additionalMounts, - ].map(m => generateMountCommand(m)) + ].map(m => { + const mountArgs = generateMountCommand(m); + return params.cliVariant === CLIVariant.Wslc ? convertMountArgsToVolume(mountArgs) : mountArgs; + }) ); const customEntrypoints = mergedConfig.entrypoints || []; @@ -396,9 +402,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t const args = [ 'run', - '--sig-proxy=false', - '-a', 'STDOUT', - '-a', 'STDERR', + ...(params.cliVariant === CLIVariant.Wslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), ...exposed, ...cwdMount, ...additionalMount, @@ -432,7 +436,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t } async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageDetails: () => Promise): Promise { - if (params.isPodman && params.common.cliHost.platform === 'linux') { + if (params.cliVariant === CLIVariant.Podman && params.common.cliHost.platform === 'linux') { const args = ['--security-opt', 'label=disable']; const hasIdMapping = (config.runArgs || []).some(arg => /--[ug]idmap(=|$)/.test(arg)); if (!hasIdMapping) { @@ -446,6 +450,32 @@ async function getPodmanArgs(params: DockerResolverParameters, config: DevContai return []; } +// Convert a --mount string (e.g., "type=bind,source=/a,target=/b,consistency=cached") to -v syntax for wslc. +function convertMountToVolume(mountStr: string): string[] { + const parts = new Map(mountStr.split(',').map(p => { + const eq = p.indexOf('='); + return eq === -1 ? [p, ''] : [p.substring(0, eq), p.substring(eq + 1)]; + })); + const source = parts.get('source') || parts.get('src') || ''; + const target = parts.get('target') || parts.get('dst') || parts.get('destination') || ''; + if (source && target) { + return ['-v', `${source}:${target}`]; + } + if (target) { + return ['-v', target]; + } + // Fallback: pass as --mount and let the runtime handle it. + return ['--mount', mountStr]; +} + +// Convert --mount args array (e.g., ['--mount', 'type=bind,...']) to -v syntax for wslc. +function convertMountArgsToVolume(args: string[]): string[] { + if (args.length === 2 && args[0] === '--mount') { + return convertMountToVolume(args[1]); + } + return args; +} + function getLabels(labels: string[]): string[] { let result: string[] = []; labels.forEach(each => result.push('-l', each)); diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 515294e7b..e6cf6980f 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -10,12 +10,13 @@ import * as os from 'os'; import { ContainerError, toErrorText } from '../spec-common/errors'; import { CLIHost, runCommandNoPty, runCommand, getLocalUsername, PlatformInfo } from '../spec-common/commonUtils'; import { Log, LogLevel, makeLog, nullLog } from '../spec-utils/log'; +import { delay } from '../spec-common/async'; import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, LifecycleCommand, ResolverParameters } from '../spec-common/injectHeadless'; import { Workspace } from '../spec-utils/workspaces'; import { URI } from 'vscode-uri'; import { ShellServer } from '../spec-common/shellServer'; -import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer } from '../spec-shutdown/dockerUtils'; +import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { getRemoteWorkspaceFolder } from './dockerCompose'; import { findGitRootFolder } from '../spec-common/git'; import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; @@ -108,7 +109,7 @@ export interface DockerResolverParameters { common: ResolverParameters; parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; - isPodman: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; workspaceMountConsistencyDefault: BindMountConsistency; @@ -170,6 +171,9 @@ export function addSubstitution, canceled: Promise, output: Log, trace: boolean) { + if (params.cliVariant === CLIVariant.Wslc) { + return startEventSeenPolling(params, labels, canceled, output, trace); + } const eventsProcess = await getEvents(params, { event: ['start'] }); return { started: new Promise((resolve, reject) => { @@ -209,6 +213,36 @@ export async function startEventSeen(params: DockerResolverParameters, labels: R }; } +// Polling-based fallback for runtimes that don't support `events` (e.g., wslc). +function startEventSeenPolling(params: DockerResolverParameters, labels: Record, canceled: Promise, output: Log, trace: boolean) { + let stopped = false; + canceled.catch(() => { stopped = true; }); + const labelFilters = Object.entries(labels).map(([k, v]) => `${k}=${v}`); + return { + started: new Promise((resolve, reject) => { + canceled.catch(reject); + const poll = async () => { + while (!stopped) { + try { + const containers = await listContainers(params, false, labelFilters); + if (trace) { + output.write(`Log: startEventSeenPolling found ${containers.length} container(s)\r\n`); + } + if (containers.length > 0) { + resolve(); + return; + } + } catch (e) { + // Ignore transient errors during polling. + } + await delay(500); + } + }; + poll(); + }) + }; +} + async function hasLabels(params: DockerResolverParameters, info: any, expectedLabels: Record) { const actualLabels = info.Actor?.Attributes // Docker uses 'id', Podman 'ID'. diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 9f0bce850..d1815fe60 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -77,7 +77,7 @@ export interface PartialPtyExecParameters { interface DockerResolverParameters { dockerCLI: string; - isPodman: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; common: { @@ -171,6 +171,7 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa } export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) { + const useEvents = !('cliVariant' in params && params.cliVariant === CLIVariant.Wslc); let eventsProcess: Exec | undefined; let removedSeenP: Promise | undefined; try { @@ -184,7 +185,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP if (i === n - 1 || !stderr.includes('already in progress')) { throw err; } - if (!removedSeenP) { + if (useEvents && !removedSeenP) { eventsProcess = await getEvents(params, { container: [nameOrId], event: ['destroy'], @@ -197,7 +198,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP }); }); } - await Promise.race([removedSeenP, delay(1000)]); + await Promise.race([removedSeenP || delay(1000), delay(1000)]); } } } finally { @@ -215,7 +216,7 @@ export async function getEvents(params: DockerCLIParameters | PartialExecParamet filterArgs.push('--filter', `${filter}=${value}`); } } - const format = 'isPodman' in params && params.isPodman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 + const format = 'cliVariant' in params && params.cliVariant === CLIVariant.Podman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 const combinedArgs = (args || []).concat(['events', '--format', format, ...filterArgs]); const p = await exec({ @@ -260,13 +261,16 @@ export async function dockerBuildKitVersion(params: DockerCLIParameters | Partia } } -export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters): Promise<{ versionString: string; versionMatch?: string } | undefined> { +export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, options?: { useSimpleVersion?: boolean }): Promise<{ versionString: string; versionMatch?: string } | undefined> { try { const execParams = { ...toExecParameters(params), print: true, }; - const result = await dockerCLI(execParams, 'version', '--format', '{{.Server.Version}}'); + const args: string[] = options?.useSimpleVersion + ? ['version'] + : ['version', '--format', '{{.Server.Version}}']; + const result = await dockerCLI(execParams, ...args); const versionString = result.stdout.toString().trim(); const versionMatch = versionString.match(/(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)/); if (!versionMatch) { @@ -286,13 +290,26 @@ export async function dockerCLI(params: DockerCLIParameters | PartialExecParamet }); } -export async function isPodman(params: PartialExecParameters) { +export enum CLIVariant { + Docker = 'docker', + Podman = 'podman', + Wslc = 'wslc', +} + +export async function lookupCLIVariant(params: PartialExecParameters): Promise { try { const { stdout } = await dockerCLI(params, '-v'); - return stdout.toString().toLowerCase().indexOf('podman') !== -1; - } catch (err) { - return false; + const lower = stdout.toString().toLowerCase(); + if (lower.indexOf('wslc') !== -1) { + return CLIVariant.Wslc; + } + if (lower.indexOf('podman') !== -1) { + return CLIVariant.Podman; + } + } catch (_err) { + // fall through } + return CLIVariant.Docker; } export async function dockerPtyCLI(params: PartialPtyExecParameters | DockerResolverParameters | DockerCLIParameters, ...args: string[]) {