diff --git a/packages/vscode-container-client/src/clients/DockerClient/DockerClient.ts b/packages/vscode-container-client/src/clients/DockerClient/DockerClient.ts index a124d3d8..9d230508 100644 --- a/packages/vscode-container-client/src/clients/DockerClient/DockerClient.ts +++ b/packages/vscode-container-client/src/clients/DockerClient/DockerClient.ts @@ -48,7 +48,7 @@ export class DockerClient extends DockerClientBase implements IContainersClient private getListContextsCommandArgs(options: ListContextsCommandOptions): CommandLineArgs { return composeArgs( withArg('context', 'ls'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), )(); } @@ -156,7 +156,7 @@ export class DockerClient extends DockerClientBase implements IContainersClient private getInspectContextsCommandArgs(options: InspectContextsCommandOptions): CommandLineArgs { return composeArgs( withArg('context', 'inspect'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), withArg(...options.contexts), )(); } diff --git a/packages/vscode-container-client/src/clients/DockerClientBase/DockerClientBase.ts b/packages/vscode-container-client/src/clients/DockerClientBase/DockerClientBase.ts index 313e3cd0..54113efe 100644 --- a/packages/vscode-container-client/src/clients/DockerClientBase/DockerClientBase.ts +++ b/packages/vscode-container-client/src/clients/DockerClientBase/DockerClientBase.ts @@ -132,6 +132,11 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo */ public readonly defaultTag: string = 'latest'; + /** + * The default argument given to `--format` + */ + public readonly defaultFormatForJson: string = "{{json .}}"; + //#region Information Commands protected getInfoCommandArgs( @@ -139,7 +144,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo ): CommandLineArgs { return composeArgs( withArg('info'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), )(); } @@ -173,7 +178,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo protected getVersionCommandArgs(options: VersionCommandOptions): CommandLineArgs { return composeArgs( withArg('version'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), )(); } @@ -243,7 +248,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo withDockerLabelFilterArgs(options.labels), withDockerFilterArg(options.types?.map((type) => `type=${type}`)), withDockerFilterArg(options.events?.map((event) => `event=${event}`)), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), )(); } @@ -391,7 +396,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo withDockerFilterArg(options.references?.map((reference) => `reference=${reference}`)), withDockerLabelFilterArgs(options.labels), withDockerNoTruncArg, - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), )(); } @@ -462,7 +467,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo protected getRemoveImagesCommandArgs(options: RemoveImagesCommandOptions): CommandLineArgs { return composeArgs( - withArg('image', 'remove'), + withArg('image', 'rm'), // Docker supports both `remove` and `rm`, but Podman supports only `rm` withFlagArg('--force', options.force), withArg(...options.imageRefs), )(); @@ -602,7 +607,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo ): CommandLineArgs { return composeArgs( withArg('image', 'inspect'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), withArg(...options.imageRefs), )(); } @@ -770,7 +775,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo withDockerFilterArg(options.volumes?.map((volume) => `volume=${volume}`)), withDockerFilterArg(options.networks?.map((network) => `network=${network}`)), withDockerNoTruncArg, - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), withDockerIgnoreSizeArg, )(); } @@ -1046,7 +1051,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo ): CommandLineArgs { return composeArgs( withArg('container', 'inspect'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), withArg(...options.containers) )(); } @@ -1117,7 +1122,6 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo withArg('volume', 'create'), withNamedArg('--driver', options.driver), withArg(options.name), - withDockerJsonFormatArg, )(); } @@ -1138,11 +1142,11 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo withDockerBooleanFilterArg('dangling', options.dangling), withDockerFilterArg(options.driver ? `driver=${options.driver}` : undefined), withDockerLabelFilterArgs(options.labels), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), )(); } - protected async parseListVolumesCommandOputput( + protected async parseListVolumesCommandOutput( options: ListVolumesCommandOptions, output: string, strict: boolean, @@ -1198,7 +1202,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return { command: this.commandName, args: this.getListVolumesCommandArgs(options), - parse: (output, strict) => this.parseListVolumesCommandOputput(options, output, strict), + parse: (output, strict) => this.parseListVolumesCommandOutput(options, output, strict), }; } @@ -1292,7 +1296,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo ): CommandLineArgs { return composeArgs( withArg('volume', 'inspect'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), withArg(...options.volumes), )(); } @@ -1375,7 +1379,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo withArg('network', 'ls'), withDockerLabelFilterArgs(options.labels), withDockerNoTruncArg, - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), )(); } @@ -1495,7 +1499,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo ): CommandLineArgs { return composeArgs( withArg('network', 'inspect'), - withDockerJsonFormatArg, + withDockerJsonFormatArg(this.defaultFormatForJson), withArg(...options.networks), )(); } diff --git a/packages/vscode-container-client/src/clients/DockerClientBase/withDockerJsonFormatArg.ts b/packages/vscode-container-client/src/clients/DockerClientBase/withDockerJsonFormatArg.ts index 0e4c5629..6c2f37f6 100644 --- a/packages/vscode-container-client/src/clients/DockerClientBase/withDockerJsonFormatArg.ts +++ b/packages/vscode-container-client/src/clients/DockerClientBase/withDockerJsonFormatArg.ts @@ -5,4 +5,6 @@ import { withNamedArg } from '../../utils/commandLineBuilder'; -export const withDockerJsonFormatArg = withNamedArg('--format', '{{json .}}'); +export function withDockerJsonFormatArg(jsonFormat: string = '{{json .}}') { + return withNamedArg('--format', jsonFormat); +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanClient.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanClient.ts new file mode 100644 index 00000000..f3e73785 --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanClient.ts @@ -0,0 +1,493 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dayjs from 'dayjs'; +import * as customParseFormat from 'dayjs/plugin/customParseFormat'; +import * as utc from 'dayjs/plugin/utc'; +import * as readline from 'readline'; +import { + EventItem, + EventStreamCommandOptions, + IContainersClient, + InfoItem, + InspectContainersCommandOptions, + InspectContainersItem, + InspectImagesCommandOptions, + InspectImagesItem, + InspectNetworksItem, + InspectVolumesItem, + ListContainersCommandOptions, + ListContainersItem, + ListImagesCommandOptions, + ListImagesItem, + ListNetworkItem, + ListNetworksCommandOptions, + ListVolumeItem, + ListVolumesCommandOptions, + PortBinding, + PruneContainersCommandOptions, + PruneContainersItem, + PruneImagesCommandOptions, + PruneImagesItem, + PruneNetworksCommandOptions, + PruneNetworksItem, + PruneVolumesCommandOptions, + PruneVolumesItem, + VersionItem +} from '../../contracts/ContainerClient'; +import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; +import { isPodmanListContainerRecord } from './PodmanListContainerRecord'; +import { isPodmanListImageRecord } from './PodmanListImageRecord'; +import { isPodmanVersionRecord } from './PodmanVersionRecord'; +import { DockerClientBase } from '../DockerClientBase/DockerClientBase'; +import { CancellationTokenLike } from '../../typings/CancellationTokenLike'; +import { CancellationError } from '../../utils/CancellationError'; +import { PodmanEventRecord, isPodmanEventRecord } from './PodmanEventRecord'; +import { asIds } from '../../utils/asIds'; +import { isPodmanInspectImageRecord, normalizePodmanInspectImageRecord } from './PodmanInspectImageRecord'; +import { isPodmanInspectContainerRecord, normalizePodmanInspectContainerRecord } from './PodmanInspectContainerRecord'; +import { isPodmanListNetworkRecord } from './PodmanListNetworkRecord'; +import { isPodmanInspectNetworkRecord, normalizePodmanInspectNetworkRecord } from './PodmanInspectNetworkRecord'; +import { isPodmanInspectVolumeRecord, normalizePodmanInspectVolumeRecord } from './PodmanInspectVolumeRecord'; + +dayjs.extend(customParseFormat); +dayjs.extend(utc); + +export class PodmanClient extends DockerClientBase implements IContainersClient { + /** + * The ID of the Podman client + */ + public static ClientId = 'com.microsoft.visualstudio.containers.podman'; + + /** + * The default argument given to `--format` + */ + public readonly defaultFormatForJson: string = "json"; + + /** + * Constructs a new {@link PodmanClient} + * @param commandName (Optional, default `podman`) The command that will be run + * as the base command. If quoting is necessary, it is the responsibility of the + * caller to add. + * @param displayName (Optional, default 'Podman') The human-friendly display + * name of the client + * @param description (Optional, with default) The human-friendly description of + * the client + */ + public constructor( + commandName: string = 'podman', + displayName: string = 'Podman', + description: string = 'Runs container commands using the Podman CLI' + ) { + super( + PodmanClient.ClientId, + commandName, + displayName, + description + ); + } + + //#region Version Command + + protected async parseVersionCommandOutput(output: string, strict: boolean): Promise { + const version = JSON.parse(output); + if (!isPodmanVersionRecord(version)) { + throw new Error('Invalid version JSON'); + } + + return { + client: version.Client.APIVersion, + server: version.Server?.APIVersion, + }; + } + + //#endregion + + //#region Info Command + + protected async parseInfoCommandOutput(output: string, strict: boolean): Promise { + return { + operatingSystem: undefined, // Podman doesn't list an OS in its `info` command + osType: 'linux', + raw: output, + }; + } + + //#endregion + + //#region GetEventStream Command + + protected override async *parseEventStreamCommandOutput( + options: EventStreamCommandOptions, + output: NodeJS.ReadableStream, + strict: boolean, + cancellationToken?: CancellationTokenLike + ): AsyncGenerator { + cancellationToken ||= CancellationTokenLike.None; + + const lineReader = readline.createInterface({ + input: output, + crlfDelay: Infinity, + }); + + for await (const line of lineReader) { + if (cancellationToken.isCancellationRequested) { + throw new CancellationError('Event stream cancelled', cancellationToken); + } + + try { + // Parse a line at a time + const item: PodmanEventRecord = JSON.parse(line); + if (!isPodmanEventRecord(item)) { + throw new Error('Invalid event JSON'); + } + + // Yield the parsed data + yield { + type: item.Type, + action: item.Status, + actor: { id: item.Name, attributes: item.Attributes || {} }, + timestamp: new Date(item.Time), + raw: JSON.stringify(line), + }; + } catch (err) { + if (strict) { + throw err; + } + } + } + } + + //#endregion + + //#region ListImages Command + + protected override async parseListImagesCommandOutput(options: ListImagesCommandOptions, output: string, strict: boolean): Promise { + const images = new Array(); + try { + const rawImages = JSON.parse(output); + rawImages.forEach((rawImage: unknown) => { + try { + if (!isPodmanListImageRecord(rawImage)) { + throw new Error('Invalid image JSON'); + } + + const createdAt = dayjs.unix(rawImage.Created).toDate(); + + // Podman lists the same image multiple times depending on how many tags it has + // So index the name based on how many times we've already seen this image ID + const countImagesOfSameId = images.filter(i => i.id === rawImage.Id).length; + + images.push({ + id: rawImage.Id, + image: parseDockerLikeImageName(rawImage.Names?.[countImagesOfSameId]), + // labels: rawImage.Labels || {}, + createdAt, + size: rawImage.Size, + }); + } catch (err) { + if (strict) { + throw err; + } + } + }); + } catch (err) { + if (strict) { + throw err; + } + } + + return images; + } + + //#endregion + + //#region PruneImages Command + + protected override parsePruneImagesCommandOutput( + options: PruneImagesCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + imageRefsDeleted: asIds(output), + }); + } + + //#endregion + + //#region InspectImages Command + + /** + * Parse the standard output from a Docker-like inspect images command and + * normalize the result + * @param options Inspect images command options + * @param output The standard out from a Docker-like runtime inspect images command + * @param strict Should strict parsing be enforced? + * @returns Normalized array of InspectImagesItem records + */ + protected async parseInspectImagesCommandOutput( + options: InspectImagesCommandOptions, + output: string, + strict: boolean, + ): Promise> { + const results = new Array(); + + try { + const resultRaw = JSON.parse(output); + + if (!Array.isArray(resultRaw)) { + throw new Error('Invalid image inspect json'); + } + + for (const inspect of resultRaw) { + if (!isPodmanInspectImageRecord(inspect)) { + throw new Error('Invalid image inspect json'); + } + + results.push(normalizePodmanInspectImageRecord(inspect)); + } + } catch (err) { + if (strict) { + throw err; + } + } + + return results; + } + + //#endregion + + //#region ListContainers Command + + protected override async parseListContainersCommandOutput(options: ListContainersCommandOptions, output: string, strict: boolean): Promise { + const containers = new Array(); + try { + const rawContainers = JSON.parse(output); + rawContainers.forEach((rawContainer: unknown) => { + try { + if (!isPodmanListContainerRecord(rawContainer)) { + throw new Error('Invalid container JSON'); + } + + const name = rawContainer.Names?.[0].trim(); + const createdAt = dayjs.unix(rawContainer.Created).toDate(); + const ports: PortBinding[] = (rawContainer.Ports || []).map(p => { + return { + containerPort: p.container_port, + hostIp: p.host_ip, + hostPort: p.host_port, + protocol: p.protocol, + }; + }); + + containers.push({ + id: rawContainer.Id, + image: parseDockerLikeImageName(rawContainer.Image), + name, + labels: rawContainer.Labels || {}, + createdAt, + ports, + networks: rawContainer.Networks || [], + state: rawContainer.State, + status: rawContainer.Status, + }); + } catch (err) { + if (strict) { + throw err; + } + } + }); + } catch (err) { + if (strict) { + throw err; + } + } + + return containers; + } + + //#endregion + + //#region PruneContainers Command + + protected override parsePruneContainersCommandOutput( + options: PruneContainersCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + containersDeleted: asIds(output), + }); + } + + //#endregion + + //#region InspectContainers Command + + protected override async parseInspectContainersCommandOutput(options: InspectContainersCommandOptions, output: string, strict: boolean): Promise { + const results = new Array(); + + try { + const resultRaw = JSON.parse(output); + + if (!Array.isArray(resultRaw)) { + throw new Error('Invalid container inspect json'); + } + + for (const inspect of resultRaw) { + if (!isPodmanInspectContainerRecord(inspect)) { + throw new Error('Invalid container inspect json'); + } + + results.push(normalizePodmanInspectContainerRecord(inspect)); + } + } catch (err) { + if (strict) { + throw err; + } + } + + return results; + } + + //#endregion + + //#region ListNetworks Command + + protected override async parseListNetworksCommandOutput(options: ListNetworksCommandOptions, output: string, strict: boolean): Promise { + // Podman networks are drastically different from Docker networks in terms of what details are available + const results = new Array(); + + try { + const resultRaw = JSON.parse(output); + + if (!Array.isArray(resultRaw)) { + throw new Error('Invalid network json'); + } + + for (const network of resultRaw) { + if (!isPodmanListNetworkRecord(network)) { + throw new Error('Invalid network json'); + } + + results.push({ + name: network.name || network.Name || '', + labels: network.Labels || {}, + createdAt: network.created ? new Date(network.created) : undefined, + internal: network.internal, + ipv6: network.ipv6_enabled, + driver: network.driver, + id: network.id, + scope: undefined, // Not available from Podman + }); + } + } catch (err) { + if (strict) { + throw err; + } + } + + return results; + } + + //#endregion + + //#region PruneNetworks Command + + protected override parsePruneNetworksCommandOutput( + options: PruneNetworksCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + networksDeleted: asIds(output), + }); + } + + //#endregion + + //#region InspectNetworks Command + + protected override async parseInspectNetworksCommandOutput(options: ListNetworksCommandOptions, output: string, strict: boolean): Promise { + // Podman networks are drastically different from Docker networks in terms of what details are available + const results = new Array(); + + try { + const resultRaw = JSON.parse(output); + + if (!Array.isArray(resultRaw)) { + throw new Error('Invalid network inspect json'); + } + + for (const network of resultRaw) { + if (!isPodmanInspectNetworkRecord(network)) { + throw new Error('Invalid network inspect json'); + } + + results.push(normalizePodmanInspectNetworkRecord(network)); + } + } catch (err) { + if (strict) { + throw err; + } + } + + return results; + } + + //#endregion + + //#region ListVolumes Command + + protected override async parseListVolumesCommandOutput(options: ListVolumesCommandOptions, output: string, strict: boolean): Promise { + // Podman volume inspect is identical to volume list + return this.parseInspectVolumesCommandOutput(options, output, strict); + } + + //#endregion + + //#region PruneVolumes Command + + protected override parsePruneVolumesCommandOutput( + options: PruneVolumesCommandOptions, + output: string, + strict: boolean, + ): Promise { + return Promise.resolve({ + volumesDeleted: asIds(output), + }); + } + + //#endregion + + //#region InspectVolumes Command + + protected override async parseInspectVolumesCommandOutput(options: ListVolumesCommandOptions, output: string, strict: boolean): Promise { + const results = new Array(); + + try { + const resultRaw = JSON.parse(output); + + if (!Array.isArray(resultRaw)) { + throw new Error('Invalid volume json'); + } + + for (const volume of resultRaw) { + if (!isPodmanInspectVolumeRecord(volume)) { + throw new Error('Invalid volume json'); + } + + results.push(normalizePodmanInspectVolumeRecord(volume)); + } + } catch (err) { + if (strict) { + throw err; + } + } + + return results; + } +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanEventRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanEventRecord.ts new file mode 100644 index 00000000..62b8bc6e --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanEventRecord.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventAction, EventType } from "../../contracts/ContainerClient"; + +export type PodmanEventRecord = { + ID?: string; // Not in v3 + Type: EventType; + Status: EventAction; + Name: string; + Time: string; + Attributes?: Record; +}; + +export function isPodmanEventRecord(maybeEvent: unknown): maybeEvent is PodmanEventRecord { + const event = maybeEvent as PodmanEventRecord; + + if (!event || typeof event !== 'object') { + return false; + } + + if (typeof event.Type !== 'string') { + return false; + } + + if (typeof event.Status !== 'string') { + return false; + } + + if (typeof event.Name !== 'string') { + return false; + } + + if (typeof event.Time !== 'string') { + return false; + } + + return true; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectContainerRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectContainerRecord.ts new file mode 100644 index 00000000..f171bdd8 --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectContainerRecord.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InspectContainersItem, InspectContainersItemMount, InspectContainersItemNetwork, PortBinding } from '../../contracts/ContainerClient'; +import { dayjs } from '../../utils/dayjs'; +import { parseDockerLikeImageName } from '../../utils/parseDockerLikeImageName'; +import { toArray } from '../../utils/toArray'; +import { parseDockerLikeEnvironmentVariables } from '../DockerClientBase/parseDockerLikeEnvironmentVariables'; + +export type PodmanInspectContainerPortHost = { + HostIp?: string; + HostPort?: number; +}; + +export type PodmanInspectContainerBindMount = { + Type: 'bind'; + Source: string; + Destination: string; + RW: boolean; +}; + +export type PodmanInspectContainerVolumeMount = { + Type: 'volume'; + Name: string; + Source: string; + Destination: string; + Driver: string; + RW: boolean; +}; + +export type PodmanInspectContainerMount = + | PodmanInspectContainerBindMount + | PodmanInspectContainerVolumeMount; + +export type PodmanInspectNetwork = { + Gateway: string; + IPAddress: string; + MacAddress: string; +}; + +export type PodmanInspectContainerConfig = { + Image: string; + Entrypoint: Array | string | null; + Cmd: Array | string | null; + Env?: Array | null; + Labels?: Record | null; + WorkingDir?: string | null; +}; + +export type PodmanInspectContainerHostConfig = { + PublishAllPorts?: boolean | null; + Isolation?: string; +}; + +export type PodmanInspectContainerNetworkSettings = { + Networks?: Record | null; + IPAddress?: string; + Ports?: Record> | null; +}; + +export type PodmanInspectContainerState = { + Status?: string; + StartedAt?: string; + FinishedAt?: string; +}; + +export type PodmanInspectContainerRecord = { + Id: string; + Name: string; + Image: string; + Created: string; + Mounts: Array; + State: PodmanInspectContainerState; + Config: PodmanInspectContainerConfig; + HostConfig: PodmanInspectContainerHostConfig; + NetworkSettings: PodmanInspectContainerNetworkSettings; +}; + +// TODO: Actually test properties +export function isPodmanInspectContainerRecord(maybeContainer: unknown): maybeContainer is PodmanInspectContainerRecord { + return true; +} + +export function normalizePodmanInspectContainerRecord(container: PodmanInspectContainerRecord): InspectContainersItem { + // Parse the environment variables assigned to the container at runtime + const environmentVariables = parseDockerLikeEnvironmentVariables(container.Config?.Env || []); + + // Parse the networks assigned to the container and normalize to InspectContainersItemNetwork + // records + const networks = Object.entries(container.NetworkSettings?.Networks || {}).map(([name, dockerNetwork]) => { + return { + name, + gateway: dockerNetwork.Gateway || undefined, + ipAddress: dockerNetwork.IPAddress || undefined, + macAddress: dockerNetwork.MacAddress || undefined, + }; + }); + + // Parse the exposed ports for the container and normalize to a PortBinding record + const ports = Object.entries(container.NetworkSettings?.Ports || {}).map(([rawPort, hostBinding]) => { + const [port, protocol] = rawPort.split('/'); + return { + hostIp: hostBinding?.[0]?.HostIp, + hostPort: hostBinding?.[0]?.HostPort, + containerPort: parseInt(port), + protocol: protocol.toLowerCase() === 'tcp' + ? 'tcp' + : protocol.toLowerCase() === 'udp' + ? 'udp' + : undefined, + }; + }); + + // Parse the volume and bind mounts associated with the given runtime and normalize to + // InspectContainersItemMount records + const mounts = (container.Mounts || []).reduce>((curMounts, mount) => { + switch (mount?.Type) { + case 'bind': + return [...curMounts, { + type: 'bind', + source: mount.Source, + destination: mount.Destination, + readOnly: !mount.RW, + }]; + case 'volume': + return [...curMounts, { + type: 'volume', + name: mount.Name, + source: mount.Source, + destination: mount.Destination, + driver: mount.Driver, + readOnly: !mount.RW, + }]; + } + + }, new Array()); + const labels = container.Config?.Labels ?? {}; + + const createdAt = dayjs.utc(container.Created); + const startedAt = container.State?.StartedAt + ? dayjs.utc(container.State?.StartedAt) + : undefined; + const finishedAt = container.State?.FinishedAt + ? dayjs.utc(container.State?.FinishedAt) + : undefined; + + // Return the normalized InspectContainersItem record + return { + id: container.Id, + name: container.Name, + imageId: container.Image, + image: parseDockerLikeImageName(container.Config.Image), + isolation: container.HostConfig?.Isolation, + status: container.State?.Status, + environmentVariables, + networks, + ipAddress: container.NetworkSettings?.IPAddress ? container.NetworkSettings?.IPAddress : undefined, + ports, + mounts, + labels, + entrypoint: toArray(container.Config?.Entrypoint ?? []), + command: toArray(container.Config?.Cmd ?? []), + currentDirectory: container.Config?.WorkingDir || undefined, + createdAt: createdAt.toDate(), + startedAt: startedAt && (startedAt.isSame(createdAt) || startedAt.isAfter(createdAt)) + ? startedAt.toDate() + : undefined, + finishedAt: finishedAt && (finishedAt.isSame(createdAt) || finishedAt.isAfter(createdAt)) + ? finishedAt.toDate() + : undefined, + raw: JSON.stringify(container), + }; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectImageRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectImageRecord.ts new file mode 100644 index 00000000..6231d1fe --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectImageRecord.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ImageNameInfo, InspectImagesItem, PortBinding } from "../../contracts/ContainerClient"; +import { dayjs } from '../../utils/dayjs'; +import { parseDockerLikeImageName } from "../../utils/parseDockerLikeImageName"; +import { toArray } from "../../utils/toArray"; +import { parseDockerLikeEnvironmentVariables } from "../DockerClientBase/parseDockerLikeEnvironmentVariables"; + +export type PodmanInspectImageConfig = { + Entrypoint?: Array | string | null; + Cmd?: Array | string | null; + Env?: Array, + ExposedPorts?: Record | null; + + // TODO: validate these remaining properties + Volumes?: Record | null; + WorkingDir?: string | null; + User?: string | null; +}; + +export type PodmanInspectImageRecord = { + Id: string; + RepoTags: Array; + Config: PodmanInspectImageConfig, + RepoDigests: Array; + Architecture: string; + Os: string; + Labels: Record | null; + Created: string; + User?: string; // TODO validate +}; + +function isPodmanInspectImageConfig(maybeImageConfig: unknown): maybeImageConfig is PodmanInspectImageConfig { + const imageConfig = maybeImageConfig as PodmanInspectImageConfig; + + if (!imageConfig || typeof imageConfig !== 'object') { + return false; + } + + if (imageConfig.Env && !Array.isArray(imageConfig.Env)) { + return false; + } + + if (imageConfig.ExposedPorts && typeof imageConfig.ExposedPorts !== 'object') { + return false; + } + + if (imageConfig.Volumes && typeof imageConfig.Volumes !== 'object') { + return false; + } + + if (imageConfig.WorkingDir && typeof imageConfig.WorkingDir !== 'string') { + return false; + } + + if (imageConfig.User && typeof imageConfig.User !== 'string') { + return false; + } + + if (imageConfig.Entrypoint && !Array.isArray(imageConfig.Entrypoint) && typeof imageConfig.Entrypoint !== 'string') { + return false; + } + + if (imageConfig.Cmd && !Array.isArray(imageConfig.Cmd) && typeof imageConfig.Cmd !== 'string') { + return false; + } + + return true; +} + +export function isPodmanInspectImageRecord(maybeImage: unknown): maybeImage is PodmanInspectImageRecord { + const image = maybeImage as PodmanInspectImageRecord; + + if (!image || typeof image !== 'object') { + return false; + } + + if (typeof image.Id !== 'string') { + return false; + } + + if (!Array.isArray(image.RepoTags)) { + return false; + } + + if (!isPodmanInspectImageConfig(image.Config)) { + return false; + } + + if (!Array.isArray(image.RepoDigests)) { + return false; + } + + if (typeof image.Architecture !== 'string') { + return false; + } + + if (typeof image.Os !== 'string') { + return false; + } + + if (typeof image.Created !== 'string') { + return false; + } + + if (image.Labels && typeof image.Labels !== 'object') { + return false; + } + + return true; +} + +export function normalizePodmanInspectImageRecord(image: PodmanInspectImageRecord): InspectImagesItem { + // This is effectively doing firstOrDefault on the RepoTags for the image. If there are any values + // in RepoTags, the first one will be parsed and returned as the tag name for the image. + const imageNameInfo: ImageNameInfo = parseDockerLikeImageName(image.RepoTags?.[0]); + + // Parse any environment variables defined for the image + const environmentVariables = parseDockerLikeEnvironmentVariables(image.Config?.Env || []); + + // Parse any default ports exposed by the image + const ports = Object.entries(image.Config?.ExposedPorts || {}).map(([rawPort]) => { + const [port, protocol] = rawPort.split('/'); + return { + containerPort: parseInt(port), + protocol: protocol.toLowerCase() === 'tcp' ? 'tcp' : protocol.toLowerCase() === 'udp' ? 'udp' : undefined, + }; + }); + + // Parse any default volumes specified by the image + const volumes = Object.entries(image.Config?.Volumes || {}).map(([rawVolume]) => rawVolume); + + // Parse any labels assigned to the image + const labels = image.Labels ?? {}; + + // Parse and normalize the image architecture + const architecture = image.Architecture?.toLowerCase() === 'amd64' + ? 'amd64' + : image.Architecture?.toLowerCase() === 'arm64' ? 'arm64' : undefined; + + // Parse and normalize the image OS + const os = image.Os?.toLowerCase() === 'linux' + ? 'linux' + : image.Architecture?.toLowerCase() === 'windows' + ? 'windows' + : undefined; + + // Determine if the image has been pushed to a remote repo + // (no repo digests or only localhost/ repo digests) + const isLocalImage = !(image.RepoDigests || []).some((digest) => !digest.toLowerCase().startsWith('localhost/')); + + return { + id: image.Id, + image: imageNameInfo, + repoDigests: image.RepoDigests, + isLocalImage, + environmentVariables, + ports, + volumes, + labels, + entrypoint: toArray(image.Config?.Entrypoint || []), + command: toArray(image.Config?.Cmd || []), + currentDirectory: image.Config?.WorkingDir || undefined, + architecture, + operatingSystem: os, + createdAt: dayjs(image.Created).toDate(), + user: image?.User || undefined, + raw: JSON.stringify(image), + }; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectNetworkRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectNetworkRecord.ts new file mode 100644 index 00000000..fd73c7f3 --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectNetworkRecord.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InspectNetworksItem } from "../../contracts/ContainerClient"; + +export type PodmanInspectNetworkRecord = { + id?: string; // Not in v3 + driver?: string; // Not in v3 + created?: string; // Not in v3 + // eslint-disable-next-line @typescript-eslint/naming-convention + ipv6_enabled?: boolean; // Not in v3 + internal?: boolean; // Not in v3 + name: string; + labels?: Record; +}; + +export function isPodmanInspectNetworkRecord(maybeNetwork: unknown): maybeNetwork is PodmanInspectNetworkRecord { + const network = maybeNetwork as PodmanInspectNetworkRecord; + + if (!network || typeof network !== 'object') { + return false; + } + + if (typeof network.name !== 'string') { + return false; + } + + if (network.labels && typeof network.labels !== 'object') { + return false; + } + + return true; +} + +export function normalizePodmanInspectNetworkRecord(network: PodmanInspectNetworkRecord): InspectNetworksItem { + return { + name: network.name, + id: network.id, + driver: network.driver, + createdAt: network.created ? new Date(network.created) : undefined, + internal: network.internal, + ipv6: network.ipv6_enabled, + labels: network.labels || {}, + scope: undefined, + attachable: undefined, + ingress: undefined, + ipam: undefined, + raw: JSON.stringify(network), + }; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectVolumeRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectVolumeRecord.ts new file mode 100644 index 00000000..f17b89f6 --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanInspectVolumeRecord.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InspectVolumesItem } from "../../contracts/ContainerClient"; + +export type PodmanInspectVolumeRecord = { + Name: string; + Driver: string; + Mountpoint: string; + CreatedAt: string; + Labels?: Record; + Scope: string; + Options?: Record; +}; + +export function isPodmanInspectVolumeRecord(maybeVolume: unknown): maybeVolume is PodmanInspectVolumeRecord { + const volume = maybeVolume as PodmanInspectVolumeRecord; + + if (!volume || typeof volume !== 'object') { + return false; + } + + if (typeof volume.Name !== 'string') { + return false; + } + + if (typeof volume.Driver !== 'string') { + return false; + } + + if (typeof volume.Mountpoint !== 'string') { + return false; + } + + if (typeof volume.CreatedAt !== 'string') { + return false; + } + + if (volume.Labels && typeof volume.Labels !== 'object') { + return false; + } + + if (typeof volume.Scope !== 'string') { + return false; + } + + if (volume.Options && typeof volume.Options !== 'object') { + return false; + } + + return true; +} + +export function normalizePodmanInspectVolumeRecord(volume: PodmanInspectVolumeRecord): InspectVolumesItem { + return { + name: volume.Name, + driver: volume.Driver, + mountpoint: volume.Mountpoint, + createdAt: new Date(volume.CreatedAt), + labels: volume.Labels || {}, + scope: volume.Scope, + options: volume.Options || {}, + raw: JSON.stringify(volume), + }; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanListContainerRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanListContainerRecord.ts new file mode 100644 index 00000000..640de5a5 --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanListContainerRecord.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type PodmanListContainerRecord = { + Id: string; + Names: Array; + Image: string; + Ports?: Array; + Networks?: string[]; + Labels?: Record; + Created: number; + State: string; + Status: string; +}; + +type PodmanPortBinding = { + /* eslint-disable @typescript-eslint/naming-convention */ + host_ip?: string; + container_port: number; + host_port?: number; + protocol: 'udp' | 'tcp'; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isPodmanListContainerRecord(maybeContainer: any): maybeContainer is PodmanListContainerRecord { + if (!maybeContainer || typeof maybeContainer !== 'object') { + return false; + } + + if (typeof maybeContainer.Id !== 'string') { + return false; + } + + if (!!maybeContainer.Names && !Array.isArray(maybeContainer.Names)) { + return false; + } + + if (typeof maybeContainer.Image !== 'string') { + return false; + } + + if (typeof maybeContainer.Created !== 'number') { + return false; + } + + if (typeof maybeContainer.State !== 'string') { + return false; + } + + if (typeof maybeContainer.Status !== 'string') { + return false; + } + + return true; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanListImageRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanListImageRecord.ts new file mode 100644 index 00000000..a5392fce --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanListImageRecord.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type PodmanListImageRecord = { + Id: string; + Names?: Array; + Size: number; + Labels?: Record; + Created: number; +}; + +export function isPodmanListImageRecord(maybeImage: unknown): maybeImage is PodmanListImageRecord { + const image = maybeImage as PodmanListImageRecord; + + if (!image || typeof image !== 'object') { + return false; + } + + if (typeof image.Id !== 'string') { + return false; + } + + if (typeof image.Size !== 'number') { + return false; + } + + if (!!image.Names && !Array.isArray(image.Names)) { + return false; + } + + if (typeof image.Created !== 'number') { + return false; + } + + return true; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanListNetworkRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanListNetworkRecord.ts new file mode 100644 index 00000000..2df37f2c --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanListNetworkRecord.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type PodmanListNetworkRecord = { + Name?: string; // v3 + name?: string; // Not in v3 + id?: string; // Not in v3 + driver?: string; // Not in v3 + created?: string; // Not in v3 + // eslint-disable-next-line @typescript-eslint/naming-convention + ipv6_enabled?: boolean; // Not in v3 + internal?: boolean; // Not in v3 + Labels?: Record; // v3 + labels?: Record; // Maybe in v4? +}; + +export function isPodmanListNetworkRecord(maybeNetwork: unknown): maybeNetwork is PodmanListNetworkRecord { + const network = maybeNetwork as PodmanListNetworkRecord; + + if (!network || typeof network !== 'object') { + return false; + } + + if (typeof network.Name !== 'string' && typeof network.name !== 'string') { + return false; + } + + return true; +} diff --git a/packages/vscode-container-client/src/clients/PodmanClient/PodmanVersionRecord.ts b/packages/vscode-container-client/src/clients/PodmanClient/PodmanVersionRecord.ts new file mode 100644 index 00000000..414fa9ac --- /dev/null +++ b/packages/vscode-container-client/src/clients/PodmanClient/PodmanVersionRecord.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type PodmanVersionRecord = { + Client: { APIVersion: string }; + Server?: { APIVersion: string }; +}; + +export function isPodmanVersionRecord(maybeVersion: unknown): maybeVersion is PodmanVersionRecord { + const version = maybeVersion as PodmanVersionRecord; + + if (typeof version !== 'object') { + return false; + } + + if (typeof version.Client !== 'object') { + return false; + } + + if (typeof version.Client.APIVersion !== 'string') { + return false; + } + + if (typeof version.Server === 'object' && typeof version.Server.APIVersion !== 'string') { + return false; + } + + return true; +} diff --git a/packages/vscode-container-client/src/contracts/ContainerClient.ts b/packages/vscode-container-client/src/contracts/ContainerClient.ts index 417dbd44..34b677b7 100644 --- a/packages/vscode-container-client/src/contracts/ContainerClient.ts +++ b/packages/vscode-container-client/src/contracts/ContainerClient.ts @@ -5,7 +5,6 @@ import type { FileType, ShellQuotedString } from 'vscode'; import { GeneratorCommandResponse, PromiseCommandResponse, VoidCommandResponse } from './CommandRunner'; -import { IShell } from './Shell'; export type ContainerOS = "linux" | "windows"; @@ -92,9 +91,7 @@ export type ImageNameDefaults = { readonly defaultTag: string; }; -export type CommonCommandOptions = { - shellProvider?: IShell; -}; +export type CommonCommandOptions = Record; // Version Command Types @@ -1436,11 +1433,11 @@ export type ListNetworkItem = { /** * The ID of the network */ - id: string; + id: string | undefined; /** * The network driver */ - driver: string; + driver: string | undefined; /** * Labels assigned to the network */ @@ -1448,19 +1445,19 @@ export type ListNetworkItem = { /** * The network scope */ - scope: string; + scope: string | undefined; /** * True if IPv6 network */ - ipv6: boolean; + ipv6: boolean | undefined; /** * The date the network was created */ - createdAt: Date; + createdAt: Date | undefined; /** * True if internal network */ - internal: boolean; + internal: boolean | undefined; }; type ListNetworksCommand = { @@ -1547,11 +1544,11 @@ export type InspectNetworksItem = { /** * The ID of the network */ - id: string; + id: string | undefined; /** * The network driver */ - driver: string; + driver: string | undefined; /** * Labels assigned to the network */ @@ -1559,31 +1556,31 @@ export type InspectNetworksItem = { /** * The network scope */ - scope: string; + scope: string | undefined; /** * The IPAM config */ - ipam: NetworkIpamConfig; + ipam: NetworkIpamConfig | undefined; /** * True if IPv6 network */ - ipv6: boolean; + ipv6: boolean | undefined; /** * True if internal network */ - internal: boolean; + internal: boolean | undefined; /** * True if attachable */ - attachable: boolean; + attachable: boolean | undefined; /** * True if ingress */ - ingress: boolean; + ingress: boolean | undefined; /** * The date the network was created */ - createdAt: Date; + createdAt: Date | undefined; /** * The raw JSON from the inspect record */ diff --git a/packages/vscode-container-client/src/index.ts b/packages/vscode-container-client/src/index.ts index 974e2195..dcbbf034 100644 --- a/packages/vscode-container-client/src/index.ts +++ b/packages/vscode-container-client/src/index.ts @@ -5,6 +5,7 @@ export * from './clients/DockerClient/DockerClient'; export * from './clients/DockerComposeClient/DockerComposeClient'; +export * from './clients/PodmanClient/PodmanClient'; export * from './commandRunners/shellStream'; export * from './commandRunners/wslStream'; export * from './contracts/CommandRunner'; diff --git a/packages/vscode-container-client/src/test/PodmanClient.test.ts b/packages/vscode-container-client/src/test/PodmanClient.test.ts new file mode 100644 index 00000000..7ac97a34 --- /dev/null +++ b/packages/vscode-container-client/src/test/PodmanClient.test.ts @@ -0,0 +1,407 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as stream from 'stream'; +import { describe, it } from 'mocha'; +import { PodmanClient } from '../clients/PodmanClient/PodmanClient'; +import { WslShellCommandRunnerFactory } from '../commandRunners/wslStream'; +import { expect } from 'chai'; +import { PodmanListImageRecord } from '../clients/PodmanClient/PodmanListImageRecord'; +import { ShellStreamCommandRunnerFactory, ShellStreamCommandRunnerOptions } from '../commandRunners/shellStream'; +import { wslifyPath } from '../utils/wslifyPath'; + +// To run tests, remove the `x` in `xdescribe('PodmanClient', () => {` on line below + +const testDockerUsername = ''; // Supply a value for this to run the login/logout tests +const testDockerPat = ''; // Supply a value for this to run the login/logout tests +const executeInWsl = true; // Change this to false to run the tests on the host machine + +const client = new PodmanClient(); +let runner: ShellStreamCommandRunnerFactory | WslShellCommandRunnerFactory; +let testDockerfileContext: string = path.resolve(__dirname, 'buildContext'); +let testDockerfile: string = path.resolve(testDockerfileContext, 'Dockerfile'); +if (executeInWsl) { + runner = new WslShellCommandRunnerFactory({ strict: true }); + testDockerfileContext = wslifyPath(testDockerfileContext); + testDockerfile = wslifyPath(testDockerfile); +} else { + runner = new ShellStreamCommandRunnerFactory({ strict: true }); +} + +// Remove the x in `xdescribe` to run PodmanClient tests +xdescribe('PodmanClient', () => { + describe('#version()', () => { + it('successfully parses version end to end', async () => { + const version = await runner.getCommandRunner()(client.version({})); + expect(version?.client).to.be.ok; + }); + }); + + describe('#checkInstall()', () => { + it('successfully checks install end to end', async () => { + const result = await runner.getCommandRunner()(client.checkInstall({})); + expect(result).to.have.string('podman'); + }); + }); + + describe('#info()', () => { + it('successfully parses info end to end', async () => { + const info = await runner.getCommandRunner()(client.info({})); + expect(info.osType).to.be.ok; + expect(info.raw).to.be.ok; + }); + }); + + describe('#getEventStream()', () => { + xit('successfully gets events end to end', async () => { + // TODO + }); + }); + + describe('#login() and #logout()', () => { + it('successfully logs in end to end', async function () { + if (!testDockerUsername || !testDockerPat) { + this.skip(); + } + + // Create a stream to write the PAT into + const stdInPipe = stream.Readable.from(testDockerPat); + const runner = new WslShellCommandRunnerFactory({ strict: true, stdInPipe: stdInPipe }); + + // Log in + await runner.getCommandRunner()(client.login({ + registry: 'docker.io', + username: testDockerUsername, + passwordStdIn: true, + })); + }); + + it('successfully logs out end to end', async function () { + if (!testDockerUsername || !testDockerPat) { + this.skip(); + } + + await runner.getCommandRunner()(client.logout({ registry: 'docker.io' })); + }); + }); + + describe('List images with same name', () => { + it('correctly parses images that have the same name', async () => { + const image: PodmanListImageRecord = { + Created: 1619710180, + Id: "3a093384ac7f6f4f1d1b3f0b2d5b0d6c0c5c8a1e2d6f8f2a8b8a4f6c0a4c8d5f", + Names: [ + "foo", + "bar" + ], + Size: 0, + }; + + const images: PodmanListImageRecord[] = [ + image, + image, // Podman will have the exact same image twice if it has two tags + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reparsedImages = await ((client as any).parseListImagesCommandOutput({}, JSON.stringify(images), true)); + expect(reparsedImages).to.be.an('array').with.lengthOf(2); + expect(reparsedImages[0].image.originalName).to.equal('foo'); + expect(reparsedImages[1].image.originalName).to.equal('bar'); + }); + }); + + describe('#buildImage()', () => { + it('successfully builds images end to end', async () => { + await runner.getCommandRunner()(client.buildImage({ + path: testDockerfileContext, + file: testDockerfile, + tags: ['test:latest'] + })); + + const images = await runner.getCommandRunner()(client.listImages({})); + const image = images.find(i => i.image.originalName === 'localhost/test:latest'); + expect(image).to.be.ok; + + // Clean up the image so as to not interfere with the prune test + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await runner.getCommandRunner()(client.removeImages({ imageRefs: [image!.id] })); + }); + }); + + describe('#pruneImage', () => { + it('successfully prunes images end to end', async () => { + // Build an image with no tag + await runner.getCommandRunner()(client.buildImage({ + path: testDockerfileContext, + file: testDockerfile, + })); + + // Prune images + const result = await runner.getCommandRunner()(client.pruneImages({})); + + expect(result).to.be.ok; + expect(result.imageRefsDeleted).to.be.an('array').with.length.greaterThan(0); + }); + }); + + describe('#listImages()', () => { + it('successfully lists images end to end', async () => { + const images = await runner.getCommandRunner()(client.listImages({})); + expect(images).to.be.an('array').with.length.greaterThan(0); + expect(images[0].id).to.be.ok; + expect(images[0].size).to.be.ok; + expect(images[0].image.originalName).to.be.ok; + }); + }); + + describe('#inspectImages()', () => { + it('successfully inspects images end to end', async () => { + const images = await runner.getCommandRunner()(client.listImages({})); + const imageInspects = await runner.getCommandRunner()(client.inspectImages({ imageRefs: [images[0].id] })); + expect(imageInspects).to.be.an('array').with.lengthOf(1); + + const image = imageInspects[0]; + expect(image.id).to.be.ok; + expect(image.image.originalName).to.be.ok; + expect(image.createdAt).to.be.ok; + expect(image.raw).to.be.ok; + }); + }); + + describe('Containers Big End To End', function () { + this.timeout(10000); + + let containerId: string; + + before(async () => { + // Start a container detached so it stays up + containerId = await runner.getCommandRunner()(client.runContainer({ + imageRef: 'alpine:latest', + detached: true, + labels: { + "FOO": "BAR" + }, + })) as string; + expect(containerId).to.be.ok; + }); + + it('successfully lists containers end to end', async () => { + const containers = await runner.getCommandRunner()(client.listContainers({})); + expect(containers).to.be.an('array').with.length.greaterThan(0); + expect(containers[0].id).to.equal(containerId); + expect(containers[0].image).to.be.ok; + expect(containers[0].createdAt).to.be.ok; + expect(containers[0].status).to.be.ok; + + // Stop the container + const stopped = await runner.getCommandRunner()(client.stopContainers({ container: [containerId], time: 1 })); + expect(stopped).to.be.an('array').with.lengthOf(1); + expect(stopped[0]).to.equal(containerId); + + // Inspect the container + const inspected = await runner.getCommandRunner()(client.inspectContainers({ containers: [containerId] })); + expect(inspected).to.be.an('array').with.lengthOf(1); + expect(inspected[0].id).to.equal(containerId); + expect(inspected[0].image).to.be.ok; + expect(inspected[0].createdAt).to.be.ok; + expect(inspected[0].status).to.equal('exited'); + }); + + after(async () => { + // Remove the container + const removed = await runner.getCommandRunner()(client.removeContainers({ containers: [containerId], force: true })); + expect(removed).to.be.an('array').with.lengthOf(1); + expect(removed[0]).to.equal(containerId); + }); + }); + + describe('#pruneContainers()', () => { + it('successfully prunes containers end to end', async () => { + // Start a hello-world container which will immediately exit + const containerId = await runner.getCommandRunner()(client.runContainer({ + imageRef: 'hello-world', + detached: true, + })); + + expect(containerId).to.be.ok; + if (!containerId) { + expect.fail('containerId should not be undefined'); + } + + // Stop it to make sure it's good and stopped + await runner.getCommandRunner()(client.stopContainers({ container: [containerId], time: 1 })); + + + // Prune containers + const result = await runner.getCommandRunner()(client.pruneContainers({})); + expect(result).to.be.ok; + expect(result.containersDeleted).to.be.an('array').with.lengthOf(1); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(result.containersDeleted![0]).to.equal(containerId); + }); + }); + + describe('#listNetworks()', () => { + it('successfully lists networks end to end', async () => { + const networks = await runner.getCommandRunner()(client.listNetworks({})); + expect(networks).to.be.an('array').with.length.greaterThan(0); + expect(networks[0].name).to.be.ok; + }); + }); + + describe('#pruneNetworks()', () => { + it('successfully prunes networks end to end', async () => { + // Create a network + await runner.getCommandRunner()(client.createNetwork({ + name: 'prune-test-network', + })); + + // Prune networks + const result = await runner.getCommandRunner()(client.pruneNetworks({})); + expect(result).to.be.ok; + expect(result.networksDeleted).to.be.an('array').with.length.greaterThan(0); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(result.networksDeleted![0]).to.equal('prune-test-network'); + }); + }); + + describe('#inspectNetworks()', () => { + it('successfully inspects networks end to end', async () => { + const networks = await runner.getCommandRunner()(client.listNetworks({})); + const networkInspects = await runner.getCommandRunner()(client.inspectNetworks({ networks: [networks[0].name] })); + expect(networkInspects).to.be.an('array').with.lengthOf(1); + + const network = networkInspects[0]; + expect(network.name).to.be.ok; + expect(network.raw).to.be.ok; + }); + }); + + describe('#listVolumes()', () => { + it('successfully lists volumes end to end', async () => { + // Create a volume + try { + await runner.getCommandRunner()(client.createVolume({ + name: 'list-test-volume', + })); + } catch { + // No-op + } + + const volumes = await runner.getCommandRunner()(client.listVolumes({})); + expect(volumes).to.be.an('array').with.length.greaterThan(0); + expect(volumes[0].name).to.be.ok; + }); + }); + + describe('#pruneVolumes()', () => { + it('successfully prunes volumes end to end', async () => { + // Create a volume + try { + await runner.getCommandRunner()(client.createVolume({ + name: 'prune-test-volume', + })); + } catch { + // No-op + } + + // Prune volumes + const result = await runner.getCommandRunner()(client.pruneVolumes({})); + expect(result).to.be.ok; + expect(result.volumesDeleted).to.be.an('array').with.length.greaterThan(0); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(result.volumesDeleted![0]).to.have.string('test-volume'); + }); + }); + + describe('#inspectVolumes()', () => { + it('successfully inspects volumes end to end', async () => { + // Create a volume + try { + await runner.getCommandRunner()(client.createVolume({ + name: 'inspect-test-volume', + })); + } catch { + // No-op + } + + const volumes = await runner.getCommandRunner()(client.listVolumes({})); + const volumeInspects = await runner.getCommandRunner()(client.inspectVolumes({ volumes: [volumes[0].name] })); + expect(volumeInspects).to.be.an('array').with.lengthOf(1); + + const volume = volumeInspects[0]; + expect(volume.name).to.be.ok; + expect(volume.raw).to.be.ok; + }); + }); + + describe('Filesystem Big End To End', function () { + this.timeout(10000); + let containerId: string; + + before(async () => { + // Create a container + containerId = await runner.getCommandRunner()(client.runContainer({ + imageRef: 'alpine:latest', + detached: true, + })) as string; + expect(containerId).to.be.ok; + }); + + it('successfully does filesystem operations', async () => { + // List files in /etc + const files = await runner.getCommandRunner()(client.listFiles({ + path: '/etc', + container: containerId + })); + expect(files).to.be.an('array').with.length.greaterThan(0); + const file = files[0]; + expect(file.name).to.be.ok; + expect(file.type).to.be.ok; + expect(file.size).to.be.ok; + expect(file.mode).to.be.ok; + + // Stat /etc/hosts + const stat = await runner.getCommandRunner()(client.statPath({ + path: '/etc/hosts', + container: containerId + })); + + expect(stat).to.be.ok; + if (!stat) { + expect.fail('stat should not be undefined'); + } + expect(stat.name).to.be.ok; + expect(stat.type).to.be.ok; + expect(stat.size).to.be.ok; + expect(stat.mode).to.be.ok; + expect(stat.mtime).to.be.ok; + expect(stat.ctime).to.be.ok; + + // Read /etc/hosts + const generator = runner.getStreamingCommandRunner()(client.readFile({ + container: containerId, + path: '/etc/hosts', + operatingSystem: 'linux', + })); + + for await (const chunk of generator) { + expect(chunk).to.be.ok; + expect(chunk.toString('utf-8')).to.be.ok; + } + }); + + xit('successfully writes a file', async () => { + // TODO + }); + + after(async () => { + // Clean up the container + await runner.getCommandRunner()(client.stopContainers({ container: [containerId], time: 1 })); + await runner.getCommandRunner()(client.removeContainers({ containers: [containerId], force: true })); + }); + }); +}); diff --git a/packages/vscode-container-client/src/test/buildContext/Dockerfile b/packages/vscode-container-client/src/test/buildContext/Dockerfile new file mode 100644 index 00000000..8104344c --- /dev/null +++ b/packages/vscode-container-client/src/test/buildContext/Dockerfile @@ -0,0 +1,3 @@ +FROM alpine + +EXPOSE 8080 diff --git a/packages/vscode-container-client/src/test/wslifyPath.test.ts b/packages/vscode-container-client/src/test/wslifyPath.test.ts new file mode 100644 index 00000000..a43ccf4a --- /dev/null +++ b/packages/vscode-container-client/src/test/wslifyPath.test.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { wslifyPath } from '../utils/wslifyPath'; + +describe('wslifyPath tests', () => { + it('Should wslify Windows paths correctly', () => { + const windowsPath = 'C:\\Users\\user\\Desktop\\file.txt'; + const wslPath = '/mnt/c/Users/user/Desktop/file.txt'; + expect(wslifyPath(windowsPath)).to.equal(wslPath); + }); + + it('Should wslify Linux paths correctly by doing nothing to them', () => { + const linuxPath = '/home/user/file.txt'; + expect(wslifyPath(linuxPath)).to.equal(linuxPath); + }); +}); diff --git a/packages/vscode-container-client/src/utils/wslifyPath.ts b/packages/vscode-container-client/src/utils/wslifyPath.ts new file mode 100644 index 00000000..7a7804f9 --- /dev/null +++ b/packages/vscode-container-client/src/utils/wslifyPath.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; + +export function wslifyPath(windowsPath: string): string { + // If it's already a Linuxy path, don't do anything to it + if (path.posix.isAbsolute(windowsPath)) { + return windowsPath; + } + + return windowsPath + .replace(/\\/g, '/') + // eslint-disable-next-line @typescript-eslint/naming-convention + .replace(/^([A-Za-z]):/, (_, drive) => `/mnt/${drive.toLowerCase()}`); +}