diff --git a/package-lock.json b/package-lock.json index 01c1a64171..2c51acfbb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "node-fetch": "^2.6.7", "semver": "^7.3.7", "tar": "^6.1.11", + "tree-kill": "^1.2.2", "vscode-languageclient": "^8.0.2", "vscode-nls": "^5.0.0", "vscode-tas-client": "^0.1.22", @@ -5808,6 +5809,14 @@ "node": "*" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-loader": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.3.1.tgz", @@ -11022,6 +11031,11 @@ "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", "dev": true }, + "tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==" + }, "ts-loader": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.3.1.tgz", diff --git a/package.json b/package.json index aad64e5fb3..288bb3cd74 100644 --- a/package.json +++ b/package.json @@ -1540,11 +1540,6 @@ "default": true, "description": "%vscode-docker.config.docker.promptForRegistryWhenPushingImages%" }, - "docker.explorerRefreshInterval": { - "type": "number", - "default": 2000, - "description": "%vscode-docker.config.docker.explorerRefreshInterval%" - }, "docker.commands.build": { "oneOf": [ { @@ -2979,6 +2974,7 @@ "node-fetch": "^2.6.7", "semver": "^7.3.7", "tar": "^6.1.11", + "tree-kill": "^1.2.2", "vscode-languageclient": "^8.0.2", "vscode-nls": "^5.0.0", "vscode-tas-client": "^0.1.22", diff --git a/package.nls.json b/package.nls.json index 2701653630..c8f4b8f7e9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -152,7 +152,6 @@ "vscode-docker.config.template.composeDown.label": "The label displayed to the user.", "vscode-docker.config.template.composeDown.match": "The regular expression for choosing the right template. Checked against docker-compose YAML files, folder name, etc.", "vscode-docker.config.template.composeDown.description": "Command templates for `docker-compose down` commands.", - "vscode-docker.config.docker.explorerRefreshInterval": "Docker view refresh interval (milliseconds)", "vscode-docker.config.docker.containers.groupBy": "The property to use to group containers in Docker view: ContainerId, ContainerName, CreatedTime, FullTag, ImageId, Networks, Ports, Registry, Repository, RepositoryName, RepositoryNameAndTag, State, Status, Tag, or None", "vscode-docker.config.docker.containers.description": "Any secondary properties to display for a container (an array). Possible elements include: ContainerId, ContainerName, CreatedTime, FullTag, ImageId, Networks, Ports, Registry, Repository, RepositoryName, RepositoryNameAndTag, State, Status, and Tag", "vscode-docker.config.docker.containers.label": "The primary property to display for a container: ContainerId, ContainerName, CreatedTime, FullTag, ImageId, Networks, Ports, Registry, Repository, RepositoryName, RepositoryNameAndTag, State, Status, or Tag", diff --git a/src/commands/containers/attachShellContainer.ts b/src/commands/containers/attachShellContainer.ts index 4e1bde1261..9d1fc1b4d7 100644 --- a/src/commands/containers/attachShellContainer.ts +++ b/src/commands/containers/attachShellContainer.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ContainerOS } from '../../runtimes/docker'; +import { ContainerOS, VoidCommandResponse } from '../../runtimes/docker'; import { IActionContext } from '@microsoft/vscode-azext-utils'; import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; @@ -34,7 +34,8 @@ export async function attachShellContainer(context: IActionContext, node?: Conta try { // If this succeeds, bash is present (exit code 0) await ext.runWithDefaultShell(client => - client.execContainer({ container: node.containerId, interactive: true, command: ['sh', '-c', 'which bash'] }) + // Since we're not interested in the output, just the exit code, we can pretend this is a `VoidCommandResponse` + client.execContainer({ container: node.containerId, interactive: true, command: ['sh', '-c', 'which bash'] }) as Promise ); shellCommand = 'bash'; } catch { diff --git a/src/commands/containers/composeGroup.ts b/src/commands/containers/composeGroup.ts index a9366b47ae..079758853a 100644 --- a/src/commands/containers/composeGroup.ts +++ b/src/commands/containers/composeGroup.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommandResponse, CommonOrchestratorCommandOptions, IContainerOrchestratorClient, LogsCommandOptions } from '../../runtimes/docker'; +import { CommonOrchestratorCommandOptions, IContainerOrchestratorClient, LogsCommandOptions, VoidCommandResponse } from '../../runtimes/docker'; import { IActionContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; import { ext } from '../../extensionVariables'; @@ -13,7 +13,8 @@ import { ContainerGroupTreeItem } from '../../tree/containers/ContainerGroupTree import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; export async function composeGroupLogs(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.logs(options), node, { follow: true, tail: 1000 }); + // Since we're not interested in the output, we can pretend this is a `VoidCommandResponse` + return composeGroup(context, (client, options) => client.logs(options) as Promise, node, { follow: true, tail: 1000 }); } export async function composeGroupStart(context: IActionContext, node: ContainerGroupTreeItem): Promise { @@ -36,7 +37,7 @@ type AdditionalOptions = Omit async function composeGroup( context: IActionContext, - composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise>, + composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise, node: ContainerGroupTreeItem, additionalOptions?: AdditionalOptions ): Promise { diff --git a/src/commands/registries/logInToDockerCli.ts b/src/commands/registries/logInToDockerCli.ts index 8338b5d10f..69bbf36cfb 100644 --- a/src/commands/registries/logInToDockerCli.ts +++ b/src/commands/registries/logInToDockerCli.ts @@ -11,7 +11,6 @@ import { ext } from '../../extensionVariables'; import { localize } from "../../localize"; import { registryExpectedContextValues } from '../../tree/registries/registryContextValues'; import { RegistryTreeItemBase } from '../../tree/registries/RegistryTreeItemBase'; -import { runWithDefaultShell } from '../../runtimes/runners/runWithDefaultShell'; export async function logInToDockerCli(context: IActionContext, node?: RegistryTreeItemBase): Promise { if (!node) { @@ -40,13 +39,12 @@ export async function logInToDockerCli(context: IActionContext, node?: RegistryT await vscode.window.withProgress(progressOptions, async () => { try { - await runWithDefaultShell( + await ext.runWithDefaultShell( client => client.login({ username: username, passwordStdIn: true, registry: creds.registryPath, }), - ext.runtimeManager, { stdInPipe: stream.Readable.from(password), } diff --git a/src/commands/selectCommandTemplate.ts b/src/commands/selectCommandTemplate.ts index 3199a751f5..3e300dc2ed 100644 --- a/src/commands/selectCommandTemplate.ts +++ b/src/commands/selectCommandTemplate.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommandResponse, PortBinding } from '../runtimes/docker'; +import { PortBinding, VoidCommandResponse } from '../runtimes/docker'; import { IActionContext, IAzureQuickPickItem, IAzureQuickPickOptions, UserCancelledError } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { ext } from '../extensionVariables'; @@ -29,7 +29,7 @@ export interface CommandTemplate { match?: string; } -export async function selectBuildCommand(context: IActionContext, folder: vscode.WorkspaceFolder, dockerfile: string, buildContext: string): Promise> { +export async function selectBuildCommand(context: IActionContext, folder: vscode.WorkspaceFolder, dockerfile: string, buildContext: string): Promise { return await selectCommandTemplate( context, 'build', @@ -39,7 +39,7 @@ export async function selectBuildCommand(context: IActionContext, folder: vscode ); } -export async function selectRunCommand(context: IActionContext, fullTag: string, interactive: boolean, exposedPorts?: PortBinding[]): Promise> { +export async function selectRunCommand(context: IActionContext, fullTag: string, interactive: boolean, exposedPorts?: PortBinding[]): Promise { let portsString: string = ''; if (exposedPorts) { portsString = exposedPorts.map(pb => `-p ${pb.containerPort}:${pb.containerPort}${pb.protocol ? '/' + pb.protocol : ''}`).join(' '); @@ -54,7 +54,7 @@ export async function selectRunCommand(context: IActionContext, fullTag: string, ); } -export async function selectAttachCommand(context: IActionContext, containerName: string, imageName: string, containerId: string, shellCommand: string): Promise> { +export async function selectAttachCommand(context: IActionContext, containerName: string, imageName: string, containerId: string, shellCommand: string): Promise { return await selectCommandTemplate( context, 'attach', @@ -64,7 +64,7 @@ export async function selectAttachCommand(context: IActionContext, containerName ); } -export async function selectLogsCommand(context: IActionContext, containerName: string, imageName: string, containerId: string): Promise> { +export async function selectLogsCommand(context: IActionContext, containerName: string, imageName: string, containerId: string): Promise { return await selectCommandTemplate( context, 'logs', @@ -74,7 +74,7 @@ export async function selectLogsCommand(context: IActionContext, containerName: ); } -export async function selectComposeCommand(context: IActionContext, folder: vscode.WorkspaceFolder, composeCommand: 'up' | 'down' | 'upSubset', configurationFile?: string, detached?: boolean, build?: boolean): Promise> { +export async function selectComposeCommand(context: IActionContext, folder: vscode.WorkspaceFolder, composeCommand: 'up' | 'down' | 'upSubset', configurationFile?: string, detached?: boolean, build?: boolean): Promise { let template: TemplateCommand; switch (composeCommand) { @@ -120,7 +120,7 @@ export async function selectCommandTemplate( // The following three are overridable for test purposes, but have default values that cover actual usage templatePicker: TemplatePicker = (i, o) => actionContext.ui.showQuickPick(i, o), // Default is the normal ext.ui.showQuickPick (this longer syntax is because doing `ext.ui.showQuickPick` alone doesn't result in the right `this` further down) getCommandSettings: () => CommandSettings = () => vscode.workspace.getConfiguration('docker').inspect(`commands.${command}`) -): Promise> { +): Promise { // Get the configured settings values const commandSettings = getCommandSettings(); const userTemplates: CommandTemplate[] = toCommandTemplateArray(commandSettings.workspaceFolderValue ?? commandSettings.workspaceValue ?? commandSettings.globalValue); diff --git a/src/debugging/DockerServerReadyAction.ts b/src/debugging/DockerServerReadyAction.ts index af220f851a..d1eb0d4958 100644 --- a/src/debugging/DockerServerReadyAction.ts +++ b/src/debugging/DockerServerReadyAction.ts @@ -7,6 +7,7 @@ // Adapted from: https://github.com/microsoft/vscode/blob/8827cf5a607b6ab7abf45817604bc21883314db7/extensions/debug-server-ready/src/extension.ts // +import * as readline from 'readline'; import * as stream from 'stream'; import * as util from 'util'; import * as vscode from 'vscode'; @@ -14,7 +15,6 @@ import { IActionContext, callWithTelemetryAndErrorHandling } from '@microsoft/vs import { ext } from '../extensionVariables'; import { localize } from '../localize'; import { ResolvedDebugConfiguration } from './DebugHelper'; -import { runWithDefaultShell } from '../runtimes/runners/runWithDefaultShell'; const PATTERN = 'listening on.* (https?://\\S+|[0-9]+)'; // matches "listening on port 3000" or "Now listening on: https://localhost:5001" const URI_FORMAT = 'http://localhost:%s'; @@ -191,60 +191,43 @@ interface DockerServerReadyDetector { } class DockerLogsTracker extends vscode.Disposable { - private logStream: stream.PassThrough; private readonly cts = new vscode.CancellationTokenSource(); + private lineReader: readline.Interface | undefined; public constructor(private readonly containerName: string, private readonly detector: DockerServerReadyDetector) { super( () => { this.cts.cancel(); + this.lineReader?.close(); }); if (!this.detector) { return; } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.startListening(); + // Don't wait + void this.listen(); } - private async startListening(): Promise { - return callWithTelemetryAndErrorHandling('dockerServerReadyAction.dockerLogsTracker.startListening', async (context: IActionContext) => { - // Don't actually telemetrize or show anything (same as prior behavior), but wrap call to get an IActionContext - context.telemetry.suppressAll = true; - context.errorHandling.suppressDisplay = true; - context.errorHandling.rethrow = false; - - this.logStream = new stream.PassThrough(); - - this.logStream.on('data', (data) => { - this.detector.detectPattern(data.toString()); - }); - - // Don't wait - void runWithDefaultShell( + private async listen(): Promise { + try { + const generator = ext.streamWithDefaultShell( client => client.logsForContainer({ container: this.containerName, follow: true }), - ext.runtimeManager, { cancellationToken: this.cts.token, - stdOutPipe: this.logStream, - } - ).then( - () => { - // Do nothing on fulfilled - }, - () => { - // Do nothing on reject - // - // The usual termination path is for `runWithDefaultShell` to throw a `CancellationError`, - // because when this `DockerLogsTracker` object is disposed, cancellation of the process - // is triggered. - // - // If we do not eat that `CancellationError` here, it bubbles up to the extension host process - // where it will be eaten, but ugly warnings show in a few places } ); - }); + + this.lineReader = readline.createInterface({ input: stream.Readable.from(generator) }); + for await (const line of this.lineReader) { + this.detector.detectPattern(line); + } + } catch { + // Do nothing + // The usual termination pathway is cancellation through the CTS above, so errors are expected + // TODO: for unknown reasons, the cancellation error does not actually get thrown to here, and ends + // up in the extension host output log. Ideally this should not happen. + } } } diff --git a/src/debugging/netcore/NetCoreDebugHelper.ts b/src/debugging/netcore/NetCoreDebugHelper.ts index e5c606e267..397c5c307a 100644 --- a/src/debugging/netcore/NetCoreDebugHelper.ts +++ b/src/debugging/netcore/NetCoreDebugHelper.ts @@ -5,7 +5,7 @@ import * as fse from 'fs-extra'; import * as path from 'path'; -import { composeArgs, ContainerOS, withArg, withQuotedArg } from '../../runtimes/docker'; +import { composeArgs, ContainerOS, VoidCommandResponse, withArg, withQuotedArg } from '../../runtimes/docker'; import { DialogResponses, IActionContext, UserCancelledError } from '@microsoft/vscode-azext-utils'; import { DebugConfiguration, MessageItem, ProgressLocation, ShellQuotedString, window } from 'vscode'; import { ext } from '../../extensionVariables'; @@ -312,25 +312,29 @@ export class NetCoreDebugHelper implements DebugHelper { containerCommand = 'cmd'; containerCommandArgs = composeArgs( withArg('/C'), - withQuotedArg(`IF EXIST "${debuggerPath}" (echo true) else (echo false)`) + withQuotedArg(`IF EXIST "${debuggerPath}" (exit 0) else (exit 1)`) )(); } else { containerCommand = '/bin/sh'; containerCommandArgs = composeArgs( withArg('-c'), - withQuotedArg(`if [ -f ${debuggerPath} ]; then echo true; fi;`) + withQuotedArg(`if [ -f ${debuggerPath} ]; then exit 0; else exit 1; fi;`) )(); } - const stdout = await ext.runWithDefaultShell(client => - client.execContainer({ - container: containerName, - command: [containerCommand, ...containerCommandArgs], - interactive: true, - }) - ); - - return /true/ig.test(stdout); + try { + await ext.runWithDefaultShell(client => + // Since we're not interested in the output, just the exit code, we can pretend this is a `VoidCommandResponse` + client.execContainer({ + container: containerName, + command: [containerCommand, ...containerCommandArgs], + interactive: true, + }) as Promise + ); + return true; + } catch { + return false; + } } private async getContainerNameToAttach(context: IActionContext): Promise { diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts index 890832b88d..ab324baee7 100644 --- a/src/extensionVariables.ts +++ b/src/extensionVariables.ts @@ -14,7 +14,7 @@ import { NetworksTreeItem } from './tree/networks/NetworksTreeItem'; import { RegistriesTreeItem } from './tree/registries/RegistriesTreeItem'; import { VolumesTreeItem } from './tree/volumes/VolumesTreeItem'; import { OrchestratorRuntimeManager } from './runtimes/OrchestratorRuntimeManager'; -import { runOrchestratorWithDefaultShellInternal, runWithDefaultShellInternal } from './runtimes/runners/runWithDefaultShell'; +import { runOrchestratorWithDefaultShellInternal, runWithDefaultShellInternal, streamOrchestratorWithDefaultShellInternal, streamWithDefaultShellInternal } from './runtimes/runners/runWithDefaultShell'; /** * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts @@ -60,5 +60,7 @@ export namespace ext { export let runtimeManager: ContainerRuntimeManager; export let orchestratorManager: OrchestratorRuntimeManager; export const runWithDefaultShell = runWithDefaultShellInternal; + export const streamWithDefaultShell = streamWithDefaultShellInternal; export const runOrchestratorWithDefaultShell = runOrchestratorWithDefaultShellInternal; + export const streamOrchestratorWithDefaultShell = streamOrchestratorWithDefaultShellInternal; } diff --git a/src/runtimes/docker/clients/DockerClient/DockerClient.ts b/src/runtimes/docker/clients/DockerClient/DockerClient.ts index 8acd841266..a124d3d840 100644 --- a/src/runtimes/docker/clients/DockerClient/DockerClient.ts +++ b/src/runtimes/docker/clients/DockerClient/DockerClient.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommandResponse } from "../../contracts/CommandRunner"; +import { PromiseCommandResponse, VoidCommandResponse } from "../../contracts/CommandRunner"; import { IContainersClient, InspectContextsCommandOptions, InspectContextsItem, ListContextItem, ListContextsCommandOptions, RemoveContextsCommandOptions, UseContextCommandOptions } from "../../contracts/ContainerClient"; import { asIds } from "../../utils/asIds"; import { CommandLineArgs, composeArgs, withArg } from "../../utils/commandLineBuilder"; @@ -96,7 +96,7 @@ export class DockerClient extends DockerClientBase implements IContainersClient return contexts; } - override async listContexts(options: ListContextsCommandOptions): Promise> { + override async listContexts(options: ListContextsCommandOptions): Promise> { return { command: this.commandName, args: this.getListContextsCommandArgs(options), @@ -123,7 +123,7 @@ export class DockerClient extends DockerClientBase implements IContainersClient return asIds(output); } - override async removeContexts(options: RemoveContextsCommandOptions): Promise> { + override async removeContexts(options: RemoveContextsCommandOptions): Promise> { return { command: this.commandName, args: this.getRemoveContextsCommandArgs(options), @@ -142,7 +142,7 @@ export class DockerClient extends DockerClientBase implements IContainersClient )(); } - override async useContext(options: UseContextCommandOptions): Promise> { + override async useContext(options: UseContextCommandOptions): Promise { return { command: this.commandName, args: this.getUseContextCommandArgs(options), @@ -203,7 +203,7 @@ export class DockerClient extends DockerClientBase implements IContainersClient return new Array(); } - override async inspectContexts(options: InspectContextsCommandOptions): Promise> { + override async inspectContexts(options: InspectContextsCommandOptions): Promise> { return { command: this.commandName, args: this.getInspectContextsCommandArgs(options), diff --git a/src/runtimes/docker/clients/DockerClientBase/DockerClientBase.ts b/src/runtimes/docker/clients/DockerClientBase/DockerClientBase.ts index e2af8a8319..8ebf4c2f9b 100644 --- a/src/runtimes/docker/clients/DockerClientBase/DockerClientBase.ts +++ b/src/runtimes/docker/clients/DockerClientBase/DockerClientBase.ts @@ -7,14 +7,17 @@ import * as dayjs from 'dayjs'; import * as customParseFormat from 'dayjs/plugin/customParseFormat'; import * as utc from 'dayjs/plugin/utc'; import * as path from 'path'; +import * as readline from 'readline'; import { ShellQuotedString, ShellQuoting } from 'vscode'; -import { CommandResponse } from '../../contracts/CommandRunner'; +import { GeneratorCommandResponse, PromiseCommandResponse, VoidCommandResponse } from '../../contracts/CommandRunner'; import { BuildImageCommandOptions, CheckInstallCommandOptions, ContainersStatsCommandOptions, CreateNetworkCommandOptions, CreateVolumeCommandOptions, + EventItem, + EventStreamCommandOptions, ExecContainerCommandOptions, IContainersClient, ImageNameInfo, @@ -75,7 +78,9 @@ import { VersionItem, WriteFileCommandOptions } from "../../contracts/ContainerClient"; +import { CancellationTokenLike } from '../../typings/CancellationTokenLike'; import { asIds } from '../../utils/asIds'; +import { CancellationError } from '../../utils/CancellationError'; import { CommandLineArgs, composeArgs, @@ -85,8 +90,10 @@ import { withQuotedArg, } from "../../utils/commandLineBuilder"; import { CommandNotSupportedError } from '../../utils/CommandNotSupportedError'; +import { byteStreamToGenerator, stringStreamToGenerator } from '../../utils/streamToGenerator'; import { toArray } from '../../utils/toArray'; import { ConfigurableClient } from '../ConfigurableClient'; +import { DockerEventRecord, isDockerEventRecord } from './DockerEventRecord'; import { DockerInfoRecord, isDockerInfoRecord } from './DockerInfoRecord'; import { DockerInspectContainerRecord, isDockerInspectContainerRecord } from './DockerInspectContainerRecord'; import { DockerInspectImageRecord, isDockerInspectImageRecord } from './DockerInspectImageRecord'; @@ -168,7 +175,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo }; } - async info(options: InfoCommandOptions): Promise> { + async info(options: InfoCommandOptions): Promise> { return { command: this.commandName, args: this.getInfoCommandArgs(options), @@ -211,7 +218,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo * @param options Standard version command options * @returns A CommandResponse object indicating how to run and parse a version command for this runtime */ - async version(options: VersionCommandOptions): Promise> { + async version(options: VersionCommandOptions): Promise> { return { command: this.commandName, args: this.getVersionCommandArgs(options), @@ -236,7 +243,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo * @returns A CommandResponse object indicating how to run and parse an install check * command for this runtime */ - async checkInstall(options: CheckInstallCommandOptions): Promise> { + async checkInstall(options: CheckInstallCommandOptions): Promise> { return { command: this.commandName, args: this.getCheckInstallCommandArgs(options), @@ -244,6 +251,82 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo }; } + protected getEventStreamCommandArgs( + options: EventStreamCommandOptions, + formatOverrides?: Partial>, + ): CommandLineArgs { + return composeArgs( + withArg('events'), + withNamedArg('--since', options.since), + withNamedArg('--until', options.until), + withDockerLabelFilterArgs(options.labels), + withNamedArg('--filter', options.types?.map((type) => `type=${type}`)), + withNamedArg('--filter', options.events?.map((event) => `event=${event}`)), + withNamedArg( + '--format', + // By specifying an explicit Go template format output, we're able to use the same normalization logic + // for both Docker and Podman clients + goTemplateJsonFormat( + options.shellProvider, { + Type: goTemplateJsonProperty`.Type`, + Action: goTemplateJsonProperty`.Action`, + Actor: goTemplateJsonProperty`.Actor`, + Time: goTemplateJsonProperty`.Time`, + Raw: goTemplateJsonProperty`.`, + }) + ), + )(); + } + + protected 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: DockerEventRecord = JSON.parse(line); + if (!isDockerEventRecord(item)) { + throw new Error('Invalid event JSON'); + } + + // Yield the parsed data + yield { + type: item.Type, + action: item.Action, + actor: { id: item.Actor.ID, attributes: item.Actor.Attributes }, + timestamp: new Date(item.Time), + raw: JSON.stringify(item.Raw), + }; + } catch (err) { + if (strict) { + throw err; + } + } + } + } + + async getEventStream(options: EventStreamCommandOptions): Promise> { + return { + command: this.commandName, + args: this.getEventStreamCommandArgs(options), + parseStream: (output, strict) => this.parseEventStreamCommandOutput(options, output, strict), + }; + } + //#endregion //#region Auth Commands @@ -257,7 +340,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - async login(options: LoginCommandOptions): Promise> { + async login(options: LoginCommandOptions): Promise { return { command: this.commandName, args: this.getLoginCommandArgs(options), @@ -271,7 +354,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - async logout(options: LogoutCommandOptions): Promise> { + async logout(options: LogoutCommandOptions): Promise { return { command: this.commandName, args: this.getLogoutCommandArgs(options), @@ -314,7 +397,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo * @param options Standard build image command options * @returns A CommandResponse object that can be used to invoke and parse the build image command for the current runtime */ - async buildImage(options: BuildImageCommandOptions): Promise> { + async buildImage(options: BuildImageCommandOptions): Promise { return { command: this.commandName, args: this.getBuildImageCommandArgs(options), @@ -410,7 +493,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo * @param options Standard list images command options * @returns A CommandResponse indicating how to run and parse/normalize a list image command for a Docker-like client */ - async listImages(options: ListImagesCommandOptions): Promise>> { + async listImages(options: ListImagesCommandOptions): Promise>> { return { command: this.commandName, args: this.getListImagesCommandArgs(options), @@ -438,7 +521,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return asIds(output); } - async removeImages(options: RemoveImagesCommandOptions): Promise> { + async removeImages(options: RemoveImagesCommandOptions): Promise> { return { command: this.commandName, args: this.getRemoveImagesCommandArgs(options), @@ -457,7 +540,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - async pushImage(options: PushImageCommandOptions): Promise> { + async pushImage(options: PushImageCommandOptions): Promise { return { command: this.commandName, args: this.getPushImageCommandArgs(options), @@ -485,7 +568,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return Promise.resolve({}); } - async pruneImages(options: PruneImagesCommandOptions): Promise> { + async pruneImages(options: PruneImagesCommandOptions): Promise> { return { command: this.commandName, args: this.getPruneImagesCommandArgs(options), @@ -516,19 +599,10 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - protected parsePullImageCommandOutput( - options: PullImageCommandOptions, - output: string, - strict: boolean, - ): Promise { - return Promise.resolve(); - } - - async pullImage(options: PullImageCommandOptions): Promise> { + async pullImage(options: PullImageCommandOptions): Promise { return { command: this.commandName, args: this.getPullImageCommandArgs(options), - parse: (output, strict) => this.parsePullImageCommandOutput(options, output, strict), }; } @@ -543,7 +617,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - async tagImage(options: TagImageCommandOptions): Promise> { + async tagImage(options: TagImageCommandOptions): Promise { return { command: this.commandName, args: this.getTagImageCommandArgs(options), @@ -712,7 +786,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return new Array(); } - async inspectImages(options: InspectImagesCommandOptions): Promise>> { + async inspectImages(options: InspectImagesCommandOptions): Promise>> { return { command: this.commandName, args: this.getInspectImagesCommandArgs(options), @@ -778,7 +852,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo * @param options Standard run container command options * @returns A CommandResponse object for a Docker-like run container command */ - async runContainer(options: RunContainerCommandOptions): Promise> { + async runContainer(options: RunContainerCommandOptions): Promise> { return { command: this.commandName, args: this.getRunContainerCommandArgs(options), @@ -802,19 +876,11 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - protected parseExecContainerCommandOutput( - options: ExecContainerCommandOptions, - output: string, - strict: boolean, - ): Promise { - return Promise.resolve(output); - } - - async execContainer(options: ExecContainerCommandOptions): Promise> { + async execContainer(options: ExecContainerCommandOptions): Promise> { return { command: this.commandName, args: this.getExecContainerCommandArgs(options), - parse: (output, strict) => this.parseExecContainerCommandOutput(options, output, strict), + parseStream: (output, strict) => stringStreamToGenerator(output), }; } @@ -929,7 +995,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return containers; } - async listContainers(options: ListContainersCommandOptions): Promise>> { + async listContainers(options: ListContainersCommandOptions): Promise>> { return { command: this.commandName, args: this.getListContainersCommandArgs(options), @@ -956,7 +1022,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return asIds(output); } - async startContainers(options: StartContainersCommandOptions): Promise>> { + async startContainers(options: StartContainersCommandOptions): Promise>> { return { command: this.commandName, args: this.getStartContainersCommandArgs(options), @@ -983,7 +1049,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return asIds(output); } - async restartContainers(options: RestartContainersCommandOptions): Promise>> { + async restartContainers(options: RestartContainersCommandOptions): Promise>> { return { command: this.commandName, args: this.getRestartContainersCommandArgs(options), @@ -1023,7 +1089,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return asIds(output); } - async stopContainers(options: StopContainersCommandOptions): Promise>> { + async stopContainers(options: StopContainersCommandOptions): Promise>> { return { command: this.commandName, args: this.getStopContainersCommandArgs(options), @@ -1051,7 +1117,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return asIds(output); } - async removeContainers(options: RemoveContainersCommandOptions): Promise>> { + async removeContainers(options: RemoveContainersCommandOptions): Promise>> { return { command: this.commandName, args: this.getRemoveContainersCommandArgs(options), @@ -1079,7 +1145,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return {}; } - async pruneContainers(options: PruneContainersCommandOptions): Promise> { + async pruneContainers(options: PruneContainersCommandOptions): Promise> { return { command: this.commandName, args: this.getPruneContainersCommandArgs(options), @@ -1106,7 +1172,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return output; } - async statsContainers(options: ContainersStatsCommandOptions): Promise> { + async statsContainers(options: ContainersStatsCommandOptions): Promise> { throw new CommandNotSupportedError('statsContainers is not supported for this runtime'); } @@ -1132,32 +1198,16 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - /** - * Parse the standard out from running a log container command on a - * Docker-like client - * @param options Options for the log container command - * @param output The standard output from running the command - * @param strict Should strict parsing be used? - * @returns An empty promise - */ - protected parseLogsForContainerCommandOutput( - options: LogsForContainerCommandOptions, - output: string, - strict: boolean, - ): Promise { - return Promise.resolve(); - } - /** * Generate a CommandResponse object for a Docker-like log container command * @param options Options for the log container command * @returns The CommandResponse object for the log container command */ - async logsForContainer(options: LogsForContainerCommandOptions): Promise> { + async logsForContainer(options: LogsForContainerCommandOptions): Promise> { return { command: this.commandName, args: this.getLogsForContainerCommandArgs(options), - parse: (output, strict) => this.parseLogsForContainerCommandOutput(options, output, strict), + parseStream: (output, strict) => stringStreamToGenerator(output), }; } @@ -1365,7 +1415,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo async inspectContainers( options: InspectContainersCommandOptions, - ): Promise> { + ): Promise> { return { command: this.commandName, args: this.getInspectContainersCommandArgs(options), @@ -1390,19 +1440,10 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - protected parseCreateVolumeCommandOutput( - options: CreateVolumeCommandOptions, - output: string, - strict: boolean, - ): Promise { - return Promise.resolve(); - } - - async createVolume(options: CreateVolumeCommandOptions): Promise> { + async createVolume(options: CreateVolumeCommandOptions): Promise { return { command: this.commandName, args: this.getCreateVolumeCommandArgs(options), - parse: (output, strict) => this.parseCreateVolumeCommandOutput(options, output, strict), }; } @@ -1480,7 +1521,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return volumes; } - async listVolumes(options: ListVolumesCommandOptions): Promise> { + async listVolumes(options: ListVolumesCommandOptions): Promise> { return { command: this.commandName, args: this.getListVolumesCommandArgs(options), @@ -1527,7 +1568,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo * @param options Options for remove volumes command * @returns CommandResponse for the remove volumes command */ - async removeVolumes(options: RemoveVolumesCommandOptions): Promise> { + async removeVolumes(options: RemoveVolumesCommandOptions): Promise> { return { command: this.commandName, args: this.getRemoveVolumesCommandArgs(options), @@ -1555,7 +1596,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return {}; } - async pruneVolumes(options: PruneVolumesCommandOptions): Promise> { + async pruneVolumes(options: PruneVolumesCommandOptions): Promise> { return { command: this.commandName, args: this.getPruneVolumesCommandArgs(options), @@ -1649,7 +1690,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return new Array(); } - async inspectVolumes(options: InspectVolumesCommandOptions): Promise>> { + async inspectVolumes(options: InspectVolumesCommandOptions): Promise>> { return { command: this.commandName, args: this.getInspectVolumesCommandArgs(options), @@ -1673,7 +1714,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - async createNetwork(options: CreateNetworkCommandOptions): Promise> { + async createNetwork(options: CreateNetworkCommandOptions): Promise { return { command: this.commandName, args: this.getCreateNetworkCommandArgs(options), @@ -1763,7 +1804,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return networks; } - async listNetworks(options: ListNetworksCommandOptions): Promise>> { + async listNetworks(options: ListNetworksCommandOptions): Promise>> { return { command: this.commandName, args: this.getListNetworksCommandArgs(options), @@ -1791,7 +1832,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return output.split('\n').map((id) => id); } - async removeNetworks(options: RemoveNetworksCommandOptions): Promise>> { + async removeNetworks(options: RemoveNetworksCommandOptions): Promise>> { return { command: this.commandName, args: this.getRemoveNetworksCommandArgs(options), @@ -1819,7 +1860,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return {}; } - async pruneNetworks(options: PruneNetworksCommandOptions): Promise> { + async pruneNetworks(options: PruneNetworksCommandOptions): Promise> { return { command: this.commandName, args: this.getPruneNetworksCommandArgs(options), @@ -1927,7 +1968,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return new Array(); } - async inspectNetworks(options: InspectNetworksCommandOptions): Promise> { + async inspectNetworks(options: InspectNetworksCommandOptions): Promise> { return { command: this.commandName, args: this.getInspectNetworksCommandArgs(options), @@ -1943,7 +1984,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo //#region ListContexts Command - async listContexts(options: ListContextsCommandOptions): Promise> { + async listContexts(options: ListContextsCommandOptions): Promise> { throw new CommandNotSupportedError('listContexts is not supported for this runtime'); } @@ -1951,7 +1992,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo //#region RemoveContexts Command - async removeContexts(options: RemoveContextsCommandOptions): Promise> { + async removeContexts(options: RemoveContextsCommandOptions): Promise> { throw new CommandNotSupportedError('removeContexts is not supported for this runtime'); } @@ -1959,7 +2000,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo //#region UseContext Command - async useContext(options: UseContextCommandOptions): Promise> { + async useContext(options: UseContextCommandOptions): Promise { throw new CommandNotSupportedError('useContext is not supported for this runtime'); } @@ -1967,7 +2008,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo //#region InspectContexts Command - async inspectContexts(options: InspectContextsCommandOptions): Promise> { + async inspectContexts(options: InspectContextsCommandOptions): Promise> { throw new CommandNotSupportedError('inspectContexts is not supported for this runtime'); } @@ -2018,7 +2059,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo } } - async listFiles(options: ListFilesCommandOptions): Promise> { + async listFiles(options: ListFilesCommandOptions): Promise> { return { command: this.commandName, args: this.getListFilesCommandArgs(options), @@ -2053,15 +2094,16 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo return composeArgs( withArg('cp'), withContainerPathArg(options), - withArg(options.outputFile || '-'), + withArg('-'), )(); } } - async readFile(options: ReadFileCommandOptions): Promise> { + async readFile(options: ReadFileCommandOptions): Promise> { return { command: this.commandName, args: this.getReadFileCommandArgs(options), + parseStream: (output, strict) => byteStreamToGenerator(output), }; } @@ -2077,7 +2119,7 @@ export abstract class DockerClientBase extends ConfigurableClient implements ICo )(); } - async writeFile(options: WriteFileCommandOptions): Promise> { + async writeFile(options: WriteFileCommandOptions): Promise { if (options.operatingSystem === 'windows') { throw new CommandNotSupportedError('Writing files is not supported on Windows containers.'); } diff --git a/src/runtimes/docker/clients/DockerClientBase/DockerEventRecord.ts b/src/runtimes/docker/clients/DockerClientBase/DockerEventRecord.ts new file mode 100644 index 0000000000..ea66721267 --- /dev/null +++ b/src/runtimes/docker/clients/DockerClientBase/DockerEventRecord.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * 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 DockerEventRecord = { + Type: EventType; + Action: EventAction; + Actor: { + ID: string; + Attributes: Record; + }; + Time: number; + Raw: object; +}; + +export function isDockerEventRecord(maybeEvent: unknown): maybeEvent is DockerEventRecord { + const event = maybeEvent as DockerEventRecord; + + if (!event || typeof event !== 'object') { + return false; + } + + if (typeof event.Type !== 'string') { + return false; + } + + if (typeof event.Action !== 'string') { + return false; + } + + if (typeof event.Actor !== 'object') { + return false; + } + + if (typeof event.Actor.ID !== 'string') { + return false; + } + + if (typeof event.Actor.Attributes !== 'object') { + return false; + } + + if (typeof event.Time !== 'number') { + return false; + } + + if (!event.Raw || typeof event.Raw !== 'object') { + return false; + } + + return true; +} diff --git a/src/runtimes/docker/clients/DockerComposeClient/DockerComposeClient.ts b/src/runtimes/docker/clients/DockerComposeClient/DockerComposeClient.ts index 98ed35b6f6..97960f1e8d 100644 --- a/src/runtimes/docker/clients/DockerComposeClient/DockerComposeClient.ts +++ b/src/runtimes/docker/clients/DockerComposeClient/DockerComposeClient.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommandResponse } from '../../contracts/CommandRunner'; +import { GeneratorCommandResponse, PromiseCommandResponse, VoidCommandResponse } from '../../contracts/CommandRunner'; import { CommonOrchestratorCommandOptions, ConfigCommandOptions, @@ -24,6 +24,7 @@ import { withFlagArg, withNamedArg } from '../../utils/commandLineBuilder'; +import { stringStreamToGenerator } from '../../utils/streamToGenerator'; import { ConfigurableClient } from '../ConfigurableClient'; function withCommonOrchestratorArgs(options: CommonOrchestratorCommandOptions): CommandLineCurryFn { @@ -105,7 +106,7 @@ export class DockerComposeClient extends ConfigurableClient implements IContaine * @param options Standard orchestrator up command options * @returns A CommandResponse indicating how to run an orchestrator up command for Docker Compose */ - public async up(options: UpCommandOptions): Promise> { + public async up(options: UpCommandOptions): Promise { return { command: this.commandName, args: this.getUpCommandArgs(options), @@ -133,7 +134,7 @@ export class DockerComposeClient extends ConfigurableClient implements IContaine * @param options Standard orchestrator down command options * @returns A CommandResponse indicating how to run an orchestrator down command for Docker Compose */ - public async down(options: DownCommandOptions): Promise> { + public async down(options: DownCommandOptions): Promise { return { command: this.commandName, args: this.getDownCommandArgs(options), @@ -157,7 +158,7 @@ export class DockerComposeClient extends ConfigurableClient implements IContaine * @param options Standard orchestrator start command options * @returns A CommandResponse indicating how to run an orchestrator start command for Docker Compose */ - public async start(options: StartCommandOptions): Promise> { + public async start(options: StartCommandOptions): Promise { return { command: this.commandName, args: this.getStartCommandArgs(options), @@ -182,7 +183,7 @@ export class DockerComposeClient extends ConfigurableClient implements IContaine * @param options Standard orchestrator stop command options * @returns A CommandResponse indicating how to run an orchestrator stop command for Docker Compose */ - public async stop(options: StopCommandOptions): Promise> { + public async stop(options: StopCommandOptions): Promise { return { command: this.commandName, args: this.getStopCommandArgs(options), @@ -207,7 +208,7 @@ export class DockerComposeClient extends ConfigurableClient implements IContaine * @param options Standard orchestrator restart command options * @returns A CommandResponse indicating how to run an orchestrator restart command for Docker Compose */ - public async restart(options: RestartCommandOptions): Promise> { + public async restart(options: RestartCommandOptions): Promise { return { command: this.commandName, args: this.getRestartCommandArgs(options), @@ -233,10 +234,11 @@ export class DockerComposeClient extends ConfigurableClient implements IContaine * @param options Standard orchestrator logs command options * @returns A CommandResponse indicating how to run an orchestrator logs command for Docker Compose */ - public async logs(options: LogsCommandOptions): Promise> { + public async logs(options: LogsCommandOptions): Promise> { return { command: this.commandName, args: this.getLogsCommandArgs(options), + parseStream: (output, strict) => stringStreamToGenerator(output), }; } @@ -265,7 +267,7 @@ export class DockerComposeClient extends ConfigurableClient implements IContaine * @param options Standard orchestrator config command options * @returns A CommandResponse indicating how to run an orchestrator config command for Docker Compose */ - public async config(options: ConfigCommandOptions): Promise>> { + public async config(options: ConfigCommandOptions): Promise>> { return { command: this.commandName, args: this.getConfigCommandArgs(options), diff --git a/src/runtimes/docker/commandRunners/shellStream.ts b/src/runtimes/docker/commandRunners/shellStream.ts index fe24b94a84..6ca66d6208 100644 --- a/src/runtimes/docker/commandRunners/shellStream.ts +++ b/src/runtimes/docker/commandRunners/shellStream.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as stream from 'stream'; -import * as streamPromise from 'stream/promises'; import { - CommandResponse, - CommandResponseLike, + CommandResponseBase, CommandRunner, + GeneratorCommandResponse, ICommandRunnerFactory, + Like, normalizeCommandResponseLike, + PromiseCommandResponse, + StreamingCommandRunner, + VoidCommandResponse, } from '../contracts/CommandRunner'; import { CancellationTokenLike } from '../typings/CancellationTokenLike'; import { AccumulatorStream } from '../utils/AccumulatorStream'; @@ -21,7 +24,7 @@ import { StreamSpawnOptions, } from '../utils/spawnStreamAsync'; -export type ShellStreamCommandRunnerOptions = StreamSpawnOptions & { +export type ShellStreamCommandRunnerOptions = Omit & { strict?: boolean; }; @@ -33,7 +36,7 @@ export class ShellStreamCommandRunnerFactory(commandResponseLike: CommandResponseLike) => { + return async (commandResponseLike: Like | Like>) => { const commandResponse = await normalizeCommandResponseLike(commandResponseLike); const { command, args } = this.getCommandAndArgs(commandResponse); @@ -41,28 +44,14 @@ export class ShellStreamCommandRunnerFactory[] = []; - let accumulator: AccumulatorStream | undefined; try { if (commandResponse.parse) { - splitterStream ??= new stream.PassThrough(); accumulator = new AccumulatorStream(); - pipelinePromises.push( - streamPromise.pipeline(splitterStream, accumulator) - ); - } - - if (this.options.stdOutPipe) { - splitterStream ??= new stream.PassThrough; - pipelinePromises.push( - streamPromise.pipeline(splitterStream, this.options.stdOutPipe) - ); } - await spawnStreamAsync(command, args, { ...this.options, stdOutPipe: splitterStream, shell: true }); + await spawnStreamAsync(command, args, { ...this.options, stdOutPipe: accumulator, shell: true }); throwIfCancellationRequested(this.options.cancellationToken); @@ -74,8 +63,6 @@ export class ShellStreamCommandRunnerFactory): { command: string, args: string[] } { + public getStreamingCommandRunner(): StreamingCommandRunner { + return this.streamingCommandRunner.bind(this); + } + + private async *streamingCommandRunner(commandResponseLike: Like>): AsyncGenerator { + const commandResponse = await normalizeCommandResponseLike(commandResponseLike); + const { command, args } = this.getCommandAndArgs(commandResponse); + + throwIfCancellationRequested(this.options.cancellationToken); + + const dataStream: stream.PassThrough = new stream.PassThrough(); + const innerGenerator = commandResponse.parseStream(dataStream, !!this.options.strict); + + // The process promise will be awaited only after the innerGenerator finishes + const processPromise = spawnStreamAsync(command, args, { ...this.options, stdOutPipe: dataStream, shell: true }); + + for await (const element of innerGenerator) { + yield element; + } + + await processPromise; + } + + protected getCommandAndArgs(commandResponse: CommandResponseBase): { command: string, args: string[] } { return { command: commandResponse.command, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/src/runtimes/docker/commandRunners/wslStream.ts b/src/runtimes/docker/commandRunners/wslStream.ts index 7ff5b1852f..745acf7272 100644 --- a/src/runtimes/docker/commandRunners/wslStream.ts +++ b/src/runtimes/docker/commandRunners/wslStream.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { - CommandResponse, + CommandResponseBase, ICommandRunnerFactory, } from '../contracts/CommandRunner'; import { Shell } from '../utils/spawnStreamAsync'; @@ -23,7 +23,7 @@ export type WslShellCommandRunnerOptions = ShellStreamCommandRunnerOptions & { */ export class WslShellCommandRunnerFactory extends ShellStreamCommandRunnerFactory implements ICommandRunnerFactory { protected override getCommandAndArgs( - commandResponse: CommandResponse, + commandResponse: CommandResponseBase, ): { command: string; args: string[]; diff --git a/src/runtimes/docker/contracts/CommandRunner.ts b/src/runtimes/docker/contracts/CommandRunner.ts index 968470782a..05b314d54c 100644 --- a/src/runtimes/docker/contracts/CommandRunner.ts +++ b/src/runtimes/docker/contracts/CommandRunner.ts @@ -6,26 +6,52 @@ import { CommandLineArgs } from '../utils/commandLineBuilder'; /** - * A CommandResponse record provides instructions on how to invoke a command - * and a parse callback that can be used to parse and normalize the standard - * output from invoking the command. This is the standard type returned by all - * commands defined by the IContainersClient interface. - * - * Parse will not be implemented for streaming operations, like container logs - * or files. - */ -export type CommandResponse = { + * A command response includes the command (i.e., the executable) to execute, and arguments to pass + */ +export type CommandResponseBase = { command: string; args: CommandLineArgs; - parse?: (output: string, strict: boolean) => Promise; }; -export type CommandResponseLike = CommandResponse | Promise> | (() => CommandResponse | Promise>); +/** + * A {@link CommandResponseBase} that also includes a method to parse the output of the command + */ +export type PromiseCommandResponse = CommandResponseBase & { + parse: (output: string, strict: boolean) => Promise; +}; + +/** + * A {@link CommandResponseBase} that also includes a method to parse streaming output of the command + * as an {@link AsyncGenerator} + */ +export type GeneratorCommandResponse = CommandResponseBase & { + parseStream: (output: NodeJS.ReadableStream, strict: boolean) => AsyncGenerator; +}; + +/** + * A {@link CommandResponseBase} that cannot include parsing methods--i.e. the output is `void` + */ +export type VoidCommandResponse = CommandResponseBase & { + parse?: never; + parseStream?: never; +}; + +/** + * A helper type that allows for several simple ways to resolve an item + */ +export type Like = T | Promise | (() => T | Promise); /** * A {@link CommandRunner} provides instructions on how to invoke a command */ -export type CommandRunner = (commandResponse: CommandResponseLike) => Promise; +export type CommandRunner = + ((commandResponseLike: Like>) => Promise) & + ((commandResponseLike: Like) => Promise); + +/** + * A {@link StreamingCommandRunner} provides instructions on how to invoke a streaming command + */ +export type StreamingCommandRunner = (commandResponseLike: Like>) => AsyncGenerator; /** * A {@link ICommandRunnerFactory} is used to build a CommandRunner instance @@ -33,9 +59,15 @@ export type CommandRunner = (commandResponse: CommandResponseLike) => Prom */ export interface ICommandRunnerFactory { getCommandRunner(): CommandRunner; + getStreamingCommandRunner(): StreamingCommandRunner; } -export function normalizeCommandResponseLike(commandResponseLike: CommandResponseLike): Promise> { +/** + * Converts a `Like` into a `CommandResponse` + * @param commandResponseLike The command response-like to normalize + * @returns The command response + */ +export function normalizeCommandResponseLike(commandResponseLike: Like): Promise { if (typeof commandResponseLike === 'function') { return Promise.resolve(commandResponseLike()); } else { diff --git a/src/runtimes/docker/contracts/ContainerClient.ts b/src/runtimes/docker/contracts/ContainerClient.ts index d235fe64b5..1221d07fca 100644 --- a/src/runtimes/docker/contracts/ContainerClient.ts +++ b/src/runtimes/docker/contracts/ContainerClient.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { FileType, ShellQuotedString } from 'vscode'; -import { CommandResponse } from "./CommandRunner"; +import { GeneratorCommandResponse, PromiseCommandResponse, VoidCommandResponse } from './CommandRunner'; import { IShell } from './Shell'; export type ContainerOS = "linux" | "windows"; @@ -108,7 +108,7 @@ type VersionCommand = { * Generate a CommandResponse to retrieve runtime version information. * @param options Command options */ - version(options: VersionCommandOptions): Promise>; + version(options: VersionCommandOptions): Promise>; }; // CheckInstall Command Types @@ -123,7 +123,7 @@ type CheckInstallCommand = { * command will return a non-zero exit code if the runtime is not installed. * @param options Command options */ - checkInstall(options: CheckInstallCommandOptions): Promise>; + checkInstall(options: CheckInstallCommandOptions): Promise>; }; // Info Command Types @@ -152,7 +152,82 @@ type InfoCommand = { * Generate a CommandResponse to retrieve runtime information * @param options Command options */ - info(options: InfoCommandOptions): Promise>; + info(options: InfoCommandOptions): Promise>; +}; + +// Event Stream Command Types + +/** + * Types of objects that can be listened for events to + */ +export type EventType = 'container' | 'image' | 'network' | 'volume' | 'daemon' | 'plugin' | 'config' | 'secret' | 'service' | 'node' | 'task' | 'engine' | string; + +/** + * Types of event actions that can be listened for. Many more beyond these exist. + */ +export type EventAction = 'create' | 'destroy' | 'delete' | 'start' | 'stop' | 'restart' | 'pause' | 'update' | string; + +/** + * Options for the Event Stream command + */ +export type EventStreamCommandOptions = CommonCommandOptions & { + /** + * Return events since a given timestamp + */ + since?: string; + /** + * Only stream events until a given timestamp + */ + until?: string; + /** + * Only listen for events affecting these object types + */ + types?: EventType[]; + /** + * Only listen for events with these labels + */ + labels?: LabelFilters; + /** + * Only listen for events of these types + */ + events?: EventAction[]; +}; + +/** + * The items returned by the Event Stream command + */ +export type EventItem = { + /** + * The event type + */ + type: EventType; + /** + * The event action + */ + action: EventAction; + /** + * The timestamp of the event + */ + timestamp: Date; + /** + * Details about the affected object + */ + actor: { + id: string; + attributes: Record; + } + /** + * The RAW event output + */ + raw: string; +}; + +type GetEventStreamCommand = { + /** + * Generate a CommandResponse for an event stream + * @param options Command options + */ + getEventStream(options: EventStreamCommandOptions): Promise>; }; // #region Login/Logout commands @@ -183,7 +258,7 @@ type LoginCommand = { * Log in to a Docker registry * @param options Command options */ - login(options: LoginCommandOptions): Promise>; + login(options: LoginCommandOptions): Promise; }; // Logout Command Types @@ -203,7 +278,7 @@ type LogoutCommand = { * Log out from a Docker registry * @param options Command options */ - logout(options: LogoutCommandOptions): Promise>; + logout(options: LogoutCommandOptions): Promise; }; // #endregion @@ -263,7 +338,7 @@ type BuildImageCommand = { * Generate a CommandResponse for building a container image. * @param options Command options */ - buildImage(options: BuildImageCommandOptions): Promise>; + buildImage(options: BuildImageCommandOptions): Promise; }; // List Images Command Types @@ -314,7 +389,7 @@ type ListImagesCommand = { * Generate a CommandResponse for listing images * @param options Command options */ - listImages(options: ListImagesCommandOptions): Promise>>; + listImages(options: ListImagesCommandOptions): Promise>>; }; // Remove Images Command Types @@ -335,7 +410,7 @@ type RemoveImagesCommand = { * Generate a CommandResponse for removing image(s). * @param options Command options */ - removeImages(options: RemoveImagesCommandOptions): Promise>>; + removeImages(options: RemoveImagesCommandOptions): Promise>>; }; // Prune Images Command Types @@ -370,7 +445,7 @@ type PruneImagesCommand = { * Generate a CommandResponse for pruning images * @param options Command options */ - pruneImages(options: PruneImagesCommandOptions): Promise>; + pruneImages(options: PruneImagesCommandOptions): Promise>; }; // Pull Image Command Types @@ -398,7 +473,7 @@ type PullImageCommand = { * Generate a CommandResponse for pulling an image. * @param options Command options */ - pullImage(options: PullImageCommandOptions): Promise>; + pullImage(options: PullImageCommandOptions): Promise; }; // Push Image Command Types @@ -418,7 +493,7 @@ type PushImageCommand = { * Generate a CommandResponse for pushing an image. * @param options Command options */ - pushImage(options: PushImageCommandOptions): Promise>; + pushImage(options: PushImageCommandOptions): Promise; }; // Tag Image Command Types @@ -440,7 +515,7 @@ type TagImageCommand = { * image. * @param options Command options */ - tagImage(options: TagImageCommandOptions): Promise>; + tagImage(options: TagImageCommandOptions): Promise; }; // Inspect Image Command Types @@ -527,7 +602,7 @@ type InspectImagesCommand = { * Generate a CommandResponse for inspecting images * @param options Command options */ - inspectImages(options: InspectImagesCommandOptions): Promise>>; + inspectImages(options: InspectImagesCommandOptions): Promise>>; }; //#endregion @@ -658,7 +733,7 @@ type RunContainerCommand = { * Generate a CommandResponse for running a container. * @param options Command options */ - runContainer(options: RunContainerCommandOptions): Promise>; + runContainer(options: RunContainerCommandOptions): Promise>; }; // Exec Container Command Types @@ -695,7 +770,7 @@ type ExecContainerCommand = { * Generate a CommandResponse for executing a command in a running container. * @param options Command options */ - execContainer(options: ExecContainerCommandOptions): Promise>; + execContainer(options: ExecContainerCommandOptions): Promise>; }; // List Containers Command Types @@ -779,7 +854,7 @@ type ListContainersCommand = { * Generate a CommandResponse for listing containers. * @param options Command options */ - listContainers(options: ListContainersCommandOptions): Promise>>; + listContainers(options: ListContainersCommandOptions): Promise>>; }; // Stop Containers Command Types @@ -800,7 +875,7 @@ type StopContainersCommand = { * Generate a CommandResponse for stopping container(s). * @param options Command options */ - stopContainers(options: StopContainersCommandOptions): Promise>>; + stopContainers(options: StopContainersCommandOptions): Promise>>; }; // Start Containers Command Types @@ -817,7 +892,7 @@ type StartContainersCommand = { * Generate a CommandResponse for starting container(s). * @param options Command options */ - startContainers(options: StartContainersCommandOptions): Promise>>; + startContainers(options: StartContainersCommandOptions): Promise>>; }; // Restart Containers Command Types @@ -834,7 +909,7 @@ type RestartContainersCommand = { * Generate a CommandResponse for restarting container(s). * @param options Command options */ - restartContainers(options: RestartContainersCommandOptions): Promise>>; + restartContainers(options: RestartContainersCommandOptions): Promise>>; }; // Remove Containers Command Types @@ -855,7 +930,7 @@ type RemoveContainersCommand = { * Generate a CommandResponse for removing container(s). * @param options Command options */ - removeContainers(options: RemoveContainersCommandOptions): Promise>>; + removeContainers(options: RemoveContainersCommandOptions): Promise>>; }; // Prune Containers Command Types @@ -880,7 +955,7 @@ export type PruneContainersItem = { }; type PruneContainersCommand = { - pruneContainers(options: PruneContainersCommandOptions): Promise> + pruneContainers(options: PruneContainersCommandOptions): Promise> }; // Logs For Container Command Types @@ -918,7 +993,7 @@ type LogsForContainerCommand = { * Generate a CommandResponse for retrieving container logs * @param options Command options */ - logsForContainer(options: LogsForContainerCommandOptions): Promise>; + logsForContainer(options: LogsForContainerCommandOptions): Promise>; }; // Inspect Container Command Types @@ -1076,7 +1151,7 @@ type InspectContainersCommand = { * Generate a CommandResponse for inspecting containers. * @param options Command options */ - inspectContainers(options: InspectContainersCommandOptions): Promise>>; + inspectContainers(options: InspectContainersCommandOptions): Promise>>; }; // Stats command types @@ -1093,7 +1168,7 @@ type ContainersStatsCommand = { * Show running container stats * @param options Command options */ - statsContainers(options: ContainersStatsCommandOptions): Promise>; + statsContainers(options: ContainersStatsCommandOptions): Promise>; }; // #endregion @@ -1118,7 +1193,7 @@ type CreateVolumeCommand = { * Generate a CommandResponse for creating a volume * @param options Command options */ - createVolume(options: CreateVolumeCommandOptions): Promise>; + createVolume(options: CreateVolumeCommandOptions): Promise; }; // List Volumes Command Types @@ -1174,7 +1249,7 @@ type ListVolumesCommand = { * Generate a CommandResponse for listing volumes * @param options Command options */ - listVolumes(options: ListVolumesCommandOptions): Promise>>; + listVolumes(options: ListVolumesCommandOptions): Promise>>; }; // Remove Volumes Command Types @@ -1195,7 +1270,7 @@ type RemoveVolumesCommand = { * Generate a CommandResponse for removing volumes * @param options Command options */ - removeVolumes(options: RemoveVolumesCommandOptions): Promise>>; + removeVolumes(options: RemoveVolumesCommandOptions): Promise>>; }; // Prune Volumes Command Types @@ -1227,7 +1302,7 @@ type PruneVolumesCommand = { * Generate a CommandResponse for pruning volumes * @param options Command options */ - pruneVolumes(options: PruneVolumesCommandOptions): Promise>; + pruneVolumes(options: PruneVolumesCommandOptions): Promise>; }; // Inspect Volumes Command Types @@ -1282,7 +1357,7 @@ type InspectVolumesCommand = { * Generate a CommandResponse for inspecting volumes. * @param options Command options */ - inspectVolumes(options: InspectVolumesCommandOptions): Promise>>; + inspectVolumes(options: InspectVolumesCommandOptions): Promise>>; }; // #endregion @@ -1307,7 +1382,7 @@ type CreateNetworkCommand = { * Generate a CommandResponse for creating a network * @param options Command options */ - createNetwork(options: CreateNetworkCommandOptions): Promise>; + createNetwork(options: CreateNetworkCommandOptions): Promise; }; // List Networks Command Types @@ -1363,7 +1438,7 @@ type ListNetworksCommand = { * Generate a CommandResponse for listing networks * @param options Command options */ - listNetworks(options: ListNetworksCommandOptions): Promise>>; + listNetworks(options: ListNetworksCommandOptions): Promise>>; }; // Remove Networks Command Types @@ -1384,7 +1459,7 @@ type RemoveNetworksCommand = { * Generate a CommandResponse for removing networks * @param options Command options */ - removeNetworks(options: RemoveNetworksCommandOptions): Promise>>; + removeNetworks(options: RemoveNetworksCommandOptions): Promise>>; }; // Prune Networks Command Types @@ -1411,7 +1486,7 @@ type PruneNetworksCommand = { * Generate a CommandResponse for pruning networks * @param options Command options */ - pruneNetworks(options: PruneNetworksCommandOptions): Promise>; + pruneNetworks(options: PruneNetworksCommandOptions): Promise>; }; // Inspect Networks Command Types @@ -1490,7 +1565,7 @@ type InspectNetworksCommand = { * Generate a CommandResponse for inspecting networks. * @param options Command options */ - inspectNetworks(options: InspectNetworksCommandOptions): Promise>>; + inspectNetworks(options: InspectNetworksCommandOptions): Promise>>; }; // #endregion @@ -1527,7 +1602,7 @@ type ListContextsCommand = { * Generate a CommandResponse for listing contexts * @param options Command options */ - listContexts(options: ListContextsCommandOptions): Promise>>; + listContexts(options: ListContextsCommandOptions): Promise>>; }; // Remove Contexts Command Types @@ -1544,7 +1619,7 @@ type RemoveContextsCommand = { * Generate a CommandResponse for removing contexts * @param options Command options */ - removeContexts(options: RemoveContextsCommandOptions): Promise>>; + removeContexts(options: RemoveContextsCommandOptions): Promise>>; }; // Use Context Command Types @@ -1561,7 +1636,7 @@ type UseContextCommand = { * Generate a CommandResponse for using a context * @param options Command options */ - useContext(options: UseContextCommandOptions): Promise>; + useContext(options: UseContextCommandOptions): Promise; }; // Inspect Contexts Command Types @@ -1597,7 +1672,7 @@ type InspectContextsCommand = { * Generate a CommandResponse for inspecting contexts. * @param options Command options */ - inspectContexts(options: InspectContextsCommandOptions): Promise>>; + inspectContexts(options: InspectContextsCommandOptions): Promise>>; }; // #endregion @@ -1653,7 +1728,7 @@ type ListFilesCommand = { * Lists the contents of a given path in a container * @param options Command options */ - listFiles(options: ListFilesCommandOptions): Promise>>; + listFiles(options: ListFilesCommandOptions): Promise>>; }; // Read file command types @@ -1667,11 +1742,6 @@ export type ReadFileCommandOptions = CommonCommandOptions & { * The absolute path of the file in the container to read */ path: string; - /** - * (Optional) The path on the host to write the container file to. If not given, it is - * necessary to handle contents from stdout in the command runner. - */ - outputFile?: string; /** * The container operating system. If not supplied, 'linux' will be assumed. */ @@ -1686,7 +1756,7 @@ type ReadFileCommand = { * NOTE: the output stream is in tarball format with Linux containers, and cleartext with Windows containers. * @param options Command options */ - readFile(options: ReadFileCommandOptions): Promise>; + readFile(options: ReadFileCommandOptions): Promise>; }; // Write file command types @@ -1719,7 +1789,7 @@ type WriteFileCommand = { * NOTE: this command is not supported on Windows containers. * @param options Command options */ - writeFile(options: WriteFileCommandOptions): Promise>; + writeFile(options: WriteFileCommandOptions): Promise; }; // #endregion @@ -1734,6 +1804,7 @@ export interface IContainersClient extends VersionCommand, CheckInstallCommand, InfoCommand, + GetEventStreamCommand, LoginCommand, LogoutCommand, // Image Commands diff --git a/src/runtimes/docker/contracts/ContainerOrchestratorClient.ts b/src/runtimes/docker/contracts/ContainerOrchestratorClient.ts index eb500a58cf..ac09e0e23d 100644 --- a/src/runtimes/docker/contracts/ContainerOrchestratorClient.ts +++ b/src/runtimes/docker/contracts/ContainerOrchestratorClient.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommandResponse } from "./CommandRunner"; +import { GeneratorCommandResponse, PromiseCommandResponse, VoidCommandResponse } from "./CommandRunner"; import { ClientIdentity, CommonCommandOptions } from "./ContainerClient"; // #region Container orchestrator commands @@ -61,10 +61,10 @@ export type UpCommandOptions = CommonOrchestratorCommandOptions & { type UpCommand = { /** - * Generate a {@link CommandResponse} for up'ing services with a container orchestrator + * Generate a {@link VoidCommandResponse} for up'ing services with a container orchestrator * @param options Command options */ - up(options: UpCommandOptions): Promise>; + up(options: UpCommandOptions): Promise; }; // Down command types @@ -89,10 +89,10 @@ export type DownCommandOptions = CommonOrchestratorCommandOptions & { type DownCommand = { /** - * Generate a {@link CommandResponse} for down'ing services with a container orchestrator + * Generate a {@link VoidCommandResponse} for down'ing services with a container orchestrator * @param options Command options */ - down(options: DownCommandOptions): Promise>; + down(options: DownCommandOptions): Promise; }; // Start command types @@ -101,10 +101,10 @@ export type StartCommandOptions = CommonOrchestratorCommandOptions; type StartCommand = { /** - * Generate a {@link CommandResponse} for starting services with a container orchestrator + * Generate a {@link VoidCommandResponse} for starting services with a container orchestrator * @param options Command options */ - start(options: StartCommandOptions): Promise>; + start(options: StartCommandOptions): Promise; }; // Stop command types @@ -117,10 +117,10 @@ export type StopCommandOptions = CommonOrchestratorCommandOptions & { type StopCommand = { /** - * Generate a {@link CommandResponse} for stopping services with a container orchestrator + * Generate a {@link VoidCommandResponse} for stopping services with a container orchestrator * @param options Command options */ - stop(options: StopCommandOptions): Promise>; + stop(options: StopCommandOptions): Promise; }; // Restart command types @@ -133,10 +133,10 @@ export type RestartCommandOptions = CommonOrchestratorCommandOptions & { type RestartCommand = { /** - * Generate a {@link CommandResponse} for restarting services with a container orchestrator + * Generate a {@link VoidCommandResponse} for restarting services with a container orchestrator * @param options Command options */ - restart(options: RestartCommandOptions): Promise>; + restart(options: RestartCommandOptions): Promise; }; // Logs command types @@ -153,10 +153,10 @@ export type LogsCommandOptions = CommonOrchestratorCommandOptions & { type LogsCommand = { /** - * Generate a {@link CommandResponse} for getting collated logs from services with a container orchestrator + * Generate a {@link GeneratorCommandResponse} for getting collated logs from services with a container orchestrator * @param options Command options */ - logs(options: LogsCommandOptions): Promise>; + logs(options: LogsCommandOptions): Promise>; }; // Config command types @@ -169,10 +169,10 @@ export type ConfigItem = string; type ConfigCommand = { /** - * Generate a {@link CommandResponse} for getting config information for services + * Generate a {@link PromiseCommandResponse} for getting config information for services * @param options Command options */ - config(options: ConfigCommandOptions): Promise>>; + config(options: ConfigCommandOptions): Promise>>; }; // #endregion diff --git a/src/runtimes/docker/test/DockerComposeClient.test.ts b/src/runtimes/docker/test/DockerComposeClient.test.ts index 94a37652f6..c2a26fc5fa 100644 --- a/src/runtimes/docker/test/DockerComposeClient.test.ts +++ b/src/runtimes/docker/test/DockerComposeClient.test.ts @@ -78,7 +78,7 @@ xdescribe('DockerComposeClient', () => { onCommand: (command: string) => { console.log(`Executing ${command}`); }, }); - await logsCRF.getCommandRunner()(client.logs(options)); + await logsCRF.getStreamingCommandRunner()(client.logs(options)); const logs = await accumulator.getString(); expect(logs).to.be.ok; }); diff --git a/src/runtimes/docker/utils/spawnStreamAsync.ts b/src/runtimes/docker/utils/spawnStreamAsync.ts index 00aaddc683..52b82acc6d 100644 --- a/src/runtimes/docker/utils/spawnStreamAsync.ts +++ b/src/runtimes/docker/utils/spawnStreamAsync.ts @@ -5,6 +5,7 @@ import { spawn, SpawnOptions } from 'child_process'; import * as os from 'os'; +import * as treeKill from 'tree-kill'; import { ShellQuotedString, ShellQuoting } from 'vscode'; import { IShell } from '../contracts/Shell'; @@ -171,10 +172,15 @@ export async function spawnStreamAsync( return new Promise((resolve, reject) => { const disposable = cancellationToken.onCancellationRequested(() => { + disposable.dispose(); options.stdOutPipe?.end(); options.stdErrPipe?.end(); childProcess.removeAllListeners(); - childProcess.kill(); + + if (childProcess.pid) { + treeKill(childProcess.pid); + } + reject(new CancellationError('Command cancelled', cancellationToken)); }); diff --git a/src/runtimes/docker/utils/streamToGenerator.ts b/src/runtimes/docker/utils/streamToGenerator.ts new file mode 100644 index 0000000000..023ba21c47 --- /dev/null +++ b/src/runtimes/docker/utils/streamToGenerator.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export async function* stringStreamToGenerator( + output: NodeJS.ReadableStream +): AsyncGenerator { + for await (const chunk of output) { + if (typeof chunk === 'string') { + yield chunk; + } else if (Buffer.isBuffer(chunk)) { + yield chunk.toString(); + } + } +} + +export async function* byteStreamToGenerator( + output: NodeJS.ReadableStream +): AsyncGenerator { + for await (const chunk of output) { + if (typeof chunk === 'string') { + yield Buffer.from(chunk); + } else if (Buffer.isBuffer(chunk)) { + yield chunk; + } + } +} diff --git a/src/runtimes/files/ContainerFilesProvider.ts b/src/runtimes/files/ContainerFilesProvider.ts index 1d2542e62c..259c5dce39 100644 --- a/src/runtimes/files/ContainerFilesProvider.ts +++ b/src/runtimes/files/ContainerFilesProvider.ts @@ -12,7 +12,6 @@ import { AccumulatorStream, CommandNotSupportedError, DisposableLike, ListFilesI import { localize } from '../../localize'; import { ext } from '../../extensionVariables'; import { tarPackStream, tarUnpackStream } from '../../utils/tarUtils'; -import { runWithDefaultShell } from '../runners/runWithDefaultShell'; class MethodNotImplementedError extends CommandNotSupportedError { public constructor() { @@ -80,19 +79,20 @@ export class ContainerFilesProvider extends vscode.Disposable implements vscode. const containerOS = dockerUri.options?.containerOS || await getDockerOSType(); const accumulator = new AccumulatorStream(); + const targetStream = containerOS === 'windows' ? accumulator : tarUnpackStream(accumulator); - await runWithDefaultShell( + const generator = ext.streamWithDefaultShell( client => client.readFile({ container: dockerUri.containerId, path: containerOS === 'windows' ? dockerUri.windowsPath : dockerUri.path, operatingSystem: containerOS, - }), - ext.runtimeManager, - { - stdOutPipe: containerOS === 'windows' ? accumulator : tarUnpackStream(accumulator), - } + }) ); + for await (const chunk of generator) { + targetStream.write(chunk); + } + return await accumulator.getBytes(); }; @@ -108,13 +108,12 @@ export class ContainerFilesProvider extends vscode.Disposable implements vscode. path.win32.dirname(dockerUri.windowsPath) : path.posix.dirname(dockerUri.path); - await runWithDefaultShell( + await ext.runWithDefaultShell( client => client.writeFile({ container: dockerUri.containerId, path: destDirectory, operatingSystem: containerOS, }), - ext.runtimeManager, { stdInPipe: tarPackStream(Buffer.from(content), path.basename(uri.path)), } diff --git a/src/runtimes/runners/TaskCommandRunnerFactory.ts b/src/runtimes/runners/TaskCommandRunnerFactory.ts index ea0a0d07fd..1f70be5229 100644 --- a/src/runtimes/runners/TaskCommandRunnerFactory.ts +++ b/src/runtimes/runners/TaskCommandRunnerFactory.ts @@ -5,7 +5,7 @@ import * as os from 'os'; import * as vscode from 'vscode'; -import { CommandResponse, CommandResponseLike, CommandRunner, ICommandRunnerFactory, normalizeCommandResponseLike } from '../docker'; +import { CommandNotSupportedError, CommandRunner, ICommandRunnerFactory, Like, normalizeCommandResponseLike, PromiseCommandResponse, StreamingCommandRunner, VoidCommandResponse } from '../docker'; interface TaskCommandRunnerOptions { taskName: string; @@ -22,13 +22,17 @@ export class TaskCommandRunnerFactory implements ICommandRunnerFactory { } public getCommandRunner(): CommandRunner { - return async (commandResponseLike: CommandResponseLike) => { - const commandResponse: CommandResponse = await normalizeCommandResponseLike(commandResponseLike); + return async (commandResponseLike: Like | VoidCommandResponse>) => { + const commandResponse = await normalizeCommandResponseLike(commandResponseLike); await executeAsTask(this.options, commandResponse.command, commandResponse.args); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return undefined!; }; } + + public getStreamingCommandRunner(): StreamingCommandRunner { + throw new CommandNotSupportedError('Streaming commands are not supported for task runners'); + } } async function executeAsTask(options: TaskCommandRunnerOptions, command: string, args?: vscode.ShellQuotedString[]): Promise { diff --git a/src/runtimes/runners/runWithDefaultShell.ts b/src/runtimes/runners/runWithDefaultShell.ts index 839e2fbf80..87493ea8af 100644 --- a/src/runtimes/runners/runWithDefaultShell.ts +++ b/src/runtimes/runners/runWithDefaultShell.ts @@ -3,70 +3,137 @@ * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AccumulatorStream, ClientIdentity, CommandResponseLike, IContainerOrchestratorClient, IContainersClient, isChildProcessError, Shell, ShellStreamCommandRunnerFactory, ShellStreamCommandRunnerOptions } from '../docker'; +import * as vscode from 'vscode'; +import { AccumulatorStream, ClientIdentity, GeneratorCommandResponse, IContainerOrchestratorClient, IContainersClient, isChildProcessError, Like, normalizeCommandResponseLike, PromiseCommandResponse, Shell, ShellStreamCommandRunnerFactory, ShellStreamCommandRunnerOptions, VoidCommandResponse } from '../docker'; import { ext } from '../../extensionVariables'; import { RuntimeManager } from '../RuntimeManager'; import { withDockerEnvSettings } from '../../utils/withDockerEnvSettings'; -type ClientCallback = (client: TClient) => CommandResponseLike; +type ClientCallback = (client: TClient) => Like>; +type VoidClientCallback = (client: TClient) => Like; +type StreamingClientCallback = (client: TClient) => Like>; -export async function runWithDefaultShellInternal(callback: ClientCallback): Promise { +// 'env', 'shellProvider', 'stdErrPipe', and 'strict' are set by this function and thus should not be included as arguments to the additional options +type DefaultEnvShellStreamCommandRunnerOptions = Omit; + +export async function runWithDefaultShellInternal(callback: ClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): Promise; +export async function runWithDefaultShellInternal(callback: VoidClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): Promise; +export async function runWithDefaultShellInternal(callback: ClientCallback | VoidClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): Promise { return await runWithDefaultShell( callback, - ext.runtimeManager + ext.runtimeManager, + additionalOptions + ); +} + +export function streamWithDefaultShellInternal(callback: StreamingClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): AsyncGenerator { + return streamWithDefaultShell( + callback, + ext.runtimeManager, + additionalOptions ); } -export async function runOrchestratorWithDefaultShellInternal(callback: ClientCallback): Promise { +export async function runOrchestratorWithDefaultShellInternal(callback: ClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): Promise; +export async function runOrchestratorWithDefaultShellInternal(callback: VoidClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): Promise; +export async function runOrchestratorWithDefaultShellInternal(callback: ClientCallback | VoidClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): Promise { return await runWithDefaultShell( callback, - ext.orchestratorManager + ext.orchestratorManager, + additionalOptions ); } -// 'env', 'shellProvider', 'stdErrPipe', and 'strict' are set by this function and thus should not be included as arguments to the additional options -type DefaultEnvShellStreamCommandRunnerOptions = Omit; +export function streamOrchestratorWithDefaultShellInternal(callback: StreamingClientCallback, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions): AsyncGenerator { + return streamWithDefaultShell( + callback, + ext.orchestratorManager, + additionalOptions + ); +} -export async function runWithDefaultShell( - callback: ClientCallback, +async function runWithDefaultShell( + callback: ClientCallback | VoidClientCallback, runtimeManager: RuntimeManager, additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions -): Promise { - const errAccumulator = new AccumulatorStream(); +): Promise { + // Get a `DefaultEnvShellStreamCommandRunnerFactory` + const factory = new DefaultEnvShellStreamCommandRunnerFactory(additionalOptions); + + // Get the active client + const client: TClient = await runtimeManager.getClient(); + + try { + // Flatten the callback + const response = await normalizeCommandResponseLike(callback(client)); + if (response.parse) { + return await factory.getCommandRunner()(response as PromiseCommandResponse); + } else { + await factory.getCommandRunner()(response as VoidCommandResponse); + } + } catch (err) { + if (isChildProcessError(err)) { + // If this is a child process error, alter the message to be the stderr output, if it isn't falsy + const stdErr = await factory.errAccumulator.getString(); + err.message = stdErr || err.message; + } + + throw err; + } finally { + factory.dispose(); + } +} + +async function* streamWithDefaultShell( + callback: StreamingClientCallback, + runtimeManager: RuntimeManager, + additionalOptions?: DefaultEnvShellStreamCommandRunnerOptions +): AsyncGenerator { // Get a `DefaultEnvShellStreamCommandRunnerFactory` - const factory = new DefaultEnvShellStreamCommandRunnerFactory({ - ...additionalOptions, - strict: true, - stdErrPipe: errAccumulator, - }); + const factory = new DefaultEnvShellStreamCommandRunnerFactory(additionalOptions); // Get the active client const client: TClient = await runtimeManager.getClient(); try { - return await factory.getCommandRunner()( - callback(client) - ); + const runner = factory.getStreamingCommandRunner(); + const generator = runner(callback(client)); + + for await (const element of generator) { + yield element; + } } catch (err) { if (isChildProcessError(err)) { // If this is a child process error, alter the message to be the stderr output, if it isn't falsy - const stdErr = await errAccumulator.getString(); + const stdErr = await factory.errAccumulator.getString(); err.message = stdErr || err.message; } throw err; } finally { - errAccumulator.destroy(); + factory.dispose(); } } -class DefaultEnvShellStreamCommandRunnerFactory extends ShellStreamCommandRunnerFactory { +class DefaultEnvShellStreamCommandRunnerFactory extends ShellStreamCommandRunnerFactory implements vscode.Disposable { + public readonly errAccumulator: AccumulatorStream; + public constructor(options: TOptions) { + const errAccumulator = new AccumulatorStream(); + super({ ...options, + strict: true, + stdErrPipe: errAccumulator, shellProvider: Shell.getShellOrDefault(), env: withDockerEnvSettings(process.env), }); + + this.errAccumulator = errAccumulator; + } + + public dispose(): void { + this.errAccumulator.destroy(); } } diff --git a/src/tasks/DockerComposeTaskProvider.ts b/src/tasks/DockerComposeTaskProvider.ts index 2c89c63f45..addd9c28d3 100644 --- a/src/tasks/DockerComposeTaskProvider.ts +++ b/src/tasks/DockerComposeTaskProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommandResponse } from '../runtimes/docker'; +import { VoidCommandResponse } from '../runtimes/docker'; import * as fse from 'fs-extra'; import * as path from 'path'; import { Task } from 'vscode'; @@ -39,7 +39,7 @@ export class DockerComposeTaskProvider extends DockerTaskProvider { const client = await ext.orchestratorManager.getClient(); const options = definition.dockerCompose; - let command: CommandResponse; + let command: VoidCommandResponse; if (definition.dockerCompose.up) { command = await client.up({ files: options.files, diff --git a/src/tasks/DockerPseudoterminal.ts b/src/tasks/DockerPseudoterminal.ts index a3581f1e95..201a086d70 100644 --- a/src/tasks/DockerPseudoterminal.ts +++ b/src/tasks/DockerPseudoterminal.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommandResponse, Shell } from '../runtimes/docker'; +import { PromiseCommandResponse, Shell, VoidCommandResponse } from '../runtimes/docker'; import { CancellationToken, CancellationTokenSource, Event, EventEmitter, Pseudoterminal, TaskScope, TerminalDimensions, WorkspaceFolder, workspace } from 'vscode'; import { resolveVariables } from '../utils/resolveVariables'; import { execAsync, ExecAsyncOutput } from '../utils/execAsync'; @@ -52,12 +52,18 @@ export class DockerPseudoterminal implements Pseudoterminal { this.closeEmitter.fire(code || 0); } - public getCommandRunner(options: Omit): (commandResponse: CommandResponse) => Promise { - return async (commandResponse: CommandResponse) => { - return await this.executeCommandInTerminal({ + public getCommandRunner(options: Omit): (commandResponse: VoidCommandResponse | PromiseCommandResponse) => Promise { + return async (commandResponse: VoidCommandResponse | PromiseCommandResponse) => { + const output = await this.executeCommandInTerminal({ ...options, commandResponse: commandResponse, }); + + if (commandResponse.parse) { + return commandResponse.parse(output.stdout, true); + } + + return undefined; }; } @@ -121,7 +127,7 @@ export class DockerPseudoterminal implements Pseudoterminal { } type ExecuteCommandInTerminalOptions = { - commandResponse: CommandResponse; + commandResponse: VoidCommandResponse | PromiseCommandResponse; folder: WorkspaceFolder; rejectOnStderr?: boolean; token?: CancellationToken; diff --git a/src/tasks/DockerRunTaskProvider.ts b/src/tasks/DockerRunTaskProvider.ts index 737eb116b1..0afa9e0b1e 100644 --- a/src/tasks/DockerRunTaskProvider.ts +++ b/src/tasks/DockerRunTaskProvider.ts @@ -81,8 +81,7 @@ export class DockerRunTaskProvider extends DockerTaskProvider { token: context.cancellationToken, }); - const { stdout } = await runner(command); - context.containerId = stdout; + context.containerId = await runner(command); throwIfCancellationRequested(context); if (helper && helper.postRun) { diff --git a/src/tree/LocalRootTreeItemBase.ts b/src/tree/LocalRootTreeItemBase.ts index 57b530634c..8804d6b9cf 100644 --- a/src/tree/LocalRootTreeItemBase.ts +++ b/src/tree/LocalRootTreeItemBase.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { isCommandNotSupportedError, ListContainersItem, ListContextItem, ListImagesItem, ListNetworkItem, ListVolumeItem } from "../runtimes/docker"; -import { AzExtParentTreeItem, AzExtTreeItem, AzureWizard, GenericTreeItem, IActionContext, parseError, registerEvent } from "@microsoft/vscode-azext-utils"; -import { ConfigurationChangeEvent, ConfigurationTarget, ThemeColor, ThemeIcon, TreeView, TreeViewVisibilityChangeEvent, WorkspaceConfiguration, window, workspace } from "vscode"; +import { AzExtParentTreeItem, AzExtTreeItem, AzureWizard, GenericTreeItem, IActionContext, parseError } from "@microsoft/vscode-azext-utils"; +import { ConfigurationTarget, ThemeColor, ThemeIcon, WorkspaceConfiguration, workspace } from "vscode"; import { showDockerInstallNotification } from "../commands/dockerInstaller"; import { configPrefix } from "../constants"; import { ext } from "../extensionVariables"; @@ -20,6 +20,7 @@ import { ITreeSettingWizardInfo, ITreeSettingsWizardContext } from "./settings/I import { TreeSettingListStep } from "./settings/TreeSettingListStep"; import { TreeSettingStep } from "./settings/TreeSettingStep"; import { DatedDockerImage } from "./images/ImagesTreeItem"; +import { TreePrefix } from "./TreePrefix"; type DockerStatus = 'NotInstalled' | 'Installed' | 'Running'; @@ -48,7 +49,7 @@ export abstract class LocalRootTreeItemBase; public abstract childGroupType: LocalChildGroupType; @@ -68,7 +69,7 @@ export abstract class LocalRootTreeItemBase('explorerRefreshInterval', 2000); - } - - public registerRefreshEvents(treeView: TreeView): void { - let intervalId: NodeJS.Timeout; - registerEvent('treeView.onDidChangeVisibility', treeView.onDidChangeVisibility, (context: IActionContext, e: TreeViewVisibilityChangeEvent) => { - context.errorHandling.suppressDisplay = true; - context.telemetry.suppressIfSuccessful = true; - context.telemetry.properties.isActivationEvent = 'true'; - - if (e.visible) { - const refreshInterval: number = this.getRefreshInterval(); - intervalId = setInterval( - async () => { - if (this.autoRefreshEnabled && await this.hasChanged(context)) { - // Auto refresh could be disabled while invoking the hasChanged() - // So check again before starting the refresh. - if (this.autoRefreshEnabled) { - await this.refresh(context); - } - } - }, - refreshInterval); - } else { - clearInterval(intervalId); - } - }); - - registerEvent('treeView.onDidChangeConfiguration', workspace.onDidChangeConfiguration, async (context: IActionContext, e: ConfigurationChangeEvent) => { - context.errorHandling.suppressDisplay = true; - context.telemetry.suppressIfSuccessful = true; - context.telemetry.properties.isActivationEvent = 'true'; - - if (e.affectsConfiguration(`${configPrefix}.${this.treePrefix}`)) { - await this.refresh(context); - } - }); - } - protected getTreeItemForEmptyList(): AzExtTreeItem[] { return [new GenericTreeItem(this, { label: localize('vscode-docker.tree.noItemsFound', 'No items found'), @@ -132,17 +88,12 @@ export abstract class LocalRootTreeItemBase { try { // eslint-disable-next-line @typescript-eslint/no-floating-promises ext.activityMeasurementService.recordActivity('overallnoedit'); - this._currentItems = this._itemsFromPolling || await this.getSortedItems(context); - this.clearPollingCache(); + this._currentItems = await this.getCachedItems(context, clearCache); this.failedToConnect = false; this._currentDockerStatus = 'Running'; } catch (error) { @@ -356,47 +307,17 @@ export abstract class LocalRootTreeItemBase { - if (ext.treeInitError === undefined) { - const items: TItem[] = await this.getItems(context) || []; - return items.sort((a, b) => getTreeId(a).localeCompare(getTreeId(b))); - } else { - throw ext.treeInitError; - } - } - - private async hasChanged(context: IActionContext): Promise { - let pollingDockerStatus: DockerStatus; - let isDockerStatusChanged = false; - - try { - this._itemsFromPolling = await this.getSortedItems(context); - pollingDockerStatus = 'Running'; - } catch (error) { - this.clearPollingCache(); - pollingDockerStatus = await runtimeInstallStatusProvider.isRuntimeInstalled() ? 'Installed' : 'NotInstalled'; - isDockerStatusChanged = pollingDockerStatus !== this._currentDockerStatus; - } - - const hasChanged = !this.areArraysEqual(this._currentItems, this._itemsFromPolling) || isDockerStatusChanged; - this._currentDockerStatus = pollingDockerStatus; - return hasChanged; - } - - protected areArraysEqual(array1: TItem[] | undefined, array2: TItem[] | undefined): boolean { - if (array1 === array2) { - return true; - } else if (array1 && array2) { - if (array1.length !== array2.length) { - return false; + private async getCachedItems(context: IActionContext, clearCache: boolean): Promise { + if (clearCache || !this._cachedItems) { + if (ext.treeInitError === undefined) { + const items: TItem[] = await this.getItems(context) || []; + this._cachedItems = items.sort((a, b) => getTreeId(a).localeCompare(getTreeId(b))); } else { - return !array1.some((item1, index) => { - return getTreeId(item1) !== getTreeId(array2[index]); - }); + throw ext.treeInitError; } - } else { - return false; } + + return this._cachedItems; } private showDockerInstallNotificationIfNeeded(): void { diff --git a/src/tree/RefreshManager.ts b/src/tree/RefreshManager.ts new file mode 100644 index 0000000000..67d4165e61 --- /dev/null +++ b/src/tree/RefreshManager.ts @@ -0,0 +1,319 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzExtTreeItem, callWithTelemetryAndErrorHandling, IActionContext, parseError, registerCommand } from '@microsoft/vscode-azext-utils'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import { ext } from '../extensionVariables'; +import { localize } from '../localize'; +import { EventAction, EventType, isCancellationError } from '../runtimes/docker'; +import { debounce } from '../utils/debounce'; +import { AllTreePrefixes, TreePrefix } from './TreePrefix'; + +const pollingIntervalMs = 60 * 1000; // One minute +const eventListenerTries = 3; // The event listener will try at most 3 times to connect for events +const debounceDelayMs = 500; // Refreshes rapidly initiated for the same tree view will be debounced to occur 500ms after the last initiation + +type RefreshTarget = AzExtTreeItem | TreePrefix; +type RefreshReason = 'interval' | 'event' | 'config' | 'manual' | 'contextChange'; + +const ContainerEventActions: EventAction[] = ['create', 'destroy', 'die', 'kill', 'pause', 'rename', 'restart', 'start', 'stop', 'unpause', 'update']; +const ImageEventActions: EventAction[] = ['delete', 'import', 'load', 'pull', 'save', 'tag', 'untag']; +const NetworkEventActions: EventAction[] = ['create', 'destroy', 'remove']; +const VolumeEventActions: EventAction[] = ['create', 'destroy']; + +export class RefreshManager extends vscode.Disposable { + private readonly autoRefreshDisposables: vscode.Disposable[] = []; + private readonly viewOpenedDisposables: vscode.Disposable[] = []; + private readonly cts: vscode.CancellationTokenSource = new vscode.CancellationTokenSource(); + private autoRefreshSetup: boolean = false; + + public constructor() { + super(() => vscode.Disposable.from(...this.autoRefreshDisposables, ...this.viewOpenedDisposables).dispose()); + + // Refresh on command will be unconditionally set up since it is a user action + this.setupRefreshOnCommand(); + + // When any of these views becomes visible, we will initiate auto refreshes + // The disposables are kept separate because we dispose of them sooner + const treeViewsToInitiateAutoRefresh = [ + ext.containersTreeView, + ext.imagesTreeView, + ext.networksTreeView, + ext.volumesTreeView, + ext.contextsTreeView + ]; + + // If one of the views is already visible, immediately initiate auto-refreshes, otherwise wait for one to become visible + if (treeViewsToInitiateAutoRefresh.some(v => v.visible)) { + this.setupAutoRefreshes(); + } else { + for (const treeView of treeViewsToInitiateAutoRefresh) { + this.viewOpenedDisposables.push( + treeView.onDidChangeVisibility(e => { + if (e.visible) { + // Dispose of the listeners to this event and empty the array + vscode.Disposable.from(...this.viewOpenedDisposables).dispose(); + this.viewOpenedDisposables.splice(0); + + // Set up auto refreshes + this.setupAutoRefreshes(); + } + }) + ); + } + } + } + + private setupAutoRefreshes(): void { + if (this.autoRefreshSetup) { + return; + } + this.autoRefreshSetup = true; + + // VSCode does *not* cancel by default on disposal of a CancellationTokenSource, so we need to manually cancel + this.autoRefreshDisposables.unshift(new vscode.Disposable(() => this.cts.cancel())); + + this.setupRefreshOnInterval(); + this.setupRefreshOnRuntimeEvent(); + this.setupRefreshOnConfigurationChange(); + this.setupRefreshOnDockerConfigurationChange(); + this.setupRefreshOnContextChange(); + } + + private setupRefreshOnInterval(): void { + const timer = setInterval(async () => { + for (const view of AllTreePrefixes) { + // Skip the registries view, which does not need to be refreshed on an interval + if (view === 'registries') { + continue; + } + await this.refresh(view, 'interval'); + } + }, pollingIntervalMs); + + this.autoRefreshDisposables.push(new vscode.Disposable( + () => clearInterval(timer) + )); + } + + private setupRefreshOnRuntimeEvent(): void { + void callWithTelemetryAndErrorHandling('vscode-docker.tree.eventRefresh', async (context: IActionContext) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.suppressIfSuccessful = true; + + const eventTypesToWatch: EventType[] = ['container', 'image', 'network', 'volume']; + const eventActionsToWatch: EventAction[] = Array.from(new Set([...ContainerEventActions, ...ImageEventActions, ...NetworkEventActions, ...VolumeEventActions])); + + // Try at most `eventListenerTries` times to (re)connect to the event stream + for (let i = 0; i < eventListenerTries; i++) { + try { + const eventGenerator = ext.streamWithDefaultShell(client => + client.getEventStream({ + types: eventTypesToWatch, + events: eventActionsToWatch, + }), + { + cancellationToken: this.cts.token, + } + ); + + for await (const event of eventGenerator) { + switch (event.type) { + case 'container': + await this.refresh('containers', 'event'); + break; + case 'image': + await this.refresh('images', 'event'); + break; + case 'network': + await this.refresh('networks', 'event'); + break; + case 'volume': + await this.refresh('volumes', 'event'); + break; + default: + // Ignore other events + break; + } + } + } catch (err) { + const error = parseError(err); + + if (isCancellationError(err) || error.isUserCancelledError) { + // Cancelled, so don't try again and don't rethrow--this is a normal termination pathway + return; + } else if (i < eventListenerTries - 1) { + // Still in the retry loop + continue; + } else { + // Emit a message and rethrow to get telemetry + ext.outputChannel.appendLine( + localize('vscode-docker.tree.refreshManager.eventSetupFailure', 'Failed to set up event listener: {0}', error.message) + ); + + throw error; + } + } + } + }); + + } + + private setupRefreshOnConfigurationChange(): void { + this.autoRefreshDisposables.push( + vscode.workspace.onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => { + for (const view of AllTreePrefixes) { + if (e.affectsConfiguration(`docker.${view}`)) { + await this.refresh(view, 'config'); + } + } + }) + ); + } + + private setupRefreshOnCommand(): void { + for (const view of AllTreePrefixes) { + // Because `registerCommand` pushes the disposables onto the `ext.context.subscriptions` array, we don't need to keep track of them + registerCommand(`vscode-docker.${view}.refresh`, async () => { + await this.refresh(view, 'manual'); + }); + } + } + + private setupRefreshOnDockerConfigurationChange(): void { + // Docker events do not include context change information, so we set up some filesystem listeners to watch + // for changes to the Docker config file, which will be triggered by context changes + + void callWithTelemetryAndErrorHandling('vscode-docker.tree.dockerConfigRefresh', async (context: IActionContext) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.suppressIfSuccessful = true; + + const dockerConfigFolderUri = vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), '.docker'); + const dockerConfigFile = 'config.json'; + const dockerConfigFileUri = vscode.Uri.joinPath(dockerConfigFolderUri, dockerConfigFile); + const dockerContextsFolderUri = vscode.Uri.joinPath(dockerConfigFolderUri, 'contexts', 'meta'); + + try { + // Ensure the file exists--this will throw if it does not + await vscode.workspace.fs.stat(dockerConfigFileUri); + + const configWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(dockerConfigFolderUri, dockerConfigFile), + true, + false, + true + ); + this.autoRefreshDisposables.push(configWatcher); + + // Changes to this file tend to happen several times in succession, so we debounce + const debounceTimerMs = 500; + let lastTime = Date.now(); + this.autoRefreshDisposables.push(configWatcher.onDidChange(async () => { + if (Date.now() - lastTime < debounceTimerMs) { + return; + } + lastTime = Date.now(); + + await this.refresh('contexts', 'event'); + })); + } catch { + // Ignore + } + + try { + // Ensure the folder exists--this will throw if it does not + await vscode.workspace.fs.stat(dockerContextsFolderUri); + + const contextWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(dockerContextsFolderUri, '*'), + false, + true, + false + ); + this.autoRefreshDisposables.push(contextWatcher); + + this.autoRefreshDisposables.push(contextWatcher.onDidCreate(async () => { + await this.refresh('contexts', 'event'); + })); + + this.autoRefreshDisposables.push(contextWatcher.onDidDelete(async () => { + await this.refresh('contexts', 'event'); + })); + } catch { + // Ignore + } + }); + } + + private setupRefreshOnContextChange(): void { + this.autoRefreshDisposables.push( + ext.runtimeManager.contextManager.onContextChanged(async () => { + for (const view of AllTreePrefixes) { + // Refresh all except contexts, which would already have been refreshed + // And registries, which does not need to be refreshed on context change + if (view === 'contexts' || view === 'registries') { + continue; + } + await this.refresh(view, 'contextChange'); + } + }) + ); + } + + private refresh(target: RefreshTarget, reason: RefreshReason): Promise { + return callWithTelemetryAndErrorHandling('vscode-docker.tree.refresh', async (context: IActionContext) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.properties.refreshReason = reason; + + if (isAzExtTreeItem(target)) { + context.telemetry.properties.refreshTarget = 'node'; + + // Refreshes targeting a specific tree item will not be debounced + await target.refresh(context); + } else if (typeof target === 'string') { + context.telemetry.properties.refreshTarget = target; + + let callback: () => Promise; + switch (target) { + case 'containers': + callback = () => ext.containersRoot.refresh(context); + break; + case 'images': + callback = () => ext.imagesRoot.refresh(context); + break; + case 'networks': + callback = () => ext.networksRoot.refresh(context); + break; + case 'registries': + callback = () => ext.registriesRoot.refresh(context); + break; + case 'volumes': + callback = () => ext.volumesRoot.refresh(context); + break; + case 'contexts': + callback = () => ext.contextsRoot.refresh(context); + break; + default: + throw new RangeError(`Unexpected view type: ${target}`); + } + + if (reason === 'manual') { + // Manual refreshes will not be debounced--they should occur instantly + await callback(); + } else { + // Debounce all other refreshes by 500 ms + debounce(debounceDelayMs, `${target}.refresh`, callback); + } + } + }); + } +} + +// TODO: temp: this function is available in newer versions of @microsoft/vscode-azext-utils, use it when available +function isAzExtTreeItem(maybeTreeItem: unknown): maybeTreeItem is AzExtTreeItem { + return typeof maybeTreeItem === 'object' && + !!(maybeTreeItem as AzExtTreeItem).fullId; +} diff --git a/src/tree/TreePrefix.ts b/src/tree/TreePrefix.ts new file mode 100644 index 0000000000..d6afd3bf32 --- /dev/null +++ b/src/tree/TreePrefix.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The prefix names for each of the tree views + * Note: the help tree is intentionally excluded because it has no refresh nor config capabilities + */ +export const AllTreePrefixes = ['containers', 'networks', 'images', 'registries', 'volumes', 'contexts'] as const; + +/** + * A union type representing the tree prefix options + */ +export type TreePrefix = (typeof AllTreePrefixes)[number]; diff --git a/src/tree/containers/ContainerProperties.ts b/src/tree/containers/ContainerProperties.ts index 05d520e9cf..f868a90c82 100644 --- a/src/tree/containers/ContainerProperties.ts +++ b/src/tree/containers/ContainerProperties.ts @@ -60,8 +60,8 @@ export function getContainerPropertyValue(item: ListContainersItem, property: Co return item.state; case 'Status': // The rapidly-refreshing status during a container's first minute causes a lot of problems with excessive refreshing - // This normalizes things like "10 seconds" to "Less than a minute", meaning the refreshes don't happen constantly - return item.status?.replace(/\d+ seconds?/i, localize('vscode-docker.tree.containers.lessThanMinute', 'Less than a minute')); + // This normalizes things like "10 seconds" and "Less than a second" to "Less than a minute", meaning the refreshes don't happen constantly + return item.status?.replace(/(\d+ seconds?)|(Less than a second)/i, localize('vscode-docker.tree.containers.lessThanMinute', 'Less than a minute')); case 'Compose Project Name': return getComposeProjectName(item); case 'Image': diff --git a/src/tree/containers/ContainersTreeItem.ts b/src/tree/containers/ContainersTreeItem.ts index d4c2f5ffdb..e3d4219cc5 100644 --- a/src/tree/containers/ContainersTreeItem.ts +++ b/src/tree/containers/ContainersTreeItem.ts @@ -15,13 +15,14 @@ import { ITreeArraySettingInfo, ITreeSettingInfo } from "../settings/ITreeSettin import { ContainerGroupTreeItem } from "./ContainerGroupTreeItem"; import { ContainerProperty, containerProperties, getContainerPropertyValue, NonComposeGroupName } from "./ContainerProperties"; import { ContainerTreeItem } from "./ContainerTreeItem"; +import { TreePrefix } from "../TreePrefix"; export type DockerContainerInfo = ListContainersItem & { showFiles: boolean; }; export class ContainersTreeItem extends LocalRootTreeItemBase { - public treePrefix: string = 'containers'; + public treePrefix: TreePrefix = 'containers'; public label: string = localize('vscode-docker.tree.containers.label', 'Containers'); public configureExplorerTitle: string = localize('vscode-docker.tree.containers.configure', 'Configure containers explorer'); @@ -98,28 +99,6 @@ export class ContainersTreeItem extends LocalRootTreeItemBase { - return this.getTreeItemLabel(item) !== this.getTreeItemLabel(array2[index]) || - this.getTreeItemDescription(item) !== this.getTreeItemDescription(array2[index]); - }); - } - private isNewContainerUser(): boolean { return ext.context.globalState.get('vscode-docker.container.newContainerUser', true); } diff --git a/src/tree/contexts/ContextsTreeItem.ts b/src/tree/contexts/ContextsTreeItem.ts index d4779a4853..a8b2be7f93 100644 --- a/src/tree/contexts/ContextsTreeItem.ts +++ b/src/tree/contexts/ContextsTreeItem.ts @@ -14,9 +14,10 @@ import { ITreeSettingWizardInfo } from '../settings/ITreeSettingsWizardContext'; import { ContextGroupTreeItem } from './ContextGroupTreeItem'; import { ContextProperty, contextProperties } from "./ContextProperties"; import { ContextTreeItem } from './ContextTreeItem'; +import { TreePrefix } from '../TreePrefix'; export class ContextsTreeItem extends LocalRootTreeItemBase { - public treePrefix: string = 'contexts'; + public treePrefix: TreePrefix = 'contexts'; public label: string = localize('vscode-docker.tree.Contexts.label', 'Contexts'); public configureExplorerTitle: string = localize('vscode-docker.tree.Contexts.configure', 'Configure Docker Contexts Explorer'); public childType: LocalChildType = ContextTreeItem; diff --git a/src/tree/images/ImagesTreeItem.ts b/src/tree/images/ImagesTreeItem.ts index 7462e737ef..646e8d4f72 100644 --- a/src/tree/images/ImagesTreeItem.ts +++ b/src/tree/images/ImagesTreeItem.ts @@ -15,6 +15,7 @@ import { OutdatedImageChecker } from "./imageChecker/OutdatedImageChecker"; import { ImageGroupTreeItem } from './ImageGroupTreeItem'; import { ImageProperty, getImagePropertyValue, imageProperties } from "./ImageProperties"; import { ImageTreeItem } from "./ImageTreeItem"; +import { TreePrefix } from "../TreePrefix"; export interface DatedDockerImage extends ListImagesItem { outdated?: boolean; @@ -31,7 +32,7 @@ export class ImagesTreeItem extends LocalRootTreeItemBase { - public treePrefix: string = 'networks'; + public treePrefix: TreePrefix = 'networks'; public label: string = localize('vscode-docker.tree.networks.label', 'Networks'); public configureExplorerTitle: string = localize('vscode-docker.tree.networks.configure', 'Configure networks explorer'); public childType: LocalChildType = NetworkTreeItem; diff --git a/src/tree/registerTrees.ts b/src/tree/registerTrees.ts index 59d6df5fca..c9f53f51a3 100644 --- a/src/tree/registerTrees.ts +++ b/src/tree/registerTrees.ts @@ -13,6 +13,7 @@ import { HelpsTreeItem } from './help/HelpsTreeItem'; import { ImagesTreeItem } from "./images/ImagesTreeItem"; import { NetworksTreeItem } from "./networks/NetworksTreeItem"; import { OpenUrlTreeItem } from './OpenUrlTreeItem'; +import { RefreshManager } from './RefreshManager'; import { RegistriesTreeItem } from "./registries/RegistriesTreeItem"; import { VolumesTreeItem } from "./volumes/VolumesTreeItem"; @@ -22,36 +23,24 @@ export function registerTrees(): void { ext.containersTree = new AzExtTreeDataProvider(ext.containersRoot, containersLoadMore); ext.containersTreeView = vscode.window.createTreeView('dockerContainers', { treeDataProvider: ext.containersTree, canSelectMany: true }); ext.context.subscriptions.push(ext.containersTreeView); - ext.containersRoot.registerRefreshEvents(ext.containersTreeView); /* eslint-disable-next-line @typescript-eslint/promise-function-async */ registerCommand(containersLoadMore, (context: IActionContext, node: AzExtTreeItem) => ext.containersTree.loadMore(node, context)); - registerCommand('vscode-docker.containers.refresh', async (context: IActionContext, node?: AzExtTreeItem) => { - await ext.containersTree.refresh(context, node); - }); ext.networksRoot = new NetworksTreeItem(undefined); const networksLoadMore = 'vscode-docker.networks.loadMore'; ext.networksTree = new AzExtTreeDataProvider(ext.networksRoot, networksLoadMore); ext.networksTreeView = vscode.window.createTreeView('dockerNetworks', { treeDataProvider: ext.networksTree, canSelectMany: true }); ext.context.subscriptions.push(ext.networksTreeView); - ext.networksRoot.registerRefreshEvents(ext.networksTreeView); /* eslint-disable-next-line @typescript-eslint/promise-function-async */ registerCommand(networksLoadMore, (context: IActionContext, node: AzExtTreeItem) => ext.networksTree.loadMore(node, context)); - registerCommand('vscode-docker.networks.refresh', async (context: IActionContext, node?: AzExtTreeItem) => { - await ext.networksTree.refresh(context, node); - }); ext.imagesRoot = new ImagesTreeItem(undefined); const imagesLoadMore = 'vscode-docker.images.loadMore'; ext.imagesTree = new AzExtTreeDataProvider(ext.imagesRoot, imagesLoadMore); ext.imagesTreeView = vscode.window.createTreeView('dockerImages', { treeDataProvider: ext.imagesTree, canSelectMany: true }); ext.context.subscriptions.push(ext.imagesTreeView); - ext.imagesRoot.registerRefreshEvents(ext.imagesTreeView); /* eslint-disable-next-line @typescript-eslint/promise-function-async */ registerCommand(imagesLoadMore, (context: IActionContext, node: AzExtTreeItem) => ext.imagesTree.loadMore(node, context)); - registerCommand('vscode-docker.images.refresh', async (context: IActionContext, node?: AzExtTreeItem) => { - await ext.imagesTree.refresh(context, node); - }); ext.registriesRoot = new RegistriesTreeItem(); const registriesLoadMore = 'vscode-docker.registries.loadMore'; @@ -60,36 +49,31 @@ export function registerTrees(): void { ext.context.subscriptions.push(ext.registriesTreeView); /* eslint-disable-next-line @typescript-eslint/promise-function-async */ registerCommand(registriesLoadMore, (context: IActionContext, node: AzExtTreeItem) => ext.registriesTree.loadMore(node, context)); - registerCommand('vscode-docker.registries.refresh', async (context: IActionContext, node?: AzExtTreeItem) => ext.registriesTree.refresh(context, node)); ext.volumesRoot = new VolumesTreeItem(undefined); const volumesLoadMore = 'vscode-docker.volumes.loadMore'; ext.volumesTree = new AzExtTreeDataProvider(ext.volumesRoot, volumesLoadMore); ext.volumesTreeView = vscode.window.createTreeView('dockerVolumes', { treeDataProvider: ext.volumesTree, canSelectMany: true }); ext.context.subscriptions.push(ext.volumesTreeView); - ext.volumesRoot.registerRefreshEvents(ext.volumesTreeView); /* eslint-disable-next-line @typescript-eslint/promise-function-async */ registerCommand(volumesLoadMore, (context: IActionContext, node: AzExtTreeItem) => ext.volumesTree.loadMore(node, context)); - registerCommand('vscode-docker.volumes.refresh', async (context: IActionContext, node?: AzExtTreeItem) => { - await ext.volumesTree.refresh(context, node); - }); ext.contextsRoot = new ContextsTreeItem(undefined); const contextsLoadMore = 'vscode-docker.contexts.loadMore'; ext.contextsTree = new AzExtTreeDataProvider(ext.contextsRoot, contextsLoadMore); ext.contextsTreeView = vscode.window.createTreeView('vscode-docker.views.dockerContexts', { treeDataProvider: ext.contextsTree, canSelectMany: false }); ext.context.subscriptions.push(ext.contextsTreeView); - ext.contextsRoot.registerRefreshEvents(ext.contextsTreeView); /* eslint-disable-next-line @typescript-eslint/promise-function-async */ registerCommand(contextsLoadMore, (context: IActionContext, node: AzExtTreeItem) => ext.contextsTree.loadMore(node, context)); - registerCommand('vscode-docker.contexts.refresh', async (context: IActionContext, node?: AzExtTreeItem) => { - await ext.contextsTree.refresh(context); - }); const helpRoot = new HelpsTreeItem(undefined); const helpTreeDataProvider = new AzExtTreeDataProvider(helpRoot, 'vscode-docker.help.loadMore'); const helpTreeView = vscode.window.createTreeView('vscode-docker.views.help', { treeDataProvider: helpTreeDataProvider, canSelectMany: false }); ext.context.subscriptions.push(helpTreeView); + // Allows OpenUrlTreeItem to open URLs registerCommand('vscode-docker.openUrl', async (context: IActionContext, node: OpenUrlTreeItem) => node.openUrl()); + + // Register the refresh manager + ext.context.subscriptions.push(new RefreshManager()); } diff --git a/src/tree/registries/RegistriesTreeItem.ts b/src/tree/registries/RegistriesTreeItem.ts index 0a5ec7becf..8a84f9d4eb 100644 --- a/src/tree/registries/RegistriesTreeItem.ts +++ b/src/tree/registries/RegistriesTreeItem.ts @@ -7,6 +7,7 @@ import { AzExtParentTreeItem, AzExtTreeItem, AzureWizard, GenericTreeItem, IActi import { ThemeIcon } from "vscode"; import { ext } from "../../extensionVariables"; import { localize } from '../../localize'; +import { TreePrefix } from "../TreePrefix"; import { getRegistryProviders } from "./all/getRegistryProviders"; import { ConnectedRegistriesTreeItem } from "./ConnectedRegistriesTreeItem"; import { IConnectRegistryWizardContext } from "./connectWizard/IConnectRegistryWizardContext"; @@ -22,6 +23,7 @@ import { RegistryTreeItemBase } from "./RegistryTreeItemBase"; const providersKey = 'docker.registryProviders'; export class RegistriesTreeItem extends AzExtParentTreeItem { + public treePrefix: TreePrefix = 'registries'; public static contextValue: string = 'registries'; public contextValue: string = RegistriesTreeItem.contextValue; public label: string = localize('vscode-docker.tree.registries.registriesLabel', 'Registries'); diff --git a/src/tree/volumes/VolumesTreeItem.ts b/src/tree/volumes/VolumesTreeItem.ts index a09d97d3e0..c334504c7b 100644 --- a/src/tree/volumes/VolumesTreeItem.ts +++ b/src/tree/volumes/VolumesTreeItem.ts @@ -13,9 +13,10 @@ import { ITreeArraySettingInfo, ITreeSettingInfo } from "../settings/ITreeSettin import { VolumeGroupTreeItem } from "./VolumeGroupTreeItem"; import { VolumeProperty, volumeProperties } from "./VolumeProperties"; import { VolumeTreeItem } from "./VolumeTreeItem"; +import { TreePrefix } from "../TreePrefix"; export class VolumesTreeItem extends LocalRootTreeItemBase { - public treePrefix: string = 'volumes'; + public treePrefix: TreePrefix = 'volumes'; public label: string = localize('vscode-docker.tree.volumes.label', 'Volumes'); public configureExplorerTitle: string = localize('vscode-docker.tree.volumes.configure', 'Configure volumes explorer'); public childType: LocalChildType = VolumeTreeItem; diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000000..fdbc4e2b49 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +const activeDebounces: { [key: string]: vscode.Disposable } = {}; + +export function debounce(delay: number, id: string, callback: () => Promise, thisArg?: unknown): void { + // If there's an existing call queued up, wipe it out (can't simply refresh as the inputs to the callback may be different) + if (activeDebounces[id]) { + activeDebounces[id].dispose(); + } + + // Schedule the callback + const timeout = setTimeout(() => { + // Clear the callback since we're about to fire it + activeDebounces[id].dispose(); + + // Fire it + void callback.call(thisArg); + }, delay); + + // Keep track of the active debounce, with a disposable that + // cancels the timeout and deletes the item from the activeDebounces map + activeDebounces[id] = new vscode.Disposable(() => { + clearTimeout(timeout); + delete activeDebounces[id]; + }); +}