diff --git a/package-lock.json b/package-lock.json index 8ed1a7405a..54f055ceb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,9 +115,9 @@ "dev": true }, "@types/dockerode": { - "version": "2.5.26", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.26.tgz", - "integrity": "sha512-1OVF2rHRtn3ue4+aMdmql2TyUUdEFvWU3sg0rvQxO3jasvNokDx3G5xXzucFFgSWMA3OFIJ9yseW3BNU1XgktQ==", + "version": "2.5.27", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.27.tgz", + "integrity": "sha512-22qr8hE+WlSOfOWGyusv5KJSw/HyJXvFesWgi+FG9v/8OMjZ/G0QADffcrJyGILnHmiLta/blJzs+kE7JUw+3w==", "dev": true, "requires": { "@types/node": "*" diff --git a/package.json b/package.json index 3a7cbba672..b613737111 100644 --- a/package.json +++ b/package.json @@ -1818,6 +1818,10 @@ "default": 10, "description": "%vscode-docker.config.docker.truncateMaxLength%" }, + "docker.dockerodeOptions": { + "type": "object", + "description": "%vscode-docker.config.docker.dockerodeOptions%" + }, "docker.host": { "type": "string", "default": "", @@ -2460,7 +2464,7 @@ "devDependencies": { "@types/adm-zip": "^0.4.32", "@types/deep-equal": "^1.0.1", - "@types/dockerode": "^2.5.26", + "@types/dockerode": "^2.5.27", "@types/fs-extra": "^8.1.0", "@types/glob": "^7.1.1", "@types/keytar": "^4.4.2", diff --git a/package.nls.json b/package.nls.json index dd506d485d..a7e472f523 100644 --- a/package.nls.json +++ b/package.nls.json @@ -146,6 +146,7 @@ "vscode-docker.config.docker.imageBuildContextPath": "Build context PATH to pass to Docker build command.", "vscode-docker.config.docker.truncateLongRegistryPaths": "Set to true to truncate long image and container registry paths in Docker view", "vscode-docker.config.docker.truncateMaxLength": "Maximum length of a registry paths displayed in Docker view, including elipsis. The truncateLongRegistryPaths setting must be set to true for truncateMaxLength setting to be effective.", + "vscode-docker.config.docker.dockerodeOptions": "If specified, this object will be passed to the Dockerode constructor. Takes precedence over DOCKER_HOST, the Docker Host setting, and any existing Docker contexts.", "vscode-docker.config.docker.host": "Equivalent to setting the DOCKER_HOST environment variable.", "vscode-docker.config.docker.certPath": "Equivalent to setting the DOCKER_CERT_PATH environment variable.", "vscode-docker.config.docker.tlsVerify": "Equivalent to setting the DOCKER_TLS_VERIFY environment variable.", diff --git a/src/extension.ts b/src/extension.ts index 4315ff8042..bcf16937aa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as fse from 'fs-extra'; +import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { AzureUserInput, callWithTelemetryAndErrorHandling, createAzExtOutputChannel, createTelemetryReporter, IActionContext, registerUIExtensionVariables, UserCancelledError } from 'vscode-azureextensionui'; @@ -30,6 +31,7 @@ import { registerTrees } from './tree/registerTrees'; import { AzureAccountExtensionListener } from './utils/AzureAccountExtensionListener'; import { Keytar } from './utils/keytar'; import { refreshDockerode } from './utils/refreshDockerode'; +import { bufferToString } from './utils/spawnAsync'; import { DefaultTerminalProvider } from './utils/TerminalProvider'; export type KeyInfo = { [keyName: string]: string }; @@ -80,6 +82,7 @@ export async function activateInternal(ctx: vscode.ExtensionContext, perfStats: await callWithTelemetryAndErrorHandling('docker.activate', async (activateContext: IActionContext) => { activateContext.telemetry.properties.isActivationEvent = 'true'; activateContext.telemetry.measurements.mainFileLoad = (perfStats.loadEndTime - perfStats.loadStartTime) / 1000; + activateContext.telemetry.properties.dockerInstallationID = await getDockerInstallationID(); validateOldPublisher(activateContext); @@ -145,6 +148,22 @@ export async function deactivateInternal(ctx: vscode.ExtensionContext): Promise< }); } +async function getDockerInstallationID(): Promise { + let result: string = 'unknown'; + let installIdFilePath: string | undefined; + if (os.platform() === 'win32' && process.env.APPDATA) { + installIdFilePath = path.join(process.env.APPDATA, 'Docker', '.trackid'); + } else if (os.platform() === 'darwin') { + installIdFilePath = path.join(os.homedir(), 'Library', 'Group Containers', 'group.com.docker', 'userId'); + } + + if (installIdFilePath && await fse.pathExists(installIdFilePath)) { + result = bufferToString(await fse.readFile(installIdFilePath)); + } + + return result; +} + /** * Workaround for https://github.com/microsoft/vscode/issues/76211 (only necessary if people are on old versions of VS Code that don't have the fix) */ @@ -197,7 +216,8 @@ namespace Configuration { if (e.affectsConfiguration('docker.host') || e.affectsConfiguration('docker.certPath') || e.affectsConfiguration('docker.tlsVerify') || - e.affectsConfiguration('docker.machineName')) { + e.affectsConfiguration('docker.machineName') || + e.affectsConfiguration('docker.dockerodeOptions')) { await refreshDockerode(); } } diff --git a/src/utils/dockerContextManager.ts b/src/utils/dockerContextManager.ts index c39882867a..21ec99e848 100644 --- a/src/utils/dockerContextManager.ts +++ b/src/utils/dockerContextManager.ts @@ -11,11 +11,9 @@ import * as url from 'url'; import { workspace, WorkspaceConfiguration } from 'vscode'; import { parseError } from "vscode-azureextensionui"; import LineSplitter from '../debugging/coreclr/lineSplitter'; -import { ext } from '../extensionVariables'; import { localize } from '../localize'; import { LocalOSProvider } from './LocalOSProvider'; import { execAsync, spawnAsync } from './spawnAsync'; -import { timeUtils } from './timeUtils'; // CONSIDER // Any of the commands related to Docker context can take a very long time to execute (a minute or longer) @@ -202,14 +200,12 @@ export class DockerContextManager { } private async refreshCachedDockerContext(): Promise { - const { Result: currentContext, DurationMs: duration } = await timeUtils.timeIt(async () => this.inspectCurrentContext()); + const currentContext = await this.inspectCurrentContext(); const contextChanged = !this.cachedContext || currentContext.FullSpec !== this.cachedContext.FullSpec; if (contextChanged) { - const previousContext = this.cachedContext; this.cachedContext = currentContext; - this.sendDockerContextEvent(currentContext, previousContext, duration); } return contextChanged; @@ -240,11 +236,6 @@ export class DockerContextManager { } catch { } return currentContext; } - - private sendDockerContextEvent(currentContext: IDockerContext, previousContext: IDockerContext, contextRetrievalTimeMs: number): void { - const eventName: string = previousContext ? 'docker-context.change' : 'docker-context.initialize'; - ext.reporter.sendTelemetryEvent(eventName, { hostProtocol: currentContext.HostProtocol }, { contextRetrievalTimeMs: contextRetrievalTimeMs }); - } } export const dockerContextManager = new DockerContextManager(); diff --git a/src/utils/refreshDockerode.ts b/src/utils/refreshDockerode.ts index 0247464ef1..63d4a562e6 100644 --- a/src/utils/refreshDockerode.ts +++ b/src/utils/refreshDockerode.ts @@ -5,8 +5,10 @@ import Dockerode = require('dockerode'); import { Socket } from 'net'; -import { CancellationTokenSource } from 'vscode'; -import { parseError } from 'vscode-azureextensionui'; +import * as os from 'os'; +import * as url from 'url'; +import { CancellationTokenSource, workspace } from 'vscode'; +import { callWithTelemetryAndErrorHandling, IActionContext } from 'vscode-azureextensionui'; import { ext } from '../extensionVariables'; import { localize } from '../localize'; import { addDockerSettingsToEnv } from './addDockerSettingsToEnv'; @@ -14,6 +16,7 @@ import { cloneObject } from './cloneObject'; import { delay } from './delay'; import { dockerContextManager, IDockerContext } from './dockerContextManager'; import { isWindows } from './osUtils'; +import { timeUtils } from './timeUtils'; const SSH_URL_REGEX = /ssh:\/\//i; @@ -22,53 +25,124 @@ const SSH_URL_REGEX = /ssh:\/\//i; * Thus we will temporarily update `process.env` and pass nothing to the constructor */ export async function refreshDockerode(): Promise { - try { - const oldEnv = process.env; - const newEnv: NodeJS.ProcessEnv = cloneObject(process.env); // make a clone before we change anything - addDockerSettingsToEnv(newEnv, oldEnv); - await addDockerHostToEnv(newEnv); - - ext.dockerodeInitError = undefined; - process.env = newEnv; - try { - ext.dockerode = new Dockerode(); - } finally { - process.env = oldEnv; + await callWithTelemetryAndErrorHandling( + ext.dockerode ? 'docker-context.change' : 'docker-context.initialize', + async (actionContext: IActionContext) => { + + try { + // If the docker.dockerodeOptions setting is present, use it only + const config = workspace.getConfiguration('docker'); + const overrideDockerodeOptions = config.get<{}>('dockerodeOptions'); + if (overrideDockerodeOptions) { + actionContext.telemetry.properties.hostSource = 'docker.dockerodeOptions'; + actionContext.telemetry.measurements.retrievalTimeMs = 0; + ext.dockerodeInitError = undefined; + ext.dockerode = new Dockerode(overrideDockerodeOptions); + return; + } + + // Set up environment variables + const oldEnv = process.env; + const newEnv: NodeJS.ProcessEnv = cloneObject(process.env); // make a clone before we change anything + + let dockerodeOptions: Dockerode.DockerOptions | undefined; + + // If DOCKER_HOST is set in the process environment, the host source is environment + if (oldEnv.DOCKER_HOST) { + actionContext.telemetry.properties.hostSource = 'env'; + } + + // Override with settings + addDockerSettingsToEnv(newEnv, oldEnv); + + // If the old value is different from the new value, then the setting overrode it + if ((oldEnv.DOCKER_HOST ?? '') !== (newEnv.DOCKER_HOST ?? '')) { + actionContext.telemetry.properties.hostSource = 'docker.host'; + } + + // If DOCKER_HOST is set, do not use docker context (same behavior as the CLI) + if (newEnv.DOCKER_HOST) { + const parsed = new url.URL(newEnv.DOCKER_HOST); + actionContext.telemetry.properties.hostProtocol = parsed.protocol; + actionContext.telemetry.measurements.retrievalTimeMs = 0; + } else { + dockerodeOptions = await getDockerOptionsFromDockerContext(actionContext, newEnv); + } + + // If host is an SSH URL, we need to configure / validate SSH_AUTH_SOCK for Dockerode + if (SSH_URL_REGEX.test(newEnv.DOCKER_HOST || dockerodeOptions?.host)) { + if (!await validateSshAuthSock(newEnv)) { + // Don't wait + /* eslint-disable-next-line @typescript-eslint/no-floating-promises */ + ext.ui.showWarningMessage(localize('vscode-docker.utils.dockerode.sshAgent', 'In order to use an SSH DOCKER_HOST, you must configure an ssh-agent.'), { learnMoreLink: 'https://aka.ms/AA7assy' }); + } + + if (dockerodeOptions) { + dockerodeOptions.sshAuthAgent = newEnv.SSH_AUTH_SOCK; + } + } + + try { + ext.dockerodeInitError = undefined; + process.env = newEnv; + ext.dockerode = new Dockerode(dockerodeOptions); + } finally { + process.env = oldEnv; + } + } catch (error) { + // The error will be displayed in the tree + ext.dockerodeInitError = error; + actionContext.errorHandling.suppressReportIssue = true; + actionContext.errorHandling.suppressDisplay = true; + + // Rethrow it so the telemetry handler can deal with it + throw error; + } } - } catch (error) { - // This will be displayed in the tree - ext.dockerodeInitError = error; - } + ); } -async function addDockerHostToEnv(newEnv: NodeJS.ProcessEnv): Promise { +async function getDockerOptionsFromDockerContext(actionContext: IActionContext, newEnv: NodeJS.ProcessEnv): Promise { + const options: Dockerode.DockerOptions = {}; let dockerContext: IDockerContext; - try { - ({ Context: dockerContext } = await dockerContextManager.getCurrentContext()); + ({ DurationMs: actionContext.telemetry.measurements.contextRetrievalTimeMs, Result: { Context: dockerContext } } = await timeUtils.timeIt(async () => dockerContextManager.getCurrentContext())); - if (!newEnv.DOCKER_HOST) { - newEnv.DOCKER_HOST = dockerContext?.Endpoints.docker.Host; - } + if (dockerContext === undefined) { // Undefined context means there's only the default context + actionContext.telemetry.properties.hostSource = 'defaultContextOnly'; + } else if (/^default$/i.test(dockerContext.Name)) { + actionContext.telemetry.properties.hostSource = 'defaultContextSelected'; + } else { + actionContext.telemetry.properties.hostSource = 'customContextSelected'; + } - if (!newEnv.DOCKER_TLS_VERIFY && dockerContext?.Endpoints.docker.SkipTLSVerify) { - // https://docs.docker.com/compose/reference/envvars/#docker_tls_verify - newEnv.DOCKER_TLS_VERIFY = ""; - } - } catch (error) { - /* eslint-disable-next-line @typescript-eslint/no-floating-promises */ - ext.ui.showWarningMessage(localize('vscode-docker.utils.dockerode.dockerContextUnobtainable', 'Docker context could not be retrieved.') + ' ' + parseError(error).message); + const host = dockerContext?.Endpoints?.docker?.Host; + + if (host) { + const parsed = new url.URL(host); + + options.host = host; // Intentionally the full URL (docker-modem can figure out the protocol and hostname from it) + options.port = parsed.port; // docker-modem can figure out the port if it is not explicit in the URL + options.username = parsed.username; + + actionContext.telemetry.properties.hostProtocol = parsed.protocol; + } else { + // If the context doesn't have a Docker host, Dockerode will assume the default npipe://... or unix://... + actionContext.telemetry.properties.hostProtocol = os.platform() === 'win32' ? 'npipe:' : 'unix:'; } - if (newEnv.DOCKER_HOST && SSH_URL_REGEX.test(newEnv.DOCKER_HOST)) { - // If DOCKER_HOST is an SSH URL, we need to configure / validate SSH_AUTH_SOCK for Dockerode - // Other than that, we use default settings, so return undefined - if (!await validateSshAuthSock(newEnv)) { - // Don't wait - /* eslint-disable-next-line @typescript-eslint/no-floating-promises */ - ext.ui.showWarningMessage(localize('vscode-docker.utils.dockerode.sshAgent', 'In order to use an SSH DOCKER_HOST, you must configure an ssh-agent.'), { learnMoreLink: 'https://aka.ms/AA7assy' }); - } + // Currently the environment variable is the only way to configure this in docker-modem + if (dockerContext?.Endpoints?.docker?.SkipTLSVerify) { + // Disabling TLS specifically requires the value to be an empty string + // https://docs.docker.com/compose/reference/envvars/#docker_tls_verify + newEnv.DOCKER_TLS_VERIFY = ''; + } else { + newEnv.DOCKER_TLS_VERIFY = '1'; } + + // TODO: Attach the TLS material to the options + + return options; } async function validateSshAuthSock(newEnv: NodeJS.ProcessEnv): Promise { diff --git a/src/utils/spawnAsync.ts b/src/utils/spawnAsync.ts index 17cb8fccdb..bd6047f69f 100644 --- a/src/utils/spawnAsync.ts +++ b/src/utils/spawnAsync.ts @@ -120,7 +120,7 @@ export async function execAsync(command: string, options?: cp.ExecOptions, progr } } -function bufferToString(buffer: Buffer): string { +export function bufferToString(buffer: Buffer): string { // Node.js treats null bytes as part of the length, which makes everything mad // There's also a trailing newline everything hates, so we'll remove return buffer.toString().replace(/\0/g, '').replace(/\r?\n$/g, '');