diff --git a/extensions/vscode-containers/package.json b/extensions/vscode-containers/package.json index c9656f64..639e51aa 100644 --- a/extensions/vscode-containers/package.json +++ b/extensions/vscode-containers/package.json @@ -2595,7 +2595,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": [ "%vscode-containers.config.containers.containerClient.default%", @@ -2603,7 +2604,8 @@ "%vscode-containers.config.containers.containerClient.podman%", "%vscode-containers.config.containers.containerClient.nerdctl%", "%vscode-containers.config.containers.containerClient.finch%", - "%vscode-containers.config.containers.containerClient.wslc%" + "%vscode-containers.config.containers.containerClient.wslc%", + "%vscode-containers.config.containers.containerClient.applecontainer%" ] }, "containers.orchestratorClient": { diff --git a/extensions/vscode-containers/package.nls.json b/extensions/vscode-containers/package.nls.json index 349b01c7..b10e0db6 100644 --- a/extensions/vscode-containers/package.nls.json +++ b/extensions/vscode-containers/package.nls.json @@ -218,6 +218,7 @@ "vscode-containers.config.containers.containerClient.nerdctl": "Nerdctl", "vscode-containers.config.containers.containerClient.finch": "Finch", "vscode-containers.config.containers.containerClient.wslc": "WSLC (Windows only, preview)", + "vscode-containers.config.containers.containerClient.applecontainer": "Container (macOS only, preview)", "vscode-containers.config.containers.orchestratorClient": "Which container orchestrator client to use. If not specified, Docker Compose will be used. Changing requires a restart to take effect.", "vscode-containers.config.containers.orchestratorClient.default": "Default", "vscode-containers.config.containers.orchestratorClient.dockerCompose": "Docker Compose", 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..eab16e8d --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerClient.ts @@ -0,0 +1,795 @@ +/*--------------------------------------------------------------------------------------------- + * 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, + toArray, + withArg, + withFlagArg, + withNamedArg, + withQuotedArg, + withVerbatimArg, +} from '@microsoft/vscode-processutils'; +import type { GeneratorCommandResponse, PromiseCommandResponse } from '../../contracts/CommandRunner'; +import type { + BuildImageCommandOptions, + CheckInstallCommandOptions, + ContainersStatsCommandOptions, + CreateNetworkCommandOptions, + CreateVolumeCommandOptions, + EventItem, + EventStreamCommandOptions, + ExecContainerCommandOptions, + InfoCommandOptions, + InfoItem, + InspectContainersCommandOptions, + 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, + ReadFileCommandOptions, + RemoveContainersCommandOptions, + RemoveNetworksCommandOptions, + RemoveVolumesCommandOptions, + RestartContainersCommandOptions, + RunContainerCommandOptions, + StartContainersCommandOptions, + StopContainersCommandOptions, + VersionCommandOptions, + VersionItem, + WriteFileCommandOptions, +} from '../../contracts/ContainerClient'; +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 { withContainerPathArg } from '../DockerClientBase/withContainerPathArg'; +import { withDockerBuildArg } from '../DockerClientBase/withDockerBuildArg'; +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 { AppleContainerInspectContainerRecordSchema, normalizeAppleContainerInspectContainerRecord } from './AppleContainerInspectContainerRecord'; +import { AppleContainerInspectImageRecordSchema, normalizeAppleContainerInspectImageRecord } from './AppleContainerInspectImageRecord'; +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. + * + * 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 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 + + // 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.'); + } + 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; + } + + // 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))); + } + + // `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 + + 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), + withDockerMountsArg(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 ?? [])), + )(); + } + + // 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 + // `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 + // 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> { + 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); + } + + // `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; + } + if (options.exited && item.state !== 'exited') { + return false; + } + if (options.names && options.names.length > 0 && !options.names.includes(item.name)) { + return false; + } + 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; + } + + 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.')); + } + + // 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), + }); + } + + // 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 { + 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 + + //#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 + + //#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/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts new file mode 100644 index 00000000..013685c8 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectContainerRecord.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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()), + ipv4Address: z.optional(z.string()), + ipv4Gateway: z.optional(z.string()), + 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 + * `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())), + mounts: z.optional(z.array(AppleContainerMountSchema)), + publishedPorts: z.optional(z.array(AppleContainerPublishedPortSchema)), + }), + status: z.object({ + startedDate: z.optional(dateStringWithFallbackSchema), + networks: z.optional(z.array(AppleContainerStatusNetworkSchema)), + }), +}); + +export type AppleContainerInspectContainerRecord = z.infer; + +/** + * 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 ?? []) + .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, + // 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, + environmentVariables: parseDockerLikeEnvironmentVariables(initProcess?.environment ?? []), + networks, + ipAddress: networks[0]?.ipAddress, + operatingSystem: 'linux', + 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. + 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..198e1da6 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerInspectImageRecord.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + 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: 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, + 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/AppleContainerListContainerRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts new file mode 100644 index 00000000..2f97fc77 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListContainerRecord.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +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, ...}, + * 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()), + 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()), + }), +}); + +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}. + */ +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), + ports: normalizeAppleContainerPublishedPorts(container.configuration.publishedPorts), + networks: (container.configuration.networks ?? []) + .map((attachment) => attachment.network) + .filter((name): name is string => !!name), + createdAt: container.configuration.creationDate ?? new Date(0), + 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/clients/AppleContainerClient/AppleContainerListImageRecord.ts b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts new file mode 100644 index 00000000..0711af59 --- /dev/null +++ b/packages/vscode-container-client/src/clients/AppleContainerClient/AppleContainerListImageRecord.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * 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()), + 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.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. 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. + * + * 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(), + configuration: z.object({ + creationDate: z.optional(dateStringWithFallbackSchema), + 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 realVariants = (image.variants ?? []) + .filter((variant) => variant.platform?.architecture !== 'unknown'); + + return { + // 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), + // 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/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/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'; 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 9c34e2b7..b8082985 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'; @@ -41,9 +42,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 () { @@ -66,6 +67,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'); } @@ -300,9 +303,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; @@ -323,7 +330,10 @@ 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; @@ -334,6 +344,15 @@ describe('(integration) ContainersClientE2E', function () { let dockerPortRange: number[] = []; 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 @@ -407,9 +426,11 @@ describe('(integration) ContainersClientE2E', function () { { hostPort: 8080, containerPort: 80 }, ...dockerPortRange.map(port => ({ hostPort: port, containerPort: port })), ] : [{ 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, }) ))!; }); @@ -635,8 +656,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 @@ -661,8 +682,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 () { @@ -942,8 +969,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/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 new file mode 100644 index 00000000..35a4a23f --- /dev/null +++ b/packages/vscode-container-client/src/test/clients/AppleContainerClient/AppleContainerClient.test.ts @@ -0,0 +1,858 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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: '/', + }, + }, +}; + +// Captured from a real `container run -d --mount type=volume,source=poc-vol,destination=/data +// -p 8080:80 alpine:3.19 sleep 300` (CLI 1.2.0). +const mountedPublishedContainerRecord = { + id: 'poc-mount-test2', + configuration: { + creationDate: '2026-08-06T04:39:50Z', + image: { + descriptor: { digest: 'sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1', mediaType: 'application/vnd.oci.image.index.v1+json', size: 8077 }, + reference: 'docker.io/library/alpine:3.19', + }, + initProcess: { + arguments: ['300'], + environment: ['PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'], + executable: 'sleep', + workingDirectory: '/', + }, + labels: {}, + mounts: [ + { + destination: '/data', + options: [], + source: '/Users/victorpuga/Library/Application Support/com.apple.container/volumes/poc-vol/volume.img', + type: { volume: { name: 'poc-vol' } }, + }, + ], + networks: [{ network: 'default', options: { hostname: 'poc-mount-test2', mtu: 1280 } }], + publishedPorts: [{ containerPort: 80, count: 1, hostAddress: '0.0.0.0', hostPort: 8080, proto: 'tcp' }], + }, + status: { + networks: [], + startedDate: '2026-08-06T04:39:51Z', + state: 'running', + }, +}; + +// Captured from a real `container run -d --mount type=bind,source=$PWD/bindhost, +// destination=/hostdata,readonly alpine:3.19 sleep 300`. +const readonlyBindMount = { + destination: '/hostdata', + options: ['ro'], + source: '/Users/victorpuga/bindhost', + type: { virtiofs: {} }, +}; + +// Captured from a real `container run -d -p 9090:80 -p 127.0.0.1:9091:81/udp ...`. +const multiPublishedPorts = [ + { containerPort: 80, count: 1, hostAddress: '0.0.0.0', hostPort: 9090, proto: 'tcp' }, + { containerPort: 81, count: 1, hostAddress: '127.0.0.1', hostPort: 9091, proto: 'udp' }, +]; + +const alpineVolumeListRecord = { + id: 'poc-vol', + configuration: { + creationDate: '2026-08-06T04:39:04Z', + driver: 'local', + format: 'ext4', + labels: {}, + name: 'poc-vol', + options: {}, + sizeInBytes: 549755813888, + source: '/Users/victorpuga/Library/Application Support/com.apple.container/volumes/poc-vol/volume.img', + }, +}; + +const defaultNetworkListRecord = { + id: 'default', + configuration: { + creationDate: '2026-08-06T04:38:28Z', + labels: { 'com.apple.container.resource.role': 'builtin' }, + mode: 'nat', + name: 'default', + options: {}, + plugin: 'container-network-vmnet', + }, + status: { ipv4Gateway: '192.168.64.1', ipv4Subnet: '192.168.64.0/24', ipv6Subnet: 'fd57:9cd7:94e6:49e0::/64' }, +}; + +const internalNetworkListRecord = { + id: 'poc-net-internal', + configuration: { + creationDate: '2026-08-06T04:47:15Z', + labels: {}, + mode: 'hostOnly', + name: 'poc-net-internal', + options: {}, + plugin: 'container-network-vmnet', + }, + status: { ipv4Gateway: '192.168.128.1', ipv4Subnet: '192.168.128.0/24', ipv6Subnet: 'fdb1:d6d8:6480:99dd::/64' }, +}; + +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('#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('#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' }); + 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('#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({ + 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 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,destination=/src,readonly'); + }); + + 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('#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'] }); + 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("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); + 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('#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 }); + 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('#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('#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'] }); + 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 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' }] }); + }); + }); + + 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']); + }); + }); +}); diff --git a/packages/vscode-container-client/src/test/e2eShared.ts b/packages/vscode-container-client/src/test/e2eShared.ts index ad834612..caa342f2 100644 --- a/packages/vscode-container-client/src/test/e2eShared.ts +++ b/packages/vscode-container-client/src/test/e2eShared.ts @@ -7,7 +7,7 @@ import * as net from 'net'; 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