Skip to content

Commit f63be01

Browse files
authored
Merge WSLC support (#1249)
2 parents 65f98a5 + 6166357 commit f63be01

9 files changed

Lines changed: 133 additions & 41 deletions

File tree

.github/workflows/test-windows.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ permissions:
1818
jobs:
1919
tests-matrix:
2020
name: Tests Matrix (Windows)
21-
runs-on: windows-latest
21+
runs-on: windows-2022
2222
timeout-minutes: 15
2323
strategy:
2424
fail-fast: false

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Notable changes.
44

5+
## June 2026
6+
7+
### [0.88.0]
8+
- Add WSLc support (https://github.com/devcontainers/cli/pull/1249)
9+
510
## May 2026
611

712
### [0.87.0]

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@devcontainers/cli",
33
"description": "Dev Containers CLI",
4-
"version": "0.87.0",
4+
"version": "0.88.0",
55
"bin": {
66
"devcontainer": "devcontainer.js"
77
},

src/spec-node/containerFeatures.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as path from 'path';
77

88
import { DevContainerConfig } from '../spec-configuration/configuration';
9-
import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils';
9+
import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters, CLIVariant } from '../spec-shutdown/dockerUtils';
1010
import { LogLevel, makeLog } from '../spec-utils/log';
1111
import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration';
1212
import { readLocalFile } from '../spec-utils/pfs';
@@ -364,7 +364,7 @@ async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise<b
364364
try {
365365
const { common } = params;
366366
const { cliHost, output } = common;
367-
return params.isPodman && cliHost.platform === 'linux'
367+
return params.cliVariant === CLIVariant.Podman && cliHost.platform === 'linux'
368368
&& (await runCommandNoPty({
369369
exec: cliHost.exec,
370370
cmd: 'getenforce',
@@ -467,7 +467,7 @@ export async function updateRemoteUserUID(params: DockerResolverParameters, merg
467467
'-f', destDockerfile,
468468
'-t', fixedImageName,
469469
...(platform ? ['--platform', platform] : []),
470-
'--build-arg', `BASE_IMAGE=${params.isPodman && !hasRegistryHostname(imageName) ? 'localhost/' : ''}${imageName}`, // Podman: https://github.com/microsoft/vscode-remote-release/issues/9748
470+
'--build-arg', `BASE_IMAGE=${params.cliVariant === CLIVariant.Podman && !hasRegistryHostname(imageName) ? 'localhost/' : ''}${imageName}`, // Podman: https://github.com/microsoft/vscode-remote-release/issues/9748
471471
'--build-arg', `REMOTE_USER=${remoteUser}`,
472472
'--build-arg', `NEW_UID=${await cliHost.getuid!()}`,
473473
'--build-arg', `NEW_GID=${await cliHost.getgid!()}`,

src/spec-node/devContainers.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { LogLevel, LogDimensions, toErrorText, createCombinedLog, createTerminal
1717
import { dockerComposeCLIConfig } from './dockerCompose';
1818
import { Mount } from '../spec-configuration/containerFeaturesConfiguration';
1919
import { getPackageConfig, PackageConfiguration } from '../spec-utils/product';
20-
import { dockerBuildKitVersion, dockerEngineVersion, isPodman } from '../spec-shutdown/dockerUtils';
20+
import { dockerBuildKitVersion, dockerEngineVersion, lookupCLIVariant, CLIVariant } from '../spec-shutdown/dockerUtils';
2121
import { Event } from '../spec-utils/event';
2222

2323

@@ -213,6 +213,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
213213
targetPlatformInfo
214214
}));
215215

216+
const cliVariant = await lookupCLIVariant({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output });
217+
216218
const dockerEngineVer = await dockerEngineVersion({
217219
cliHost,
218220
dockerCLI: dockerPath,
@@ -221,13 +223,13 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
221223
output,
222224
buildPlatformInfo,
223225
targetPlatformInfo
224-
});
226+
}, { useSimpleVersion: cliVariant === CLIVariant.Wslc });
225227

226228
return {
227229
common,
228230
parsedAuthority,
229231
dockerCLI: dockerPath,
230-
isPodman: await isPodman({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }),
232+
cliVariant,
231233
dockerComposeCLI: dockerComposeCLI,
232234
dockerEnv: cliHost.env,
233235
workspaceMountConsistencyDefault: workspaceMountConsistency,

src/spec-node/dockerCompose.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ContainerProperties, setupInContainer, ResolverProgress } from '../spec
1111
import { ContainerError } from '../spec-common/errors';
1212
import { Workspace } from '../spec-utils/workspaces';
1313
import { equalPaths, parseVersion, isEarlierVersion, CLIHost } from '../spec-common/commonUtils';
14-
import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters, removeContainer } from '../spec-shutdown/dockerUtils';
14+
import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils';
1515
import { DevContainerFromDockerComposeConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration';
1616
import { Log, LogLevel, makeLog, terminalEscapeSequences } from '../spec-utils/log';
1717
import { getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures';
@@ -188,7 +188,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
188188

189189
// determine whether we need to extend with features
190190
const version = parseVersion((await params.dockerComposeCLI()).version);
191-
const supportsAdditionalBuildContexts = !params.isPodman && version && !isEarlierVersion(version, [2, 17, 0]);
191+
const supportsAdditionalBuildContexts = params.cliVariant === CLIVariant.Docker && version && !isEarlierVersion(version, [2, 17, 0]);
192192
const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined };
193193
const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer);
194194

src/spec-node/singleContainer.ts

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, getDockerfilePath, getDockerContextPath, DockerResolverParameters, isDockerFileConfig, uriToWSLFsPath, WorkspaceConfiguration, getFolderImageName, inspectDockerImage, logUMask, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError, isBuildxCacheToInline } from './utils';
88
import { ContainerProperties, setupInContainer, ResolverProgress, ResolverParameters } from '../spec-common/injectHeadless';
99
import { ContainerError, toErrorText } from '../spec-common/errors';
10-
import { ContainerDetails, listContainers, DockerCLIParameters, inspectContainers, dockerCLI, dockerPtyCLI, toPtyExecParameters, ImageDetails, toExecParameters, removeContainer } from '../spec-shutdown/dockerUtils';
10+
import { ContainerDetails, listContainers, DockerCLIParameters, inspectContainers, dockerCLI, dockerPtyCLI, toPtyExecParameters, ImageDetails, toExecParameters, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils';
1111
import { DevContainerConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig } from '../spec-configuration/configuration';
1212
import { LogLevel, Log, makeLog } from '../spec-utils/log';
1313
import { extendImage, getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures';
@@ -347,8 +347,8 @@ export async function spawnDevContainer(params: DockerResolverParameters, config
347347
const exposedPorts = typeof appPort === 'number' || typeof appPort === 'string' ? [appPort] : appPort || [];
348348
const exposed = (<string[]>[]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port]));
349349

350-
const cwdMount = workspaceMount ? ['--mount', workspaceMount] : [];
351-
const additionalMount = additionalMountString ? ['--mount', additionalMountString] : [];
350+
const cwdMount = workspaceMount ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : [];
351+
const additionalMount = additionalMountString ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : [];
352352

353353
const envObj = mergedConfig.containerEnv || {};
354354
const containerEnv = Object.keys(envObj)
@@ -360,24 +360,30 @@ export async function spawnDevContainer(params: DockerResolverParameters, config
360360
const containerUserArgs = containerUser ? ['-u', containerUser] : [];
361361

362362
const featureArgs: string[] = [];
363-
if (mergedConfig.init) {
364-
featureArgs.push('--init');
365-
}
366-
if (mergedConfig.privileged) {
367-
featureArgs.push('--privileged');
368-
}
369-
for (const cap of mergedConfig.capAdd || []) {
370-
featureArgs.push('--cap-add', cap);
371-
}
372-
for (const securityOpt of mergedConfig.securityOpt || []) {
373-
featureArgs.push('--security-opt', securityOpt);
363+
// wslc does not support --init, --privileged, --cap-add, or --security-opt
364+
if (params.cliVariant !== CLIVariant.Wslc) {
365+
if (mergedConfig.init) {
366+
featureArgs.push('--init');
367+
}
368+
if (mergedConfig.privileged) {
369+
featureArgs.push('--privileged');
370+
}
371+
for (const cap of mergedConfig.capAdd || []) {
372+
featureArgs.push('--cap-add', cap);
373+
}
374+
for (const securityOpt of mergedConfig.securityOpt || []) {
375+
featureArgs.push('--security-opt', securityOpt);
376+
}
374377
}
375378

376379
const featureMounts = ([] as string[]).concat(
377380
...[
378381
...mergedConfig.mounts || [],
379382
...params.additionalMounts,
380-
].map(m => generateMountCommand(m))
383+
].map(m => {
384+
const mountArgs = generateMountCommand(m);
385+
return params.cliVariant === CLIVariant.Wslc ? convertMountArgsToVolume(mountArgs) : mountArgs;
386+
})
381387
);
382388

383389
const customEntrypoints = mergedConfig.entrypoints || [];
@@ -396,9 +402,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t
396402

397403
const args = [
398404
'run',
399-
'--sig-proxy=false',
400-
'-a', 'STDOUT',
401-
'-a', 'STDERR',
405+
...(params.cliVariant === CLIVariant.Wslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']),
402406
...exposed,
403407
...cwdMount,
404408
...additionalMount,
@@ -432,7 +436,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t
432436
}
433437

434438
async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageDetails: () => Promise<ImageDetails>): Promise<string[]> {
435-
if (params.isPodman && params.common.cliHost.platform === 'linux') {
439+
if (params.cliVariant === CLIVariant.Podman && params.common.cliHost.platform === 'linux') {
436440
const args = ['--security-opt', 'label=disable'];
437441
const hasIdMapping = (config.runArgs || []).some(arg => /--[ug]idmap(=|$)/.test(arg));
438442
if (!hasIdMapping) {
@@ -446,6 +450,32 @@ async function getPodmanArgs(params: DockerResolverParameters, config: DevContai
446450
return [];
447451
}
448452

453+
// Convert a --mount string (e.g., "type=bind,source=/a,target=/b,consistency=cached") to -v syntax for wslc.
454+
function convertMountToVolume(mountStr: string): string[] {
455+
const parts = new Map(mountStr.split(',').map(p => {
456+
const eq = p.indexOf('=');
457+
return eq === -1 ? [p, ''] : [p.substring(0, eq), p.substring(eq + 1)];
458+
}));
459+
const source = parts.get('source') || parts.get('src') || '';
460+
const target = parts.get('target') || parts.get('dst') || parts.get('destination') || '';
461+
if (source && target) {
462+
return ['-v', `${source}:${target}`];
463+
}
464+
if (target) {
465+
return ['-v', target];
466+
}
467+
// Fallback: pass as --mount and let the runtime handle it.
468+
return ['--mount', mountStr];
469+
}
470+
471+
// Convert --mount args array (e.g., ['--mount', 'type=bind,...']) to -v syntax for wslc.
472+
function convertMountArgsToVolume(args: string[]): string[] {
473+
if (args.length === 2 && args[0] === '--mount') {
474+
return convertMountToVolume(args[1]);
475+
}
476+
return args;
477+
}
478+
449479
function getLabels(labels: string[]): string[] {
450480
let result: string[] = [];
451481
labels.forEach(each => result.push('-l', each));

src/spec-node/utils.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import * as os from 'os';
1010
import { ContainerError, toErrorText } from '../spec-common/errors';
1111
import { CLIHost, runCommandNoPty, runCommand, getLocalUsername, PlatformInfo } from '../spec-common/commonUtils';
1212
import { Log, LogLevel, makeLog, nullLog } from '../spec-utils/log';
13+
import { delay } from '../spec-common/async';
1314

1415
import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, LifecycleCommand, ResolverParameters } from '../spec-common/injectHeadless';
1516
import { Workspace } from '../spec-utils/workspaces';
1617
import { URI } from 'vscode-uri';
1718
import { ShellServer } from '../spec-common/shellServer';
18-
import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer } from '../spec-shutdown/dockerUtils';
19+
import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils';
1920
import { getRemoteWorkspaceFolder } from './dockerCompose';
2021
import { findGitRootFolder } from '../spec-common/git';
2122
import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils';
@@ -108,7 +109,7 @@ export interface DockerResolverParameters {
108109
common: ResolverParameters;
109110
parsedAuthority: ParsedAuthority | undefined;
110111
dockerCLI: string;
111-
isPodman: boolean;
112+
cliVariant: CLIVariant;
112113
dockerComposeCLI: () => Promise<DockerComposeCLI>;
113114
dockerEnv: NodeJS.ProcessEnv;
114115
workspaceMountConsistencyDefault: BindMountConsistency;
@@ -170,6 +171,9 @@ export function addSubstitution<T extends DevContainerConfig | ImageMetadataEntr
170171
}
171172

172173
export async function startEventSeen(params: DockerResolverParameters, labels: Record<string, string>, canceled: Promise<void>, output: Log, trace: boolean) {
174+
if (params.cliVariant === CLIVariant.Wslc) {
175+
return startEventSeenPolling(params, labels, canceled, output, trace);
176+
}
173177
const eventsProcess = await getEvents(params, { event: ['start'] });
174178
return {
175179
started: new Promise<void>((resolve, reject) => {
@@ -209,6 +213,36 @@ export async function startEventSeen(params: DockerResolverParameters, labels: R
209213
};
210214
}
211215

216+
// Polling-based fallback for runtimes that don't support `events` (e.g., wslc).
217+
function startEventSeenPolling(params: DockerResolverParameters, labels: Record<string, string>, canceled: Promise<void>, output: Log, trace: boolean) {
218+
let stopped = false;
219+
canceled.catch(() => { stopped = true; });
220+
const labelFilters = Object.entries(labels).map(([k, v]) => `${k}=${v}`);
221+
return {
222+
started: new Promise<void>((resolve, reject) => {
223+
canceled.catch(reject);
224+
const poll = async () => {
225+
while (!stopped) {
226+
try {
227+
const containers = await listContainers(params, false, labelFilters);
228+
if (trace) {
229+
output.write(`Log: startEventSeenPolling found ${containers.length} container(s)\r\n`);
230+
}
231+
if (containers.length > 0) {
232+
resolve();
233+
return;
234+
}
235+
} catch (e) {
236+
// Ignore transient errors during polling.
237+
}
238+
await delay(500);
239+
}
240+
};
241+
poll();
242+
})
243+
};
244+
}
245+
212246
async function hasLabels(params: DockerResolverParameters, info: any, expectedLabels: Record<string, string>) {
213247
const actualLabels = info.Actor?.Attributes
214248
// Docker uses 'id', Podman 'ID'.

src/spec-shutdown/dockerUtils.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export interface PartialPtyExecParameters {
7777

7878
interface DockerResolverParameters {
7979
dockerCLI: string;
80-
isPodman: boolean;
80+
cliVariant: CLIVariant;
8181
dockerComposeCLI: () => Promise<DockerComposeCLI>;
8282
dockerEnv: NodeJS.ProcessEnv;
8383
common: {
@@ -171,6 +171,7 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa
171171
}
172172

173173
export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) {
174+
const useEvents = !('cliVariant' in params && params.cliVariant === CLIVariant.Wslc);
174175
let eventsProcess: Exec | undefined;
175176
let removedSeenP: Promise<void> | undefined;
176177
try {
@@ -184,7 +185,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP
184185
if (i === n - 1 || !stderr.includes('already in progress')) {
185186
throw err;
186187
}
187-
if (!removedSeenP) {
188+
if (useEvents && !removedSeenP) {
188189
eventsProcess = await getEvents(params, {
189190
container: [nameOrId],
190191
event: ['destroy'],
@@ -197,7 +198,11 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP
197198
});
198199
});
199200
}
200-
await Promise.race([removedSeenP, delay(1000)]);
201+
if (removedSeenP) {
202+
await Promise.race([removedSeenP, delay(1000)]);
203+
} else {
204+
await delay(1000);
205+
}
201206
}
202207
}
203208
} finally {
@@ -215,7 +220,7 @@ export async function getEvents(params: DockerCLIParameters | PartialExecParamet
215220
filterArgs.push('--filter', `${filter}=${value}`);
216221
}
217222
}
218-
const format = 'isPodman' in params && params.isPodman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981
223+
const format = 'cliVariant' in params && params.cliVariant === CLIVariant.Podman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981
219224
const combinedArgs = (args || []).concat(['events', '--format', format, ...filterArgs]);
220225

221226
const p = await exec({
@@ -260,13 +265,16 @@ export async function dockerBuildKitVersion(params: DockerCLIParameters | Partia
260265
}
261266
}
262267

263-
export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters): Promise<{ versionString: string; versionMatch?: string } | undefined> {
268+
export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, options?: { useSimpleVersion?: boolean }): Promise<{ versionString: string; versionMatch?: string } | undefined> {
264269
try {
265270
const execParams = {
266271
...toExecParameters(params),
267272
print: true,
268273
};
269-
const result = await dockerCLI(execParams, 'version', '--format', '{{.Server.Version}}');
274+
const args: string[] = options?.useSimpleVersion
275+
? ['version']
276+
: ['version', '--format', '{{.Server.Version}}'];
277+
const result = await dockerCLI(execParams, ...args);
270278
const versionString = result.stdout.toString().trim();
271279
const versionMatch = versionString.match(/(?<major>[0-9]+)\.(?<minor>[0-9]+)\.(?<patch>[0-9]+)/);
272280
if (!versionMatch) {
@@ -286,13 +294,26 @@ export async function dockerCLI(params: DockerCLIParameters | PartialExecParamet
286294
});
287295
}
288296

289-
export async function isPodman(params: PartialExecParameters) {
297+
export enum CLIVariant {
298+
Docker = 'docker',
299+
Podman = 'podman',
300+
Wslc = 'wslc',
301+
}
302+
303+
export async function lookupCLIVariant(params: PartialExecParameters): Promise<CLIVariant> {
290304
try {
291305
const { stdout } = await dockerCLI(params, '-v');
292-
return stdout.toString().toLowerCase().indexOf('podman') !== -1;
293-
} catch (err) {
294-
return false;
306+
const lower = stdout.toString().toLowerCase();
307+
if (lower.indexOf('wslc') !== -1) {
308+
return CLIVariant.Wslc;
309+
}
310+
if (lower.indexOf('podman') !== -1) {
311+
return CLIVariant.Podman;
312+
}
313+
} catch (_err) {
314+
// fall through
295315
}
316+
return CLIVariant.Docker;
296317
}
297318

298319
export async function dockerPtyCLI(params: PartialPtyExecParameters | DockerResolverParameters | DockerCLIParameters, ...args: string[]) {

0 commit comments

Comments
 (0)