Skip to content

Commit

Permalink
Refactor Docker context some (#1868)
Browse files Browse the repository at this point in the history
* Context refactoring

* Finish telemetry implementation

* Provide way for unconditional dockerode options
  • Loading branch information
bwateratmsft authored Apr 20, 2020
1 parent 3b524bb commit df38c83
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 55 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
22 changes: 21 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 };
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -145,6 +148,22 @@ export async function deactivateInternal(ctx: vscode.ExtensionContext): Promise<
});
}

async function getDockerInstallationID(): Promise<string> {
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)
*/
Expand Down Expand Up @@ -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();
}
}
Expand Down
11 changes: 1 addition & 10 deletions src/utils/dockerContextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -202,14 +200,12 @@ export class DockerContextManager {
}

private async refreshCachedDockerContext(): Promise<boolean> {
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;
Expand Down Expand Up @@ -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();
Expand Down
152 changes: 113 additions & 39 deletions src/utils/refreshDockerode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@

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';
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;

Expand All @@ -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<void> {
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(<Dockerode.DockerOptions>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<void> {
async function getDockerOptionsFromDockerContext(actionContext: IActionContext, newEnv: NodeJS.ProcessEnv): Promise<Dockerode.DockerOptions> {
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<boolean> {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/spawnAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '');
Expand Down

0 comments on commit df38c83

Please sign in to comment.