Skip to content
This repository was archived by the owner on Jul 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0732c96
Add Finch container client support
tinovyatkin Jan 11, 2026
c7d6e60
Address code review feedback for Finch client
tinovyatkin Jan 11, 2026
63b293a
Rename FinchClient to NerdctlClient with configurable command
tinovyatkin Jan 12, 2026
d25a334
Fix nerdctl/finch compose compatibility issues
tinovyatkin Jan 13, 2026
4d025b8
Fix container E2E tests for nerdctl/finch compatibility
tinovyatkin Jan 13, 2026
88f686c
Refactor NerdctlEventRecord to use Zod transform for nested JSON parsing
tinovyatkin Jan 13, 2026
78a04ad
Add reusable Zod transforms and apply to Nerdctl record schemas
tinovyatkin Jan 13, 2026
927930f
Fix CodeRabbit review issues
tinovyatkin Jan 13, 2026
62e8eb9
Address review feedback: simplify ZodTransforms, compose client, tests
bwateratmsft Jul 16, 2026
943e01d
test: add unit tests for nerdctl client logic
bwateratmsft Jul 16, 2026
43324d6
refactor: hoist nerdctl output parsing into DockerClientBase helpers
bwateratmsft Jul 16, 2026
493d81e
Fix panel-review findings in nerdctl client
bwateratmsft Jul 16, 2026
b76f6cd
Split lines on /\r?\n/ in JSON parse helpers
bwateratmsft Jul 16, 2026
aeb047b
Merge branch 'main' into feature/add-finch-support
bwateratmsft Jul 16, 2026
b17152f
refactor: convert Nerdctl schemas to zod/mini API
bwateratmsft Jul 16, 2026
f81e80b
refactor: use native nerdctl cp for readFile/writeFile (nerdctl >= 2.…
bwateratmsft Jul 16, 2026
80f3c3e
test: cover writeFile stdin streaming (cp -) in E2E
bwateratmsft Jul 16, 2026
cbb7a7a
test: TEMP standalone stdin writeFile check (delete before merge)
bwateratmsft Jul 16, 2026
3c37a44
test: codespace E2E fixes for nerdctl file streaming
bwateratmsft Jul 16, 2026
49783c0
fix(test): keep detached E2E containers alive and clear lint errors
bwateratmsft Jul 16, 2026
cfa1075
test: remove temporary stdin writeFile validation test
bwateratmsft Jul 16, 2026
0d76f39
fix(nerdctl): address Copilot review feedback on NerdctlClient
bwateratmsft Jul 16, 2026
d1a75b9
refactor: unify Docker-like label parsing in ZodTransforms
bwateratmsft Jul 16, 2026
c132a39
fix: parse Docker IPv6 wildcard port bindings
bwateratmsft Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"<node_internals>/**"
],
"env": {
"CONTAINER_CLIENT_TYPE": "${input:containerRuntime}", // Set to 'docker' or 'podman'
"CONTAINER_CLIENT_TYPE": "${input:containerRuntime}", // Set to 'docker', 'podman', or 'nerdctl'
"RUN_IN_WSL": "0", // Set to 1 if running in WSL
"DOCKER_HUB_USERNAME": "${input:dockerHubUsername}",
"DOCKER_HUB_PAT": "${input:dockerHubPAT}"
Expand Down Expand Up @@ -87,7 +87,8 @@
"description": "Select the container runtime to use for integration tests",
"options": [
"docker",
"podman"
"podman",
"nerdctl"
],
"default": "docker"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,107 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo
*/
protected readonly defaultFormatForJson: string = "{{json .}}";

//#region Output parsing helpers

/**
* Parse newline-delimited JSON output (one JSON object per line, as emitted
* by `--format "{{json .}}"`) into normalized items. Empty lines are skipped.
* In non-strict mode, per-line and top-level parse errors are swallowed and
* whatever was successfully parsed is returned; in strict mode they propagate.
* @param output The raw command output
* @param strict Whether to throw on parse errors
* @param parseOne Callback that parses and normalizes a single line of JSON
* @returns The normalized items
*/
protected parsePerLineJson<T>(output: string, strict: boolean, parseOne: (json: string) => T): Promise<Array<T>> {
const results = new Array<T>();

// Split on \r?\n so both LF and CRLF line endings are handled cleanly.
for (const line of output.split(/\r?\n/)) {
if (!line) {
continue;
}

try {
results.push(parseOne(line));
} catch (err) {
if (strict) {
throw err;
}
}
}

return Promise.resolve(results);
}

/**
* Parse command output that may be either a single JSON value (array or
* object) or newline-delimited JSON objects. This handles clients/commands
* that inconsistently emit a JSON array vs. one object per line (e.g. multi
* target inspect).
* @param output The raw command output
* @param strict Whether to throw when a newline-delimited line fails to parse
* @returns The parsed values as a flat array of unknowns
*/
protected parseJsonArrayOrLines(output: string, strict: boolean = false): Array<unknown> {
const trimmed = output.trim();
if (!trimmed) {
return [];
}

// First, try to parse the whole output as a single JSON value
try {
const parsed: unknown = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed as Array<unknown> : [parsed];
} catch {
// Not a single JSON value (e.g. newline-delimited objects); fall through.
}

// Fall back to newline-delimited JSON objects (LF or CRLF)
const results = new Array<unknown>();
for (const line of trimmed.split(/\r?\n/)) {
if (!line) {
continue;
}
try {
results.push(JSON.parse(line));
} catch (err) {
if (strict) {
throw err;
}
// Otherwise skip unparseable lines
}
}
return results;
}

/**
* Parse inspect-style output (a JSON array or newline-delimited JSON objects)
* into normalized items. In non-strict mode, per-item normalization errors are
* swallowed; in strict mode they propagate.
* @param output The raw command output
* @param strict Whether to throw on normalization errors
* @param normalizeOne Callback that validates and normalizes a single parsed record
* @returns The normalized items
*/
protected parseInspectJson<T>(output: string, strict: boolean, normalizeOne: (item: unknown) => T): Promise<Array<T>> {
const results = new Array<T>();

for (const item of this.parseJsonArrayOrLines(output, strict)) {
try {
results.push(normalizeOne(item));
} catch (err) {
if (strict) {
throw err;
}
}
}

return Promise.resolve(results);
}

//#endregion

//#region Information Commands

protected getInfoCommandArgs(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,17 @@
*--------------------------------------------------------------------------------------------*/

import { Labels } from "../../contracts/ContainerClient";
import { parseLabelsString } from "../../contracts/ZodTransforms";

/**
* Parse Docker-like label string
* @param rawLabels Comma seperated string of labels
* @param rawLabels Comma separated string of labels
* @returns A {@link Labels} record
*
* Thin wrapper that delegates to {@link parseLabelsString} so the schema
* transforms in `contracts/ZodTransforms` and this client helper share a single
* implementation.
*/
export function parseDockerLikeLabels(rawLabels: string): Labels {
const labels: Labels = {};
let lastKey: string | undefined;

for (const fragment of rawLabels.split(',')) {
const index = fragment.indexOf('=');

if (index < 0) {
// No '=' means this fragment is a continuation of the previous label's
// value, which itself contained a comma (e.g. multiple compose config
// files in `com.docker.compose.project.config_files`). `docker ... ls`
// joins labels with commas and does not escape commas inside values, so
// we stitch the value back together here.
if (lastKey !== undefined) {
labels[lastKey] += `,${fragment}`;
}
continue;
}

lastKey = fragment.substring(0, index);
labels[lastKey] = fragment.substring(index + 1);
}

return labels;
return parseLabelsString(rawLabels);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,49 @@
import { PortBinding } from '../../contracts/ContainerClient';
import { normalizeIpAddress } from './normalizeIpAddress';

const shortFormRegex = /^(?<containerPort>\d+)\/(?<protocol>tcp|udp)$/i;

// Supports:
// - hostPort->containerPort[/protocol]
// - hostIp:hostPort->containerPort[/protocol]
// - [ipv6]:hostPort->containerPort[/protocol]
// - bare IPv6 host without brackets, e.g. Docker's `:::8080->80/tcp` wildcard
// or `::1:8080->80/tcp`. The optional host is captured lazily up to the last
// `:` before the host port, so embedded IPv6 colons are preserved; brackets
// (if any) are stripped by normalizeIpAddress.
const longFormRegex = /^(?:(?<host>\[[^\]]*\]|[^\s]*?):)?(?<hostPort>\d+)\s*->\s*(?<containerPort>\d+)(?:\/(?<protocol>tcp|udp))?$/i;

/**
* Attempt to parse a Docker-like raw port binding string
* @param portString the raw port string to parse, e.g. "1234/tcp" or "0.0.0.0:1234->1234/udp"
* @returns Parsed raw port string as a PortBinding record or undefined if invalid
*/
export function parseDockerRawPortString(portString: string): PortBinding | undefined {
const portRegex = /((?<hostIp>[\da-f.:[\]]+)(:(?<hostPort>\d+)))?(\s*->\s*)?((?<containerPort>\d+)\/(?<protocol>tcp|udp))/i;
const result = portRegex.exec(portString);

if (!result?.groups) {
const trimmed = portString.trim();
if (!trimmed) {
return undefined;
}

const hostIp = result.groups.hostIp || undefined;
const hostPort = result.groups.hostPort ? Number.parseInt(result.groups.hostPort) : undefined;
const containerPort = result.groups.containerPort ? Number.parseInt(result.groups.containerPort) : undefined;
const protocol = result.groups.protocol || undefined;
const shortMatch = shortFormRegex.exec(trimmed);
if (shortMatch?.groups) {
return {
containerPort: Number.parseInt(shortMatch.groups.containerPort, 10),
protocol: shortMatch.groups.protocol.toLowerCase() as 'tcp' | 'udp',
};
}

if (containerPort === undefined || (protocol !== 'tcp' && protocol !== 'udp')) {
const longMatch = longFormRegex.exec(trimmed);
if (!longMatch?.groups) {
return undefined;
}

const hostIp = normalizeIpAddress(longMatch.groups.host);
const protocol = (longMatch.groups.protocol?.toLowerCase() as 'tcp' | 'udp' | undefined) ?? 'tcp';

return {
hostIp: normalizeIpAddress(hostIp),
hostPort,
containerPort,
...(hostIp !== undefined ? { hostIp } : {}),
hostPort: Number.parseInt(longMatch.groups.hostPort, 10),
containerPort: Number.parseInt(longMatch.groups.containerPort, 10),
protocol,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
} from '../../contracts/ContainerOrchestratorClient';
import { ConfigurableClient } from '../ConfigurableClient';

function withCommonOrchestratorArgs(options: CommonOrchestratorCommandOptions): CommandLineCurryFn {
export function withCommonOrchestratorArgs(options: CommonOrchestratorCommandOptions): CommandLineCurryFn {
return composeArgs(
withNamedArg('--file', options.files),
withNamedArg('--env-file', options.environmentFile),
Expand All @@ -38,7 +38,7 @@ function withCommonOrchestratorArgs(options: CommonOrchestratorCommandOptions):
);
}

function withComposeArg(composeV2: boolean): CommandLineCurryFn {
export function withComposeArg(composeV2: boolean): CommandLineCurryFn {
// If using Compose V2, then add the `compose` argument at the beginning
// That way, the command is `docker compose` instead of `docker-compose`
return withArg(composeV2 ? 'compose' : undefined);
Expand Down
Loading
Loading