From 43e3c46666dbf3b72328c8e02866890cf6f8674f Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:32:50 -0600 Subject: [PATCH 01/11] vscode-container-client: add AppleContainerClient Adds a container-client for Apple's `container` CLI (macOS 26+, Apple Silicon only), gated on isMac() && isArm64() in officialRuntimeRegistrations.ts and exposed via the containers.containerClient setting. Extends DockerClientBase but overrides most command builders, since the CLI's surface diverges from Docker's beyond what Finch/Podman needed: container-object verbs are top-level (`run`/`list`/`stop`/ `delete`, not `container run`-style), `list`/`image list` accept no `--filter` flag at all (filtering is done client-side instead), and `list`/`image list` JSON is a nested, non-Docker-shaped record, so it gets its own schema/normalizer files. `image pull` is pinned to `--arch arm64` since it otherwise fetches every platform in a multi-arch manifest by default. `events`/`restart` are rejected since the CLI has no equivalent subcommand. All behavior was verified against a real CLI 1.2.0 install rather than assumed from docs, including a stdout/stderr split test confirming progress output never pollutes stdout, so the base class's stdout-only output parsing for run/stop/remove needed no override. Co-Authored-By: Claude Sonnet 5 --- extensions/vscode-containers/package.json | 6 +- .../runtimes/officialRuntimeRegistrations.ts | 6 +- .../AppleContainerClient.ts | 315 ++++++++++++++++++ .../AppleContainerListContainerRecord.ts | 63 ++++ .../AppleContainerListImageRecord.ts | 51 +++ packages/vscode-container-client/src/index.ts | 1 + 6 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts diff --git a/extensions/vscode-containers/package.json b/extensions/vscode-containers/package.json index ce771c37..a466bcb1 100644 --- a/extensions/vscode-containers/package.json +++ b/extensions/vscode-containers/package.json @@ -2594,7 +2594,8 @@ "com.microsoft.visualstudio.containers.podman", "com.microsoft.visualstudio.containers.nerdctl", "com.microsoft.visualstudio.containers.finch", - "com.microsoft.visualstudio.containers.wslc" + "com.microsoft.visualstudio.containers.wslc", + "com.microsoft.visualstudio.containers.applecontainer" ], "enumItemLabels": [ "Default", @@ -2602,7 +2603,8 @@ "Podman", "Nerdctl", "Finch", - "WSLC (Windows only, preview)" + "WSLC (Windows only, preview)", + "Container (macOS only, preview)" ] }, "containers.orchestratorClient": { diff --git a/extensions/vscode-containers/src/runtimes/officialRuntimeRegistrations.ts b/extensions/vscode-containers/src/runtimes/officialRuntimeRegistrations.ts index 9cdb4ad7..82c53823 100644 --- a/extensions/vscode-containers/src/runtimes/officialRuntimeRegistrations.ts +++ b/extensions/vscode-containers/src/runtimes/officialRuntimeRegistrations.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DockerClient, DockerComposeClient, FinchClient, FinchComposeClient, IContainerOrchestratorClient, IContainersClient, NerdctlClient, NerdctlComposeClient, PodmanClient, PodmanComposeClient, WslcClient } from '@microsoft/vscode-container-client'; -import { isWindows } from '../utils/osUtils'; +import { AppleContainerClient, DockerClient, DockerComposeClient, FinchClient, FinchComposeClient, IContainerOrchestratorClient, IContainersClient, NerdctlClient, NerdctlComposeClient, PodmanClient, PodmanComposeClient, WslcClient } from '@microsoft/vscode-container-client'; +import { isArm64, isMac, isWindows } from '../utils/osUtils'; /** * A client class that can be instantiated with no arguments and exposes its well-known id as a @@ -43,6 +43,8 @@ export const officialRuntimeRegistrations: readonly OfficialRuntimeRegistration[ { containerClient: FinchClient, orchestratorClient: FinchComposeClient }, // The WSL Container CLI is Windows-only and has no compose counterpart. { containerClient: WslcClient, isSupported: isWindows }, + // The Apple container CLI is Apple Silicon Mac only and has no compose counterpart. + { containerClient: AppleContainerClient, isSupported: () => isMac() && isArm64() }, ]; /** diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts new file mode 100644 index 00000000..9858038f --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -0,0 +1,315 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type CommandLineArgs, + composeArgs, + withArg, + withFlagArg, + withNamedArg, + withVerbatimArg, +} from '@microsoft/vscode-processutils'; +import type { GeneratorCommandResponse, PromiseCommandResponse } from '../../contracts/CommandRunner'; +import type { + CheckInstallCommandOptions, + EventItem, + EventStreamCommandOptions, + InfoCommandOptions, + InfoItem, + ListContainersCommandOptions, + ListContainersItem, + ListImagesCommandOptions, + ListImagesItem, + PullImageCommandOptions, + RemoveContainersCommandOptions, + RestartContainersCommandOptions, + RunContainerCommandOptions, + StopContainersCommandOptions, + VersionCommandOptions, + VersionItem, +} from '../../contracts/ContainerClient'; +import type { IContainersClient } from '../../contracts/ContainerClient'; +import { CommandNotSupportedError } from '../../utils/CommandNotSupportedError'; +import { DockerClientBase } from '../DockerClientBase/DockerClientBase'; +import { withDockerEnvArg } from '../DockerClientBase/withDockerEnvArg'; +import { withDockerLabelsArg } from '../DockerClientBase/withDockerLabelsArg'; +import { withDockerPlatformArg } from '../DockerClientBase/withDockerPlatformArg'; +import { withDockerPortsArg } from '../DockerClientBase/withDockerPortsArg'; +import { matchesLabelFilters } from '../DockerClientBase/matchesLabelFilters'; +import { AppleContainerListContainerRecordSchema, normalizeAppleContainerListContainerRecord } from './AppleContainerListContainerRecord'; +import { AppleContainerListImageRecordSchema, normalizeAppleContainerListImageRecord } from './AppleContainerListImageRecord'; + +/** + * {@link AppleContainerClient} implements {@link IContainersClient} for Apple's `container` + * CLI (macOS 26+, Apple Silicon only -- see https://github.com/apple/container). It extends + * {@link DockerClientBase} for its output-parsing helpers, but its command surface is not + * Docker-CLI-compatible enough to inherit much else -- most command-building methods are + * overridden. All behavior below was verified against real CLI 1.2.0 output; see + * `apple-container-poc-plan.md` at the repo root for the raw captures. + * + * Key differences vs. Docker: + * - The binary itself is the container noun -- container-object verbs are top-level + * (`container run`, `container list`, `container stop`, `container delete`), not + * `docker container run`-style. Image verbs do nest under `image`, matching Docker. + * - `list` / `image list` accept no `--filter` flag at all (stricter than even `wslc`); all + * filtering in {@link ListContainersCommandOptions} is applied client-side. + * - `--format json` is a literal token, not a Go template. + * - `image pull` fetches every platform in a multi-arch manifest by default; pinned to + * `--arch arm64` here since this client only ever runs on Apple Silicon. + * - No `events`, `restart`, or `info` subcommand exists. + * - `--version`/`-v` only accepts the long form; the short form errors. + */ +export class AppleContainerClient extends DockerClientBase implements IContainersClient { + /** + * The ID of the AppleContainer client + */ + public static ClientId = 'com.microsoft.visualstudio.containers.applecontainer'; + + /** + * `container ... --format` accepts the literal tokens `json`/`table`/`yaml`/`toml`, not a + * Go template. + */ + protected override readonly defaultFormatForJson: string = 'json'; + + /** + * Constructs a new {@link AppleContainerClient} + */ + public constructor( + commandName: string = 'container', + displayName: string = 'Container', + description: string = 'Runs container commands using the Apple container CLI (macOS, Apple Silicon only)' + ) { + super( + AppleContainerClient.ClientId, + commandName, + displayName, + description + ); + } + + //#region Information Commands + + // There is no `container info` subcommand. This client only ever runs on Apple Silicon + // Macs running Linux containers, so synthesize a minimal record rather than shelling out + // further (mirrors WslcClient, which has the same gap). `--version` is the cheapest command + // that proves the CLI is present; its output is not used. + protected override getInfoCommandArgs(options: InfoCommandOptions): CommandLineArgs { + return composeArgs(withArg('--version'))(); + } + + protected override parseInfoCommandOutput(output: string, strict: boolean): Promise { + return Promise.resolve({ + operatingSystem: undefined, + osType: 'linux', + raw: output, + }); + } + + // There is no top-level `version` subcommand -- only the daemon-dependent, plugin-backed + // `system version` (which, like all plugin subcommands, fails outright if system services + // aren't running, even just to print its own --help). The `--version` flag works + // unconditionally, so it's reused for both `version` and `checkInstall`. + protected override getVersionCommandArgs(options: VersionCommandOptions): CommandLineArgs { + return composeArgs(withArg('--version'))(); + } + + // Real output: "container CLI version 1.2.0 (build: release, commit: 6e65319)" + protected override parseVersionCommandOutput(output: string, strict: boolean): Promise { + const match = /version\s+(\d+(?:\.\d+)+)/i.exec(output); + if (!match && strict) { + throw new Error(`Unable to parse container version output: ${output}`); + } + + return Promise.resolve({ + client: match?.[1] ?? '', + server: undefined, + }); + } + + // Confirmed: `container -v` errors ("unknown option '-v'"); only the long flag works. + protected override getCheckInstallCommandArgs(options: CheckInstallCommandOptions): CommandLineArgs { + return composeArgs(withArg('--version'))(); + } + + // There is no `events` subcommand. + public override getEventStream(options: EventStreamCommandOptions): Promise> { + return Promise.reject(new CommandNotSupportedError('container does not support the events command.')); + } + + //#endregion + + //#region Image Commands + + protected override getPullImageCommandArgs(options: PullImageCommandOptions): CommandLineArgs { + if (options.allTags) { + throw new CommandNotSupportedError('container image pull does not support pulling all tags at once.'); + } + if (options.disableContentTrust !== undefined) { + throw new CommandNotSupportedError('container image pull does not support content trust settings.'); + } + + return composeArgs( + withArg('image', 'pull'), + // Without an explicit --arch, `image pull` fetches every platform in a multi-arch + // manifest (confirmed: 8 platforms fetched for one `alpine:latest` pull). This + // client only ever runs on Apple Silicon, so pin to arm64. + withNamedArg('--arch', 'arm64'), + withArg(options.imageRef), + )(); + } + + protected override getListImagesCommandArgs(options: ListImagesCommandOptions): CommandLineArgs { + // No --all, --filter, or --label-filter flags exist for `image list`; every option in + // ListImagesCommandOptions is applied client-side in parseListImagesCommandOutput. + return composeArgs( + withArg('image', 'list'), + withNamedArg('--format', this.defaultFormatForJson), + )(); + } + + protected override parseListImagesCommandOutput( + options: ListImagesCommandOptions, + output: string, + strict: boolean, + ): Promise> { + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerListImageRecord(AppleContainerListImageRecordSchema.parse(item))) + .then((items) => items.filter((item) => this.matchesListImagesOptions(item, options))); + } + + // `dangling` and `labels` have no equivalent in the captured `image list` output (no + // dangling concept, no per-image label surfaced at the top level) and are left + // unfiltered -- a known limitation, not a silent bug, since nothing in the schema claims + // to support them. + private matchesListImagesOptions(item: ListImagesItem, options: ListImagesCommandOptions): boolean { + if (options.references && options.references.length > 0) { + const name = item.image.originalName; + if (!name || !options.references.some((reference) => name === reference || name.startsWith(`${reference}:`) || name.startsWith(`${reference}@`))) { + return false; + } + } + + return true; + } + + //#endregion + + //#region Container Commands + + protected override getRunContainerCommandArgs(options: RunContainerCommandOptions): CommandLineArgs { + if (options.publishAllPorts) { + throw new CommandNotSupportedError('container run does not support publishing all ports.'); + } + if (options.networkAlias) { + throw new CommandNotSupportedError('container run does not support a network alias.'); + } + if (options.addHost && options.addHost.length > 0) { + throw new CommandNotSupportedError('container run does not support --add-host.'); + } + if (options.exposePorts && options.exposePorts.length > 0) { + throw new CommandNotSupportedError('container run does not support --expose.'); + } + + return composeArgs( + withArg('run'), + withFlagArg('--detach', options.detached), + withFlagArg('--interactive', options.interactive), + withFlagArg('--tty', options.detached || options.interactive), + withFlagArg('--rm', options.removeOnExit), + withNamedArg('--name', options.name), + withDockerPortsArg(options.ports), + withNamedArg('--network', options.network), + this.getRunContainerMountsArg(options.mounts), + withDockerLabelsArg(options.labels), + withDockerEnvArg(options.environmentVariables), + withNamedArg('--env-file', options.environmentFiles), + withNamedArg('--entrypoint', options.entrypoint), + withDockerPlatformArg(options.platform), + withVerbatimArg(options.customOptions), + withArg(options.imageRef), + typeof options.command === 'string' + ? withVerbatimArg(options.command) + : withArg(...(options.command ?? [])), + )(); + } + + // `container run --mount` uses `target=` for the in-container path, not Docker's + // `destination=`. + protected override getRunContainerMountsArg(mounts: RunContainerCommandOptions['mounts']) { + return withNamedArg( + '--mount', + (mounts ?? []).map((mount) => + [`type=${mount.type}`, `source=${mount.source}`, `target=${mount.destination}`, mount.readOnly ? 'readonly' : ''] + .filter((part) => !!part) + .join(',')), + ); + } + + protected override getListContainersCommandArgs(options: ListContainersCommandOptions): CommandLineArgs { + // No --filter flag exists for `list`. `--all` is passed whenever a filter that needs + // to see non-running containers is requested; the default (no --all) already limits + // results to running containers, which covers the common "list running" case for free. + // Every other option in ListContainersCommandOptions is applied client-side below. + return composeArgs( + withArg('list'), + withFlagArg('--all', options.all || options.exited), + withNamedArg('--format', this.defaultFormatForJson), + )(); + } + + protected override parseListContainersCommandOutput( + options: ListContainersCommandOptions, + output: string, + strict: boolean, + ): Promise> { + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerListContainerRecord(AppleContainerListContainerRecordSchema.parse(item))) + .then((items) => items.filter((item) => this.matchesListContainersOptions(item, options))); + } + + // `imageAncestors`/`volumes`/`networks` filters have no client-side equivalent that can be + // derived safely from `list` output (no resolved image digest or volume attachment info is + // present) and are left unfiltered -- deferred along with the rest of the volume/network + // command surface. + private matchesListContainersOptions(item: ListContainersItem, options: ListContainersCommandOptions): boolean { + if (options.running && item.state !== 'running') { + return false; + } + if (options.exited && item.state !== 'stopped') { + return false; + } + if (options.names && options.names.length > 0 && !options.names.includes(item.name)) { + return false; + } + if (!matchesLabelFilters(item.labels, options.labels)) { + return false; + } + + return true; + } + + protected override getStopContainersCommandArgs(options: StopContainersCommandOptions): CommandLineArgs { + return composeArgs( + withArg('stop'), + withNamedArg('--time', typeof options.time === 'number' ? options.time.toString() : undefined), + withArg(...options.container), + )(); + } + + protected override getRemoveContainersCommandArgs(options: RemoveContainersCommandOptions): CommandLineArgs { + return composeArgs( + withArg('delete'), + withFlagArg('--force', options.force), + withArg(...options.containers), + )(); + } + + // There is no `restart` subcommand. + public override restartContainers(options: RestartContainersCommandOptions): Promise>> { + return Promise.reject(new CommandNotSupportedError('container does not support the restart command.')); + } + + //#endregion +} diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts new file mode 100644 index 00000000..8e2a72bf --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as z from 'zod/mini'; +import type { ListContainersItem } from '../../contracts/ContainerClient'; +import { dateStringWithFallbackSchema } from '../../contracts/ZodTransforms'; +import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; + +const AppleContainerNetworkAttachmentSchema = z.object({ + network: z.optional(z.string()), +}); + +/** + * `container list --format json` emits a nested, non-Docker-like shape (captured against + * real CLI 1.2.0 output): `{configuration: {id, image: {reference}, labels, networks, ...}, + * id, status: {state, networks, startedDate}}`. There is no flat `Names`/`Image`/`Ports` + * record to reuse from `SharedListContainerRecordSchema`, so this keeps its own module. + */ +export const AppleContainerListContainerRecordSchema = z.object({ + id: z.string(), + configuration: z.object({ + creationDate: z.optional(dateStringWithFallbackSchema), + image: z.object({ + reference: z.optional(z.string()), + }), + labels: z.optional(z.record(z.string(), z.string())), + networks: z.optional(z.array(AppleContainerNetworkAttachmentSchema)), + }), + status: z.object({ + state: z.optional(z.string()), + }), +}); + +export type AppleContainerListContainerRecord = z.infer; + +/** + * Normalize a parsed {@link AppleContainerListContainerRecord} to the common + * {@link ListContainersItem}. + */ +export function normalizeAppleContainerListContainerRecord(container: AppleContainerListContainerRecord): ListContainersItem { + return { + id: container.id, + // The `container` CLI has no name distinct from the container's ID -- `--name` (or + // the auto-generated ID) is the same value in both places. + name: container.id, + labels: container.configuration.labels ?? {}, + image: parseDockerLikeImageName(container.configuration.image.reference), + // `configuration.publishedPorts` shape hasn't been captured against a real `--publish` + // run yet; left empty rather than guessing field names. Fill in once verified. + ports: [], + networks: (container.configuration.networks ?? []) + .map((attachment) => attachment.network) + .filter((name): name is string => !!name), + createdAt: container.configuration.creationDate ?? new Date(0), + // Observed values: 'running', 'stopped'. Passed through as-is; the contract's `state` + // is a loosely-typed string, and no other values have been observed to map. + state: container.status.state ?? 'unknown', + // No human-readable status string (e.g. Docker's "Up 5 minutes") is emitted. + status: undefined, + }; +} diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts new file mode 100644 index 00000000..2dd1d21e --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as z from 'zod/mini'; +import type { ListImagesItem } from '../../contracts/ContainerClient'; +import { dateStringWithFallbackSchema } from '../../contracts/ZodTransforms'; +import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; + +const AppleContainerImageVariantSchema = z.object({ + size: z.optional(z.number()), +}); + +/** + * `container image list --format json` emits a manifest-list-oriented shape (captured + * against real CLI 1.2.0 output), not Docker's flat `Repository`/`Tag`/`ID`/`Size`: the + * repository/tag live in `configuration.descriptor.name` (e.g. + * `"docker.io/library/alpine:latest"`), and per-platform blobs live under `variants[]`. + * `image pull` defaults to fetching every platform in a multi-arch manifest -- `variants` + * reflects what's actually present locally (confirmed: an image pulled with `--arch arm64` + * has exactly one variant), so summing `variants[].size` gives the real on-disk size rather + * than double-counting undownloaded platforms. + */ +export const AppleContainerListImageRecordSchema = z.object({ + id: z.string(), + configuration: z.object({ + creationDate: z.optional(dateStringWithFallbackSchema), + descriptor: z.object({ + name: z.optional(z.string()), + }), + }), + variants: z.optional(z.array(AppleContainerImageVariantSchema)), +}); + +export type AppleContainerListImageRecord = z.infer; + +/** + * Normalize a parsed {@link AppleContainerListImageRecord} to the common + * {@link ListImagesItem}. + */ +export function normalizeAppleContainerListImageRecord(image: AppleContainerListImageRecord): ListImagesItem { + const size = (image.variants ?? []).reduce((total, variant) => total + (variant.size ?? 0), 0); + + return { + id: image.id, + image: parseDockerLikeImageName(image.configuration.descriptor.name), + createdAt: image.configuration.creationDate ?? new Date(0), + size: size > 0 ? size : undefined, + }; +} diff --git a/packages/vscode-container-client/src/index.ts b/packages/vscode-container-client/src/index.ts index df5d432c..8d159ffc 100644 --- a/packages/vscode-container-client/src/index.ts +++ b/packages/vscode-container-client/src/index.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ +export * from './clients/AppleContainerClient/AppleContainerClient'; export * from './clients/DockerClient/DockerClient'; export * from './clients/DockerComposeClient/DockerComposeClient'; export * from './clients/FinchClient/FinchClient'; From c66052f91ff98a8ed1147d59bfc5f9dbca352448 Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:38:04 -0600 Subject: [PATCH 02/11] vscode-container-client: fix AppleContainer image name/size parsing configuration.name is a sibling of configuration.descriptor in real `container image list --format json` output, not nested inside it. The schema had it nested, so zod/mini silently dropped the field instead of erroring, and every image parsed as : -- visible in the Images tree as a parent with children. Also exclude platform.architecture "unknown" variants from the size sum. Each real platform in a multi-arch pull is paired with a ~86KB attestation/provenance blob reported as its own "unknown/unknown" variant; summing those in inflated the reported image size. Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerListImageRecord.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts index 2dd1d21e..5b1496e4 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts @@ -10,25 +10,29 @@ import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; const AppleContainerImageVariantSchema = z.object({ size: z.optional(z.number()), + platform: z.optional(z.object({ + architecture: z.optional(z.string()), + })), }); /** * `container image list --format json` emits a manifest-list-oriented shape (captured * against real CLI 1.2.0 output), not Docker's flat `Repository`/`Tag`/`ID`/`Size`: the - * repository/tag live in `configuration.descriptor.name` (e.g. + * repository/tag live in `configuration.name` (a sibling of `configuration.descriptor`, e.g. * `"docker.io/library/alpine:latest"`), and per-platform blobs live under `variants[]`. * `image pull` defaults to fetching every platform in a multi-arch manifest -- `variants` * reflects what's actually present locally (confirmed: an image pulled with `--arch arm64` * has exactly one variant), so summing `variants[].size` gives the real on-disk size rather - * than double-counting undownloaded platforms. + * than double-counting undownloaded platforms. Each real platform variant is paired with a + * same-sized-ish `platform.architecture: "unknown"` attestation/provenance blob (~86KB, + * confirmed present for every real platform in a multi-arch pull); those are excluded from + * the size sum since they aren't part of the image itself. */ export const AppleContainerListImageRecordSchema = z.object({ id: z.string(), configuration: z.object({ creationDate: z.optional(dateStringWithFallbackSchema), - descriptor: z.object({ - name: z.optional(z.string()), - }), + name: z.optional(z.string()), }), variants: z.optional(z.array(AppleContainerImageVariantSchema)), }); @@ -40,11 +44,13 @@ export type AppleContainerListImageRecord = z.infer total + (variant.size ?? 0), 0); + const size = (image.variants ?? []) + .filter((variant) => variant.platform?.architecture !== 'unknown') + .reduce((total, variant) => total + (variant.size ?? 0), 0); return { id: image.id, - image: parseDockerLikeImageName(image.configuration.descriptor.name), + image: parseDockerLikeImageName(image.configuration.name), createdAt: image.configuration.creationDate ?? new Date(0), size: size > 0 ? size : undefined, }; From b5d9602daf3ee0da72902e43fedd6ec0cda6f257 Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:53 -0600 Subject: [PATCH 03/11] vscode-container-client: fix AppleContainer image/container inspect `image inspect` and `inspect` accept no --format flag (confirmed: "Unknown option '--format'"); JSON is their only output. `inspect` (containers) is also a bare verb like run/list/stop/delete, not `container inspect`. Both were left un-overridden and inherited DockerClientBase's --format-based defaults, breaking "Run Interactive" and the image/container hover tooltips entirely. Adds AppleContainerInspectImageRecord.ts and AppleContainerInspectContainerRecord.ts, following the same real-JSON-shape-first approach as the list records. Also fixes ListImagesItem.id/InspectImagesItem.id: `container` has no ID-based image addressing at all -- image inspect/rm/run reject a bare digest, a sha256:-prefixed digest, and even name@sha256:digest, only a name:tag reference resolves. id was set to the manifest digest, which this CLI can never look up, breaking every downstream call that reuses it (tooltip inspection, image-ancestor container filtering). id is now the name:tag reference itself, the only value actually usable as a CLI argument for this runtime. Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerClient.ts | 42 ++++++++ .../AppleContainerInspectContainerRecord.ts | 97 +++++++++++++++++++ .../AppleContainerInspectImageRecord.ts | 97 +++++++++++++++++++ .../AppleContainerListImageRecord.ts | 13 ++- 4 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts index 9858038f..ee437fdc 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -18,6 +18,10 @@ import type { EventStreamCommandOptions, InfoCommandOptions, InfoItem, + InspectContainersCommandOptions, + InspectContainersItem, + InspectImagesCommandOptions, + InspectImagesItem, ListContainersCommandOptions, ListContainersItem, ListImagesCommandOptions, @@ -38,6 +42,8 @@ import { withDockerLabelsArg } from '../DockerClientBase/withDockerLabelsArg'; import { withDockerPlatformArg } from '../DockerClientBase/withDockerPlatformArg'; import { withDockerPortsArg } from '../DockerClientBase/withDockerPortsArg'; import { matchesLabelFilters } from '../DockerClientBase/matchesLabelFilters'; +import { AppleContainerInspectContainerRecordSchema, normalizeAppleContainerInspectContainerRecord } from './AppleContainerInspectContainerRecord'; +import { AppleContainerInspectImageRecordSchema, normalizeAppleContainerInspectImageRecord } from './AppleContainerInspectImageRecord'; import { AppleContainerListContainerRecordSchema, normalizeAppleContainerListContainerRecord } from './AppleContainerListContainerRecord'; import { AppleContainerListImageRecordSchema, normalizeAppleContainerListImageRecord } from './AppleContainerListImageRecord'; @@ -194,6 +200,24 @@ export class AppleContainerClient extends DockerClientBase implements IContainer return true; } + // No --format flag exists for `image inspect` (confirmed: errors with "Unknown option + // '--format'"); JSON is the only output it produces. + protected override getInspectImagesCommandArgs(options: InspectImagesCommandOptions): CommandLineArgs { + return composeArgs( + withArg('image', 'inspect'), + withArg(...options.imageRefs), + )(); + } + + protected override parseInspectImagesCommandOutput( + options: InspectImagesCommandOptions, + output: string, + strict: boolean, + ): Promise> { + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerInspectImageRecord(AppleContainerInspectImageRecordSchema.parse(item), JSON.stringify(item))); + } + //#endregion //#region Container Commands @@ -311,5 +335,23 @@ export class AppleContainerClient extends DockerClientBase implements IContainer return Promise.reject(new CommandNotSupportedError('container does not support the restart command.')); } + // Bare `inspect` (not `container inspect`), and no --format flag exists (confirmed: errors + // with "Unknown option '--format'"); JSON is the only output it produces. + protected override getInspectContainersCommandArgs(options: InspectContainersCommandOptions): CommandLineArgs { + return composeArgs( + withArg('inspect'), + withArg(...options.containers), + )(); + } + + protected override parseInspectContainersCommandOutput( + options: InspectContainersCommandOptions, + output: string, + strict: boolean, + ): Promise> { + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerInspectContainerRecord(AppleContainerInspectContainerRecordSchema.parse(item), JSON.stringify(item))); + } + //#endregion } diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts new file mode 100644 index 00000000..ee140565 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as z from 'zod/mini'; +import type { InspectContainersItem, InspectContainersItemNetwork } from '../../contracts/ContainerClient'; +import { dateStringWithFallbackSchema } from '../../contracts/ZodTransforms'; +import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; +import { parseDockerLikeEnvironmentVariables } from '../DockerClientBase/parseDockerLikeEnvironmentVariables'; + +const AppleContainerStatusNetworkSchema = z.object({ + network: z.optional(z.string()), + ipv4Address: z.optional(z.string()), + ipv4Gateway: z.optional(z.string()), + macAddress: z.optional(z.string()), +}); + +/** + * `container inspect ` emits the same nested shape as `container list` (see + * `AppleContainerListContainerRecord.ts`), with the full `initProcess` and richer + * `status.networks` entries added. No `--format` flag exists for this command -- confirmed: + * `container inspect --format json ` errors with "Unknown option '--format'"; JSON is the + * only output this command produces. The verb is also bare `inspect`, not `container inspect`. + */ +export const AppleContainerInspectContainerRecordSchema = z.object({ + id: z.string(), + configuration: z.object({ + creationDate: z.optional(dateStringWithFallbackSchema), + image: z.object({ + descriptor: z.optional(z.object({ + digest: z.optional(z.string()), + })), + reference: z.optional(z.string()), + }), + initProcess: z.optional(z.object({ + executable: z.optional(z.string()), + arguments: z.optional(z.array(z.string())), + environment: z.optional(z.array(z.string())), + workingDirectory: z.optional(z.string()), + })), + labels: z.optional(z.record(z.string(), z.string())), + }), + status: z.object({ + startedDate: z.optional(dateStringWithFallbackSchema), + networks: z.optional(z.array(AppleContainerStatusNetworkSchema)), + }), +}); + +export type AppleContainerInspectContainerRecord = z.infer; + +function stripDigestPrefix(digest: string | undefined): string { + return digest?.replace(/^sha256:/, '') ?? ''; +} + +/** + * Normalize a parsed {@link AppleContainerInspectContainerRecord} to the common + * {@link InspectContainersItem}. + */ +export function normalizeAppleContainerInspectContainerRecord(container: AppleContainerInspectContainerRecord, raw: string): InspectContainersItem { + const initProcess = container.configuration.initProcess; + const networks: InspectContainersItemNetwork[] = (container.status.networks ?? []).map((network) => ({ + name: network.network ?? '', + gateway: network.ipv4Gateway, + ipAddress: network.ipv4Address, + macAddress: network.macAddress, + })); + + return { + id: container.id, + // The `container` CLI has no name distinct from the container's ID; see the same note + // in AppleContainerListContainerRecord.ts. + name: container.id, + imageId: stripDigestPrefix(container.configuration.image.descriptor?.digest), + image: parseDockerLikeImageName(container.configuration.image.reference), + isolation: undefined, + status: undefined, + environmentVariables: parseDockerLikeEnvironmentVariables(initProcess?.environment ?? []), + networks, + ipAddress: networks[0]?.ipAddress, + operatingSystem: 'linux', + // `configuration.publishedPorts`/`.mounts` shapes haven't been captured against real + // `--publish`/`--mount` runs yet; left empty rather than guessing field names. + ports: [], + mounts: [], + labels: container.configuration.labels ?? {}, + // Apple Container has no separate entrypoint/cmd split in inspect output -- only the + // fully resolved init process (executable + arguments) is reported. + entrypoint: [], + command: initProcess?.executable ? [initProcess.executable, ...(initProcess.arguments ?? [])] : [], + currentDirectory: initProcess?.workingDirectory, + createdAt: container.configuration.creationDate ?? new Date(0), + startedAt: container.status.startedDate, + finishedAt: undefined, + raw, + }; +} diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts new file mode 100644 index 00000000..16470957 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as z from 'zod/mini'; +import type { InspectImagesItem } from '../../contracts/ContainerClient'; +import { architectureStringSchema, dateStringWithFallbackSchema, osTypeStringSchema } from '../../contracts/ZodTransforms'; +import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; +import { parseDockerLikeEnvironmentVariables } from '../DockerClientBase/parseDockerLikeEnvironmentVariables'; + +const AppleContainerImageVariantConfigSchema = z.object({ + Cmd: z.optional(z.array(z.string())), + Entrypoint: z.optional(z.array(z.string())), + Env: z.optional(z.array(z.string())), + WorkingDir: z.optional(z.string()), + Labels: z.optional(z.record(z.string(), z.string())), + User: z.optional(z.string()), +}); + +const AppleContainerInspectImageVariantSchema = z.object({ + platform: z.optional(z.object({ + architecture: z.optional(z.string()), + os: z.optional(z.string()), + })), + config: z.optional(z.object({ + config: z.optional(AppleContainerImageVariantConfigSchema), + })), +}); + +/** + * `container image inspect ` emits the same manifest-list-oriented shape as `image list` + * (see `AppleContainerListImageRecord.ts`), just with the full OCI image config nested under + * `variants[].config.config` instead of only `variants[].size`. No `--format` flag exists for + * this command -- confirmed: `container image inspect --format json ` errors with + * "Unknown option '--format'"; JSON is the only output this command produces. + */ +export const AppleContainerInspectImageRecordSchema = z.object({ + id: z.string(), + configuration: z.object({ + creationDate: z.optional(dateStringWithFallbackSchema), + descriptor: z.optional(z.object({ + digest: z.optional(z.string()), + })), + name: z.optional(z.string()), + }), + variants: z.optional(z.array(AppleContainerInspectImageVariantSchema)), +}); + +export type AppleContainerInspectImageRecord = z.infer; + +/** + * A multi-platform image reports one variant per platform, plus an unrelated + * `platform.architecture: "unknown"` attestation blob per real platform (see + * `AppleContainerListImageRecord.ts`). Prefer the arm64/linux variant, since this client only + * ever runs on Apple Silicon; fall back to the first non-attestation variant otherwise. + */ +function selectPrimaryVariant(variants: AppleContainerInspectImageRecord['variants']) { + const usable = (variants ?? []).filter((variant) => variant.platform?.architecture !== 'unknown'); + return usable.find((variant) => variant.platform?.architecture === 'arm64' && variant.platform?.os === 'linux') ?? usable[0]; +} + +/** + * Normalize a parsed {@link AppleContainerInspectImageRecord} to the common + * {@link InspectImagesItem}. + */ +export function normalizeAppleContainerInspectImageRecord(image: AppleContainerInspectImageRecord, raw: string): InspectImagesItem { + const variant = selectPrimaryVariant(image.variants); + const config = variant?.config?.config; + + return { + // `container` has no ID-based image addressing (see the note in + // AppleContainerListImageRecord.ts); mirror that file's `id` choice so this stays a + // usable CLI reference rather than an inert digest. + id: image.configuration.name ?? image.id, + image: parseDockerLikeImageName(image.configuration.name), + repoDigests: image.configuration.descriptor?.digest ? [image.configuration.descriptor.digest] : [], + // `image inspect` doesn't distinguish local-only images from ones pulled from a + // registry; every inspectable image is on-disk, so this is always true. + isLocalImage: true, + environmentVariables: parseDockerLikeEnvironmentVariables(config?.Env ?? []), + // No ExposedPorts-equivalent field observed in the OCI image config `image inspect` + // emits. + ports: [], + // No Volumes-equivalent field observed. + volumes: [], + labels: config?.Labels ?? {}, + entrypoint: config?.Entrypoint ?? [], + command: config?.Cmd ?? [], + currentDirectory: config?.WorkingDir, + architecture: architectureStringSchema.parse(variant?.platform?.architecture ?? ''), + operatingSystem: osTypeStringSchema.parse(variant?.platform?.os ?? ''), + createdAt: image.configuration.creationDate, + user: config?.User, + raw, + }; +} diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts index 5b1496e4..424ae3e2 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts @@ -27,6 +27,14 @@ const AppleContainerImageVariantSchema = z.object({ * same-sized-ish `platform.architecture: "unknown"` attestation/provenance blob (~86KB, * confirmed present for every real platform in a multi-arch pull); those are excluded from * the size sum since they aren't part of the image itself. + * + * Unlike every other client this extension supports, `container` has no ID-based image + * addressing at all: `image inspect`/`image rm`/`run` reject a bare digest, a `sha256:`- + * prefixed digest, and even `name@sha256:digest` (all confirmed to fail with "image not + * found"); only a `name:tag` reference resolves. `ListImagesItem.id` round-trips into those + * commands elsewhere in the extension (tooltip inspection, image-ancestor container filtering), + * so it's set to the name:tag reference here rather than the manifest digest -- the only value + * that's actually usable as a CLI argument for this runtime. */ export const AppleContainerListImageRecordSchema = z.object({ id: z.string(), @@ -49,7 +57,10 @@ export function normalizeAppleContainerListImageRecord(image: AppleContainerList .reduce((total, variant) => total + (variant.size ?? 0), 0); return { - id: image.id, + // Falls back to the (functionally unusable) digest only for images with no name -- + // ListImagesItem.id must be a non-empty string, and such images can't be individually + // referenced by this CLI at all regardless of what string is put here. + id: image.configuration.name ?? image.id, image: parseDockerLikeImageName(image.configuration.name), createdAt: image.configuration.creationDate ?? new Date(0), size: size > 0 ? size : undefined, From 62b24e33125439faeaa8ca39c7f57d582083407e Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:18:39 -0600 Subject: [PATCH 04/11] vscode-container-client: add AppleContainerClient unit tests Adds 34 unit tests covering every overridden command: checkInstall, version, info, pullImage, listImages, inspectImages, runContainer, listContainers, stopContainers, removeContainers, inspectContainers, and the unsupported getEventStream/restartContainers rejections. Fixtures are trimmed from real `container` CLI 1.2.0 output captured on Apple Silicon hardware (see apple-container-poc-plan.md), not hand-guessed shapes, and assert the specific quirks already found and fixed: configuration.name vs configuration.descriptor.name, the "unknown" attestation variant excluded from image size, id being the name:tag reference rather than the digest, bare verbs (run/list/stop/ delete/inspect) instead of container-prefixed ones, and --mount using target= instead of destination=. Full package suite (269 tests) and lint pass with this change. Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerClient.test.ts | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts new file mode 100644 index 00000000..172dd90a --- /dev/null +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -0,0 +1,364 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CommandLineArgs, NoShell } from '@microsoft/vscode-processutils'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { AppleContainerClient } from '../../../clients/AppleContainerClient/AppleContainerClient'; +import { CommandNotSupportedError } from '../../../utils/CommandNotSupportedError'; + +// NoShell(false).quote() returns the raw, unquoted arg values (platform-independent), which is what +// these arg-shape assertions compare against. +const noShell = new NoShell(false); +function asStrings(args: CommandLineArgs): string[] { + return noShell.quote(args); +} + +async function expectRejection(promiseOrFn: Promise | (() => Promise)): Promise { + let caught: unknown; + try { + const promise = typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn; + await promise; + } catch (err) { + caught = err; + } + expect(caught).to.be.instanceOf(CommandNotSupportedError); +} + +// Fixtures below are trimmed from real `container` CLI 1.2.0 output captured on real Apple +// Silicon hardware (see apple-container-poc-plan.md at the repo root), not hand-guessed shapes. + +const alpineImageListRecord = { + id: '28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', + configuration: { + creationDate: '2026-06-16T00:00:15Z', + descriptor: { digest: 'sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', mediaType: 'application/vnd.oci.image.index.v1+json', size: 9218 }, + name: 'docker.io/library/alpine:latest', + }, + variants: [ + { digest: 'sha256:e7a1a92a5bfeee40966aea60f0796b0e7917cc35591542701834f03a68fa3d18', platform: { architecture: 'arm64', os: 'linux', variant: 'v8' }, size: 4184689 }, + { digest: 'sha256:d9dc32c63a23ac682a41ab2eae01051d2a4fbe472eefd109faf97be63a5216e5', platform: { architecture: 'unknown', os: 'unknown' }, size: 86390 }, + ], +}; + +const alpineImageInspectRecord = { + id: '28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', + configuration: { + creationDate: '2026-06-16T00:00:15Z', + descriptor: { digest: 'sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', mediaType: 'application/vnd.oci.image.index.v1+json', size: 9218 }, + name: 'docker.io/library/alpine:latest', + }, + variants: [ + { + digest: 'sha256:e7a1a92a5bfeee40966aea60f0796b0e7917cc35591542701834f03a68fa3d18', + platform: { architecture: 'arm64', os: 'linux', variant: 'v8' }, + size: 4184689, + config: { config: { Cmd: ['/bin/sh'], Env: ['PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'], WorkingDir: '/' } }, + }, + { + digest: 'sha256:d9dc32c63a23ac682a41ab2eae01051d2a4fbe472eefd109faf97be63a5216e5', + platform: { architecture: 'unknown', os: 'unknown' }, + size: 86390, + config: { config: {} }, + }, + ], +}; + +const pocContainerListRecord = { + id: 'poc-test', + configuration: { + creationDate: '2026-08-04T18:09:35Z', + image: { descriptor: { digest: 'sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', mediaType: 'application/vnd.oci.image.index.v1+json', size: 9218 }, reference: 'docker.io/library/alpine:latest' }, + labels: {}, + networks: [{ network: 'default', options: { hostname: 'poc-test', mtu: 1280 } }], + }, + status: { + networks: [{ hostname: 'poc-test', ipv4Address: '192.168.65.2/24', ipv4Gateway: '192.168.65.1', macAddress: 'fa:84:d6:66:7f:af', mtu: 1280, network: 'default', variant: 'reserved' }], + startedDate: '2026-08-04T18:09:37Z', + state: 'running', + }, +}; + +const pocContainerInspectRecord = { + ...pocContainerListRecord, + configuration: { + ...pocContainerListRecord.configuration, + initProcess: { + arguments: ['300'], + environment: ['PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'], + executable: 'sleep', + workingDirectory: '/', + }, + }, +}; + +describe('(unit) AppleContainerClient', () => { + const client = new AppleContainerClient(); + + it('Has the expected ClientId and default command', () => { + expect(AppleContainerClient.ClientId).to.equal('com.microsoft.visualstudio.containers.applecontainer'); + expect(client.id).to.equal('com.microsoft.visualstudio.containers.applecontainer'); + expect(client.commandName).to.equal('container'); + expect(client.displayName).to.equal('Container'); + }); + + describe('#checkInstall()', () => { + it('Produces `--version` args (no `-v` shorthand; confirmed unsupported)', async () => { + const response = await client.checkInstall({}); + expect(asStrings(response.args)).to.deep.equal(['--version']); + }); + }); + + describe('#version()', () => { + it('Produces `--version` args (no top-level `version` subcommand)', async () => { + const response = await client.version({}); + expect(asStrings(response.args)).to.deep.equal(['--version']); + }); + + it('Parses "container CLI version 1.2.0 (build: release, commit: 6e65319)"', async () => { + const response = await client.version({}); + const parsed = await response.parse('container CLI version 1.2.0 (build: release, commit: 6e65319)\n', true); + expect(parsed).to.have.property('client', '1.2.0'); + expect(parsed).to.have.property('server', undefined); + }); + }); + + describe('#info()', () => { + it('Synthesizes a linux InfoItem because container has no info command', async () => { + const response = await client.info({}); + const item = await response.parse('whatever', false); + expect(item).to.have.property('osType', 'linux'); + }); + }); + + describe('Unsupported commands', () => { + it('getEventStream rejects with CommandNotSupportedError', async () => { + await expectRejection(client.getEventStream({})); + }); + + it('restartContainers rejects with CommandNotSupportedError', async () => { + await expectRejection(client.restartContainers({ container: ['abc'] })); + }); + }); + + describe('#pullImage()', () => { + it('Pins --arch arm64 to avoid the default multi-platform fetch', async () => { + const response = await client.pullImage({ imageRef: 'alpine:latest' }); + expect(asStrings(response.args)).to.deep.equal(['image', 'pull', '--arch', 'arm64', 'alpine:latest']); + }); + + it('Throws when allTags is set', async () => { + await expectRejection(() => client.pullImage({ imageRef: 'alpine', allTags: true })); + }); + + it('Throws when disableContentTrust is set', async () => { + await expectRejection(() => client.pullImage({ imageRef: 'alpine', disableContentTrust: false })); + }); + }); + + describe('#listImages()', () => { + it('Produces `image list --format json` args with no --filter flags', async () => { + const response = await client.listImages({ dangling: true, labels: { foo: 'bar' } }); + expect(asStrings(response.args)).to.deep.equal(['image', 'list', '--format', 'json']); + }); + + it('Parses the manifest-list shape, using configuration.name (not configuration.descriptor.name)', async () => { + const response = await client.listImages({}); + const items = await response.parse(JSON.stringify([alpineImageListRecord]), true); + expect(items).to.have.lengthOf(1); + expect(items[0].image.originalName).to.equal('docker.io/library/alpine:latest'); + expect(items[0].image.tag).to.equal('latest'); + // id must be the reference, not the digest -- see AppleContainerListImageRecord.ts for why. + expect(items[0].id).to.equal('docker.io/library/alpine:latest'); + }); + + it('Excludes the "unknown" attestation variant from the size sum', async () => { + const response = await client.listImages({}); + const items = await response.parse(JSON.stringify([alpineImageListRecord]), true); + // Only the real arm64 variant's size (4184689) counts; the 86390-byte "unknown" blob is excluded. + expect(items[0].size).to.equal(4184689); + }); + + it('Falls back to the digest for an unnamed image', async () => { + const response = await client.listImages({}); + const unnamed = { ...alpineImageListRecord, configuration: { ...alpineImageListRecord.configuration, name: undefined } }; + const items = await response.parse(JSON.stringify([unnamed]), true); + expect(items[0].id).to.equal(alpineImageListRecord.id); + }); + + it('Filters by reference client-side', async () => { + const response = await client.listImages({ references: ['docker.io/library/alpine'] }); + const items = await response.parse(JSON.stringify([alpineImageListRecord]), true); + expect(items).to.have.lengthOf(1); + }); + + it('Excludes non-matching references client-side', async () => { + const response = await client.listImages({ references: ['docker.io/library/busybox'] }); + const items = await response.parse(JSON.stringify([alpineImageListRecord]), true); + expect(items).to.have.lengthOf(0); + }); + }); + + describe('#inspectImages()', () => { + it('Produces `image inspect ` args with no --format flag (confirmed unsupported)', async () => { + const response = await client.inspectImages({ imageRefs: ['docker.io/library/alpine:latest'] }); + expect(asStrings(response.args)).to.deep.equal(['image', 'inspect', 'docker.io/library/alpine:latest']); + }); + + it('Selects the arm64/linux variant and reads its OCI config', async () => { + const response = await client.inspectImages({ imageRefs: ['docker.io/library/alpine:latest'] }); + const items = await response.parse(JSON.stringify([alpineImageInspectRecord]), true); + expect(items).to.have.lengthOf(1); + expect(items[0]).to.include({ architecture: 'arm64', operatingSystem: 'linux', currentDirectory: '/' }); + expect(items[0].command).to.deep.equal(['/bin/sh']); + expect(items[0].environmentVariables).to.deep.equal({ PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' }); + // id must be the reference, not the digest -- see AppleContainerInspectImageRecord.ts. + expect(items[0].id).to.equal('docker.io/library/alpine:latest'); + }); + }); + + describe('#runContainer()', () => { + it('Emits supported flags with bare `run` (not `container run`)', async () => { + const response = await client.runContainer({ + imageRef: 'alpine:latest', + name: 'demo', + detached: true, + removeOnExit: true, + network: 'mynet', + ports: [{ hostPort: 8080, containerPort: 80, protocol: 'tcp' }], + labels: { env: 'dev' }, + environmentVariables: { FOO: 'bar' }, + entrypoint: '/bin/sh', + }); + const args = asStrings(response.args); + expect(args[0]).to.equal('run'); + expect(args).to.not.include('container'); + expect(args).to.include('--detach'); + expect(args).to.include('--rm'); + expect(args).to.include('--name'); + expect(args).to.include('demo'); + expect(args).to.include('--network'); + expect(args).to.include('mynet'); + expect(args).to.include('--publish'); + expect(args).to.include('--label'); + expect(args).to.include('env=dev'); + expect(args).to.include('--env'); + expect(args).to.include('FOO=bar'); + expect(args).to.include('--entrypoint'); + expect(args).to.include('/bin/sh'); + expect(args).to.include('alpine:latest'); + }); + + it('Emits --mount with target= (not destination=)', async () => { + const response = await client.runContainer({ + imageRef: 'alpine:latest', + mounts: [{ type: 'bind', source: '/host/src', destination: '/src', readOnly: true }], + }); + const args = asStrings(response.args); + expect(args).to.include('--mount'); + expect(args).to.include('type=bind,source=/host/src,target=/src,readonly'); + expect(args).to.not.include('destination=/src'); + }); + + it('Throws when publishAllPorts is set', async () => { + await expectRejection(() => client.runContainer({ imageRef: 'alpine:latest', publishAllPorts: true })); + }); + + it('Throws when networkAlias is set', async () => { + await expectRejection(() => client.runContainer({ imageRef: 'alpine:latest', networkAlias: 'alias' })); + }); + + it('Throws when addHost has entries', async () => { + await expectRejection(() => client.runContainer({ + imageRef: 'alpine:latest', + addHost: [{ hostname: 'foo.local', ip: '127.0.0.1' }], + })); + }); + + it('Throws when exposePorts has entries', async () => { + await expectRejection(() => client.runContainer({ imageRef: 'alpine:latest', exposePorts: [3000] })); + }); + }); + + describe('#listContainers()', () => { + it('Produces `list --format json` args with no --filter flags', async () => { + const response = await client.listContainers({ labels: { foo: 'bar' }, names: ['x'] }); + expect(asStrings(response.args)).to.deep.equal(['list', '--format', 'json']); + }); + + it('Passes --all when `all` or `exited` is requested', async () => { + expect(asStrings((await client.listContainers({ all: true })).args)).to.include('--all'); + expect(asStrings((await client.listContainers({ exited: true })).args)).to.include('--all'); + expect(asStrings((await client.listContainers({})).args)).to.not.include('--all'); + }); + + it('Parses the nested list shape', async () => { + const response = await client.listContainers({}); + const items = await response.parse(JSON.stringify([pocContainerListRecord]), true); + expect(items).to.have.lengthOf(1); + expect(items[0]).to.include({ id: 'poc-test', name: 'poc-test', state: 'running' }); + expect(items[0].image.originalName).to.equal('docker.io/library/alpine:latest'); + expect(items[0].networks).to.deep.equal(['default']); + }); + + it('Filters by running/exited state client-side', async () => { + const response = await client.listContainers({ exited: true }); + const items = await response.parse(JSON.stringify([pocContainerListRecord]), true); + // The fixture container is 'running'; requesting only exited containers excludes it. + expect(items).to.have.lengthOf(0); + }); + + it('Filters by name client-side', async () => { + const response = await client.listContainers({ names: ['not-poc-test'] }); + const items = await response.parse(JSON.stringify([pocContainerListRecord]), true); + expect(items).to.have.lengthOf(0); + }); + + it('Filters by labels client-side', async () => { + const withLabel = { ...pocContainerListRecord, configuration: { ...pocContainerListRecord.configuration, labels: { keep: 'yes' } } }; + const response = await client.listContainers({ labels: { keep: 'yes' } }); + const items = await response.parse(JSON.stringify([withLabel]), true); + expect(items).to.have.lengthOf(1); + const response2 = await client.listContainers({ labels: { keep: 'no' } }); + const items2 = await response2.parse(JSON.stringify([withLabel]), true); + expect(items2).to.have.lengthOf(0); + }); + }); + + describe('#stopContainers()', () => { + it('Produces bare `stop` args (not `container stop`)', async () => { + const response = await client.stopContainers({ container: ['abc'], time: 10 }); + expect(asStrings(response.args)).to.deep.equal(['stop', '--time', '10', 'abc']); + }); + }); + + describe('#removeContainers()', () => { + it('Produces `delete` args (not `container rm`)', async () => { + const response = await client.removeContainers({ containers: ['abc'], force: true }); + expect(asStrings(response.args)).to.deep.equal(['delete', '--force', 'abc']); + }); + }); + + describe('#inspectContainers()', () => { + it('Produces bare `inspect` args with no --format flag (confirmed unsupported)', async () => { + const response = await client.inspectContainers({ containers: ['poc-test'] }); + expect(asStrings(response.args)).to.deep.equal(['inspect', 'poc-test']); + }); + + it('Parses the nested inspect shape, including the resolved init process as command', async () => { + const response = await client.inspectContainers({ containers: ['poc-test'] }); + const items = await response.parse(JSON.stringify([pocContainerInspectRecord]), true); + expect(items).to.have.lengthOf(1); + expect(items[0]).to.include({ id: 'poc-test', name: 'poc-test', currentDirectory: '/' }); + expect(items[0].command).to.deep.equal(['sleep', '300']); + expect(items[0].environmentVariables).to.deep.equal({ PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' }); + expect(items[0].networks).to.have.lengthOf(1); + expect(items[0].networks[0]).to.include({ name: 'default', ipAddress: '192.168.65.2/24' }); + // imageId strips the sha256: prefix. + expect(items[0].imageId).to.equal('28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b'); + }); + }); +}); From 60f9d816ce7bc331e569dc0c6c82683e1dd2f8ef Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:39:34 -0600 Subject: [PATCH 05/11] vscode-container-client: fix AppleContainer state icon and start command State-icon bug: getContainerStateIcon (extension-side) only recognizes Docker's state vocabulary (exited, dead, created, etc.). Apple's own "stopped" string was passed through unmapped and fell into that switch's default arm, which renders the *running* icon -- so every container looked like it needed "start" regardless of actual state. Confirmed container's vocabulary is just running/stopped, even for a created-but-never-started container (no separate "created" state). Maps "stopped" to Docker's "exited" so state-dependent UI reads correctly. Start-command bug: getStartContainersCommandArgs was never overridden, so it inherited "container container start " -- wrong noun prefix, same class of bug already fixed for run/list/stop/delete/ inspect. Also discovered `container start` only accepts one container ID at a time (confirmed: a second ID errors and neither container starts, unlike stop/delete which accept multiple), so a multi-select "Start" request now throws CommandNotSupportedError explicitly rather than silently starting only the first container or none. Both verified against the real CLI and covered by 3 new unit tests (272 passing, lint clean). Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerClient.ts | 20 +++++++++++++++- .../AppleContainerListContainerRecord.ts | 20 +++++++++++++--- .../AppleContainerClient.test.ts | 23 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts index ee437fdc..4e601509 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -30,6 +30,7 @@ import type { RemoveContainersCommandOptions, RestartContainersCommandOptions, RunContainerCommandOptions, + StartContainersCommandOptions, StopContainersCommandOptions, VersionCommandOptions, VersionItem, @@ -271,6 +272,23 @@ export class AppleContainerClient extends DockerClientBase implements IContainer ); } + // `container start` accepts exactly one positional container ID -- confirmed: a second ID + // errors with "Unexpected argument ''" and *neither* container ends up started. The + // "Start" tree command can multi-select several stopped containers into one + // `startContainers({ container: [...] })` call, but a single command invocation here can't + // fan out to N separate CLI calls, so reject a multi-container request outright rather than + // silently starting only the first (or none). + protected override getStartContainersCommandArgs(options: StartContainersCommandOptions): CommandLineArgs { + if (options.container.length > 1) { + throw new CommandNotSupportedError('container start only supports starting one container at a time.'); + } + + return composeArgs( + withArg('start'), + withArg(...options.container), + )(); + } + protected override getListContainersCommandArgs(options: ListContainersCommandOptions): CommandLineArgs { // No --filter flag exists for `list`. `--all` is passed whenever a filter that needs // to see non-running containers is requested; the default (no --all) already limits @@ -301,7 +319,7 @@ export class AppleContainerClient extends DockerClientBase implements IContainer if (options.running && item.state !== 'running') { return false; } - if (options.exited && item.state !== 'stopped') { + if (options.exited && item.state !== 'exited') { return false; } if (options.names && options.names.length > 0 && !options.names.includes(item.name)) { diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts index 8e2a72bf..e65f301b 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts @@ -35,6 +35,22 @@ export const AppleContainerListContainerRecordSchema = z.object({ export type AppleContainerListContainerRecord = z.infer; +/** + * `container` only ever reports `status.state` as `"running"` or `"stopped"` -- confirmed for + * a running container, a container stopped after running, and a `container create`d-but-never- + * started container (all three produce one of those two strings; there is no separate + * "created" state, matching the CLI having no `pause`/`unpause` and hence no "paused" state + * either). The rest of the extension keys off Docker's vocabulary instead (see + * `getContainerStateIcon` in `ContainerProperties.ts`, whose switch has no `"stopped"` case) -- + * passing `"stopped"` through unmapped landed in that switch's `default:` arm, which is the + * *running*-icon case, so a stopped container rendered with the running/start icon. Map onto + * Docker's `"exited"` instead so state-dependent UI (icons, context-menu start/stop visibility) + * reads correctly. + */ +function mapAppleContainerState(state: string | undefined): string { + return state === 'running' ? 'running' : 'exited'; +} + /** * Normalize a parsed {@link AppleContainerListContainerRecord} to the common * {@link ListContainersItem}. @@ -54,9 +70,7 @@ export function normalizeAppleContainerListContainerRecord(container: AppleConta .map((attachment) => attachment.network) .filter((name): name is string => !!name), createdAt: container.configuration.creationDate ?? new Date(0), - // Observed values: 'running', 'stopped'. Passed through as-is; the contract's `state` - // is a loosely-typed string, and no other values have been observed to map. - state: container.status.state ?? 'unknown', + state: mapAppleContainerState(container.status.state), // No human-readable status string (e.g. Docker's "Up 5 minutes") is emitted. status: undefined, }; diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts index 172dd90a..30676a22 100644 --- a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -311,6 +311,18 @@ describe('(unit) AppleContainerClient', () => { expect(items).to.have.lengthOf(0); }); + it("Maps `status.state: 'stopped'` to Docker's 'exited' (not passed through as-is)", async () => { + // container's own vocabulary is just 'running'/'stopped' (confirmed for a stopped-after- + // running container AND a created-but-never-started one -- there's no separate "created" + // state). Passing 'stopped' through unmapped landed in getContainerStateIcon's `default:` + // arm, which renders the *running* icon -- see ContainerProperties.ts. Regression coverage + // for that bug: 'stopped' must become 'exited', a state getContainerStateIcon recognizes. + const stopped = { ...pocContainerListRecord, status: { ...pocContainerListRecord.status, state: 'stopped', networks: [] } }; + const response = await client.listContainers({ all: true }); + const items = await response.parse(JSON.stringify([stopped]), true); + expect(items[0].state).to.equal('exited'); + }); + it('Filters by name client-side', async () => { const response = await client.listContainers({ names: ['not-poc-test'] }); const items = await response.parse(JSON.stringify([pocContainerListRecord]), true); @@ -328,6 +340,17 @@ describe('(unit) AppleContainerClient', () => { }); }); + describe('#startContainers()', () => { + it('Produces bare `start ` args (not `container start`)', async () => { + const response = await client.startContainers({ container: ['abc'] }); + expect(asStrings(response.args)).to.deep.equal(['start', 'abc']); + }); + + it('Throws when more than one container is requested (confirmed CLI limitation)', async () => { + await expectRejection(() => client.startContainers({ container: ['abc', 'def'] })); + }); + }); + describe('#stopContainers()', () => { it('Produces bare `stop` args (not `container stop`)', async () => { const response = await client.stopContainers({ container: ['abc'], time: 10 }); From c4cb4c8251cb14c238759507a223d96482144786 Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:25:36 -0600 Subject: [PATCH 06/11] vscode-container-client: implement AppleContainer exec and logs getExecContainerCommandArgs / getLogsForContainerCommandArgs were unoverridden and inherited container container exec / container container logs from DockerClientBase -- same wrong-noun-prefix bug already fixed for run/list/stop/start/delete/inspect. Real command is bare exec/logs. logs also needed a flag fix: container logs has no --tail (tailing is -n instead), and no --timestamps/--since/--until support at all. Those now throw CommandNotSupportedError when explicitly requested rather than being silently dropped or erroring at the CLI layer. Both verified against the real CLI. 8 new unit tests (280 passing total across the package, lint clean). Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerClient.ts | 38 +++++++++++++ .../AppleContainerClient.test.ts | 54 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts index 4e601509..0f10fb0a 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -6,6 +6,7 @@ import { type CommandLineArgs, composeArgs, + toArray, withArg, withFlagArg, withNamedArg, @@ -16,6 +17,7 @@ import type { CheckInstallCommandOptions, EventItem, EventStreamCommandOptions, + ExecContainerCommandOptions, InfoCommandOptions, InfoItem, InspectContainersCommandOptions, @@ -26,6 +28,7 @@ import type { ListContainersItem, ListImagesCommandOptions, ListImagesItem, + LogsForContainerCommandOptions, PullImageCommandOptions, RemoveContainersCommandOptions, RestartContainersCommandOptions, @@ -272,6 +275,41 @@ export class AppleContainerClient extends DockerClientBase implements IContainer ); } + // Bare `exec` (not `container exec`). Otherwise identical to the Docker-shaped default -- + // -i/--interactive, -d/--detach, -t/--tty, -e/--env all match. + protected override getExecContainerCommandArgs(options: ExecContainerCommandOptions): CommandLineArgs { + return composeArgs( + withArg('exec'), + withFlagArg('--interactive', options.interactive), + withFlagArg('--detach', options.detached), + withFlagArg('--tty', options.tty), + withDockerEnvArg(options.environmentVariables), + withArg(options.container), + typeof options.command === 'string' ? withVerbatimArg(options.command) : withArg(...toArray(options.command)), + )(); + } + + // Bare `logs` (not `container logs`). `-n ` is the tail flag (not `--tail`), and + // there is no `--timestamps`/`--since`/`--until` support at all. + protected override getLogsForContainerCommandArgs(options: LogsForContainerCommandOptions): CommandLineArgs { + if (options.timestamps) { + throw new CommandNotSupportedError('container logs does not support timestamps.'); + } + if (options.since) { + throw new CommandNotSupportedError('container logs does not support --since.'); + } + if (options.until) { + throw new CommandNotSupportedError('container logs does not support --until.'); + } + + return composeArgs( + withArg('logs'), + withFlagArg('--follow', options.follow), + withNamedArg('-n', options.tail?.toString()), + withArg(options.container), + )(); + } + // `container start` accepts exactly one positional container ID -- confirmed: a second ID // errors with "Unexpected argument ''" and *neither* container ends up started. The // "Start" tree command can multi-select several stopped containers into one diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts index 30676a22..94859585 100644 --- a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -283,6 +283,60 @@ describe('(unit) AppleContainerClient', () => { }); }); + describe('#execContainer()', () => { + it('Produces bare `exec` args (not `container exec`)', async () => { + const response = await client.execContainer({ container: 'abc', command: ['echo', 'hi'] }); + expect(response.command).to.equal('container'); + expect(asStrings(response.args)).to.deep.equal(['exec', 'abc', 'echo', 'hi']); + }); + + it('Emits -i/-t/-d/--env flags matching Apple\'s own naming', async () => { + const response = await client.execContainer({ + container: 'abc', + interactive: true, + detached: true, + tty: true, + environmentVariables: { FOO: 'bar' }, + command: ['sh'], + }); + const args = asStrings(response.args); + expect(args).to.include('--interactive'); + expect(args).to.include('--detach'); + expect(args).to.include('--tty'); + expect(args).to.include('--env'); + expect(args).to.include('FOO=bar'); + }); + }); + + describe('#logsForContainer()', () => { + it('Produces bare `logs` args (not `container logs`)', async () => { + const response = await client.logsForContainer({ container: 'abc' }); + expect(asStrings(response.args)).to.deep.equal(['logs', 'abc']); + }); + + it('Maps `tail` to `-n` (not `--tail`)', async () => { + const response = await client.logsForContainer({ container: 'abc', tail: 10 }); + expect(asStrings(response.args)).to.deep.equal(['logs', '-n', '10', 'abc']); + }); + + it('Emits --follow when requested', async () => { + const response = await client.logsForContainer({ container: 'abc', follow: true }); + expect(asStrings(response.args)).to.include('--follow'); + }); + + it('Throws when timestamps is requested (confirmed unsupported)', async () => { + await expectRejection(() => client.logsForContainer({ container: 'abc', timestamps: true })); + }); + + it('Throws when since is requested (confirmed unsupported)', async () => { + await expectRejection(() => client.logsForContainer({ container: 'abc', since: '10m' })); + }); + + it('Throws when until is requested (confirmed unsupported)', async () => { + await expectRejection(() => client.logsForContainer({ container: 'abc', until: '1m' })); + }); + }); + describe('#listContainers()', () => { it('Produces `list --format json` args with no --filter flags', async () => { const response = await client.listContainers({ labels: { foo: 'bar' }, names: ['x'] }); From 471ff4def5fd8de8a9586c50d2be6686284bc4c2 Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:55:03 -0600 Subject: [PATCH 07/11] vscode-container-client: address PR review feedback on Apple normalizers Fixes 3 issues flagged in PR #577 review: - ListImageRecord: a legitimate 0-byte size no longer collapses to undefined; only an empty (non-"unknown") variant list does. - InspectContainerRecord: network attachments with no name are dropped instead of surfacing as an empty-string network name. - InspectContainerRecord: imageId keeps its sha256: prefix so downstream slicing (ImageTreeItem, ContainerTreeItem, askCopilot) still works. --- .../AppleContainerInspectContainerRecord.ts | 22 +++++++++---------- .../AppleContainerListImageRecord.ts | 9 ++++---- .../AppleContainerClient.test.ts | 4 ++-- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts index ee140565..16a5f234 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts @@ -49,29 +49,29 @@ export const AppleContainerInspectContainerRecordSchema = z.object({ export type AppleContainerInspectContainerRecord = z.infer; -function stripDigestPrefix(digest: string | undefined): string { - return digest?.replace(/^sha256:/, '') ?? ''; -} - /** * Normalize a parsed {@link AppleContainerInspectContainerRecord} to the common * {@link InspectContainersItem}. */ export function normalizeAppleContainerInspectContainerRecord(container: AppleContainerInspectContainerRecord, raw: string): InspectContainersItem { const initProcess = container.configuration.initProcess; - const networks: InspectContainersItemNetwork[] = (container.status.networks ?? []).map((network) => ({ - name: network.network ?? '', - gateway: network.ipv4Gateway, - ipAddress: network.ipv4Address, - macAddress: network.macAddress, - })); + const networks: InspectContainersItemNetwork[] = (container.status.networks ?? []) + .filter((network): network is typeof network & { network: string } => !!network.network) + .map((network) => ({ + name: network.network, + gateway: network.ipv4Gateway, + ipAddress: network.ipv4Address, + macAddress: network.macAddress, + })); return { id: container.id, // The `container` CLI has no name distinct from the container's ID; see the same note // in AppleContainerListContainerRecord.ts. name: container.id, - imageId: stripDigestPrefix(container.configuration.image.descriptor?.digest), + // Kept in the same `sha256:` form as SharedInspectContainerRecord -- consumers + // (ImageTreeItem, ContainerTreeItem, askCopilot) slice this assuming that prefix. + imageId: container.configuration.image.descriptor?.digest ?? '', image: parseDockerLikeImageName(container.configuration.image.reference), isolation: undefined, status: undefined, diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts index 424ae3e2..0711af59 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts @@ -52,9 +52,8 @@ export type AppleContainerListImageRecord = z.infer variant.platform?.architecture !== 'unknown') - .reduce((total, variant) => total + (variant.size ?? 0), 0); + const realVariants = (image.variants ?? []) + .filter((variant) => variant.platform?.architecture !== 'unknown'); return { // Falls back to the (functionally unusable) digest only for images with no name -- @@ -63,6 +62,8 @@ export function normalizeAppleContainerListImageRecord(image: AppleContainerList id: image.configuration.name ?? image.id, image: parseDockerLikeImageName(image.configuration.name), createdAt: image.configuration.creationDate ?? new Date(0), - size: size > 0 ? size : undefined, + // Only undefined when no real (non-"unknown") variants were reported -- a real variant + // summing to 0 bytes is a legitimate size, not an "unknown" sentinel. + size: realVariants.length > 0 ? realVariants.reduce((total, variant) => total + (variant.size ?? 0), 0) : undefined, }; } diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts index 94859585..23e2f432 100644 --- a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -434,8 +434,8 @@ describe('(unit) AppleContainerClient', () => { expect(items[0].environmentVariables).to.deep.equal({ PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' }); expect(items[0].networks).to.have.lengthOf(1); expect(items[0].networks[0]).to.include({ name: 'default', ipAddress: '192.168.65.2/24' }); - // imageId strips the sha256: prefix. - expect(items[0].imageId).to.equal('28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b'); + // imageId keeps the sha256: prefix (matches SharedInspectContainerRecord's form). + expect(items[0].imageId).to.equal('sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b'); }); }); }); From 1b5d5fb64297184e83ceac04db956d83a783650a Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:15:48 -0600 Subject: [PATCH 08/11] vscode-container-client: implement Volume/Network commands, fix prune/login/ports/mounts Addresses the remaining review feedback on PR #577 (Apple container CLI runtime client), all re-verified against a real CLI 1.2.0 install: - Add full Volume and Network command support (list/inspect/create/remove/prune), with new Zod schemas built from real captured JSON -- these were entirely unimplemented before. - Fix prune: the base class emitted `container container prune` (duplicate noun) and unconditionally passed `--force`, which none of container/image/volume/ network prune actually support. Each of the four has a distinct real output format, now parsed correctly instead of guessed. - Fix login/logout: route through `registry login`/`registry logout`, the real command path, instead of a nonexistent top-level `login`/`logout`. - Wire up real ports/mounts in container list/inspect from configuration.publishedPorts/.mounts, including port-range expansion and volume-vs-bind mount detection (previously hardcoded empty). - Wire up real imageAncestors/volumes/networks list-filters using raw record fields confirmed to be present (previously left unfiltered). - Fix repoDigests to the repository@sha256:... form used elsewhere. - Drop the now-unnecessary --mount destination= override (confirmed the real CLI accepts it directly via the shared withDockerMountsArg helper). - Remove stray references to the AI-assisted planning doc from code comments. - Add an AppleContainerCanary integration test (23 assertions, gated on CONTAINER_CLIENT_TYPE=applecontainer) so a future CLI release that adds any of the capabilities worked around here gets caught automatically. Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerClient.ts | 353 +++++++++++++++-- .../AppleContainerInspectContainerRecord.ts | 43 ++- .../AppleContainerInspectImageRecord.ts | 9 +- .../AppleContainerListContainerRecord.ts | 22 +- .../AppleContainerListNetworkRecord.ts | 83 ++++ .../AppleContainerListVolumeRecord.ts | 68 ++++ .../AppleContainerPublishedPort.ts | 46 +++ .../AppleContainerCanary.test.ts | 171 +++++++++ .../AppleContainerClient.test.ts | 362 +++++++++++++++++- 9 files changed, 1118 insertions(+), 39 deletions(-) create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListNetworkRecord.ts create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListVolumeRecord.ts create mode 100644 packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerPublishedPort.ts create mode 100644 packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerCanary.test.ts diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts index 0f10fb0a..88497b3a 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -15,6 +15,8 @@ import { import type { GeneratorCommandResponse, PromiseCommandResponse } from '../../contracts/CommandRunner'; import type { CheckInstallCommandOptions, + CreateNetworkCommandOptions, + CreateVolumeCommandOptions, EventItem, EventStreamCommandOptions, ExecContainerCommandOptions, @@ -24,13 +26,33 @@ import type { InspectContainersItem, InspectImagesCommandOptions, InspectImagesItem, + InspectNetworksCommandOptions, + InspectNetworksItem, + InspectVolumesCommandOptions, + InspectVolumesItem, ListContainersCommandOptions, ListContainersItem, ListImagesCommandOptions, ListImagesItem, + ListNetworkItem, + ListNetworksCommandOptions, + ListVolumeItem, + ListVolumesCommandOptions, + LoginCommandOptions, + LogoutCommandOptions, LogsForContainerCommandOptions, + PruneContainersCommandOptions, + PruneContainersItem, + PruneImagesCommandOptions, + PruneImagesItem, + PruneNetworksCommandOptions, + PruneNetworksItem, + PruneVolumesCommandOptions, + PruneVolumesItem, PullImageCommandOptions, RemoveContainersCommandOptions, + RemoveNetworksCommandOptions, + RemoveVolumesCommandOptions, RestartContainersCommandOptions, RunContainerCommandOptions, StartContainersCommandOptions, @@ -41,23 +63,52 @@ import type { import type { IContainersClient } from '../../contracts/ContainerClient'; import { CommandNotSupportedError } from '../../utils/CommandNotSupportedError'; import { DockerClientBase } from '../DockerClientBase/DockerClientBase'; +import { filterByLabelsAndDriver } from '../DockerClientBase/filterByLabelsAndDriver'; +import { matchesLabelFilters } from '../DockerClientBase/matchesLabelFilters'; +import { parsePruneLikeOutput } from '../DockerClientBase/parsePruneLikeOutput'; +import { tryParseSize } from '../DockerClientBase/tryParseSize'; import { withDockerEnvArg } from '../DockerClientBase/withDockerEnvArg'; import { withDockerLabelsArg } from '../DockerClientBase/withDockerLabelsArg'; +import { withDockerMountsArg } from '../DockerClientBase/withDockerMountsArg'; import { withDockerPlatformArg } from '../DockerClientBase/withDockerPlatformArg'; import { withDockerPortsArg } from '../DockerClientBase/withDockerPortsArg'; -import { matchesLabelFilters } from '../DockerClientBase/matchesLabelFilters'; import { AppleContainerInspectContainerRecordSchema, normalizeAppleContainerInspectContainerRecord } from './AppleContainerInspectContainerRecord'; import { AppleContainerInspectImageRecordSchema, normalizeAppleContainerInspectImageRecord } from './AppleContainerInspectImageRecord'; -import { AppleContainerListContainerRecordSchema, normalizeAppleContainerListContainerRecord } from './AppleContainerListContainerRecord'; +import { AppleContainerListContainerRecordSchema, normalizeAppleContainerListContainerRecord, type AppleContainerListContainerRecord } from './AppleContainerListContainerRecord'; import { AppleContainerListImageRecordSchema, normalizeAppleContainerListImageRecord } from './AppleContainerListImageRecord'; +import { AppleContainerListNetworkRecordSchema, normalizeAppleContainerListNetworkRecord, normalizeAppleContainerInspectNetworkRecord } from './AppleContainerListNetworkRecord'; +import { AppleContainerListVolumeRecordSchema, normalizeAppleContainerListVolumeRecord, normalizeAppleContainerInspectVolumeRecord } from './AppleContainerListVolumeRecord'; + +/** + * `container prune`/`image prune`/`volume prune`/`network prune` all report a + * `Reclaimed X in disk space` summary line (not Docker's `Total reclaimed space:`); `volume + * prune` and `network prune` omit it entirely for volumes (no size line at all) and include it + * for networks... -- see the per-command overrides below for the exact shape of each, since no + * two of the four are identical. + */ +const AppleContainerReclaimedSpaceRegex = /^Reclaimed\s+([\d.]+\s*[KMGT]?B)\s+in disk space$/im; + +function parseAppleContainerReclaimedSpace(output: string): number | undefined { + const match = AppleContainerReclaimedSpaceRegex.exec(output); + return match ? tryParseSize(match[1]) : undefined; +} + +// Bare resource names/IDs, one per line -- used for `container prune`'s deleted-container list. +// Docker's default `parsePruneLikeOutput` resource regex (`^(\w+)$`) doesn't allow the hyphens +// container names commonly have (e.g. `poc-mount-test2`), so this client needs its own. +const AppleContainerPruneResourceRegex = /^([\w.-]+)$/gm; + +// `image prune` reports each deleted image as `deleted ` (lowercase, space-separated, +// no `sha256:` prefix) -- confirmed against real output, distinct from Docker's `deleted: +// sha256:`. +const AppleContainerPruneDeletedImageRegex = /^deleted\s+(\S+)$/img; /** * {@link AppleContainerClient} implements {@link IContainersClient} for Apple's `container` * CLI (macOS 26+, Apple Silicon only -- see https://github.com/apple/container). It extends * {@link DockerClientBase} for its output-parsing helpers, but its command surface is not * Docker-CLI-compatible enough to inherit much else -- most command-building methods are - * overridden. All behavior below was verified against real CLI 1.2.0 output; see - * `apple-container-poc-plan.md` at the repo root for the raw captures. + * overridden. All behavior below was verified against real CLI 1.2.0 output. * * Key differences vs. Docker: * - The binary itself is the container noun -- container-object verbs are top-level @@ -150,6 +201,30 @@ export class AppleContainerClient extends DockerClientBase implements IContainer //#endregion + //#region Auth Commands + + // There is no top-level `login`/`logout` -- confirmed: `container help login` errors with + // "unknown command 'login'". The real path is `container registry login`/`registry + // logout`, which otherwise matches the base's Docker-shaped args (`--username`, + // `--password-stdin`, a trailing registry argument). + protected override getLoginCommandArgs(options: LoginCommandOptions): CommandLineArgs { + return composeArgs( + withArg('registry', 'login'), + withNamedArg('--username', options.username), + withArg('--password-stdin'), + withArg(options.registry), + )(); + } + + protected override getLogoutCommandArgs(options: LogoutCommandOptions): CommandLineArgs { + return composeArgs( + withArg('registry', 'logout'), + withArg(options.registry), + )(); + } + + //#endregion + //#region Image Commands protected override getPullImageCommandArgs(options: PullImageCommandOptions): CommandLineArgs { @@ -222,6 +297,27 @@ export class AppleContainerClient extends DockerClientBase implements IContainer normalizeAppleContainerInspectImageRecord(AppleContainerInspectImageRecordSchema.parse(item), JSON.stringify(item))); } + // `image prune` accepts `--all` but not `--force` (confirmed: errors with "Unknown option + // '--force'"), unlike the base which always passes `--force`. Real output: a "Reclaimed X + // in disk space" summary line, then one `deleted ` line per removed image. + protected override getPruneImagesCommandArgs(options: PruneImagesCommandOptions): CommandLineArgs { + return composeArgs( + withArg('image', 'prune'), + withFlagArg('--all', options.all), + )(); + } + + protected override parsePruneImagesCommandOutput( + options: PruneImagesCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + imageRefsDeleted: parsePruneLikeOutput(output, { resourceRegex: AppleContainerPruneDeletedImageRegex }).resources, + spaceReclaimed: parseAppleContainerReclaimedSpace(output), + }); + } + //#endregion //#region Container Commands @@ -249,7 +345,7 @@ export class AppleContainerClient extends DockerClientBase implements IContainer withNamedArg('--name', options.name), withDockerPortsArg(options.ports), withNamedArg('--network', options.network), - this.getRunContainerMountsArg(options.mounts), + withDockerMountsArg(options.mounts), withDockerLabelsArg(options.labels), withDockerEnvArg(options.environmentVariables), withNamedArg('--env-file', options.environmentFiles), @@ -263,18 +359,6 @@ export class AppleContainerClient extends DockerClientBase implements IContainer )(); } - // `container run --mount` uses `target=` for the in-container path, not Docker's - // `destination=`. - protected override getRunContainerMountsArg(mounts: RunContainerCommandOptions['mounts']) { - return withNamedArg( - '--mount', - (mounts ?? []).map((mount) => - [`type=${mount.type}`, `source=${mount.source}`, `target=${mount.destination}`, mount.readOnly ? 'readonly' : ''] - .filter((part) => !!part) - .join(',')), - ); - } - // Bare `exec` (not `container exec`). Otherwise identical to the Docker-shaped default -- // -i/--interactive, -d/--detach, -t/--tty, -e/--env all match. protected override getExecContainerCommandArgs(options: ExecContainerCommandOptions): CommandLineArgs { @@ -344,16 +428,31 @@ export class AppleContainerClient extends DockerClientBase implements IContainer output: string, strict: boolean, ): Promise> { - return this.parseInspectJson(output, strict, (item) => - normalizeAppleContainerListContainerRecord(AppleContainerListContainerRecordSchema.parse(item))) - .then((items) => items.filter((item) => this.matchesListContainersOptions(item, options))); + const results = new Array(); + + for (const raw of this.parseJsonArrayOrLines(output, strict)) { + try { + const record = AppleContainerListContainerRecordSchema.parse(raw); + const item = normalizeAppleContainerListContainerRecord(record); + if (this.matchesListContainersOptions(record, item, options)) { + results.push(item); + } + } catch (err) { + if (strict) { + throw err; + } + } + } + + return Promise.resolve(results); } - // `imageAncestors`/`volumes`/`networks` filters have no client-side equivalent that can be - // derived safely from `list` output (no resolved image digest or volume attachment info is - // present) and are left unfiltered -- deferred along with the rest of the volume/network - // command surface. - private matchesListContainersOptions(item: ListContainersItem, options: ListContainersCommandOptions): boolean { + // `networks` is matched against the already-normalized `item.networks`. `imageAncestors`/ + // `volumes` need the raw record instead -- ListContainersItem carries neither an image + // digest/reference nor per-mount volume names -- so both the raw record and the normalized + // item are threaded through here (confirmed available: `configuration.image.{reference, + // descriptor.digest}` and `configuration.mounts[].type.volume.name`). + private matchesListContainersOptions(record: AppleContainerListContainerRecord, item: ListContainersItem, options: ListContainersCommandOptions): boolean { if (options.running && item.state !== 'running') { return false; } @@ -366,6 +465,29 @@ export class AppleContainerClient extends DockerClientBase implements IContainer if (!matchesLabelFilters(item.labels, options.labels)) { return false; } + if (options.networks && options.networks.length > 0 && !options.networks.some((network) => item.networks.includes(network))) { + return false; + } + if (options.imageAncestors && options.imageAncestors.length > 0) { + // `ListImagesItem.id` for this runtime is the `name:tag` reference, not a digest + // (see AppleContainerListImageRecord.ts), and that's what callers like + // ImageTreeItem pass as `imageAncestors` -- so match the reference primarily, with + // the manifest digest as a fallback in case a caller ever passes one instead. + const reference = record.configuration.image.reference; + const digest = record.configuration.image.descriptor?.digest; + if (!options.imageAncestors.some((ancestor) => ancestor === reference || ancestor === digest)) { + return false; + } + } + if (options.volumes && options.volumes.length > 0) { + const volumeNames = new Set( + (record.configuration.mounts ?? []) + .map((mount) => mount.type?.volume?.name) + .filter((name): name is string => !!name)); + if (!options.volumes.some((volume) => volumeNames.has(volume))) { + return false; + } + } return true; } @@ -391,6 +513,27 @@ export class AppleContainerClient extends DockerClientBase implements IContainer return Promise.reject(new CommandNotSupportedError('container does not support the restart command.')); } + // The base builds `container prune` for this (Docker's noun-prefixed `docker container + // prune`), which becomes `container container prune` here since the binary itself is + // already the container noun -- confirmed to error. The real verb is bare `prune`, and it + // accepts no `--force` at all (confirmed: errors with "Unknown option '--force'"). Real + // output: a "Reclaimed X in disk space" summary line, then one deleted-container name per + // line. + protected override getPruneContainersCommandArgs(options: PruneContainersCommandOptions): CommandLineArgs { + return composeArgs(withArg('prune'))(); + } + + protected override parsePruneContainersCommandOutput( + options: PruneContainersCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + containersDeleted: parsePruneLikeOutput(output, { resourceRegex: AppleContainerPruneResourceRegex }).resources, + spaceReclaimed: parseAppleContainerReclaimedSpace(output), + }); + } + // Bare `inspect` (not `container inspect`), and no --format flag exists (confirmed: errors // with "Unknown option '--format'"); JSON is the only output it produces. protected override getInspectContainersCommandArgs(options: InspectContainersCommandOptions): CommandLineArgs { @@ -410,4 +553,164 @@ export class AppleContainerClient extends DockerClientBase implements IContainer } //#endregion + + //#region Volume Commands + + // `volume create` has no `--driver` flag at all (confirmed via --help: only --label, + // --opt, -s exist); reject rather than silently dropping a driver the caller explicitly + // asked for. + protected override getCreateVolumeCommandArgs(options: CreateVolumeCommandOptions): CommandLineArgs { + if (options.driver) { + throw new CommandNotSupportedError('container volume create does not support a driver.'); + } + + return composeArgs( + withArg('volume', 'create'), + withArg(options.name), + )(); + } + + // No --filter flag exists for `volume list` (confirmed via --help); `dangling` has no + // equivalent in the captured output (no container-attachment info is present) and is left + // unfiltered, while `driver`/`labels` are applied client-side. + protected override getListVolumesCommandArgs(options: ListVolumesCommandOptions): CommandLineArgs { + return composeArgs( + withArg('volume', 'list'), + withNamedArg('--format', this.defaultFormatForJson), + )(); + } + + protected override parseListVolumesCommandOutput( + options: ListVolumesCommandOptions, + output: string, + strict: boolean, + ): Promise> { + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerListVolumeRecord(AppleContainerListVolumeRecordSchema.parse(item))) + .then((items) => filterByLabelsAndDriver(items, options)); + } + + // `volume delete` (not `rm`) accepts no `--force` (confirmed via --help). + protected override getRemoveVolumesCommandArgs(options: RemoveVolumesCommandOptions): CommandLineArgs { + return composeArgs( + withArg('volume', 'delete'), + withArg(...options.volumes), + )(); + } + + // `volume prune` accepts no options at all (confirmed via --help: no --force, no + // --filter). Real output is just a "Reclaimed X in disk space" summary line -- unlike + // container/image prune, there's no per-volume deleted-name list at all. + protected override getPruneVolumesCommandArgs(options: PruneVolumesCommandOptions): CommandLineArgs { + return composeArgs(withArg('volume', 'prune'))(); + } + + protected override parsePruneVolumesCommandOutput( + options: PruneVolumesCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + spaceReclaimed: parseAppleContainerReclaimedSpace(output), + }); + } + + // Bare `volume inspect` (no --format flag at all -- confirmed via --help; only JSON is + // produced), and its output shares the exact shape `volume list` uses. + protected override getInspectVolumesCommandArgs(options: InspectVolumesCommandOptions): CommandLineArgs { + return composeArgs( + withArg('volume', 'inspect'), + withArg(...options.volumes), + )(); + } + + protected override parseInspectVolumesCommandOutput( + options: InspectVolumesCommandOptions, + output: string, + strict: boolean, + ): Promise> { + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerInspectVolumeRecord(AppleContainerListVolumeRecordSchema.parse(item), JSON.stringify(item))); + } + + //#endregion + + //#region Network Commands + + // `network create` has no `--driver` flag; `--plugin` is the closest equivalent + // (confirmed via --help) -- it selects the network backend rather than acting as a + // Docker-style driver name, but it's the only knob `options.driver` can map onto. + protected override getCreateNetworkCommandArgs(options: CreateNetworkCommandOptions): CommandLineArgs { + return composeArgs( + withArg('network', 'create'), + withNamedArg('--plugin', options.driver), + withArg(options.name), + )(); + } + + // No --filter flag exists for `network list` (confirmed via --help); `driver`/`labels` + // are applied client-side. + protected override getListNetworksCommandArgs(options: ListNetworksCommandOptions): CommandLineArgs { + return composeArgs( + withArg('network', 'list'), + withNamedArg('--format', this.defaultFormatForJson), + )(); + } + + protected override parseListNetworksCommandOutput( + options: ListNetworksCommandOptions, + output: string, + strict: boolean, + ): Promise> { + // Unlike wslc, `network list` emits a JSON array sharing `network inspect`'s nested + // shape, not per-line Docker-style objects. + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerListNetworkRecord(AppleContainerListNetworkRecordSchema.parse(item))) + .then((items) => filterByLabelsAndDriver(items, options)); + } + + // `network delete` (not `remove`) accepts no `--force` (confirmed via --help). + protected override getRemoveNetworksCommandArgs(options: RemoveNetworksCommandOptions): CommandLineArgs { + return composeArgs( + withArg('network', 'delete'), + withArg(...options.networks), + )(); + } + + // `network prune` accepts no options at all (confirmed via --help). Unlike container/ + // image/volume prune, its output has no "Reclaimed X in disk space" summary line at all -- + // just one deleted-network name per line (confirmed against real output). + protected override getPruneNetworksCommandArgs(options: PruneNetworksCommandOptions): CommandLineArgs { + return composeArgs(withArg('network', 'prune'))(); + } + + protected override parsePruneNetworksCommandOutput( + options: PruneNetworksCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + networksDeleted: parsePruneLikeOutput(output, { resourceRegex: AppleContainerPruneResourceRegex }).resources, + }); + } + + // Bare `network inspect` (no --format flag at all -- confirmed via --help; only JSON is + // produced), and its output shares the exact shape `network list` uses. + protected override getInspectNetworksCommandArgs(options: InspectNetworksCommandOptions): CommandLineArgs { + return composeArgs( + withArg('network', 'inspect'), + withArg(...options.networks), + )(); + } + + protected override parseInspectNetworksCommandOutput( + options: InspectNetworksCommandOptions, + output: string, + strict: boolean, + ): Promise> { + return this.parseInspectJson(output, strict, (item) => + normalizeAppleContainerInspectNetworkRecord(AppleContainerListNetworkRecordSchema.parse(item), JSON.stringify(item))); + } + + //#endregion } diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts index 16a5f234..013685c8 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as z from 'zod/mini'; -import type { InspectContainersItem, InspectContainersItemNetwork } from '../../contracts/ContainerClient'; +import type { InspectContainersItem, InspectContainersItemMount, InspectContainersItemNetwork } from '../../contracts/ContainerClient'; import { dateStringWithFallbackSchema } from '../../contracts/ZodTransforms'; import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; import { parseDockerLikeEnvironmentVariables } from '../DockerClientBase/parseDockerLikeEnvironmentVariables'; +import { AppleContainerPublishedPortSchema, normalizeAppleContainerPublishedPorts } from './AppleContainerPublishedPort'; const AppleContainerStatusNetworkSchema = z.object({ network: z.optional(z.string()), @@ -16,6 +17,38 @@ const AppleContainerStatusNetworkSchema = z.object({ macAddress: z.optional(z.string()), }); +/** + * `configuration.mounts[]` -- a volume mount reports its volume name under + * `type.volume.name` (the sibling top-level `source` is the host-side backing file, not usable + * as a `--mount source=` value); anything else (confirmed: a bind mount reports `type.virtiofs: + * {}`) is treated as a bind mount using the top-level `source` path. `readOnly` isn't a + * dedicated field -- a `readonly` mount adds `"ro"` to `options` (confirmed against a real + * `--mount ...,readonly` bind mount). + */ +const AppleContainerMountSchema = z.object({ + destination: z.optional(z.string()), + source: z.optional(z.string()), + options: z.optional(z.array(z.string())), + type: z.optional(z.object({ + volume: z.optional(z.object({ + name: z.optional(z.string()), + })), + })), +}); + +function normalizeAppleContainerMounts(mounts: Array> | undefined): Array { + return (mounts ?? []) + .filter((mount): mount is typeof mount & { destination: string } => !!mount.destination) + .map((mount) => { + const readOnly = (mount.options ?? []).includes('ro'); + const volumeName = mount.type?.volume?.name; + + return volumeName + ? { type: 'volume' as const, source: volumeName, destination: mount.destination, readOnly } + : { type: 'bind' as const, source: mount.source ?? '', destination: mount.destination, readOnly }; + }); +} + /** * `container inspect ` emits the same nested shape as `container list` (see * `AppleContainerListContainerRecord.ts`), with the full `initProcess` and richer @@ -40,6 +73,8 @@ export const AppleContainerInspectContainerRecordSchema = z.object({ workingDirectory: z.optional(z.string()), })), labels: z.optional(z.record(z.string(), z.string())), + mounts: z.optional(z.array(AppleContainerMountSchema)), + publishedPorts: z.optional(z.array(AppleContainerPublishedPortSchema)), }), status: z.object({ startedDate: z.optional(dateStringWithFallbackSchema), @@ -79,10 +114,8 @@ export function normalizeAppleContainerInspectContainerRecord(container: AppleCo networks, ipAddress: networks[0]?.ipAddress, operatingSystem: 'linux', - // `configuration.publishedPorts`/`.mounts` shapes haven't been captured against real - // `--publish`/`--mount` runs yet; left empty rather than guessing field names. - ports: [], - mounts: [], + ports: normalizeAppleContainerPublishedPorts(container.configuration.publishedPorts), + mounts: normalizeAppleContainerMounts(container.configuration.mounts), labels: container.configuration.labels ?? {}, // Apple Container has no separate entrypoint/cmd split in inspect output -- only the // fully resolved init process (executable + arguments) is reported. diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts index 16470957..198e1da6 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts @@ -67,14 +67,19 @@ function selectPrimaryVariant(variants: AppleContainerInspectImageRecord['varian export function normalizeAppleContainerInspectImageRecord(image: AppleContainerInspectImageRecord, raw: string): InspectImagesItem { const variant = selectPrimaryVariant(image.variants); const config = variant?.config?.config; + const nameInfo = parseDockerLikeImageName(image.configuration.name); + const digest = image.configuration.descriptor?.digest; + // Matches the `repository@sha256:...` form SharedInspectImageRecord uses, rather than a + // bare digest. + const repository = nameInfo.registry ? `${nameInfo.registry}/${nameInfo.image}` : nameInfo.image; return { // `container` has no ID-based image addressing (see the note in // AppleContainerListImageRecord.ts); mirror that file's `id` choice so this stays a // usable CLI reference rather than an inert digest. id: image.configuration.name ?? image.id, - image: parseDockerLikeImageName(image.configuration.name), - repoDigests: image.configuration.descriptor?.digest ? [image.configuration.descriptor.digest] : [], + image: nameInfo, + repoDigests: repository && digest ? [`${repository}@${digest}`] : [], // `image inspect` doesn't distinguish local-only images from ones pulled from a // registry; every inspectable image is on-disk, so this is always true. isLocalImage: true, diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts index e65f301b..2f97fc77 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts @@ -7,11 +7,24 @@ import * as z from 'zod/mini'; import type { ListContainersItem } from '../../contracts/ContainerClient'; import { dateStringWithFallbackSchema } from '../../contracts/ZodTransforms'; import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; +import { AppleContainerPublishedPortSchema, normalizeAppleContainerPublishedPorts } from './AppleContainerPublishedPort'; const AppleContainerNetworkAttachmentSchema = z.object({ network: z.optional(z.string()), }); +/** + * Only the `type.volume.name` sliver is needed here (to match the `volumes` list filter) -- + * see `AppleContainerInspectContainerRecord.ts` for the full mount shape used by `inspect`. + */ +const AppleContainerMountVolumeRefSchema = z.object({ + type: z.optional(z.object({ + volume: z.optional(z.object({ + name: z.optional(z.string()), + })), + })), +}); + /** * `container list --format json` emits a nested, non-Docker-like shape (captured against * real CLI 1.2.0 output): `{configuration: {id, image: {reference}, labels, networks, ...}, @@ -24,9 +37,14 @@ export const AppleContainerListContainerRecordSchema = z.object({ creationDate: z.optional(dateStringWithFallbackSchema), image: z.object({ reference: z.optional(z.string()), + descriptor: z.optional(z.object({ + digest: z.optional(z.string()), + })), }), labels: z.optional(z.record(z.string(), z.string())), networks: z.optional(z.array(AppleContainerNetworkAttachmentSchema)), + mounts: z.optional(z.array(AppleContainerMountVolumeRefSchema)), + publishedPorts: z.optional(z.array(AppleContainerPublishedPortSchema)), }), status: z.object({ state: z.optional(z.string()), @@ -63,9 +81,7 @@ export function normalizeAppleContainerListContainerRecord(container: AppleConta name: container.id, labels: container.configuration.labels ?? {}, image: parseDockerLikeImageName(container.configuration.image.reference), - // `configuration.publishedPorts` shape hasn't been captured against a real `--publish` - // run yet; left empty rather than guessing field names. Fill in once verified. - ports: [], + ports: normalizeAppleContainerPublishedPorts(container.configuration.publishedPorts), networks: (container.configuration.networks ?? []) .map((attachment) => attachment.network) .filter((name): name is string => !!name), diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListNetworkRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListNetworkRecord.ts new file mode 100644 index 00000000..6a23a4b8 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListNetworkRecord.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as z from 'zod/mini'; +import type { InspectNetworksItem, ListNetworkItem } from '../../contracts/ContainerClient'; +import { dateStringWithFallbackSchema } from '../../contracts/ZodTransforms'; + +/** + * `container network list --format json` and `container network inspect ` emit the + * identical nested shape (captured against real CLI 1.2.0 output): `{configuration: {name, + * mode, plugin, labels, options, creationDate}, id, status: {ipv4Gateway, ipv4Subnet, + * ipv6Subnet}}`. There is no flat `Name`/`Driver`/`IPAM` record to reuse from + * `SharedListNetworkRecordSchema`/`SharedInspectNetworkRecordSchema`, so this keeps its own + * module and is shared by both commands, same as the container/image records. + * + * `mode` is `"nat"` for a normal network and `"hostOnly"` for one created with `--internal` + * (confirmed: `network create --internal` produces `mode: "hostOnly"`); there is no separate + * boolean field for it. + */ +export const AppleContainerListNetworkRecordSchema = z.object({ + id: z.string(), + configuration: z.object({ + name: z.optional(z.string()), + mode: z.optional(z.string()), + plugin: z.optional(z.string()), + labels: z.optional(z.record(z.string(), z.string())), + creationDate: z.optional(dateStringWithFallbackSchema), + }), + status: z.optional(z.object({ + ipv4Gateway: z.optional(z.string()), + ipv4Subnet: z.optional(z.string()), + ipv6Subnet: z.optional(z.string()), + })), +}); + +export type AppleContainerListNetworkRecord = z.infer; + +/** + * Normalize a parsed {@link AppleContainerListNetworkRecord} to the common + * {@link ListNetworkItem}. + */ +export function normalizeAppleContainerListNetworkRecord(network: AppleContainerListNetworkRecord): ListNetworkItem { + return { + name: network.configuration.name ?? network.id, + id: network.id, + // `plugin` (e.g. `container-network-vmnet`) is the closest equivalent to Docker's + // network driver name. + driver: network.configuration.plugin, + labels: network.configuration.labels ?? {}, + scope: 'local', + ipv6: !!network.status?.ipv6Subnet, + createdAt: network.configuration.creationDate, + internal: network.configuration.mode === 'hostOnly', + }; +} + +/** + * Normalize a parsed {@link AppleContainerListNetworkRecord} to the common + * {@link InspectNetworksItem}. + */ +export function normalizeAppleContainerInspectNetworkRecord(network: AppleContainerListNetworkRecord, raw: string): InspectNetworksItem { + const ipv4Subnet = network.status?.ipv4Subnet; + const ipv4Gateway = network.status?.ipv4Gateway; + + return { + name: network.configuration.name ?? network.id, + id: network.id, + driver: network.configuration.plugin, + labels: network.configuration.labels ?? {}, + scope: 'local', + // No IPAM driver name is reported for this runtime; 'default' matches the fallback + // SharedInspectNetworkRecord uses for the same case. + ipam: (ipv4Subnet || ipv4Gateway) ? { driver: 'default', config: [{ subnet: ipv4Subnet ?? '', gateway: ipv4Gateway ?? '' }] } : undefined, + ipv6: !!network.status?.ipv6Subnet, + internal: network.configuration.mode === 'hostOnly', + attachable: undefined, + ingress: undefined, + createdAt: network.configuration.creationDate, + raw, + }; +} diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListVolumeRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListVolumeRecord.ts new file mode 100644 index 00000000..efeaba17 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListVolumeRecord.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as z from 'zod/mini'; +import type { InspectVolumesItem, ListVolumeItem } from '../../contracts/ContainerClient'; +import { dateStringWithFallbackSchema } from '../../contracts/ZodTransforms'; + +/** + * `container volume list --format json` and `container volume inspect ` emit the + * identical nested shape (captured against real CLI 1.2.0 output): `{configuration: {name, + * driver, format, labels, options, sizeInBytes, source, creationDate}, id}`. There is no flat + * `Name`/`Driver`/`Mountpoint` record to reuse from `SharedListVolumeRecordSchema`/ + * `SharedInspectVolumeRecordSchema`, so this keeps its own module and is shared by both + * commands, same as the container/image records. + */ +export const AppleContainerListVolumeRecordSchema = z.object({ + id: z.string(), + configuration: z.object({ + name: z.optional(z.string()), + driver: z.optional(z.string()), + // `source` is the host-side path to the volume's backing file -- the closest + // equivalent to Docker's `Mountpoint`, even though it's a file (a raw disk image), not + // a directory. + source: z.optional(z.string()), + labels: z.optional(z.record(z.string(), z.string())), + options: z.optional(z.record(z.string(), z.unknown())), + sizeInBytes: z.optional(z.number()), + creationDate: z.optional(dateStringWithFallbackSchema), + }), +}); + +export type AppleContainerListVolumeRecord = z.infer; + +/** + * Normalize a parsed {@link AppleContainerListVolumeRecord} to the common {@link ListVolumeItem}. + */ +export function normalizeAppleContainerListVolumeRecord(volume: AppleContainerListVolumeRecord): ListVolumeItem { + return { + name: volume.configuration.name ?? volume.id, + driver: volume.configuration.driver ?? 'local', + labels: volume.configuration.labels ?? {}, + mountpoint: volume.configuration.source ?? '', + // Apple volumes are always local -- there is no volume-sharing/plugin-driven remote + // scope concept for this runtime. + scope: 'local', + createdAt: volume.configuration.creationDate, + size: volume.configuration.sizeInBytes, + }; +} + +/** + * Normalize a parsed {@link AppleContainerListVolumeRecord} to the common + * {@link InspectVolumesItem}. + */ +export function normalizeAppleContainerInspectVolumeRecord(volume: AppleContainerListVolumeRecord, raw: string): InspectVolumesItem { + return { + name: volume.configuration.name ?? volume.id, + driver: volume.configuration.driver ?? 'local', + mountpoint: volume.configuration.source ?? '', + scope: 'local', + labels: volume.configuration.labels ?? {}, + options: volume.configuration.options ?? {}, + createdAt: volume.configuration.creationDate ?? new Date(0), + raw, + }; +} diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerPublishedPort.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerPublishedPort.ts new file mode 100644 index 00000000..603fb37c --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerPublishedPort.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as z from 'zod/mini'; +import type { PortBinding } from '../../contracts/ContainerClient'; + +/** + * `configuration.publishedPorts[]` -- shared by both `list --format json` and `inspect` output + * (confirmed identical in both against real CLI 1.2.0, captured from a `-p 9090:80 -p + * 127.0.0.1:9091:81/udp` run). + */ +export const AppleContainerPublishedPortSchema = z.object({ + containerPort: z.optional(z.number()), + hostPort: z.optional(z.number()), + hostAddress: z.optional(z.string()), + proto: z.optional(z.string()), + count: z.optional(z.number()), +}); + +export type AppleContainerPublishedPort = z.infer; + +/** + * Normalize `configuration.publishedPorts[]` into {@link PortBinding}s. A `count > 1` entry + * represents a contiguous published range (e.g. `-p 9090-9092:80-82` reports one entry with + * `count: 3`, not three entries); expand it into `count` individual bindings with the container + * and host ports incrementing together, since {@link PortBinding} has no range concept of its + * own. + */ +export function normalizeAppleContainerPublishedPorts(ports: Array | undefined): Array { + return (ports ?? []).flatMap((port) => { + const containerPort = port.containerPort; + if (typeof containerPort !== 'number') { + return []; + } + + const count = port.count ?? 1; + return Array.from({ length: count }, (_, offset) => ({ + containerPort: containerPort + offset, + hostPort: typeof port.hostPort === 'number' ? port.hostPort + offset : undefined, + hostIp: port.hostAddress, + protocol: port.proto === 'udp' ? 'udp' as const : 'tcp' as const, + })); + }); +} diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerCanary.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerCanary.test.ts new file mode 100644 index 00000000..618223f2 --- /dev/null +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerCanary.test.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from 'chai'; +import { exec } from 'child_process'; +import { describe, it } from 'mocha'; +import { promisify } from 'util'; +import { AppleContainerClient } from '../../../clients/AppleContainerClient/AppleContainerClient'; + +const execAsync = promisify(exec); + +// `container` has no dedicated `ClientType` entry (see e2eShared.ts) since this client's E2E +// suite wiring is intentionally deferred (network/volume/exec/restart/build surface needed by +// ContainersClientE2E.test.ts's shared `before` hook goes beyond what's implemented so far). +// This canary suite is lighter-weight than that -- it only probes the real CLI's `--help` text +// for the handful of gaps AppleContainerClient works around -- so it's gated on the same +// CONTAINER_CLIENT_TYPE env var directly, without needing that wiring. +const clientTypeToTest = process.env.CONTAINER_CLIENT_TYPE || 'docker'; + +// Invoke container by its default command name so the canaries follow the client if that ever changes. +const containerCommand = new AppleContainerClient().commandName; + +interface AppleContainerCliResult { + stdout: string; + stderr: string; +} + +/** + * Runs `container ` and returns its stdout/stderr. Some failure modes (e.g. an unknown + * subcommand) exit non-zero, so `exec`'s thrown error is unwrapped to still expose the captured + * streams to the assertions. + */ +async function runAppleContainer(args: string): Promise { + try { + return await execAsync(`${containerCommand} ${args}`); + } catch (err) { + const e = err as { stdout?: string; stderr?: string }; + return { stdout: e.stdout ?? '', stderr: e.stderr ?? '' }; + } +} + +/** + * "Canary" tests that pass while the real `container` CLI LACKS a capability the + * {@link AppleContainerClient} currently works around, and FAIL once a future CLI release adds + * it -- signaling that the corresponding override or guard in {@link AppleContainerClient} + * should be replaced with real support. Every assertion probes the real `container` CLI, so + * this suite only runs against the AppleContainer integration matrix and is skipped for all + * other runtimes. All assertions below were confirmed against real CLI 1.2.0. + */ +describe('(integration) AppleContainerCanary', function () { + this.timeout(20000); + + before(function () { + if (clientTypeToTest !== 'applecontainer') { + this.skip(); + } + }); + + // Top-level subcommands the CLI doesn't implement. `container help ` reports + // "unknown command ''" on stderr for these; when the CLI implements one, that message + // disappears and the matching canary fails. (Unlike `container --help`, which for a + // plugin-routed subcommand fails with a *different*, always-present "Plugin ... not found" + // message regardless of whether the subcommand itself is real -- `help` is the only form + // that actually distinguishes "doesn't exist" from "exists but has no separate --help".) + describe('unsupported subcommands', function () { + const cases: Array<{ description: string; args: string; unrecognizedToken: string; workaround: string }> = [ + { description: '`events`', args: 'help events', unrecognizedToken: 'events', workaround: 'AppleContainerClient.getEventStream rejects with CommandNotSupportedError' }, + { description: '`restart`', args: 'help restart', unrecognizedToken: 'restart', workaround: 'AppleContainerClient.restartContainers rejects with CommandNotSupportedError' }, + { description: '`info`', args: 'help info', unrecognizedToken: 'info', workaround: 'AppleContainerClient.getInfoCommandArgs/parseInfoCommandOutput synthesize a linux InfoItem' }, + { description: '`login`', args: 'help login', unrecognizedToken: 'login', workaround: 'AppleContainerClient routes through `registry login`/`registry logout` instead of a top-level login/logout' }, + ]; + + cases.forEach(({ description, args, unrecognizedToken, workaround }) => { + it(`container still lacks the ${description} command`, async function () { + const { stderr } = await runAppleContainer(args); + expect(stderr).to.contain(`unknown command '${unrecognizedToken}'`, + `container now recognizes ${description}; add real support and remove the workaround (${workaround}).`); + }); + }); + }); + + // `run` flags AppleContainerClient rejects outright. They're absent from `container run + // --help` today; their appearance means it's time to emit them instead of throwing. + describe('unsupported `run` flags', function () { + const unsupportedRunFlags = ['--add-host', '--expose', '--publish-all', '--network-alias']; + + unsupportedRunFlags.forEach((flag) => { + it(`\`container run\` still lacks ${flag}`, async function () { + const { stdout } = await runAppleContainer('run --help'); + expect(stdout).to.not.contain(flag, + `container run now supports ${flag}; emit it in AppleContainerClient.getRunContainerCommandArgs and drop the CommandNotSupportedError guard.`); + }); + }); + }); + + // `logs` flags AppleContainerClient rejects outright (no --timestamps/--since/--until at + // all -- confirmed via --help, which only lists --boot/--follow/-n). + describe('unsupported `logs` flags', function () { + const unsupportedLogsFlags = ['--timestamps', '--since', '--until']; + + unsupportedLogsFlags.forEach((flag) => { + it(`\`container logs\` still lacks ${flag}`, async function () { + const { stdout } = await runAppleContainer('logs --help'); + expect(stdout).to.not.contain(flag, + `container logs now supports ${flag}; emit it in AppleContainerClient.getLogsForContainerCommandArgs and drop the CommandNotSupportedError guard.`); + }); + }); + }); + + // `start` accepts exactly one positional container ID today (a second one errors with + // "Unexpected argument"). AppleContainerClient rejects a multi-container start request + // outright rather than guessing how to fan it out; if the CLI ever accepts multiple IDs, + // that guard (and this canary) should be replaced with a real multi-ID invocation. + it('`container start` still rejects a second positional container ID', async function () { + const { stderr } = await runAppleContainer('start canary-nonexistent-1 canary-nonexistent-2'); + expect(stderr).to.contain(`Unexpected argument 'canary-nonexistent-2'`, + 'container start now accepts more than one container ID; replace the CommandNotSupportedError guard in AppleContainerClient.getStartContainersCommandArgs with a real multi-ID invocation.'); + }); + + // List verbs that lack `--filter`. AppleContainerClient filters these client-side because + // the CLI can't; when a `--filter` flag appears, push the filtering server-side instead. + describe('list `--filter` support', function () { + const listVerbs: Array<{ label: string; args: string }> = [ + { label: 'list', args: 'list --help' }, + { label: 'image list', args: 'image list --help' }, + { label: 'volume list', args: 'volume list --help' }, + { label: 'network list', args: 'network list --help' }, + ]; + + listVerbs.forEach(({ label, args }) => { + it(`\`container ${label}\` still lacks --filter`, async function () { + const { stdout } = await runAppleContainer(args); + expect(stdout).to.not.contain('--filter', + `container ${label} now supports --filter; push filtering server-side in AppleContainerClient instead of client-side matching.`); + }); + }); + }); + + // `--force` support for the four prune verbs and the two volume/network delete verbs. + // AppleContainerClient drops `--force` for all of these (confirmed unsupported); when the + // CLI adds it, thread it through from the corresponding CommandOptions.force instead of + // silently dropping it. + describe('`--force` support', function () { + const cases: Array<{ label: string; args: string }> = [ + { label: 'prune', args: 'prune --help' }, + { label: 'image prune', args: 'image prune --help' }, + { label: 'volume prune', args: 'volume prune --help' }, + { label: 'network prune', args: 'network prune --help' }, + { label: 'volume delete', args: 'volume delete --help' }, + { label: 'network delete', args: 'network delete --help' }, + ]; + + cases.forEach(({ label, args }) => { + it(`\`container ${label}\` still lacks --force`, async function () { + const { stdout } = await runAppleContainer(args); + expect(stdout).to.not.contain('--force', + `container ${label} now supports --force; thread it through from CommandOptions.force in AppleContainerClient instead of always omitting it.`); + }); + }); + }); + + // `volume create` flags AppleContainerClient rejects outright (no --driver at all -- + // confirmed via --help, which only lists --label/--opt/-s). + it('`container volume create` still lacks --driver', async function () { + const { stdout } = await runAppleContainer('volume create --help'); + expect(stdout).to.not.contain('--driver', + 'container volume create now supports --driver; emit it in AppleContainerClient.getCreateVolumeCommandArgs and drop the CommandNotSupportedError guard.'); + }); +}); diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts index 23e2f432..5acc3fd4 100644 --- a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -29,7 +29,7 @@ async function expectRejection(promiseOrFn: Promise | (() => Promise { const client = new AppleContainerClient(); @@ -144,6 +234,18 @@ describe('(unit) AppleContainerClient', () => { }); }); + describe('#login()/#logout()', () => { + it('Produces `registry login` args (not top-level `login`, which does not exist)', async () => { + const response = await client.login({ registry: 'ghcr.io', username: 'me', passwordStdIn: true }); + expect(asStrings(response.args)).to.deep.equal(['registry', 'login', '--username', 'me', '--password-stdin', 'ghcr.io']); + }); + + it('Produces `registry logout` args (not top-level `logout`)', async () => { + const response = await client.logout({ registry: 'ghcr.io' }); + expect(asStrings(response.args)).to.deep.equal(['registry', 'logout', 'ghcr.io']); + }); + }); + describe('#pullImage()', () => { it('Pins --arch arm64 to avoid the default multi-platform fetch', async () => { const response = await client.pullImage({ imageRef: 'alpine:latest' }); @@ -220,6 +322,31 @@ describe('(unit) AppleContainerClient', () => { }); }); + describe('#pruneImages()', () => { + it('Produces `image prune` args without --force (confirmed unsupported)', async () => { + const response = await client.pruneImages({}); + expect(asStrings(response.args)).to.deep.equal(['image', 'prune']); + }); + + it('Includes --all when requested', async () => { + const response = await client.pruneImages({ all: true }); + expect(asStrings(response.args)).to.include('--all'); + }); + + it('Parses "Reclaimed X in disk space" + "deleted " lines (real 1.2.0 output)', async () => { + const response = await client.pruneImages({}); + const output = 'Reclaimed 81.2 MB in disk space\n' + + 'deleted 65d86f451d12fb1de9db57a9226a899d80a0897cf0f1645faa565be3a268e621\n' + + 'deleted 3be987e6cde1d07e873c012bf6cfe941e6e85d16ca5fc5b8bedc675451d2de67\n'; + const result = await response.parse(output, true); + expect(result.imageRefsDeleted).to.deep.equal([ + '65d86f451d12fb1de9db57a9226a899d80a0897cf0f1645faa565be3a268e621', + '3be987e6cde1d07e873c012bf6cfe941e6e85d16ca5fc5b8bedc675451d2de67', + ]); + expect(result.spaceReclaimed).to.equal(Math.round(81.2 * 1024 * 1024)); + }); + }); + describe('#runContainer()', () => { it('Emits supported flags with bare `run` (not `container run`)', async () => { const response = await client.runContainer({ @@ -252,15 +379,14 @@ describe('(unit) AppleContainerClient', () => { expect(args).to.include('alpine:latest'); }); - it('Emits --mount with target= (not destination=)', async () => { + it('Emits --mount with destination= (confirmed accepted by the real CLI)', async () => { const response = await client.runContainer({ imageRef: 'alpine:latest', mounts: [{ type: 'bind', source: '/host/src', destination: '/src', readOnly: true }], }); const args = asStrings(response.args); expect(args).to.include('--mount'); - expect(args).to.include('type=bind,source=/host/src,target=/src,readonly'); - expect(args).to.not.include('destination=/src'); + expect(args).to.include('type=bind,source=/host/src,destination=/src,readonly'); }); it('Throws when publishAllPorts is set', async () => { @@ -419,6 +545,21 @@ describe('(unit) AppleContainerClient', () => { }); }); + describe('#pruneContainers()', () => { + it('Produces `prune` args without --force (confirmed unsupported; also drops the base\'s wrong `container prune` noun)', async () => { + const response = await client.pruneContainers({}); + expect(asStrings(response.args)).to.deep.equal(['prune']); + }); + + it('Parses "Reclaimed X in disk space" + one hyphenated name per line (real 1.2.0 output)', async () => { + const response = await client.pruneContainers({}); + const output = 'Reclaimed 4.12 GB in disk space\npoc-bind-test\nprune-container-test\npoc-mount-test2\n'; + const result = await response.parse(output, true); + expect(result.containersDeleted).to.deep.equal(['poc-bind-test', 'prune-container-test', 'poc-mount-test2']); + expect(result.spaceReclaimed).to.equal(Math.round(4.12 * 1024 * 1024 * 1024)); + }); + }); + describe('#inspectContainers()', () => { it('Produces bare `inspect` args with no --format flag (confirmed unsupported)', async () => { const response = await client.inspectContainers({ containers: ['poc-test'] }); @@ -437,5 +578,218 @@ describe('(unit) AppleContainerClient', () => { // imageId keeps the sha256: prefix (matches SharedInspectContainerRecord's form). expect(items[0].imageId).to.equal('sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b'); }); + + it('Parses published ports (from configuration.publishedPorts)', async () => { + const response = await client.inspectContainers({ containers: ['poc-mount-test2'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items[0].ports).to.deep.equal([{ containerPort: 80, hostPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }]); + }); + + it('Expands a port range (`count` > 1) into `count` individual bindings', async () => { + const ranged = { ...mountedPublishedContainerRecord, configuration: { ...mountedPublishedContainerRecord.configuration, publishedPorts: [{ containerPort: 80, count: 3, hostAddress: '0.0.0.0', hostPort: 9090, proto: 'tcp' }] } }; + const response = await client.inspectContainers({ containers: ['poc-mount-test2'] }); + const items = await response.parse(JSON.stringify([ranged]), true); + expect(items[0].ports).to.deep.equal([ + { containerPort: 80, hostPort: 9090, hostIp: '0.0.0.0', protocol: 'tcp' }, + { containerPort: 81, hostPort: 9091, hostIp: '0.0.0.0', protocol: 'tcp' }, + { containerPort: 82, hostPort: 9092, hostIp: '0.0.0.0', protocol: 'tcp' }, + ]); + }); + + it('Parses a udp port with a specific host IP', async () => { + const withUdp = { ...mountedPublishedContainerRecord, configuration: { ...mountedPublishedContainerRecord.configuration, publishedPorts: multiPublishedPorts } }; + const response = await client.inspectContainers({ containers: ['poc-mount-test2'] }); + const items = await response.parse(JSON.stringify([withUdp]), true); + expect(items[0].ports).to.deep.equal([ + { containerPort: 80, hostPort: 9090, hostIp: '0.0.0.0', protocol: 'tcp' }, + { containerPort: 81, hostPort: 9091, hostIp: '127.0.0.1', protocol: 'udp' }, + ]); + }); + + it('Parses a volume mount, using the volume name (not the backing file path) as source', async () => { + const response = await client.inspectContainers({ containers: ['poc-mount-test2'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items[0].mounts).to.deep.equal([{ type: 'volume', source: 'poc-vol', destination: '/data', readOnly: false }]); + }); + + it('Parses a readonly bind mount, reading readOnly from `options` (not a dedicated field)', async () => { + const withBind = { ...mountedPublishedContainerRecord, configuration: { ...mountedPublishedContainerRecord.configuration, mounts: [readonlyBindMount] } }; + const response = await client.inspectContainers({ containers: ['poc-mount-test2'] }); + const items = await response.parse(JSON.stringify([withBind]), true); + expect(items[0].mounts).to.deep.equal([{ type: 'bind', source: '/Users/victorpuga/bindhost', destination: '/hostdata', readOnly: true }]); + }); + }); + + describe('imageAncestors/volumes/networks list filters', () => { + it('Filters by imageAncestors matching the name:tag reference (the value ImageTreeItem.imageId actually passes)', async () => { + const response = await client.listContainers({ imageAncestors: ['docker.io/library/alpine:3.19'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items).to.have.lengthOf(1); + }); + + it('Filters by imageAncestors matching the manifest digest as a fallback', async () => { + const response = await client.listContainers({ imageAncestors: ['sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items).to.have.lengthOf(1); + }); + + it('Excludes non-matching imageAncestors', async () => { + const response = await client.listContainers({ imageAncestors: ['docker.io/library/busybox:latest'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items).to.have.lengthOf(0); + }); + + it('Filters by volumes matching a mount\'s `type.volume.name`', async () => { + const response = await client.listContainers({ volumes: ['poc-vol'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items).to.have.lengthOf(1); + }); + + it('Excludes non-matching volumes', async () => { + const response = await client.listContainers({ volumes: ['other-vol'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items).to.have.lengthOf(0); + }); + + it('Filters by networks matching the already-normalized item.networks', async () => { + const response = await client.listContainers({ networks: ['default'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items).to.have.lengthOf(1); + }); + + it('Excludes non-matching networks', async () => { + const response = await client.listContainers({ networks: ['other-net'] }); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items).to.have.lengthOf(0); + }); + }); + + describe('#listContainers() published ports', () => { + it('Parses published ports (list output carries configuration.publishedPorts too)', async () => { + const response = await client.listContainers({}); + const items = await response.parse(JSON.stringify([mountedPublishedContainerRecord]), true); + expect(items[0].ports).to.deep.equal([{ containerPort: 80, hostPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }]); + }); + }); + + describe('#createVolume()', () => { + it('Produces `volume create ` args (no --driver flag exists)', async () => { + const response = await client.createVolume({ name: 'my-vol' }); + expect(asStrings(response.args)).to.deep.equal(['volume', 'create', 'my-vol']); + }); + + it('Throws when a driver is requested (confirmed unsupported)', async () => { + await expectRejection(() => client.createVolume({ name: 'my-vol', driver: 'somedriver' })); + }); + }); + + describe('#listVolumes()', () => { + it('Produces `volume list --format json` args with no --filter flags', async () => { + const response = await client.listVolumes({ driver: 'local', labels: { foo: 'bar' } }); + expect(asStrings(response.args)).to.deep.equal(['volume', 'list', '--format', 'json']); + }); + + it('Parses the nested volume shape', async () => { + const response = await client.listVolumes({}); + const items = await response.parse(JSON.stringify([alpineVolumeListRecord]), true); + expect(items).to.have.lengthOf(1); + expect(items[0]).to.include({ name: 'poc-vol', driver: 'local', scope: 'local', size: 549755813888 }); + }); + + it('Filters by driver client-side', async () => { + const response = await client.listVolumes({ driver: 'nfs' }); + const items = await response.parse(JSON.stringify([alpineVolumeListRecord]), true); + expect(items).to.have.lengthOf(0); + }); + }); + + describe('#removeVolumes()', () => { + it('Produces `volume delete` args (not `volume rm`, no --force)', async () => { + const response = await client.removeVolumes({ volumes: ['my-vol'], force: true }); + expect(asStrings(response.args)).to.deep.equal(['volume', 'delete', 'my-vol']); + }); + }); + + describe('#pruneVolumes()', () => { + it('Produces `volume prune` args with no options at all (confirmed unsupported)', async () => { + const response = await client.pruneVolumes({}); + expect(asStrings(response.args)).to.deep.equal(['volume', 'prune']); + }); + + it('Parses "Reclaimed X in disk space" with no per-volume deleted-name list (real 1.2.0 output)', async () => { + const response = await client.pruneVolumes({}); + const result = await response.parse('Reclaimed 69.4 MB in disk space\n', true); + expect(result.spaceReclaimed).to.equal(Math.round(69.4 * 1024 * 1024)); + expect(result.volumesDeleted).to.be.undefined; + }); + }); + + describe('#inspectVolumes()', () => { + it('Produces bare `volume inspect` args with no --format flag (confirmed unsupported)', async () => { + const response = await client.inspectVolumes({ volumes: ['poc-vol'] }); + expect(asStrings(response.args)).to.deep.equal(['volume', 'inspect', 'poc-vol']); + }); + + it('Parses the same nested shape `volume list` uses', async () => { + const response = await client.inspectVolumes({ volumes: ['poc-vol'] }); + const items = await response.parse(JSON.stringify([alpineVolumeListRecord]), true); + expect(items[0]).to.include({ name: 'poc-vol', driver: 'local', mountpoint: alpineVolumeListRecord.configuration.source, scope: 'local' }); + }); + }); + + describe('#createNetwork()', () => { + it('Produces `network create ` args, mapping driver onto --plugin (no --driver flag exists)', async () => { + const response = await client.createNetwork({ name: 'my-net', driver: 'container-network-vmnet' }); + expect(asStrings(response.args)).to.deep.equal(['network', 'create', '--plugin', 'container-network-vmnet', 'my-net']); + }); + }); + + describe('#listNetworks()', () => { + it('Produces `network list --format json` args with no --filter flags', async () => { + const response = await client.listNetworks({ driver: 'container-network-vmnet' }); + expect(asStrings(response.args)).to.deep.equal(['network', 'list', '--format', 'json']); + }); + + it('Parses the nested network shape, including `internal` from mode', async () => { + const response = await client.listNetworks({}); + const items = await response.parse(JSON.stringify([defaultNetworkListRecord, internalNetworkListRecord]), true); + expect(items).to.have.lengthOf(2); + expect(items[0]).to.include({ name: 'default', driver: 'container-network-vmnet', internal: false }); + expect(items[1]).to.include({ name: 'poc-net-internal', internal: true }); + }); + }); + + describe('#removeNetworks()', () => { + it('Produces `network delete` args (not `network remove`, no --force)', async () => { + const response = await client.removeNetworks({ networks: ['my-net'], force: true }); + expect(asStrings(response.args)).to.deep.equal(['network', 'delete', 'my-net']); + }); + }); + + describe('#pruneNetworks()', () => { + it('Produces `network prune` args with no options at all (confirmed unsupported)', async () => { + const response = await client.pruneNetworks({}); + expect(asStrings(response.args)).to.deep.equal(['network', 'prune']); + }); + + it('Parses one bare deleted-network name per line, with no "Reclaimed" summary at all (real 1.2.0 output)', async () => { + const response = await client.pruneNetworks({}); + const result = await response.parse('prune-net-1\nprune-net-2\n', true); + expect(result.networksDeleted).to.deep.equal(['prune-net-1', 'prune-net-2']); + }); + }); + + describe('#inspectNetworks()', () => { + it('Produces bare `network inspect` args with no --format flag (confirmed unsupported)', async () => { + const response = await client.inspectNetworks({ networks: ['default'] }); + expect(asStrings(response.args)).to.deep.equal(['network', 'inspect', 'default']); + }); + + it('Parses the same nested shape `network list` uses, including IPAM from status', async () => { + const response = await client.inspectNetworks({ networks: ['default'] }); + const items = await response.parse(JSON.stringify([defaultNetworkListRecord]), true); + expect(items[0]).to.include({ name: 'default', driver: 'container-network-vmnet' }); + expect(items[0].ipam).to.deep.equal({ driver: 'default', config: [{ subnet: '192.168.64.0/24', gateway: '192.168.64.1' }] }); + }); }); }); From 2b5847c1c218856e1d3773f078cb2628e9308491 Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:06:25 -0600 Subject: [PATCH 09/11] vscode-container-client: fix AppleContainer buildImage/statsContainers overrides Both commands were broken as inherited from DockerClientBase. buildImage built `image build`, but Apple's CLI has `build` as a top-level verb with no `image build` subcommand; it also drops --iidfile/--disable-content-trust, which don't exist on `container build`. statsContainers built `container stats`, which becomes `container container stats` here and fails to route; the real verb is bare `stats` with no --all flag at all. Addresses review feedback from bwateratmsft on PR #577. Verified against real CLI 1.2.0 output on Apple Silicon per the repo's AppleContainerClient verification convention. Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerClient.ts | 33 +++++++++++++++++ .../AppleContainerClient.test.ts | 35 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts index 88497b3a..0cb9d470 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -10,11 +10,14 @@ import { withArg, withFlagArg, withNamedArg, + withQuotedArg, withVerbatimArg, } from '@microsoft/vscode-processutils'; import type { GeneratorCommandResponse, PromiseCommandResponse } from '../../contracts/CommandRunner'; import type { + BuildImageCommandOptions, CheckInstallCommandOptions, + ContainersStatsCommandOptions, CreateNetworkCommandOptions, CreateVolumeCommandOptions, EventItem, @@ -67,6 +70,7 @@ import { filterByLabelsAndDriver } from '../DockerClientBase/filterByLabelsAndDr import { matchesLabelFilters } from '../DockerClientBase/matchesLabelFilters'; import { parsePruneLikeOutput } from '../DockerClientBase/parsePruneLikeOutput'; import { tryParseSize } from '../DockerClientBase/tryParseSize'; +import { withDockerBuildArg } from '../DockerClientBase/withDockerBuildArg'; import { withDockerEnvArg } from '../DockerClientBase/withDockerEnvArg'; import { withDockerLabelsArg } from '../DockerClientBase/withDockerLabelsArg'; import { withDockerMountsArg } from '../DockerClientBase/withDockerMountsArg'; @@ -227,6 +231,26 @@ export class AppleContainerClient extends DockerClientBase implements IContainer //#region Image Commands + // The base builds `image build` (Docker's `docker image build`), but `build` is a + // top-level verb here, not an `image` subcommand -- confirmed: `container image build` + // routes to the `image` help instead of building, and a real `container build --tag x .` + // succeeds. `--iidfile` and `--disable-content-trust` are dropped since neither flag + // exists on `container build` (confirmed via --help); everything else maps over as-is. + protected override getBuildImageCommandArgs(options: BuildImageCommandOptions): CommandLineArgs { + return composeArgs( + withArg('build'), + withFlagArg('--pull', options.pull), + withNamedArg('--file', options.file), + withNamedArg('--target', options.stage), + withNamedArg('--tag', options.tags), + withDockerLabelsArg(options.labels), + withDockerPlatformArg(options.platform), + withDockerBuildArg(options.args), + withVerbatimArg(options.customOptions), + withQuotedArg(options.path), + )(); + } + protected override getPullImageCommandArgs(options: PullImageCommandOptions): CommandLineArgs { if (options.allTags) { throw new CommandNotSupportedError('container image pull does not support pulling all tags at once.'); @@ -534,6 +558,15 @@ export class AppleContainerClient extends DockerClientBase implements IContainer }); } + // The base builds `container stats` (Docker's `docker container stats`), which becomes + // `container container stats` here since the binary itself is already the container noun + // -- confirmed to error with "Plugin 'container-container' not found". The real verb is + // bare `stats`, and it accepts no `--all` at all (confirmed via --help and a real + // "Unknown option '--all'" error); it shows all running containers by default. + protected override getStatsContainersCommandArgs(options: ContainersStatsCommandOptions): CommandLineArgs { + return composeArgs(withArg('stats'))(); + } + // Bare `inspect` (not `container inspect`), and no --format flag exists (confirmed: errors // with "Unknown option '--format'"); JSON is the only output it produces. protected override getInspectContainersCommandArgs(options: InspectContainersCommandOptions): CommandLineArgs { diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts index 5acc3fd4..0a99da4c 100644 --- a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -246,6 +246,34 @@ describe('(unit) AppleContainerClient', () => { }); }); + describe('#buildImage()', () => { + it('Produces bare `build` args (not `image build`), dropping --iidfile/--disable-content-trust', async () => { + const response = await client.buildImage({ + path: '.', + file: 'Dockerfile', + stage: 'final', + tags: 'alpine:latest', + pull: true, + labels: { foo: 'bar' }, + platform: { os: 'linux', architecture: 'arm64' }, + args: { KEY: 'value' }, + disableContentTrust: false, + imageIdFile: '/tmp/iid', + }); + expect(asStrings(response.args)).to.deep.equal([ + 'build', + '--pull', + '--file', 'Dockerfile', + '--target', 'final', + '--tag', 'alpine:latest', + '--label', 'foo=bar', + '--platform', 'linux/arm64', + '--build-arg', 'KEY=value', + '.', + ]); + }); + }); + describe('#pullImage()', () => { it('Pins --arch arm64 to avoid the default multi-platform fetch', async () => { const response = await client.pullImage({ imageRef: 'alpine:latest' }); @@ -560,6 +588,13 @@ describe('(unit) AppleContainerClient', () => { }); }); + describe('#statsContainers()', () => { + it('Produces bare `stats` args without --all (confirmed unsupported; also drops the base\'s wrong `container stats` noun)', async () => { + const response = await client.statsContainers({ all: true }); + expect(asStrings(response.args)).to.deep.equal(['stats']); + }); + }); + describe('#inspectContainers()', () => { it('Produces bare `inspect` args with no --format flag (confirmed unsupported)', async () => { const response = await client.inspectContainers({ containers: ['poc-test'] }); From d24acf069650b32204dcc743ed1a094929fbf65d Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:57:59 -0600 Subject: [PATCH 10/11] vscode-container-client: fix AppleContainer readFile/writeFile overrides Both were broken as inherited from DockerClientBase. `container cp` doesn't accept a stdin or stdout `-`: `container cp CONTAINER:PATH -` just writes a local file literally named `-` and streams nothing, and the equivalent stdin write silently drops the piped content. readFile now tars the target file inside the container via `exec ... tar -cf - -C `, mirroring the same gap in WslcClient. writeFile extracts a streamed tar via `exec -i ... tar -xf -` when no host inputFile is given, and falls back to the base's plain `cp CONTAINER:DIR` (which works fine for host-to-container copies) when one is. Addresses review feedback from bwateratmsft on PR #577. Verified against a real running container on Apple Silicon per the repo's AppleContainerClient verification convention. Co-Authored-By: Claude Sonnet 5 --- .../AppleContainerClient.ts | 46 +++++++++++++++++++ .../AppleContainerClient.test.ts | 28 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts index 0cb9d470..eab16e8d 100644 --- a/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -53,6 +53,7 @@ import type { PruneVolumesCommandOptions, PruneVolumesItem, PullImageCommandOptions, + ReadFileCommandOptions, RemoveContainersCommandOptions, RemoveNetworksCommandOptions, RemoveVolumesCommandOptions, @@ -62,6 +63,7 @@ import type { StopContainersCommandOptions, VersionCommandOptions, VersionItem, + WriteFileCommandOptions, } from '../../contracts/ContainerClient'; import type { IContainersClient } from '../../contracts/ContainerClient'; import { CommandNotSupportedError } from '../../utils/CommandNotSupportedError'; @@ -70,6 +72,7 @@ import { filterByLabelsAndDriver } from '../DockerClientBase/filterByLabelsAndDr import { matchesLabelFilters } from '../DockerClientBase/matchesLabelFilters'; import { parsePruneLikeOutput } from '../DockerClientBase/parsePruneLikeOutput'; import { tryParseSize } from '../DockerClientBase/tryParseSize'; +import { withContainerPathArg } from '../DockerClientBase/withContainerPathArg'; import { withDockerBuildArg } from '../DockerClientBase/withDockerBuildArg'; import { withDockerEnvArg } from '../DockerClientBase/withDockerEnvArg'; import { withDockerLabelsArg } from '../DockerClientBase/withDockerLabelsArg'; @@ -746,4 +749,47 @@ export class AppleContainerClient extends DockerClientBase implements IContainer } //#endregion + + //#region File Commands + + // `container cp` supports neither a stdin nor a stdout `-` -- confirmed: `container cp + // c:/etc/hostname -` just writes a local file literally named `-` and streams nothing, and + // the equivalent stdin write silently drops the piped content. Read the file by tarring it + // inside the container via `exec` instead, so the caller still gets the single-entry tarball + // stream it expects (mirrors WslcClient, which has the same `cp` gap). Requires `tar` in the + // image; this client only ever runs Linux containers. + protected override getReadFileCommandArgs(options: ReadFileCommandOptions): CommandLineArgs { + const containerPath = options.path.replace(/\/+$/, ''); + const lastSlash = containerPath.lastIndexOf('/'); + const directory = lastSlash <= 0 ? '/' : containerPath.slice(0, lastSlash); + const fileName = containerPath.slice(lastSlash + 1); + + return this.getExecContainerCommandArgs({ + container: options.container, + command: ['tar', '-cf', '-', '-C', directory, fileName], + }); + } + + // The streamed case (no inputFile) can't use `cp - CONTAINER:DIR` like WslcClient does -- + // Apple's `cp` doesn't accept stdin `-` (see above) -- so it goes through `exec -i ... tar + // -xf -` to extract the incoming tar instead (confirmed working). When a host inputFile is + // given there's no stdin stream to smuggle through `exec`, and Apple's `cp + // CONTAINER:DIR` already works normally, so that case falls back to the base's plain `cp`. + protected override getWriteFileCommandArgs(options: WriteFileCommandOptions): CommandLineArgs { + if (!options.inputFile) { + return this.getExecContainerCommandArgs({ + container: options.container, + interactive: true, + command: ['tar', '-xf', '-', '-C', options.path], + }); + } + + return composeArgs( + withArg('cp'), + withArg(options.inputFile), + withContainerPathArg(options), + )(); + } + + //#endregion } diff --git a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts index 0a99da4c..35a4a23f 100644 --- a/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -827,4 +827,32 @@ describe('(unit) AppleContainerClient', () => { expect(items[0].ipam).to.deep.equal({ driver: 'default', config: [{ subnet: '192.168.64.0/24', gateway: '192.168.64.1' }] }); }); }); + + describe('#readFile()', () => { + it('Tars the file via `exec` (container cp has no stdout streaming)', async () => { + const response = await client.readFile({ container: 'abc', path: '/tmp/sub/file.txt' }); + const args = asStrings(response.args); + expect(args).to.deep.equal(['exec', 'abc', 'tar', '-cf', '-', '-C', '/tmp/sub', 'file.txt']); + }); + + it('Handles a root-level file path', async () => { + const response = await client.readFile({ container: 'abc', path: '/file.txt' }); + const args = asStrings(response.args); + expect(args).to.deep.equal(['exec', 'abc', 'tar', '-cf', '-', '-C', '/', 'file.txt']); + }); + }); + + describe('#writeFile()', () => { + it('Extracts a stdin tar via `exec -i tar -xf -` (container cp has no stdin streaming)', async () => { + const response = await client.writeFile({ container: 'abc', path: '/tmp/dest' }); + const args = asStrings(response.args); + expect(args).to.deep.equal(['exec', '--interactive', 'abc', 'tar', '-xf', '-', '-C', '/tmp/dest']); + }); + + it('Falls back to plain `cp` when a host input file is given', async () => { + const response = await client.writeFile({ container: 'abc', path: '/tmp/dest', inputFile: '/local/file.tar' }); + const args = asStrings(response.args); + expect(args).to.deep.equal(['cp', '/local/file.tar', 'abc:/tmp/dest']); + }); + }); }); From c35ed70eaaa646a6b2fd81ed5cf19b89e8036f4d Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:14:12 -0600 Subject: [PATCH 11/11] vscode-container-client: wire AppleContainer into the container client E2E suite Adds 'applecontainer' as a ClientType and instantiates AppleContainerClient in ContainersClientE2E.test.ts, skipping paths the CLI genuinely lacks (restart, events, --expose/--publish-all, and the whole orchestrator suite, since there is no Compose equivalent). Running the suite against real hardware surfaced three runtime differences that only show up under live execution, not static review: - Network names must be lowercase (confirmed: `container network create testNetworkCamel` errors with "invalid network name"). The shared `testContainerNetworkName` fixture had mixed case, breaking container setup for every runtime that reached it; renamed to an all-lowercase name. - `container build` always auto-tags with a random UUID even without --tag, so it never produces a dangling image the way Docker's untagged builds do. Bare `image prune` reclaims nothing as a result; PruneImagesCommand now passes `{ all: true }` for this client so the test still proves prune works. - The first `container run` in a session fetches a per-machine kernel + init VM image (~20s cold, ~1s once cached), which blew the suite's 10s default timeout. Bumped to 60s for the Containers `before` hook, Apple-only. Verified: 38 passing / 8 pending against the real `container` CLI, and re-ran the same suite against docker (44 passing / 2 pending) to confirm the shared network-name fixture fix didn't regress other runtimes. Addresses review feedback from bwateratmsft on PR #577. Co-Authored-By: Claude Sonnet 5 --- .../ContainerOrchestratorClientE2E.test.ts | 5 +- .../src/test/ContainersClientE2E.test.ts | 55 ++++++++++++++----- .../src/test/e2eShared.ts | 2 +- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/vscode-container-client/src/test/ContainerOrchestratorClientE2E.test.ts b/packages/vscode-container-client/src/test/ContainerOrchestratorClientE2E.test.ts index 65a010d7..8582f33a 100644 --- a/packages/vscode-container-client/src/test/ContainerOrchestratorClientE2E.test.ts +++ b/packages/vscode-container-client/src/test/ContainerOrchestratorClientE2E.test.ts @@ -45,8 +45,9 @@ describe('(integration) ContainerOrchestratorClientE2E', function () { this.timeout(10000); // Set a longer timeout for integration tests before(async function () { - // wslc has no Compose/orchestrator equivalent, so skip the orchestrator suite entirely. - if (clientTypeToTest === 'wslc') { + // wslc and applecontainer have no Compose/orchestrator equivalent, so skip the + // orchestrator suite entirely. + if (clientTypeToTest === 'wslc' || clientTypeToTest === 'applecontainer') { this.skip(); } diff --git a/packages/vscode-container-client/src/test/ContainersClientE2E.test.ts b/packages/vscode-container-client/src/test/ContainersClientE2E.test.ts index 502f6765..13ec76b9 100644 --- a/packages/vscode-container-client/src/test/ContainersClientE2E.test.ts +++ b/packages/vscode-container-client/src/test/ContainersClientE2E.test.ts @@ -9,6 +9,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import * as stream from 'stream'; +import { AppleContainerClient } from '../clients/AppleContainerClient/AppleContainerClient'; import { DockerClient } from '../clients/DockerClient/DockerClient'; import { FinchClient } from '../clients/FinchClient/FinchClient'; import { NerdctlClient } from '../clients/NerdctlClient/NerdctlClient'; @@ -42,9 +43,9 @@ const runInWsl: boolean = (process.env.RUN_IN_WSL === '1' || process.env.RUN_IN_ export const KeepAliveEntrypoint = 'tail'; export const KeepAliveCommand = ['-f', '/dev/null']; -// wslc does not support the `--expose` flag on `run`, but it does support -// network create/list/inspect/remove/prune. -const supportsExposeFlag = clientTypeToTest !== 'wslc'; +// wslc and the Apple `container` CLI don't support the `--expose` flag on `run`, but both +// support network create/list/inspect/remove/prune. +const supportsExposeFlag = clientTypeToTest !== 'wslc' && clientTypeToTest !== 'applecontainer'; describe('(integration) ContainersClientE2E', function () { @@ -67,6 +68,8 @@ describe('(integration) ContainersClientE2E', function () { client = new NerdctlClient('nerdctl', 'Nerdctl', 'Runs container commands using the nerdctl CLI'); } else if (clientTypeToTest === 'wslc') { client = new WslcClient(); + } else if (clientTypeToTest === 'applecontainer') { + client = new AppleContainerClient(); } else { throw new Error('Invalid clientTypeToTest'); } @@ -301,9 +304,13 @@ describe('(integration) ContainersClientE2E', function () { }) ); - // Prune the image + // Prune the image. The Apple `container` CLI always auto-assigns a real tag to a + // build even without --tag (a random UUID, confirmed via `--help`'s "-t, --tag" + // default), so the image above is never "dangling" the way Docker's untagged builds + // are -- only `--all` actually removes it (confirmed: a bare `image prune` reclaims + // nothing here since nothing on this runtime is ever tagless). const pruneResult = await defaultRunner.getCommandRunner()( - client.pruneImages({}) + client.pruneImages({ all: clientTypeToTest === 'applecontainer' }) ); expect(pruneResult).to.be.ok; @@ -324,12 +331,24 @@ describe('(integration) ContainersClientE2E', function () { describe('Containers', function () { const imageToTest = 'alpine:latest'; const testContainerName = 'test-container-e2e'; - const testContainerNetworkName = 'test-networkForContainer-e2e'; + // Lowercase-only: the Apple `container` CLI rejects mixed-case network names outright + // (confirmed: `container network create testNetworkCamel` errors with "invalid network + // name"), unlike volume/container names, which accept any case on every runtime tested. + const testContainerNetworkName = 'test-network-for-container-e2e'; const testContainerVolumeName = 'test-volumeForContainer-e2e'; let testContainerBindMountSource: string; let testContainerId: string; before('Containers', async function () { + if (clientTypeToTest === 'applecontainer') { + // The Apple `container` CLI fetches a per-machine kernel + init VM image the + // first time anything is actually run (not on pull/build), which can take well + // over the suite's default 10s timeout on a cold cache (confirmed: ~20s on a + // clean fetch, ~1s once cached). This is the first `runContainer` call in the + // suite, so it's the one most likely to eat that one-time cost. + this.timeout(60000); + } + testContainerBindMountSource = import.meta.dirname; // If running in WSL, convert the bind mount source path to WSL format @@ -396,9 +415,11 @@ describe('(integration) ContainersClientE2E', function () { ports: clientTypeToTest === 'nerdctl' ? [{ hostPort: 8080, containerPort: 80 }, { hostPort: 3000, containerPort: 3000 }] : [{ hostPort: 8080, containerPort: 80 }], - // wslc has no --expose flag; rootless nerdctl cannot auto-allocate host ports + // wslc/applecontainer have no --expose flag; rootless nerdctl cannot auto-allocate host ports exposePorts: (clientTypeToTest === 'nerdctl' || !supportsExposeFlag) ? undefined : [3000], - publishAllPorts: clientTypeToTest === 'nerdctl' ? undefined : true, // Rootless nerdctl cannot auto-allocate host ports + // Rootless nerdctl cannot auto-allocate host ports; the Apple `container` CLI has no + // `--publish-all`/`-P` equivalent at all (confirmed via `container run --help`). + publishAllPorts: (clientTypeToTest === 'nerdctl' || clientTypeToTest === 'applecontainer') ? undefined : true, }) ))!; }); @@ -619,8 +640,8 @@ describe('(integration) ContainersClientE2E', function () { }); it('RestartContainersCommand', async function () { - if (clientTypeToTest === 'wslc') { - this.skip(); // wslc has no `restart` subcommand + if (clientTypeToTest === 'wslc' || clientTypeToTest === 'applecontainer') { + this.skip(); // wslc/applecontainer have no `restart` subcommand } // Restart the container @@ -645,8 +666,14 @@ describe('(integration) ContainersClientE2E', function () { expect(command.command).to.be.a('string'); expect(command.args).to.be.an('array'); - // We expect `container stats --all` - expect(getBashCommandLine(command)).to.equal(`${client.commandName} container stats --all`); + if (clientTypeToTest === 'applecontainer') { + // The Apple `container` CLI has `stats` as a bare top-level verb with no `--all` + // flag at all (confirmed via --help; it shows all running containers by default). + expect(getBashCommandLine(command)).to.equal(`${client.commandName} stats`); + } else { + // We expect `container stats --all` + expect(getBashCommandLine(command)).to.equal(`${client.commandName} container stats --all`); + } }); it('RemoveContainersCommand', async function () { @@ -926,8 +953,8 @@ describe('(integration) ContainersClientE2E', function () { let container: string | undefined; before('Events', async function () { - if (clientTypeToTest === 'wslc') { - this.skip(); // wslc has no `events` subcommand + if (clientTypeToTest === 'wslc' || clientTypeToTest === 'applecontainer') { + this.skip(); // wslc/applecontainer have no `events` subcommand } // For Docker/Podman: Create a container so that the event stream has something to report diff --git a/packages/vscode-container-client/src/test/e2eShared.ts b/packages/vscode-container-client/src/test/e2eShared.ts index 6f6cb73a..6331ee90 100644 --- a/packages/vscode-container-client/src/test/e2eShared.ts +++ b/packages/vscode-container-client/src/test/e2eShared.ts @@ -6,7 +6,7 @@ import type { ICommandRunnerFactory } from '../contracts/CommandRunner'; import type { IContainersClient, ListContainersItem } from '../contracts/ContainerClient'; -export type ClientType = 'docker' | 'podman' | 'finch' | 'nerdctl' | 'wslc'; +export type ClientType = 'docker' | 'podman' | 'finch' | 'nerdctl' | 'wslc' | 'applecontainer'; /** * Shell command that keeps a container alive while responding to SIGTERM for a