From c7120bd5d08cfeb02227471bae38435331cfa990 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Fri, 10 Aug 2018 16:32:18 -0700 Subject: [PATCH 01/16] Support private registries --- .vscode/tasks.json | 2 +- commands/utils/TerminalProvider.ts | 101 +++++++++-- dockerExtension.ts | 15 +- explorer/deploy/webAppCreator.ts | 16 +- explorer/models/azureRegistryNodes.ts | 228 ++++++++++--------------- explorer/models/commonRegistryUtils.ts | 110 ++++++++++++ explorer/models/customRegistries.ts | 140 +++++++++++++++ explorer/models/customRegistryNodes.ts | 145 ++++++++++++++++ explorer/models/dockerHubNodes.ts | 58 +++---- explorer/models/registryRootNode.ts | 42 +++-- explorer/models/registryType.ts | 1 + explorer/models/rootNode.ts | 4 +- explorer/utils/azureUtils.ts | 6 +- explorer/utils/dockerHubUtils.ts | 29 +++- explorer/utils/utils.ts | 18 +- package.json | 24 ++- test/buildAndRun.test.ts | 12 +- test/customRegistries.test.ts | 72 ++++++++ test/global.test.ts | 11 +- tsconfig.json | 4 +- 20 files changed, 805 insertions(+), 233 deletions(-) create mode 100644 explorer/models/commonRegistryUtils.ts create mode 100644 explorer/models/customRegistries.ts create mode 100644 explorer/models/customRegistryNodes.ts create mode 100644 test/customRegistries.test.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7709c9af8f..f2f0e5e0f6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -27,7 +27,7 @@ ], "isBackground": true, "presentation": { - "reveal": "silent" + "reveal": "never" }, "problemMatcher": "$tsc-watch" }, diff --git a/commands/utils/TerminalProvider.ts b/commands/utils/TerminalProvider.ts index 94a2b88cf7..e7a27d6a52 100644 --- a/commands/utils/TerminalProvider.ts +++ b/commands/utils/TerminalProvider.ts @@ -30,7 +30,7 @@ export class DefaultTerminalProvider { export class TestTerminalProvider { private _currentTerminal: TestTerminal; - public createTerminal(name: string): Terminal { + public createTerminal(name: string): TestTerminal { let terminal = new DefaultTerminalProvider().createTerminal(name); let testTerminal = new TestTerminal(terminal); this._currentTerminal = testTerminal; @@ -43,11 +43,13 @@ export class TestTerminalProvider { } class TestTerminal implements vscode.Terminal { + private static _lastSuffix: number = 1; + private _outputFilePath: string; private _errFilePath: string; private _semaphorePath: string; private _suffix: number; - private static _lastSuffix: number = 1; + private _disposed: boolean; constructor(private _terminal: vscode.Terminal) { let root = vscode.workspace.rootPath || os.tmpdir(); @@ -59,28 +61,95 @@ class TestTerminal implements vscode.Terminal { } /** - * Causes the terminal to exit after completing the current command, and returns the + * Causes the terminal to exit after completing the current commands, and returns the * redirected standard and error output. */ public async exit(): Promise<{ errorText: string, outputText: string }> { + this.ensureNotDisposed(); + let results = await this.waitForCompletion(); + this.hide(); + this.dispose(); + return results; + } + + /** + * Causes the terminal to wait for completion of the current commands, and returns the + * redirected standard and error output since the last call. + */ + public async waitForCompletion(): Promise<{ errorText: string, outputText: string }> { + return this.waitForCompletionCore(); + } + + private async waitForCompletionCore(options: { ignoreErrors?: boolean } = {}): Promise<{ errorText: string, outputText: string }> { + this.ensureNotDisposed(); + console.log('Waiting for terminal command completion...'); + // Output text to a semaphore file. This will execute when the terminal is no longer busy. this.sendTextRaw(`echo Done > ${this._semaphorePath}`); // Wait for the semaphore file await this.waitForFileCreation(this._semaphorePath); - assert(await fse.pathExists(this._outputFilePath), 'The output file from the command was not created.'); - let output = bufferToString(await fse.readFile(this._outputFilePath)); + assert(await fse.pathExists(this._outputFilePath), 'The output file from the command was not created. Sometimes this can mean the command to execute was not found.'); + let outputText = bufferToString(await fse.readFile(this._outputFilePath)); assert(await fse.pathExists(this._errFilePath), 'The error file from the command was not created.'); - let err = bufferToString(await fse.readFile(this._errFilePath)); + let errorText = bufferToString(await fse.readFile(this._errFilePath)); + + console.log("OUTPUT:"); + console.log(outputText ? outputText : '(NONE)'); + console.log("END OF OUTPUT"); + + if (errorText) { + if (options.ignoreErrors) { + // console.log("ERROR OUTPUT (IGNORED):"); + // console.log(errorText.replace(/\r/, "\rIGNORED: ")); + // console.log("END OF ERROR OUTPUT (IGNORED)"); + } else { + console.log("ERRORS:"); + console.log(errorText.replace(/\r/, "\rERROR: ")); + console.log("END OF ERRORS"); + } + } + + // Remove files in preparation for next commands, if any + await fse.remove(this._semaphorePath); + await fse.remove(this._outputFilePath); + await fse.remove(this._errFilePath); + + return { outputText: outputText, errorText: errorText }; + } - return { outputText: output, errorText: err }; + /** + * Executes one or more commands and waits for them to complete + */ + public async execute(commands: string | string[], options: { ignoreErrors?: boolean } = {}): Promise { + if (typeof commands === 'string') { + commands = [commands]; + } + + this.show(); + for (let command of commands) { + this.sendText(command); + } + + let results = await this.waitForCompletionCore({ ignoreErrors: options.ignoreErrors }); + + if (!options.ignoreErrors) { + assert.equal(results.errorText, '', `Encountered errors executing in terminal`); + } + + return results.outputText; } - public get name(): string { return this._terminal.name; } + public get name(): string { + this.ensureNotDisposed(); return this._terminal.name; + } - public get processId(): Thenable { return this._terminal.processId; } + public get processId(): Thenable { + this.ensureNotDisposed(); + return this._terminal.processId; + } private async waitForFileCreation(filePath: string): Promise { return new Promise((resolve, _reject) => { @@ -94,10 +163,15 @@ class TestTerminal implements vscode.Terminal { }); } + /** + * Sends text to the terminal, does not wait for completion + */ public sendText(text: string, addNewLine?: boolean): void { + this.ensureNotDisposed(); + console.log(`Executing in terminal: ${text}`); if (addNewLine !== false) { // Redirect the output and error output to files (not a perfect solution, but it works) - text += ` >${this._outputFilePath} 2>${this._errFilePath}`; + text += ` >>${this._outputFilePath} 2>>${this._errFilePath}`; } this.sendTextRaw(text, addNewLine); } @@ -107,16 +181,23 @@ class TestTerminal implements vscode.Terminal { } public show(preserveFocus?: boolean): void { + this.ensureNotDisposed(); this._terminal.show(preserveFocus); } public hide(): void { + this.ensureNotDisposed(); this._terminal.hide(); } public dispose(): void { + this._disposed = true; this._terminal.dispose(); } + + private ensureNotDisposed(): void { + assert(!this._disposed, 'Terminal has already been disposed.'); + } } function bufferToString(buffer: Buffer): string { diff --git a/dockerExtension.ts b/dockerExtension.ts index fcadd4320d..d613d2bf3f 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -34,8 +34,9 @@ import { AzureAccountWrapper } from './explorer/deploy/azureAccountWrapper'; import * as util from "./explorer/deploy/util"; import { WebAppCreator } from './explorer/deploy/webAppCreator'; import { DockerExplorerProvider } from './explorer/dockerExplorer'; -import { AzureImageNode, AzureRegistryNode, AzureRepositoryNode } from './explorer/models/azureRegistryNodes'; -import { DockerHubImageNode, DockerHubOrgNode, DockerHubRepositoryNode } from './explorer/models/dockerHubNodes'; +import { AzureImageTagNode, AzureRegistryNode, AzureRepositoryNode } from './explorer/models/azureRegistryNodes'; +import { connectCustomRegistry, disconnectCustomRegistry } from './explorer/models/customRegistries'; +import { DockerHubImageTagNode, DockerHubOrgNode, DockerHubRepositoryNode } from './explorer/models/dockerHubNodes'; import { browseAzurePortal } from './explorer/utils/azureUtils'; import { browseDockerHub, dockerHubLogout } from './explorer/utils/dockerHubUtils'; import { ext } from "./extensionVariables"; @@ -126,8 +127,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { registerCommand('vscode-docker.compose.down', composeDown); registerCommand('vscode-docker.compose.restart', composeRestart); registerCommand('vscode-docker.system.prune', systemPrune); - - registerCommand('vscode-docker.createWebApp', async (context?: AzureImageNode | DockerHubImageNode) => { + registerCommand('vscode-docker.createWebApp', async (context?: AzureImageTagNode | DockerHubImageTagNode) => { if (context) { if (azureAccount) { const azureAccountWrapper = new AzureAccountWrapper(ctx, azureAccount); @@ -147,14 +147,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } }); - registerCommand('vscode-docker.dockerHubLogout', dockerHubLogout); - registerCommand('vscode-docker.browseDockerHub', (context?: DockerHubImageNode | DockerHubRepositoryNode | DockerHubOrgNode) => { + registerCommand('vscode-docker.browseDockerHub', (context?: DockerHubImageTagNode | DockerHubRepositoryNode | DockerHubOrgNode) => { browseDockerHub(context); }); - registerCommand('vscode-docker.browseAzurePortal', (context?: AzureRegistryNode | AzureRepositoryNode | AzureImageNode) => { + registerCommand('vscode-docker.browseAzurePortal', (context?: AzureRegistryNode | AzureRepositoryNode | AzureImageTagNode) => { browseAzurePortal(context); }); + registerCommand('vscode-docker.connectCustomRegistry', connectCustomRegistry); + registerCommand('vscode-docker.disconnectCustomRegistry', disconnectCustomRegistry); ctx.subscriptions.push(vscode.debug.registerDebugConfigurationProvider('docker', new DockerDebugConfigProvider())); diff --git a/explorer/deploy/webAppCreator.ts b/explorer/deploy/webAppCreator.ts index c1511c4e48..49303a46d7 100644 --- a/explorer/deploy/webAppCreator.ts +++ b/explorer/deploy/webAppCreator.ts @@ -11,8 +11,9 @@ import WebSiteManagementClient = require('azure-arm-website'); import * as WebSiteModels from 'azure-arm-website/lib/models'; import * as vscode from 'vscode'; import { reporter } from '../../telemetry/telemetry'; -import { AzureImageNode } from '../models/azureRegistryNodes'; -import { DockerHubImageNode } from '../models/dockerHubNodes'; +import { AzureImageTagNode } from '../models/azureRegistryNodes'; +import { CustomImageTagNode } from '../models/customRegistryNodes'; +import { DockerHubImageTagNode } from '../models/dockerHubNodes'; import { AzureAccountWrapper } from './azureAccountWrapper'; import * as util from './util'; import { QuickPickItemWithData, SubscriptionStepBase, UserCancelledError, WizardBase, WizardResult, WizardStep } from './wizard'; @@ -20,7 +21,7 @@ import { QuickPickItemWithData, SubscriptionStepBase, UserCancelledError, Wizard const teleCmdId: string = 'vscode-docker.deploy.azureAppService'; export class WebAppCreator extends WizardBase { - constructor(output: vscode.OutputChannel, readonly azureAccount: AzureAccountWrapper, context: AzureImageNode | DockerHubImageNode, subscription?: SubscriptionModels.Subscription) { + constructor(output: vscode.OutputChannel, readonly azureAccount: AzureAccountWrapper, context: AzureImageTagNode | DockerHubImageTagNode, subscription?: SubscriptionModels.Subscription) { super(output); this.steps.push(new SubscriptionStep(this, azureAccount, subscription)); this.steps.push(new ResourceGroupStep(this, azureAccount)); @@ -421,16 +422,19 @@ class WebsiteStep extends WebAppCreatorStepBase { private _imageSubscription: Subscription; private _registry: Registry; - constructor(wizard: WizardBase, azureAccount: AzureAccountWrapper, context: AzureImageNode | DockerHubImageNode) { + constructor(wizard: WizardBase, azureAccount: AzureAccountWrapper, context: AzureImageTagNode | DockerHubImageTagNode | CustomImageTagNode) { super(wizard, 'Create Web App', azureAccount); this._serverUrl = context.serverUrl; - if (context instanceof DockerHubImageNode) { + if (context instanceof DockerHubImageTagNode) { this._serverPassword = context.password; this._serverUserName = context.userName; - } else if (context instanceof AzureImageNode) { + } else if (context instanceof AzureImageTagNode) { this._imageSubscription = context.subscription; this._registry = context.registry; + } else if (context instanceof CustomImageTagNode) { + this._serverPassword = context.registry.credentials.password; + this._serverUserName = context.registry.credentials.userName; } else { throw Error(`Invalid context, cannot deploy to Azure App services from ${context}`); } diff --git a/explorer/models/azureRegistryNodes.ts b/explorer/models/azureRegistryNodes.ts index 01d20b281e..ae48c95290 100644 --- a/explorer/models/azureRegistryNodes.ts +++ b/explorer/models/azureRegistryNodes.ts @@ -8,25 +8,26 @@ import { parseError } from 'vscode-azureextensionui'; import { MAX_CONCURRENT_REQUESTS } from '../../constants' import { AzureAccount, AzureSession } from '../../typings/azure-account.api'; import { AsyncPool } from '../../utils/asyncpool'; +import { getRepositories } from '../utils/dockerHubUtils'; +import { formatTag, getCatalog, getTags } from './commonRegistryUtils'; import { NodeBase } from './nodeBase'; import { RegistryType } from './registryType'; export class AzureRegistryNode extends NodeBase { - private _azureAccount: AzureAccount; - constructor( public readonly label: string, - public readonly contextValue: string, - public readonly iconPath: any = {}, - public readonly azureAccount?: AzureAccount + public readonly azureAccount: AzureAccount | undefined, + public readonly registry: ContainerModels.Registry, + public readonly subscription: SubscriptionModels.Subscription ) { super(label); - this._azureAccount = azureAccount; } - public registry: ContainerModels.Registry; - public subscription: SubscriptionModels.Subscription; - public type: RegistryType; + public readonly contextValue: string = 'azureRegistryNode'; + public readonly iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { + light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Registry_16x.svg'), + dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Registry_16x.svg') + }; public getTreeItem(): vscode.TreeItem { return { @@ -42,11 +43,11 @@ export class AzureRegistryNode extends NodeBase { let node: AzureRepositoryNode; const tenantId: string = element.subscription.tenantId; - if (!this._azureAccount) { + if (!this.azureAccount) { return []; } - const session: AzureSession = this._azureAccount.sessions.find((s, i, array) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); + const session: AzureSession = this.azureAccount.sessions.find((s, i, array) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); const { accessToken, refreshToken } = await acquireToken(session); if (accessToken && refreshToken) { @@ -83,51 +84,44 @@ export class AzureRegistryNode extends NodeBase { return []; } }); - await request.get('https://' + element.label + '/v2/_catalog', { - auth: { - bearer: accessTokenARC - } - }, (err, httpResponse, body) => { - if (body.length > 0) { - const repositories = JSON.parse(body).repositories; - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let i = 0; i < repositories.length; i++) { - node = new AzureRepositoryNode(repositories[i], "azureRepositoryNode"); - node.accessTokenARC = accessTokenARC; - node.azureAccount = element.azureAccount; - node.refreshTokenARC = refreshTokenARC; - node.registry = element.registry; - node.repository = element.label; - node.subscription = element.subscription; - repoNodes.push(node); - } - } - }); + + let repositories = await getCatalog('https://' + element.label, { bearer: accessTokenARC }); + for (let repository of repositories) { + node = new AzureRepositoryNode(repository, + this.azureAccount, + element.subscription, + accessTokenARC, + refreshTokenARC, + element.registry, + element.label); + repoNodes.push(node); + } } + //Note these are ordered by default in alphabetical order return repoNodes; } } export class AzureRepositoryNode extends NodeBase { - constructor( public readonly label: string, - public readonly contextValue: string, - public readonly iconPath: { light: string | vscode.Uri; dark: string | vscode.Uri } = { - light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Repository_16x.svg'), - dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Repository_16x.svg') - } + public readonly azureAccount: AzureAccount, + public readonly subscription: SubscriptionModels.Subscription, + public readonly accessTokenARC: string, + public readonly refreshTokenARC: string, + public readonly registry: ContainerModels.Registry, + public readonly repositoryName: string ) { super(label); } - public accessTokenARC: string; - public azureAccount: AzureAccount - public refreshTokenARC: string; - public registry: ContainerModels.Registry; - public repository: string; - public subscription: SubscriptionModels.Subscription; + public static readonly contextValue: string = 'azureRepositoryNode'; + public readonly contextValue: string = AzureRepositoryNode.contextValue; + public readonly iconPath: { light: string | vscode.Uri; dark: string | vscode.Uri } = { + light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Repository_16x.svg'), + dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Repository_16x.svg') + }; public getTreeItem(): vscode.TreeItem { return { @@ -138,121 +132,83 @@ export class AzureRepositoryNode extends NodeBase { } } - public async getChildren(element: AzureRepositoryNode): Promise { - const imageNodes: AzureImageNode[] = []; - let node: AzureImageNode; - let created: string = ''; + public async getChildren(element: AzureRepositoryNode): Promise { + const imageNodes: AzureImageTagNode[] = []; + let node: AzureImageTagNode; let refreshTokenARC; let accessTokenARC; - let tags; const tenantId: string = element.subscription.tenantId; const session: AzureSession = element.azureAccount.sessions.find((s, i, array) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); const { accessToken, refreshToken } = await acquireToken(session); - if (accessToken && refreshToken) { - await request.post('https://' + element.repository + '/oauth2/exchange', { - form: { - grant_type: 'access_token_refresh_token', - service: element.repository, - tenant: tenantId, - refresh_token: refreshToken, - access_token: accessToken - } - }, (err, httpResponse, body) => { - if (body.length > 0) { - refreshTokenARC = JSON.parse(body).refresh_token; - } else { - return []; - } - }); - - await request.post('https://' + element.repository + '/oauth2/token', { - form: { - grant_type: 'refresh_token', - service: element.repository, - scope: 'repository:' + element.label + ':pull', - refresh_token: refreshTokenARC - } - }, (err, httpResponse, body) => { - if (body.length > 0) { - accessTokenARC = JSON.parse(body).access_token; - } else { - return []; - } - }); - - await request.get('https://' + element.repository + '/v2/' + element.label + '/tags/list', { - auth: { - bearer: accessTokenARC - } - }, (err, httpResponse, body) => { - if (err) { return []; } - if (body.length > 0) { - tags = JSON.parse(body).tags; - } - }); - - const pool = new AsyncPool(MAX_CONCURRENT_REQUESTS); - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let i = 0; i < tags.length; i++) { - pool.addTask(async () => { - let data: string; - try { - data = await request.get('https://' + element.repository + '/v2/' + element.label + `/manifests/${tags[i]}`, { - auth: { - bearer: accessTokenARC - } - }); - } catch (error) { - vscode.window.showErrorMessage(parseError(error).message); - } + await request.post('https://' + element.repositoryName + '/oauth2/exchange', { + form: { + grant_type: 'access_token_refresh_token', + service: element.repositoryName, + tenant: tenantId, + refresh_token: refreshToken, + access_token: accessToken + } + }, (err, httpResponse, body) => { + if (body.length > 0) { + refreshTokenARC = JSON.parse(body).refresh_token; + } else { + return []; + } + }); - if (data) { - //Acquires each image's manifest to acquire build time. - let manifest = JSON.parse(data); - node = new AzureImageNode(`${element.label}:${tags[i]}`, 'azureImageNode'); - node.azureAccount = element.azureAccount; - node.registry = element.registry; - node.serverUrl = element.repository; - node.subscription = element.subscription; - node.created = moment(new Date(JSON.parse(manifest.history[0].v1Compatibility).created)).fromNow(); - imageNodes.push(node); - } - }); + await request.post('https://' + element.repositoryName + '/oauth2/token', { + form: { + grant_type: 'refresh_token', + service: element.repositoryName, + scope: 'repository:' + element.label + ':pull', + refresh_token: refreshTokenARC + } + }, (err, httpResponse, body) => { + if (body.length > 0) { + accessTokenARC = JSON.parse(body).access_token; + } else { + return []; } - await pool.runAll(); + }); + let tagInfos = await getTags('https://' + element.repositoryName, element.label, { bearer: accessTokenARC }); + for (let tagInfo of tagInfos) { + node = new AzureImageTagNode( + element.azureAccount, + element.subscription, + element.registry, + element.registry.loginServer, + element.label, + tagInfo.tag, + tagInfo.created); + imageNodes.push(node); } - function sortFunction(a: AzureImageNode, b: AzureImageNode): number { - return a.created.localeCompare(b.created); - } - imageNodes.sort(sortFunction); + return imageNodes; } } -export class AzureImageNode extends NodeBase { +export class AzureImageTagNode extends NodeBase { constructor( - public readonly label: string, - public readonly contextValue: string + public readonly azureAccount: AzureAccount, + public readonly subscription: SubscriptionModels.Subscription, + public readonly registry: ContainerModels.Registry, + public readonly serverUrl: string, + public readonly repositoryName: string, + public readonly tag: string, + public readonly created: Date, ) { - super(label); + super(`${repositoryName}:${tag}`); } - public azureAccount: AzureAccount - public created: string; - public registry: ContainerModels.Registry; - public serverUrl: string; - public subscription: SubscriptionModels.Subscription; + public static readonly contextValue: string = 'azureImageTagNode'; + public readonly contextValue: string = AzureImageTagNode.contextValue; public getTreeItem(): vscode.TreeItem { - let displayName: string = this.label; - - displayName = `${displayName} (${this.created})`; - return { - label: `${displayName}`, + label: formatTag(this.label, this.created), collapsibleState: vscode.TreeItemCollapsibleState.None, contextValue: this.contextValue } diff --git a/explorer/models/commonRegistryUtils.ts b/explorer/models/commonRegistryUtils.ts new file mode 100644 index 0000000000..8875add315 --- /dev/null +++ b/explorer/models/commonRegistryUtils.ts @@ -0,0 +1,110 @@ +import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; +import { SubscriptionModels } from 'azure-arm-resource'; +import * as moment from 'moment'; +import * as path from 'path'; +import * as request from 'request-promise'; +import * as vscode from 'vscode'; +import { parseError } from 'vscode-azureextensionui'; +import { MAX_CONCURRENT_REQUESTS } from '../../constants' +import { AzureAccount, AzureSession } from '../../typings/azure-account.api'; +import { AsyncPool } from '../../utils/asyncpool'; +import { Manifest, ManifestHistory, ManifestHistoryV1Compatibility, Repository } from '../utils/dockerHubUtils'; +import { NodeBase } from './nodeBase'; +import { RegistryType } from './registryType'; + +interface RegistryNonsensitiveInfo { + url: string, +} + +export interface RegistryCredentials { + bearer?: string; + userName?: string; + password?: string; +} + +export interface RegistryInfo extends RegistryNonsensitiveInfo { + credentials: RegistryCredentials; +} + +export interface TagInfo { + repositoryName: string; + tag: string; + created: Date; +} + +export async function registryRequest( + registryUrl: string, + relativeUrl: string, + credentials: RegistryCredentials +): Promise { + let httpSettings = vscode.workspace.getConfiguration('http'); + let strictSSL = httpSettings.get('proxyStrictSSL', true); + + let response = await request.get( + `${registryUrl}/${relativeUrl}`, + { + json: true, + resolveWithFullResponse: false, + strictSSL: strictSSL, + auth: { + bearer: credentials.bearer, + user: credentials.userName, + pass: credentials.password + } + }); + return response; +} + +export async function getCatalog(registryUrl: string, credentials?: RegistryCredentials): Promise { + // Note: Note that the contents of the response are specific to the registry implementation. Some registries may opt to provide a full + // catalog output, limit it based on the user’s access level or omit upstream results, if providing mirroring functionality. + // (https://docs.docker.com/registry/spec/api/#listing-repositories) + // Azure and private registries just return the repository names + let response = await registryRequest<{ repositories: string[] }>(registryUrl, 'v2/_catalog', credentials); + return response.repositories; +} + +export async function getTags(registryUrl: string, repositoryName: string, credentials?: RegistryCredentials): Promise { + let result = await registryRequest<{ tags: string[] }>(registryUrl, `v2/${repositoryName}/tags/list?page_size=100&page=1`, credentials); + let tags = result.tags; + let tagInfos: TagInfo[] = []; + + //Acquires each image's manifest (in parallel) to acquire build time + const pool = new AsyncPool(MAX_CONCURRENT_REQUESTS); + for (let tag of tags) { + pool.addTask(async (): Promise => { + try { + let manifest: Manifest = await registryRequest(registryUrl, `v2/${repositoryName}/manifests/${tag}`, credentials); + let history: ManifestHistoryV1Compatibility = JSON.parse(manifest.history[0].v1Compatibility); + let created = new Date(history.created); + let info = { + tag: tag, + created + }; + tagInfos.push(info); + } catch (error) { + vscode.window.showErrorMessage(parseError(error).message); + } + }); + } + + await pool.runAll(); + + tagInfos.sort(compareTagsReverse); + return tagInfos; +} + +function compareTagsReverse(a: TagInfo, b: TagInfo): number { + if (a.created < b.created) { + return 1; + } else if (a.created > b.created) { + return -1; + } else { + return 0; + } +} + +export function formatTag(tag: string, created: Date): string { + let displayName = `${tag} (${moment(created).fromNow()})`; + return displayName; +} diff --git a/explorer/models/customRegistries.ts b/explorer/models/customRegistries.ts new file mode 100644 index 0000000000..1af0e1f222 --- /dev/null +++ b/explorer/models/customRegistries.ts @@ -0,0 +1,140 @@ +import * as Keytar from 'keytar'; +import * as path from 'path'; +import * as request from 'request-promise'; +import * as vscode from 'vscode'; +import { DialogResponses } from 'vscode-azureextensionui'; + +import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; +import { ResourceManagementClient, SubscriptionClient, SubscriptionModels } from 'azure-arm-resource'; +import { CredentialDetails } from 'crypto'; +import { callWithTelemetryAndErrorHandling, IActionContext, IAzureNode, parseError } from 'vscode-azureextensionui'; +import { keytarConstants, MAX_CONCURRENT_REQUESTS } from '../../constants' +import { ext } from '../../extensionVariables'; +import { AsyncPool } from '../../utils/asyncpool'; +import { getCoreNodeModule, getKeytarModule } from '../utils/utils'; +import { CustomRegistryNode } from './customRegistryNodes'; +import { NodeBase } from './nodeBase'; +import { RegistryType } from './registryType'; + +const keytar: typeof Keytar = getCoreNodeModule('keytar'); + +interface CustomRegistryNonsensitive { + url: string, +} + +export interface CustomRegistryCredentials { + userName: string; + password: string; +} + +export interface CustomRegistry extends CustomRegistryNonsensitive { + credentials: CustomRegistryCredentials; +} + +const customRegistriesKey = 'customRegistries'; + +export async function connectCustomRegistry(): Promise { + let registries = await getCustomRegistries(); + + // tslint:disable-next-line:no-constant-condition + let url = await ext.ui.showInputBox({ + ignoreFocusOut: true, + prompt: "Enter the URL for the registry", + placeHolder: 'Example: http://localhost:5000', + validateInput: (value: string): string | undefined => { + let uri = vscode.Uri.parse(value); + if (!uri.scheme || !uri.authority || !uri.path) { + return "Please enter a valid URL"; + } + + if (registries.find(reg => reg.url.toLowerCase() === value.toLowerCase())) { + return `There is already an entry for a container registry at ${value}`; + } + + return undefined; + } + }); + let userName = await ext.ui.showInputBox({ + prompt: "Enter the username for connecting, or ENTER for none" + }); + let password: string; + if (userName) { + password = await ext.ui.showInputBox({ + prompt: "Enter the password" + }); + } + + let newRegistry: CustomRegistry = { + url, + credentials: { userName, password } + }; + + let invalidMessage = await CustomRegistryNode.isValidRegistryUrl(newRegistry); + if (invalidMessage) { + throw new Error(invalidMessage); + } + + // Save + let sensitive: string = JSON.stringify(newRegistry.credentials); + let key = getUsernamePwdKey(newRegistry.url); + await keytar.setPassword(keytarConstants.serviceId, key, sensitive); + registries.push(newRegistry); + await saveCustomRegistriesNonsensitive(registries); + + await refresh(); +} + +export async function disconnectCustomRegistry(node: CustomRegistryNode): Promise { + let registries = await getCustomRegistries(); + let registry = registries.find(reg => reg.url.toLowerCase() === node.registry.url.toLowerCase()); + + if (registry) { + let response = await ext.ui.showWarningMessage(`Disconnect from container registry at "${registry.url}"?`, DialogResponses.yes, DialogResponses.no); + if (response === DialogResponses.yes) { + let key = getUsernamePwdKey(node.registry.url); + await keytar.deletePassword(keytarConstants.serviceId, key); + registries.splice(registries.indexOf(registry), 1); + await saveCustomRegistriesNonsensitive(registries); + await refresh(); + } + } +} + +function getUsernamePwdKey(registryUrl: string): string { + return `usernamepwd_${registryUrl}`; +} + +export async function getCustomRegistries(): Promise { + let nonsensitive = ext.context.workspaceState.get(customRegistriesKey) || []; + let registries: CustomRegistry[] = []; + + for (let reg of nonsensitive) { + await callWithTelemetryAndErrorHandling('getCustomRegistryUsernamePwd', async function (this: IActionContext): Promise { + this.suppressTelemetry = true; + + try { + let key = getUsernamePwdKey(reg.url); + let credentialsString = await keytar.getPassword(keytarConstants.serviceId, key); + let credentials: CustomRegistryCredentials = JSON.parse(credentialsString); + registries.push({ + url: reg.url, + credentials + }); + registries.push() + } catch (error) { + throw new Error(`Unable to retrieve password for container registry ${reg.url}: ${parseError(error).message}`); + } + }); + } + + return registries; +} + +async function refresh(): Promise { + await vscode.commands.executeCommand('vscode-docker.explorer.refresh'); +} + +async function saveCustomRegistriesNonsensitive(registries: CustomRegistry[]): Promise { + let minimal: CustomRegistryNonsensitive[] = registries.map(reg => { url: reg.url }); + await ext.context.workspaceState.update(customRegistriesKey, minimal); +} diff --git a/explorer/models/customRegistryNodes.ts b/explorer/models/customRegistryNodes.ts new file mode 100644 index 0000000000..6934cebe4d --- /dev/null +++ b/explorer/models/customRegistryNodes.ts @@ -0,0 +1,145 @@ +import * as moment from 'moment'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { parseError } from 'vscode-azureextensionui'; +import { formatTag, getCatalog, getTags, registryRequest } from './commonRegistryUtils'; +import { CustomRegistry } from './customRegistries'; +import { NodeBase } from './nodeBase'; +import { RegistryType } from './registryType'; + +export class CustomRegistryNode extends NodeBase { + public type: RegistryType = RegistryType.Custom; + public static readonly contextValue: string = 'customRegistry'; + public contextValue: string = CustomRegistryNode.contextValue; + public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { + light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Registry_16x.svg'), + dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Registry_16x.svg') + }; + + constructor( + public registryName: string, + public registry: CustomRegistry + ) { + super(registryName); + } + + public getTreeItem(): vscode.TreeItem { + return { + label: this.registryName, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: this.contextValue, + iconPath: this.iconPath + } + } + + // Returns undefined if it's valid, otherwise returns an error message + public static async isValidRegistryUrl(registry: CustomRegistry): Promise { + try { + let response = await registryRequest<{}>(registry.url, 'v2', registry.credentials); + + // If the call succeeded, it's a V2 registry + return undefined; + } catch (error) { + return parseError(error).message; + } + } + + public async getChildren(element: CustomRegistryNode): Promise { + const repoNodes: CustomRepositoryNode[] = []; + try { + let repositories = await getCatalog(this.registry.url, this.registry.credentials); + for (let repoName of repositories) { + repoNodes.push(new CustomRepositoryNode(repoName, this.registry)); + } + } catch (error) { + vscode.window.showErrorMessage(parseError(error).message); + } + + return repoNodes; + } +} + +export class CustomRepositoryNode extends NodeBase { + public static readonly contextValue: string = 'customRepository'; + public contextValue: string = CustomRepositoryNode.contextValue; + public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { + light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Repository_16x.svg'), + dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Repository_16x.svg') + }; + + constructor( + public readonly repositoryName: string, // e.g. 'hello-world' or 'registry' + public readonly registry: CustomRegistry + ) { + super(repositoryName); + } + + public getTreeItem(): vscode.TreeItem { + return { + label: this.label, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: this.contextValue, + iconPath: this.iconPath + } + } + + public async getChildren(element: CustomRepositoryNode): Promise { + const imageNodes: CustomImageTagNode[] = []; + let node: CustomImageTagNode; + + try { + let tagInfos = await getTags(this.registry.url, this.repositoryName, this.registry.credentials); + for (let tagInfo of tagInfos) { + node = new CustomImageTagNode(this.registry, this.repositoryName, tagInfo.tag, tagInfo.created); + imageNodes.push(node); + } + + return imageNodes; + } catch (error) { + let message = `Docker: Unable to retrieve Repository Tags: ${parseError(error).message}`; + console.error(message); + vscode.window.showErrorMessage(message); + } + + return imageNodes; + } +} + +export class CustomImageTagNode extends NodeBase { + public static contextValue: string = 'customImageTag'; + public contextValue: string = CustomImageTagNode.contextValue; + + constructor( + public readonly registry: CustomRegistry, + public readonly repositoryName: string, + public readonly tag: string, + public readonly created: Date + ) { + super(`${repositoryName}:${tag}`); + } + + public get serverUrl(): string { + return this.registry.url; + } + + public getTreeItem(): vscode.TreeItem { + return { + label: formatTag(this.label, this.created), + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextValue: this.contextValue + } + } +} + +export class CustomLoadingNode extends NodeBase { + constructor() { + super('Loading...'); + } + + public getTreeItem(): vscode.TreeItem { + return { + label: this.label, + collapsibleState: vscode.TreeItemCollapsibleState.None + } + } +} diff --git a/explorer/models/dockerHubNodes.ts b/explorer/models/dockerHubNodes.ts index 832951f2b7..0d9a9e15c6 100644 --- a/explorer/models/dockerHubNodes.ts +++ b/explorer/models/dockerHubNodes.ts @@ -4,18 +4,23 @@ import * as vscode from 'vscode'; import { MAX_CONCURRENT_REQUESTS } from '../../constants' import { AsyncPool } from '../../utils/asyncpool'; import * as dockerHub from '../utils/dockerHubUtils'; +import { formatTag } from './commonRegistryUtils'; import { NodeBase } from './nodeBase'; export class DockerHubOrgNode extends NodeBase { constructor( - public readonly label: string, - public readonly contextValue: string, - public readonly iconPath: any = {} + public readonly label: string ) { super(label); } + public readonly contextValue: string = 'dockerHubNamespace'; + public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { + light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Registry_16x.svg'), + dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Registry_16x.svg') + }; + public repository: string; public userName: string; public password: string; @@ -41,11 +46,7 @@ export class DockerHubOrgNode extends NodeBase { for (let i = 0; i < myRepos.length; i++) { repoPool.addTask(async () => { let myRepo: dockerHub.RepositoryInfo = await dockerHub.getRepositoryInfo(myRepos[i]); - let iconPath = { - light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Repository_16x.svg'), - dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Repository_16x.svg') - }; - node = new DockerHubRepositoryNode(myRepo.name, 'dockerHubRepository', iconPath); + node = new DockerHubRepositoryNode(myRepo.name); node.repository = myRepo; node.userName = element.userName; node.password = element.password; @@ -60,13 +61,16 @@ export class DockerHubOrgNode extends NodeBase { export class DockerHubRepositoryNode extends NodeBase { constructor( - public readonly label: string, - public readonly contextValue: string, - public readonly iconPath: any = {} + public readonly label: string ) { super(label); } + public readonly contextValue: string = 'dockerHubRepository'; + public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { + light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Repository_16x.svg'), + dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Repository_16x.svg') + }; public repository: any; public userName: string; public password: string; @@ -80,48 +84,44 @@ export class DockerHubRepositoryNode extends NodeBase { } } - public async getChildren(element: DockerHubRepositoryNode): Promise { - const imageNodes: DockerHubImageNode[] = []; - let node: DockerHubImageNode; + public async getChildren(element: DockerHubRepositoryNode): Promise { + const imageNodes: DockerHubImageTagNode[] = []; + let node: DockerHubImageTagNode; const myTags: dockerHub.Tag[] = await dockerHub.getRepositoryTags({ namespace: element.repository.namespace, name: element.repository.name }); - // tslint:disable-next-line:prefer-for-of // Grandfathered in - for (let i = 0; i < myTags.length; i++) { - node = new DockerHubImageNode(`${element.repository.name}:${myTags[i].name}`, 'dockerHubImageTag'); + for (let tag of myTags) { + node = new DockerHubImageTagNode(element.repository.name, tag.name); node.password = element.password; node.userName = element.userName; node.repository = element.repository; - node.created = moment(new Date(myTags[i].last_updated)).fromNow(); + node.created = new Date(tag.last_updated); imageNodes.push(node); } return imageNodes; - } } -export class DockerHubImageNode extends NodeBase { +export class DockerHubImageTagNode extends NodeBase { constructor( - public readonly label: string, - public readonly contextValue: string + public readonly repositoryName: string, + public readonly tag: string ) { - super(label); + super(`${repositoryName}:${tag}`); } + public readonly contextValue: string = 'dockerHubImageTag'; + // this needs to be empty string for Docker Hub public serverUrl: string = ''; public userName: string; public password: string; public repository: any; - public created: string; + public created: Date; public getTreeItem(): vscode.TreeItem { - let displayName: string = this.label; - - displayName = `${displayName} (${this.created})`; - return { - label: `${displayName}`, + label: formatTag(this.label, this.created), collapsibleState: vscode.TreeItemCollapsibleState.None, contextValue: this.contextValue } diff --git a/explorer/models/registryRootNode.ts b/explorer/models/registryRootNode.ts index 31a6b08e20..1ebb5a7a56 100644 --- a/explorer/models/registryRootNode.ts +++ b/explorer/models/registryRootNode.ts @@ -1,16 +1,18 @@ +import * as assert from 'assert'; import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; import { SubscriptionModels } from 'azure-arm-resource'; import * as keytarType from 'keytar'; import { ServiceClientCredentials } from 'ms-rest'; -import * as path from 'path'; import * as vscode from 'vscode'; import { parseError } from 'vscode-azureextensionui'; import { keytarConstants, MAX_CONCURRENT_REQUESTS, MAX_CONCURRENT_SUBSCRIPTON_REQUESTS } from '../../constants'; import { AzureAccount } from '../../typings/azure-account.api'; import { AsyncPool } from '../../utils/asyncpool'; import * as dockerHub from '../utils/dockerHubUtils' -import { getCoreNodeModule } from '../utils/utils'; +import { getKeytarModule } from '../utils/utils'; import { AzureLoadingNode, AzureNotSignedInNode, AzureRegistryNode } from './azureRegistryNodes'; +import { getCustomRegistries } from './customRegistries'; +import { CustomRegistryNode } from './customRegistryNodes'; import { DockerHubOrgNode } from './dockerHubNodes'; import { NodeBase } from './nodeBase'; import { RegistryType } from './registryType'; @@ -24,12 +26,12 @@ export class RegistryRootNode extends NodeBase { constructor( public readonly label: string, - public readonly contextValue: string, + public readonly contextValue: 'dockerHubRootNode' | 'azureRegistryRootNode' | 'customRootNode', public readonly eventEmitter: vscode.EventEmitter, public readonly azureAccount?: AzureAccount ) { super(label); - this._keytar = getCoreNodeModule('keytar'); + this._keytar = getKeytarModule(); this._azureAccount = azureAccount; @@ -61,7 +63,8 @@ export class RegistryRootNode extends NodeBase { } else if (element.contextValue === 'dockerHubRootNode') { return this.getDockerHubOrgs(); } else { - return []; + assert(element.contextValue === 'customRootNode'); + return await this.getCustomRegistryNodes(); } } @@ -96,11 +99,7 @@ export class RegistryRootNode extends NodeBase { const myRepos: dockerHub.Repository[] = await dockerHub.getRepositories(user.username); const namespaces = [...new Set(myRepos.map(item => item.namespace))]; namespaces.forEach((namespace) => { - let iconPath = { - light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Registry_16x.svg'), - dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Registry_16x.svg') - }; - let node = new DockerHubOrgNode(`${namespace}`, 'dockerHubNamespace', iconPath); + let node = new DockerHubOrgNode(`${namespace}`); node.userName = id.username; node.password = id.password; node.token = id.token; @@ -110,6 +109,16 @@ export class RegistryRootNode extends NodeBase { return orgNodes; } + private async getCustomRegistryNodes(): Promise { + let registries = await getCustomRegistries(); + let nodes: CustomRegistryNode[] = []; + for (let registry of registries) { + nodes.push(new CustomRegistryNode(vscode.Uri.parse(registry.url).authority, registry)); + } + + return nodes; + } + private async getAzureRegistries(): Promise { if (!this._azureAccount) { @@ -161,14 +170,11 @@ export class RegistryRootNode extends NodeBase { for (let j = 0; j < registries.length; j++) { if (!registries[j].sku.tier.includes('Classic')) { regPool.addTask(async () => { - let iconPath = { - light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Registry_16x.svg'), - dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Registry_16x.svg') - }; - let node = new AzureRegistryNode(registries[j].loginServer, 'azureRegistryNode', iconPath, this._azureAccount); - node.type = RegistryType.Azure; - node.subscription = subscription; - node.registry = registries[j]; + let node = new AzureRegistryNode( + registries[j].loginServer, + this._azureAccount, + registries[j], + subscription); azureRegistryNodes.push(node); }); } diff --git a/explorer/models/registryType.ts b/explorer/models/registryType.ts index 81cad479dd..5730deeecb 100644 --- a/explorer/models/registryType.ts +++ b/explorer/models/registryType.ts @@ -2,5 +2,6 @@ export enum RegistryType { DockerHub, Azure, + Custom, Unknown } diff --git a/explorer/models/rootNode.ts b/explorer/models/rootNode.ts index a14001f48f..b612281dab 100644 --- a/explorer/models/rootNode.ts +++ b/explorer/models/rootNode.ts @@ -31,7 +31,7 @@ export class RootNode extends NodeBase { constructor( public readonly label: string, - public readonly contextValue: string, + public readonly contextValue: 'imagesRootNode' | 'containersRootNode' | 'registriesRootNode', public eventEmitter: vscode.EventEmitter, public azureAccount?: AzureAccount ) { @@ -270,6 +270,8 @@ export class RootNode extends NodeBase { registryRootNodes.push(new RegistryRootNode('Azure', "azureRegistryRootNode", this.eventEmitter, this._azureAccount)); } + registryRootNodes.push(new RegistryRootNode('Private registries', 'customRootNode', null)); + return registryRootNodes; } } diff --git a/explorer/utils/azureUtils.ts b/explorer/utils/azureUtils.ts index c31c1a9aa1..d428f7d5e9 100644 --- a/explorer/utils/azureUtils.ts +++ b/explorer/utils/azureUtils.ts @@ -1,14 +1,14 @@ import * as opn from 'opn'; import { AzureSession } from '../../typings/azure-account.api'; -import { AzureImageNode, AzureRegistryNode, AzureRepositoryNode } from '../models/azureRegistryNodes'; +import { AzureImageTagNode, AzureRegistryNode, AzureRepositoryNode } from '../models/azureRegistryNodes'; -export function browseAzurePortal(context?: AzureRegistryNode | AzureRepositoryNode | AzureImageNode): void { +export function browseAzurePortal(context?: AzureRegistryNode | AzureRepositoryNode | AzureImageTagNode): void { if (context) { const tenantId: string = context.subscription.tenantId; const session: AzureSession = context.azureAccount.sessions.find((s, i, array) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); let url: string = `${session.environment.portalUrl}/${tenantId}/#resource${context.registry.id}`; - if (context.contextValue === 'azureImageNode' || context.contextValue === 'azureRepositoryNode') { + if (context.contextValue === AzureImageTagNode.contextValue || context.contextValue === AzureRepositoryNode.contextValue) { url = `${url}/repository`; } opn(url); diff --git a/explorer/utils/dockerHubUtils.ts b/explorer/utils/dockerHubUtils.ts index 1d7401a488..8dc35a3994 100644 --- a/explorer/utils/dockerHubUtils.ts +++ b/explorer/utils/dockerHubUtils.ts @@ -3,7 +3,7 @@ import * as opn from 'opn'; import request = require('request-promise'); import * as vscode from 'vscode'; import { keytarConstants } from '../../constants'; -import { DockerHubImageNode, DockerHubOrgNode, DockerHubRepositoryNode } from '../models/dockerHubNodes'; +import { DockerHubImageTagNode, DockerHubOrgNode, DockerHubRepositoryNode } from '../models/dockerHubNodes'; import { getCoreNodeModule } from './utils'; let _token: Token; @@ -79,6 +79,27 @@ export interface Image { variant: any } +export interface ManifestFsLayer { + blobSum: string; +} + +export interface ManifestHistory { + v1Compatibility: string; // stringified ManifestHistoryV1Compatibility +} + +export interface ManifestHistoryV1Compatibility { + created: string; +} + +export interface Manifest { + name: string; + tag: string; + architecture: string; + fsLayers: ManifestFsLayer[]; + history: ManifestHistory[]; + schemaVersion: number; +} + export async function dockerHubLogout(): Promise { const keytar: typeof keytarType = getCoreNodeModule('keytar'); @@ -104,7 +125,6 @@ export async function dockerHubLogin(): Promise<{ username: string, password: st } return; - } export function setDockerHubToken(token: string): void { @@ -132,7 +152,6 @@ async function login(username: string, password: string): Promise { } return t; - } export async function getUser(): Promise { @@ -157,7 +176,6 @@ export async function getUser(): Promise { } return u; - } export async function getRepositories(username: string): Promise { @@ -225,10 +243,9 @@ export async function getRepositoryTags(repository: Repository): Promise } return tagsPage.results; - } -export function browseDockerHub(context?: DockerHubImageNode | DockerHubRepositoryNode | DockerHubOrgNode): void { +export function browseDockerHub(context?: DockerHubImageTagNode | DockerHubRepositoryNode | DockerHubOrgNode): void { if (context) { let url: string = 'https://hub.docker.com/'; diff --git a/explorer/utils/utils.ts b/explorer/utils/utils.ts index 04af531888..ace0e92698 100644 --- a/explorer/utils/utils.ts +++ b/explorer/utils/utils.ts @@ -1,3 +1,4 @@ +import * as Keytar from 'keytar'; import * as vscode from 'vscode'; export function trimWithElipsis(str: string, max: number = 10): string { @@ -15,7 +16,7 @@ export function trimWithElipsis(str: string, max: number = 10): string { } /** - * Returns a node module installed with VSCode, or null if it fails. + * Returns a node module installed with VSCode, or undefined if it fails. */ export function getCoreNodeModule(moduleName: string): any { try { @@ -28,5 +29,18 @@ export function getCoreNodeModule(moduleName: string): any { return require(`${vscode.env.appRoot}/node_modules/${moduleName}`); } catch (err) { } - return null; + return undefined; +} + +/** + * Returns the keytar module installed with vscode + */ +export function getKeytarModule(): typeof Keytar { + const keytar: typeof Keytar | undefined = getCoreNodeModule('keytar'); + + if (!keytar) { + throw new Error("Internal error: Could not find keytar module for reading and writing passwords"); + } + + return keytar; } diff --git a/package.json b/package.json index d14e45e0d6..5c23c9f68a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "onCommand:vscode-docker.browseDockerHub", "onCommand:vscode-docker.browseAzurePortal", "onCommand:vscode-docker.explorer.refresh", + "onCommand:vscode-docker.connectCustomRegistry", + "onCommand:vscode-docker.disconnectCustomRegistry", "onView:dockerExplorer", "onDebugInitialConfigurations" ], @@ -185,7 +187,7 @@ }, { "command": "vscode-docker.createWebApp", - "when": "view == dockerExplorer && viewItem =~ /^(azureImageNode|dockerHubImageTag)$/" + "when": "view == dockerExplorer && viewItem =~ /^(azureImageTagNode|dockerHubImageTag|customImageTag)$/" }, { "command": "vscode-docker.dockerHubLogout", @@ -197,7 +199,15 @@ }, { "command": "vscode-docker.browseAzurePortal", - "when": "view == dockerExplorer && viewItem =~ /^(azureRegistryNode|azureRepositoryNode|azureImageNode)$/" + "when": "view == dockerExplorer && viewItem =~ /^(azureRegistryNode|azureRepositoryNode|azureImageTagNode)$/" + }, + { + "command": "vscode-docker.connectCustomRegistry", + "when": "view == dockerExplorer && viewItem =~ /^(customRootNode|registriesRootNode)$/" + }, + { + "command": "vscode-docker.disconnectCustomRegistry", + "when": "view == dockerExplorer && viewItem =~ /^(customRegistry)$/" } ] }, @@ -534,6 +544,16 @@ "command": "vscode-docker.browseAzurePortal", "title": "Browse in the Azure Portal", "category": "Docker" + }, + { + "command": "vscode-docker.connectCustomRegistry", + "title": "Connect to a private registry...", + "category": "Docker" + }, + { + "command": "vscode-docker.disconnectCustomRegistry", + "title": "Disconnect from private registry", + "category": "Docker" } ], "views": { diff --git a/test/buildAndRun.test.ts b/test/buildAndRun.test.ts index 2a16e83d0f..f627609139 100644 --- a/test/buildAndRun.test.ts +++ b/test/buildAndRun.test.ts @@ -92,14 +92,6 @@ suite("Build Image", function (this: Suite): void { let { outputText, errorText } = await testTerminalProvider.currentTerminal.exit(); - console.log("=== OUTPUT BEGIN ================================"); - console.log(outputText ? outputText : '(NONE)'); - console.log("=== OUTPUT END =================================="); - - console.log("=== ERROR OUTPUT BEGIN ================================"); - console.log(errorText ? errorText : '(NONE)'); - console.log("=== ERROR OUTPUT END =================================="); - assert.equal(errorText, '', 'Expected no errors from Build Image'); assertEx.assertContains(outputText, 'Successfully built'); assertEx.assertContains(outputText, 'Successfully tagged') @@ -124,9 +116,11 @@ suite("Build Image", function (this: Suite): void { ['3001'], ['testoutput:latest'] ); + + // CONSIDER: Run the built image }); - // NEEDED TESTS: + // CONSIDER TESTS: // 'Java' // '.NET Core Console' // 'ASP.NET Core' diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts new file mode 100644 index 0000000000..f8f8c39129 --- /dev/null +++ b/test/customRegistries.test.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// The module 'assert' provides assertion methods from node +import * as assert from 'assert'; +import * as assertEx from './assertEx'; +import { commands, OutputChannel, window } from 'vscode'; +import { ext } from '../extensionVariables'; +import { Suite, Test, Context } from 'mocha'; +import { testInEmptyFolder } from './global.test'; +import { TestTerminalProvider } from '../commands/utils/TerminalProvider'; +import { TestUserInput } from 'vscode-azureextensionui'; +import { CustomRegistryNode } from '../explorer/models/customRegistryNodes'; + +const registryContainerName = 'test-registry'; + +suite("Custom registries", async function (this: Suite): Promise { + this.timeout(Math.max(60 * 1000, this.timeout())); + + const outputChannel: OutputChannel = window.createOutputChannel('Docker extension tests'); + ext.outputChannel = outputChannel; + + let testTerminalProvider = new TestTerminalProvider(); + ext.terminalProvider = testTerminalProvider; + let registryTerminal = await testTerminalProvider.createTerminal('custom registry'); + + async function stopRegistry(): Promise { + await registryTerminal.execute( + [ + `docker stop ${registryContainerName}`, + `docker rm ${registryContainerName}`, + ], + { + ignoreErrors: true + } + ); + } + + suite("localhost", async function (this: Suite): Promise { + this.timeout(Math.max(60 * 1000, this.timeout())); + + suiteSetup(async function (this: Context): Promise { + await stopRegistry(); + await registryTerminal.execute(`docker run -d --rm --name ${registryContainerName} -p 5100:5000 registry`); + + // Make sure it's running + await registryTerminal.execute(`curl http://localhost:5100/v2 --silent --show-error`); + }); + + suiteTeardown(async function (this: Context): Promise { + await stopRegistry(); + }); + + test("Connect, no credentials needed", async function (this: Context) { + let input = new TestUserInput([ + 'http://localhost:5100', + 'fake username', // TODO: TestUserInput doesn't currently allow '' as an input + 'fake password' + ]); + ext.ui = input; + await commands.executeCommand('vscode-docker.connectCustomRegistry'); + + // TODO: Verify the node is there (have to start using common tree provider first) + }); + + test("Connect with credentials"); + test("Publish to Azure app service with credentials"); + test("Disconnect"); + }); +}); diff --git a/test/global.test.ts b/test/global.test.ts index 8572e6e563..7c9de8c93c 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import * as fse from "fs-extra"; import mocha = require("mocha"); -import { pathExists } from 'fs-extra'; +import * as assert from 'assert'; export namespace constants { export const testOutputName = 'testOutput'; @@ -39,6 +39,15 @@ export function getTestRootFolder(): string { return testRootFolder; } +export function testInEmptyFolder(name: string, func: () => Promise): void { + test(name, async () => { + // Delete everything in the root testing folder + assert(path.basename(testRootFolder) === constants.testOutputName, "Trying to delete wrong folder");; + await fse.emptyDir(testRootFolder); + await func(); + }); +} + // Runs before all tests suiteSetup(function (this: mocha.IHookCallbackContext): void { }); diff --git a/tsconfig.json b/tsconfig.json index 225be310bd..bbb7cc9f4f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "commonjs", - "target": "es6", + "target": "es2018", "outDir": "out", "lib": [ "es7" @@ -13,4 +13,4 @@ "node_modules", ".vscode-test" ] -} \ No newline at end of file +} From dcbf570ff687827bdd93c84537b162f23c086207 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 11:12:47 -0700 Subject: [PATCH 02/16] Password flag --- explorer/models/customRegistries.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/explorer/models/customRegistries.ts b/explorer/models/customRegistries.ts index 1af0e1f222..8d8d447d61 100644 --- a/explorer/models/customRegistries.ts +++ b/explorer/models/customRegistries.ts @@ -38,7 +38,6 @@ export async function connectCustomRegistry(): Promise { // tslint:disable-next-line:no-constant-condition let url = await ext.ui.showInputBox({ - ignoreFocusOut: true, prompt: "Enter the URL for the registry", placeHolder: 'Example: http://localhost:5000', validateInput: (value: string): string | undefined => { @@ -60,7 +59,8 @@ export async function connectCustomRegistry(): Promise { let password: string; if (userName) { password = await ext.ui.showInputBox({ - prompt: "Enter the password" + prompt: "Enter the password", + password: true }); } From 682fb8c2a4831ef6b235edcadd95b02af7500273 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 11:16:32 -0700 Subject: [PATCH 03/16] headers: --- explorer/models/customRegistries.ts | 13 +++++-------- explorer/models/customRegistryNodes.ts | 6 +++++- test/customRegistries.test.ts | 3 --- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/explorer/models/customRegistries.ts b/explorer/models/customRegistries.ts index 8d8d447d61..6c4da18b68 100644 --- a/explorer/models/customRegistries.ts +++ b/explorer/models/customRegistries.ts @@ -1,20 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as Keytar from 'keytar'; -import * as path from 'path'; -import * as request from 'request-promise'; import * as vscode from 'vscode'; import { DialogResponses } from 'vscode-azureextensionui'; -import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; -import { ResourceManagementClient, SubscriptionClient, SubscriptionModels } from 'azure-arm-resource'; -import { CredentialDetails } from 'crypto'; import { callWithTelemetryAndErrorHandling, IActionContext, IAzureNode, parseError } from 'vscode-azureextensionui'; import { keytarConstants, MAX_CONCURRENT_REQUESTS } from '../../constants' import { ext } from '../../extensionVariables'; -import { AsyncPool } from '../../utils/asyncpool'; import { getCoreNodeModule, getKeytarModule } from '../utils/utils'; import { CustomRegistryNode } from './customRegistryNodes'; -import { NodeBase } from './nodeBase'; -import { RegistryType } from './registryType'; const keytar: typeof Keytar = getCoreNodeModule('keytar'); diff --git a/explorer/models/customRegistryNodes.ts b/explorer/models/customRegistryNodes.ts index 6934cebe4d..187164e35d 100644 --- a/explorer/models/customRegistryNodes.ts +++ b/explorer/models/customRegistryNodes.ts @@ -1,4 +1,8 @@ -import * as moment from 'moment'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as path from 'path'; import * as vscode from 'vscode'; import { parseError } from 'vscode-azureextensionui'; diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index f8f8c39129..9ca6c19d86 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -3,16 +3,13 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// The module 'assert' provides assertion methods from node import * as assert from 'assert'; import * as assertEx from './assertEx'; import { commands, OutputChannel, window } from 'vscode'; import { ext } from '../extensionVariables'; import { Suite, Test, Context } from 'mocha'; -import { testInEmptyFolder } from './global.test'; import { TestTerminalProvider } from '../commands/utils/TerminalProvider'; import { TestUserInput } from 'vscode-azureextensionui'; -import { CustomRegistryNode } from '../explorer/models/customRegistryNodes'; const registryContainerName = 'test-registry'; From 3b3b1886a8f35fcd37c0fb55c60fe3d47cd35905 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 11:37:06 -0700 Subject: [PATCH 04/16] Fix test --- test/customRegistries.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 9ca6c19d86..47a19d377f 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -40,6 +40,11 @@ suite("Custom registries", async function (this: Suite): Promise { suiteSetup(async function (this: Context): Promise { await stopRegistry(); + await registryTerminal.execute(`docker pull registry`, + { + // docker uses stderr to indicate that it didn't find a local cache and has to download + ignoreErrors: true + }); await registryTerminal.execute(`docker run -d --rm --name ${registryContainerName} -p 5100:5000 registry`); // Make sure it's running From 76b4ca74f3054b8dc18f197a155df478a938d906 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 14:47:28 -0700 Subject: [PATCH 05/16] increase timeout --- test/customRegistries.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 47a19d377f..b350c7ff01 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -14,7 +14,7 @@ import { TestUserInput } from 'vscode-azureextensionui'; const registryContainerName = 'test-registry'; suite("Custom registries", async function (this: Suite): Promise { - this.timeout(Math.max(60 * 1000, this.timeout())); + this.timeout(Math.max(60 * 1000 * 3, this.timeout())); const outputChannel: OutputChannel = window.createOutputChannel('Docker extension tests'); ext.outputChannel = outputChannel; From 5a54de33baffaf363959016ba36e3ecffe720415 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 15:06:25 -0700 Subject: [PATCH 06/16] test fixes --- commands/utils/TerminalProvider.ts | 3 ++- test/customRegistries.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/commands/utils/TerminalProvider.ts b/commands/utils/TerminalProvider.ts index e7a27d6a52..3df1b6628d 100644 --- a/commands/utils/TerminalProvider.ts +++ b/commands/utils/TerminalProvider.ts @@ -121,7 +121,8 @@ class TestTerminal implements vscode.Terminal { } /** - * Executes one or more commands and waits for them to complete + * Executes one or more commands and waits for them to complete. Returns stdout output and + * throws if there is output to stdout. */ public async execute(commands: string | string[], options: { ignoreErrors?: boolean } = {}): Promise { if (typeof commands === 'string') { diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index b350c7ff01..e7f650950c 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -36,7 +36,7 @@ suite("Custom registries", async function (this: Suite): Promise { } suite("localhost", async function (this: Suite): Promise { - this.timeout(Math.max(60 * 1000, this.timeout())); + this.timeout(Math.max(60 * 1000 * 2, this.timeout())); suiteSetup(async function (this: Context): Promise { await stopRegistry(); @@ -48,7 +48,8 @@ suite("Custom registries", async function (this: Suite): Promise { await registryTerminal.execute(`docker run -d --rm --name ${registryContainerName} -p 5100:5000 registry`); // Make sure it's running - await registryTerminal.execute(`curl http://localhost:5100/v2 --silent --show-error`); + let curlResult = await registryTerminal.execute(`curl http://localhost:5100/v2/_catalog`); + assertEx.assertContains(curlResult, '"repositories":'); }); suiteTeardown(async function (this: Context): Promise { From 3cbe922f92c1080c31cd3fbb209a5c5b12b65802 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 15:12:03 -0700 Subject: [PATCH 07/16] test fixes --- test/customRegistries.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index e7f650950c..9d149ddf52 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -48,7 +48,9 @@ suite("Custom registries", async function (this: Suite): Promise { await registryTerminal.execute(`docker run -d --rm --name ${registryContainerName} -p 5100:5000 registry`); // Make sure it's running - let curlResult = await registryTerminal.execute(`curl http://localhost:5100/v2/_catalog`); + // (On some Linux systems, --silent and --show-error are necessary otherwise errors don't go to + // correct output). + let curlResult = await registryTerminal.execute(`curl http://localhost:5100/v2/_catalog --silent --show-error`); assertEx.assertContains(curlResult, '"repositories":'); }); @@ -56,7 +58,7 @@ suite("Custom registries", async function (this: Suite): Promise { await stopRegistry(); }); - test("Connect, no credentials needed", async function (this: Context) { + test("Connect, no auth", async function (this: Context) { let input = new TestUserInput([ 'http://localhost:5100', 'fake username', // TODO: TestUserInput doesn't currently allow '' as an input From d29d6432b2ed537dfb3c9d352fb43ab96e73b94d Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 15:39:12 -0700 Subject: [PATCH 08/16] comment --- test/customRegistries.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 9d149ddf52..26fbcee2b1 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -49,7 +49,7 @@ suite("Custom registries", async function (this: Suite): Promise { // Make sure it's running // (On some Linux systems, --silent and --show-error are necessary otherwise errors don't go to - // correct output). + // correct output). On others these may not be valid and may show an error which can be ignored. let curlResult = await registryTerminal.execute(`curl http://localhost:5100/v2/_catalog --silent --show-error`); assertEx.assertContains(curlResult, '"repositories":'); }); From b550cdd74da3e10ef928b337afa64c66ca57aae4 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 17:16:41 -0700 Subject: [PATCH 09/16] mock keytar --- commands/system-prune.ts | 2 +- dockerExtension.ts | 22 ++++-- explorer/models/customRegistries.ts | 11 +-- explorer/models/registryRootNode.ts | 20 ++--- explorer/utils/dockerHubUtils.ts | 12 +-- explorer/utils/utils.ts | 30 -------- extensionVariables.ts | 2 + test/customRegistries.test.ts | 4 + utils/getCoreNodeModule.ts | 23 ++++++ utils/keytar.ts | 114 ++++++++++++++++++++++++++++ 10 files changed, 172 insertions(+), 68 deletions(-) create mode 100644 utils/getCoreNodeModule.ts create mode 100644 utils/keytar.ts diff --git a/commands/system-prune.ts b/commands/system-prune.ts index 82003207ce..c03e2df665 100644 --- a/commands/system-prune.ts +++ b/commands/system-prune.ts @@ -1,7 +1,7 @@ import vscode = require('vscode'); -import { getCoreNodeModule } from '../explorer/utils/utils'; import { ext } from '../extensionVariables'; import { reporter } from '../telemetry/telemetry'; +import { getCoreNodeModule } from '../utils/getCoreNodeModule'; import { docker } from './utils/docker-endpoint'; const teleCmdId: string = 'vscode-docker.system.prune'; diff --git a/dockerExtension.ts b/dockerExtension.ts index d613d2bf3f..34d35eb69d 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -43,6 +43,7 @@ import { ext } from "./extensionVariables"; import { initializeTelemetryReporter, reporter } from './telemetry/telemetry'; import { AzureAccount } from './typings/azure-account.api'; import { AzureUtilityManager } from './utils/azureUtilityManager'; +import { Keytar } from './utils/keytar'; export const FROM_DIRECTIVE_PATTERN = /^\s*FROM\s*([\w-\/:]*)(\s*AS\s*[a-z][a-z0-9-_\\.]*)?$/i; export const COMPOSE_FILE_GLOB_PATTERN = '**/[dD]ocker-[cC]ompose*.{yaml,yml}'; @@ -64,24 +65,29 @@ const DOCUMENT_SELECTOR: DocumentSelector = [ { language: 'dockerfile', scheme: 'file' } ]; -export async function activate(ctx: vscode.ExtensionContext): Promise { - const installedExtensions: any[] = vscode.extensions.all; - const outputChannel = util.getOutputChannel(); - let azureAccount: AzureAccount; - - // Set up extension variables +function initializeExtensionVariables(ctx: vscode.ExtensionContext): void { registerUIExtensionVariables(ext); if (!ext.ui) { // This allows for standard interactions with the end user (as opposed to test input) ext.ui = new AzureUserInput(ctx.globalState); } ext.context = ctx; - ext.outputChannel = outputChannel; + ext.outputChannel = util.getOutputChannel(); if (!ext.terminalProvider) { ext.terminalProvider = new DefaultTerminalProvider(); } initializeTelemetryReporter(createTelemetryReporter(ctx)); ext.reporter = reporter; + if (!ext.keytar) { + ext.keytar = new Keytar(); + } +} + +export async function activate(ctx: vscode.ExtensionContext): Promise { + const installedExtensions: any[] = vscode.extensions.all; + let azureAccount: AzureAccount; + + initializeExtensionVariables(ctx); // tslint:disable-next-line:prefer-for-of // Grandfathered in for (let i = 0; i < installedExtensions.length; i++) { @@ -131,7 +137,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (context) { if (azureAccount) { const azureAccountWrapper = new AzureAccountWrapper(ctx, azureAccount); - const wizard = new WebAppCreator(outputChannel, azureAccountWrapper, context); + const wizard = new WebAppCreator(ext.outputChannel, azureAccountWrapper, context); const result = await wizard.run(); if (result.status === 'Faulted') { throw result.error; diff --git a/explorer/models/customRegistries.ts b/explorer/models/customRegistries.ts index 6c4da18b68..fffbf4dffc 100644 --- a/explorer/models/customRegistries.ts +++ b/explorer/models/customRegistries.ts @@ -3,18 +3,13 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as Keytar from 'keytar'; import * as vscode from 'vscode'; import { DialogResponses } from 'vscode-azureextensionui'; - import { callWithTelemetryAndErrorHandling, IActionContext, IAzureNode, parseError } from 'vscode-azureextensionui'; import { keytarConstants, MAX_CONCURRENT_REQUESTS } from '../../constants' import { ext } from '../../extensionVariables'; -import { getCoreNodeModule, getKeytarModule } from '../utils/utils'; import { CustomRegistryNode } from './customRegistryNodes'; -const keytar: typeof Keytar = getCoreNodeModule('keytar'); - interface CustomRegistryNonsensitive { url: string, } @@ -74,7 +69,7 @@ export async function connectCustomRegistry(): Promise { // Save let sensitive: string = JSON.stringify(newRegistry.credentials); let key = getUsernamePwdKey(newRegistry.url); - await keytar.setPassword(keytarConstants.serviceId, key, sensitive); + await ext.keytar.setPassword(keytarConstants.serviceId, key, sensitive); registries.push(newRegistry); await saveCustomRegistriesNonsensitive(registries); @@ -89,7 +84,7 @@ export async function disconnectCustomRegistry(node: CustomRegistryNode): Promis let response = await ext.ui.showWarningMessage(`Disconnect from container registry at "${registry.url}"?`, DialogResponses.yes, DialogResponses.no); if (response === DialogResponses.yes) { let key = getUsernamePwdKey(node.registry.url); - await keytar.deletePassword(keytarConstants.serviceId, key); + await ext.keytar.deletePassword(keytarConstants.serviceId, key); registries.splice(registries.indexOf(registry), 1); await saveCustomRegistriesNonsensitive(registries); await refresh(); @@ -111,7 +106,7 @@ export async function getCustomRegistries(): Promise { try { let key = getUsernamePwdKey(reg.url); - let credentialsString = await keytar.getPassword(keytarConstants.serviceId, key); + let credentialsString = await ext.keytar.getPassword(keytarConstants.serviceId, key); let credentials: CustomRegistryCredentials = JSON.parse(credentialsString); registries.push({ url: reg.url, diff --git a/explorer/models/registryRootNode.ts b/explorer/models/registryRootNode.ts index 1ebb5a7a56..dae846d20b 100644 --- a/explorer/models/registryRootNode.ts +++ b/explorer/models/registryRootNode.ts @@ -6,10 +6,10 @@ import { ServiceClientCredentials } from 'ms-rest'; import * as vscode from 'vscode'; import { parseError } from 'vscode-azureextensionui'; import { keytarConstants, MAX_CONCURRENT_REQUESTS, MAX_CONCURRENT_SUBSCRIPTON_REQUESTS } from '../../constants'; +import { ext } from '../../extensionVariables'; import { AzureAccount } from '../../typings/azure-account.api'; import { AsyncPool } from '../../utils/asyncpool'; import * as dockerHub from '../utils/dockerHubUtils' -import { getKeytarModule } from '../utils/utils'; import { AzureLoadingNode, AzureNotSignedInNode, AzureRegistryNode } from './azureRegistryNodes'; import { getCustomRegistries } from './customRegistries'; import { CustomRegistryNode } from './customRegistryNodes'; @@ -21,7 +21,6 @@ import { RegistryType } from './registryType'; const ContainerRegistryManagement = require('azure-arm-containerregistry'); export class RegistryRootNode extends NodeBase { - private _keytar: typeof keytarType; private _azureAccount: AzureAccount; constructor( @@ -31,7 +30,6 @@ export class RegistryRootNode extends NodeBase { public readonly azureAccount?: AzureAccount ) { super(label); - this._keytar = getKeytarModule(); this._azureAccount = azureAccount; @@ -73,21 +71,17 @@ export class RegistryRootNode extends NodeBase { let id: { username: string, password: string, token: string } = { username: null, password: null, token: null }; - if (this._keytar) { - id.token = await this._keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); - id.username = await this._keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); - id.password = await this._keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); - } + id.token = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); + id.username = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); + id.password = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); if (!id.token) { id = await dockerHub.dockerHubLogin(); if (id && id.token) { - if (this._keytar) { - await this._keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey, id.token); - await this._keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey, id.password); - await this._keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey, id.username); - } + await ext.keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey, id.token); + await ext.keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey, id.password); + await ext.keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey, id.username); } else { return orgNodes; } diff --git a/explorer/utils/dockerHubUtils.ts b/explorer/utils/dockerHubUtils.ts index 8dc35a3994..9327285d84 100644 --- a/explorer/utils/dockerHubUtils.ts +++ b/explorer/utils/dockerHubUtils.ts @@ -3,8 +3,8 @@ import * as opn from 'opn'; import request = require('request-promise'); import * as vscode from 'vscode'; import { keytarConstants } from '../../constants'; +import { ext } from '../../extensionVariables'; import { DockerHubImageTagNode, DockerHubOrgNode, DockerHubRepositoryNode } from '../models/dockerHubNodes'; -import { getCoreNodeModule } from './utils'; let _token: Token; @@ -101,13 +101,9 @@ export interface Manifest { } export async function dockerHubLogout(): Promise { - - const keytar: typeof keytarType = getCoreNodeModule('keytar'); - if (keytar) { - await keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); - await keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); - await keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); - } + await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); + await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); + await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); _token = null; } diff --git a/explorer/utils/utils.ts b/explorer/utils/utils.ts index ace0e92698..fad9d9a7c3 100644 --- a/explorer/utils/utils.ts +++ b/explorer/utils/utils.ts @@ -14,33 +14,3 @@ export function trimWithElipsis(str: string, max: number = 10): string { return front + elipsis + back; } - -/** - * Returns a node module installed with VSCode, or undefined if it fails. - */ -export function getCoreNodeModule(moduleName: string): any { - try { - // tslint:disable-next-line:non-literal-require - return require(`${vscode.env.appRoot}/node_modules.asar/${moduleName}`); - } catch (err) { } - - try { - // tslint:disable-next-line:non-literal-require - return require(`${vscode.env.appRoot}/node_modules/${moduleName}`); - } catch (err) { } - - return undefined; -} - -/** - * Returns the keytar module installed with vscode - */ -export function getKeytarModule(): typeof Keytar { - const keytar: typeof Keytar | undefined = getCoreNodeModule('keytar'); - - if (!keytar) { - throw new Error("Internal error: Could not find keytar module for reading and writing passwords"); - } - - return keytar; -} diff --git a/extensionVariables.ts b/extensionVariables.ts index 07e7e40111..5162c7c6ce 100644 --- a/extensionVariables.ts +++ b/extensionVariables.ts @@ -6,6 +6,7 @@ import { ExtensionContext, OutputChannel, Terminal } from "vscode"; import { IAzureUserInput, ITelemetryReporter } from "vscode-azureextensionui"; import { ITerminalProvider } from "./commands/utils/TerminalProvider"; +import { IKeytar } from './utils/keytar'; /** * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts @@ -16,4 +17,5 @@ export namespace ext { export let ui: IAzureUserInput; export let reporter: ITelemetryReporter; export let terminalProvider: ITerminalProvider; + export let keytar: IKeytar; } diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 26fbcee2b1..9e41ddd1c7 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -10,6 +10,7 @@ import { ext } from '../extensionVariables'; import { Suite, Test, Context } from 'mocha'; import { TestTerminalProvider } from '../commands/utils/TerminalProvider'; import { TestUserInput } from 'vscode-azureextensionui'; +import { DebugKeytar } from '../utils/keytar'; const registryContainerName = 'test-registry'; @@ -39,6 +40,8 @@ suite("Custom registries", async function (this: Suite): Promise { this.timeout(Math.max(60 * 1000 * 2, this.timeout())); suiteSetup(async function (this: Context): Promise { + ext.keytar = new DebugKeytar(); + await stopRegistry(); await registryTerminal.execute(`docker pull registry`, { @@ -56,6 +59,7 @@ suite("Custom registries", async function (this: Suite): Promise { suiteTeardown(async function (this: Context): Promise { await stopRegistry(); + ext.keytar = undefined; }); test("Connect, no auth", async function (this: Context) { diff --git a/utils/getCoreNodeModule.ts b/utils/getCoreNodeModule.ts new file mode 100644 index 0000000000..9ec9a21f86 --- /dev/null +++ b/utils/getCoreNodeModule.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +/** + * Returns a node module installed with VSCode, or undefined if it fails. + */ +export function getCoreNodeModule(moduleName: string): any { + try { + // tslint:disable-next-line:non-literal-require + return require(`${vscode.env.appRoot}/node_modules.asar/${moduleName}`); + } catch (err) { } + + try { + // tslint:disable-next-line:non-literal-require + return require(`${vscode.env.appRoot}/node_modules/${moduleName}`); + } catch (err) { } + + return undefined; +} diff --git a/utils/keytar.ts b/utils/keytar.ts new file mode 100644 index 0000000000..da0901deb4 --- /dev/null +++ b/utils/keytar.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as keytarType from 'keytar'; +import { getCoreNodeModule } from './getCoreNodeModule'; + +export interface IKeytar { + /** + * Get the stored password for the service and account. + * + * @param service The string service name. + * @param account The string account name. + * + * @returns A promise for the password string. + */ + getPassword(service: string, account: string): Promise; + + /** + * Add the password for the service and account to the keychain. + * + * @param service The string service name. + * @param account The string account name. + * @param password The string password. + * + * @returns A promise for the set password completion. + */ + setPassword(service: string, account: string, password: string): Promise; + + /** + * Delete the stored password for the service and account. + * + * @param service The string service name. + * @param account The string account name. + * + * @returns A promise for the deletion status. True on success. + */ + deletePassword(service: string, account: string): Promise; +} + +/** + * Returns the keytar module installed with vscode + */ +function getKeytarModule(): typeof keytarType { + const keytar: typeof keytarType | undefined = getCoreNodeModule('keytar'); + if (!keytar) { + throw new Error("Internal error: Could not find keytar module for reading and writing passwords"); + } else { + return keytar; + } +} + +export class Keytar implements IKeytar { + private _keytar: typeof keytarType = getKeytarModule(); + + public async getPassword(service: string, account: string): Promise { + return await this._keytar.getPassword(service, account); + } + + public async setPassword(service: string, account: string, password: string): Promise { + await this._keytar.setPassword(service, account, password); + } + + public async deletePassword(service: string, account: string): Promise { + return await this._keytar.deletePassword(service, account); + } +} + +export class DebugKeytar implements IKeytar { + private _services: Map>; + + public async getPassword(service: string, account: string): Promise { + await this.delay(); + let foundService = this._services.get(service); + if (foundService) { + return foundService.get(account); + } + + return undefined; + } + + public async setPassword(service: string, account: string, password: string): Promise { + await this.delay(); + let foundService = this._services.get(service); + if (!foundService) { + foundService = new Map(); + this._services.set(service, foundService); + } + + foundService.set(account, password); + } + + public async deletePassword(service: string, account: string): Promise { + await this.delay(); + let foundService = this._services.get(service); + if (foundService) { + if (foundService.has(account)) { + foundService.delete(account); + return true; + } + } + + return false; + } + + private delay(): Promise { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1); + }); + } +} From 08bb3bd427cfe3433fc499227a18cbc2bec59421 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 17:39:00 -0700 Subject: [PATCH 10/16] test fixes --- .gitignore | 2 ++ dockerExtension.ts | 2 +- test/customRegistries.test.ts | 6 +++--- utils/keytar.ts | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index f27ac41d51..d0797fedc3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ out node_modules *.vsix package-lock.json +.nyc_output/** +coverage/** # Artifacts from running vscode extension tests .vscode-test diff --git a/dockerExtension.ts b/dockerExtension.ts index 34d35eb69d..30bc0a8cdf 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -43,7 +43,7 @@ import { ext } from "./extensionVariables"; import { initializeTelemetryReporter, reporter } from './telemetry/telemetry'; import { AzureAccount } from './typings/azure-account.api'; import { AzureUtilityManager } from './utils/azureUtilityManager'; -import { Keytar } from './utils/keytar'; +import { DebugKeytar, Keytar } from './utils/keytar'; export const FROM_DIRECTIVE_PATTERN = /^\s*FROM\s*([\w-\/:]*)(\s*AS\s*[a-z][a-z0-9-_\\.]*)?$/i; export const COMPOSE_FILE_GLOB_PATTERN = '**/[dD]ocker-[cC]ompose*.{yaml,yml}'; diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 9e41ddd1c7..718639c95a 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -48,12 +48,12 @@ suite("Custom registries", async function (this: Suite): Promise { // docker uses stderr to indicate that it didn't find a local cache and has to download ignoreErrors: true }); - await registryTerminal.execute(`docker run -d --rm --name ${registryContainerName} -p 5100:5000 registry`); + await registryTerminal.execute(`docker run -d --rm --name ${registryContainerName} -p 5900:5000 registry`); // Make sure it's running // (On some Linux systems, --silent and --show-error are necessary otherwise errors don't go to // correct output). On others these may not be valid and may show an error which can be ignored. - let curlResult = await registryTerminal.execute(`curl http://localhost:5100/v2/_catalog --silent --show-error`); + let curlResult = await registryTerminal.execute(`curl http://localhost:5900/v2/_catalog --silent --show-error`); assertEx.assertContains(curlResult, '"repositories":'); }); @@ -64,7 +64,7 @@ suite("Custom registries", async function (this: Suite): Promise { test("Connect, no auth", async function (this: Context) { let input = new TestUserInput([ - 'http://localhost:5100', + 'http://localhost:5900', 'fake username', // TODO: TestUserInput doesn't currently allow '' as an input 'fake password' ]); diff --git a/utils/keytar.ts b/utils/keytar.ts index da0901deb4..2f3e4352cc 100644 --- a/utils/keytar.ts +++ b/utils/keytar.ts @@ -68,7 +68,7 @@ export class Keytar implements IKeytar { } export class DebugKeytar implements IKeytar { - private _services: Map>; + private _services: Map> = new Map>(); public async getPassword(service: string, account: string): Promise { await this.delay(); @@ -104,7 +104,7 @@ export class DebugKeytar implements IKeytar { return false; } - private delay(): Promise { + private async delay(): Promise { return new Promise(resolve => { setTimeout(() => { resolve(); From 4afc9fcd325f0ab035325dce315fcfba30d6eab3 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 16 Aug 2018 18:02:36 -0700 Subject: [PATCH 11/16] test fixes --- dockerExtension.ts | 2 +- test/assertEx.ts | 4 ++-- test/customRegistries.test.ts | 12 +++++++----- test/test.code-workspace | 3 ++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/dockerExtension.ts b/dockerExtension.ts index 30bc0a8cdf..34d35eb69d 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -43,7 +43,7 @@ import { ext } from "./extensionVariables"; import { initializeTelemetryReporter, reporter } from './telemetry/telemetry'; import { AzureAccount } from './typings/azure-account.api'; import { AzureUtilityManager } from './utils/azureUtilityManager'; -import { DebugKeytar, Keytar } from './utils/keytar'; +import { Keytar } from './utils/keytar'; export const FROM_DIRECTIVE_PATTERN = /^\s*FROM\s*([\w-\/:]*)(\s*AS\s*[a-z][a-z0-9-_\\.]*)?$/i; export const COMPOSE_FILE_GLOB_PATTERN = '**/[dD]ocker-[cC]ompose*.{yaml,yml}'; diff --git a/test/assertEx.ts b/test/assertEx.ts index 7efda7dd03..a6931a16f6 100644 --- a/test/assertEx.ts +++ b/test/assertEx.ts @@ -65,11 +65,11 @@ function areUnorderedArraysEqual(actual: T[], expected: T[]): { areEqual: boo } export function assertContains(s: string, searchString: string): void { - assert(s.includes(searchString), `Expected to find '${searchString}' in ${s}`); + assert(s.includes(searchString), `Expected to find '${searchString}' in '${s}'`); } export function assertNotContains(s: string, searchString: string): void { - assert(!s.includes(searchString), `Unexpected text '${searchString}' found in ${s}`); + assert(!s.includes(searchString), `Unexpected text '${searchString}' found in '${s}'`); } function fileContains(filePath: string, text: string): boolean { diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 718639c95a..6987bc979c 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -50,11 +50,13 @@ suite("Custom registries", async function (this: Suite): Promise { }); await registryTerminal.execute(`docker run -d --rm --name ${registryContainerName} -p 5900:5000 registry`); - // Make sure it's running - // (On some Linux systems, --silent and --show-error are necessary otherwise errors don't go to - // correct output). On others these may not be valid and may show an error which can be ignored. - let curlResult = await registryTerminal.execute(`curl http://localhost:5900/v2/_catalog --silent --show-error`); - assertEx.assertContains(curlResult, '"repositories":'); + if (false) { // Too inconsistent between terminals + // Make sure it's running + // (On some Linux systems, --silent and --show-error are necessary otherwise errors don't go to + // correct output). On others these may not be valid and may show an error which can be ignored. + let curlResult = await registryTerminal.execute(`curl http://localhost:5900/v2/_catalog --silent --show-error`); + assertEx.assertContains(curlResult, '"repositories":'); + } }); suiteTeardown(async function (this: Context): Promise { diff --git a/test/test.code-workspace b/test/test.code-workspace index bf4d0fa6e2..671af4c557 100644 --- a/test/test.code-workspace +++ b/test/test.code-workspace @@ -3,5 +3,6 @@ { "path": "../testOutput" } - ] + ], + "settings": {} } From 260e078a1b536b87e374305ddbe54fe6398de32b Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Fri, 17 Aug 2018 17:02:41 -0700 Subject: [PATCH 12/16] test fixes --- test/customRegistries.test.ts | 3 --- test/global.test.ts | 4 ++++ utils/keytar.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 6987bc979c..cb3bb3a3ce 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -10,7 +10,6 @@ import { ext } from '../extensionVariables'; import { Suite, Test, Context } from 'mocha'; import { TestTerminalProvider } from '../commands/utils/TerminalProvider'; import { TestUserInput } from 'vscode-azureextensionui'; -import { DebugKeytar } from '../utils/keytar'; const registryContainerName = 'test-registry'; @@ -40,8 +39,6 @@ suite("Custom registries", async function (this: Suite): Promise { this.timeout(Math.max(60 * 1000 * 2, this.timeout())); suiteSetup(async function (this: Context): Promise { - ext.keytar = new DebugKeytar(); - await stopRegistry(); await registryTerminal.execute(`docker pull registry`, { diff --git a/test/global.test.ts b/test/global.test.ts index 7c9de8c93c..5a3aa81609 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -3,6 +3,8 @@ import * as path from "path"; import * as fse from "fs-extra"; import mocha = require("mocha"); import * as assert from 'assert'; +import { ext } from "../extensionVariables"; +import { TestKeytar } from "../utils/keytar"; export namespace constants { export const testOutputName = 'testOutput'; @@ -50,6 +52,8 @@ export function testInEmptyFolder(name: string, func: () => Promise): void // Runs before all tests suiteSetup(function (this: mocha.IHookCallbackContext): void { + // Otherwise the app can blocking asking for keychain access + ext.keytar = new TestKeytar(); }); // Runs after all tests diff --git a/utils/keytar.ts b/utils/keytar.ts index 2f3e4352cc..ed72f727bf 100644 --- a/utils/keytar.ts +++ b/utils/keytar.ts @@ -67,7 +67,7 @@ export class Keytar implements IKeytar { } } -export class DebugKeytar implements IKeytar { +export class TestKeytar implements IKeytar { private _services: Map> = new Map>(); public async getPassword(service: string, account: string): Promise { From 75e10c43ba2af5c364d0bec0bf663229884faf9f Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Wed, 22 Aug 2018 17:09:03 -0700 Subject: [PATCH 13/16] PR fixes --- commands/utils/TerminalProvider.ts | 2 +- constants.ts | 3 ++ dockerExtension.ts | 2 +- explorer/models/commonRegistryUtils.ts | 9 +++- explorer/models/customRegistries.ts | 47 ++++++++++----------- explorer/models/customRegistryNodes.ts | 18 ++++---- explorer/models/dockerHubNodes.ts | 11 +++-- explorer/models/registryRootNode.ts | 10 +++-- explorer/utils/dockerHubUtils.ts | 18 ++++---- extensionVariables.ts | 2 +- package.json | 6 +-- test/customRegistries.test.ts | 27 +++++++++++- test/global.test.ts | 2 +- test/testKeytar.ts | 52 +++++++++++++++++++++++ utils/keytar.ts | 58 +++++--------------------- 15 files changed, 160 insertions(+), 107 deletions(-) create mode 100644 test/testKeytar.ts diff --git a/commands/utils/TerminalProvider.ts b/commands/utils/TerminalProvider.ts index 8393ce0a32..5a5530835d 100644 --- a/commands/utils/TerminalProvider.ts +++ b/commands/utils/TerminalProvider.ts @@ -138,7 +138,7 @@ class TestTerminal implements vscode.Terminal { this.sendText(command); } - let results = await this.waitForCompletionCore({ ignoreErrors: options.ignoreErrors }); + let results = await this.waitForCompletionCore(options); if (!options.ignoreErrors) { assert.equal(results.errorText, '', `Encountered errors executing in terminal`); diff --git a/constants.ts b/constants.ts index 15742757f0..4f4805520f 100644 --- a/constants.ts +++ b/constants.ts @@ -7,6 +7,9 @@ export const MAX_CONCURRENT_REQUESTS = 8; export const MAX_CONCURRENT_SUBSCRIPTON_REQUESTS = 5; +// Consider downloading multiple pages (images, tags, etc) +export const PAGE_SIZE = 100; + export namespace keytarConstants { export const serviceId: string = 'vscode-docker'; diff --git a/dockerExtension.ts b/dockerExtension.ts index 51edaf3e57..ffe6310f05 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -80,7 +80,7 @@ function initializeExtensionVariables(ctx: vscode.ExtensionContext): void { initializeTelemetryReporter(createTelemetryReporter(ctx)); ext.reporter = reporter; if (!ext.keytar) { - ext.keytar = new Keytar(); + ext.keytar = Keytar.tryCreate(); } } diff --git a/explorer/models/commonRegistryUtils.ts b/explorer/models/commonRegistryUtils.ts index 8875add315..fcde7cc385 100644 --- a/explorer/models/commonRegistryUtils.ts +++ b/explorer/models/commonRegistryUtils.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as ContainerModels from 'azure-arm-containerregistry/lib/models'; import { SubscriptionModels } from 'azure-arm-resource'; import * as moment from 'moment'; @@ -5,7 +10,7 @@ import * as path from 'path'; import * as request from 'request-promise'; import * as vscode from 'vscode'; import { parseError } from 'vscode-azureextensionui'; -import { MAX_CONCURRENT_REQUESTS } from '../../constants' +import { MAX_CONCURRENT_REQUESTS, PAGE_SIZE } from '../../constants' import { AzureAccount, AzureSession } from '../../typings/azure-account.api'; import { AsyncPool } from '../../utils/asyncpool'; import { Manifest, ManifestHistory, ManifestHistoryV1Compatibility, Repository } from '../utils/dockerHubUtils'; @@ -65,7 +70,7 @@ export async function getCatalog(registryUrl: string, credentials?: RegistryCred } export async function getTags(registryUrl: string, repositoryName: string, credentials?: RegistryCredentials): Promise { - let result = await registryRequest<{ tags: string[] }>(registryUrl, `v2/${repositoryName}/tags/list?page_size=100&page=1`, credentials); + let result = await registryRequest<{ tags: string[] }>(registryUrl, `v2/${repositoryName}/tags/list?page_size=${PAGE_SIZE}&page=1`, credentials); let tags = result.tags; let tagInfos: TagInfo[] = []; diff --git a/explorer/models/customRegistries.ts b/explorer/models/customRegistries.ts index fffbf4dffc..f1fb610d14 100644 --- a/explorer/models/customRegistries.ts +++ b/explorer/models/customRegistries.ts @@ -61,17 +61,16 @@ export async function connectCustomRegistry(): Promise { credentials: { userName, password } }; - let invalidMessage = await CustomRegistryNode.isValidRegistryUrl(newRegistry); - if (invalidMessage) { - throw new Error(invalidMessage); - } + await CustomRegistryNode.verifyIsValidRegistryUrl(newRegistry); // Save - let sensitive: string = JSON.stringify(newRegistry.credentials); - let key = getUsernamePwdKey(newRegistry.url); - await ext.keytar.setPassword(keytarConstants.serviceId, key, sensitive); - registries.push(newRegistry); - await saveCustomRegistriesNonsensitive(registries); + if (ext.keytar) { + let sensitive: string = JSON.stringify(newRegistry.credentials); + let key = getUsernamePwdKey(newRegistry.url); + await ext.keytar.setPassword(keytarConstants.serviceId, key, sensitive); + registries.push(newRegistry); + await saveCustomRegistriesNonsensitive(registries); + } await refresh(); } @@ -81,14 +80,13 @@ export async function disconnectCustomRegistry(node: CustomRegistryNode): Promis let registry = registries.find(reg => reg.url.toLowerCase() === node.registry.url.toLowerCase()); if (registry) { - let response = await ext.ui.showWarningMessage(`Disconnect from container registry at "${registry.url}"?`, DialogResponses.yes, DialogResponses.no); - if (response === DialogResponses.yes) { - let key = getUsernamePwdKey(node.registry.url); + let key = getUsernamePwdKey(node.registry.url); + if (ext.keytar) { await ext.keytar.deletePassword(keytarConstants.serviceId, key); - registries.splice(registries.indexOf(registry), 1); - await saveCustomRegistriesNonsensitive(registries); - await refresh(); } + registries.splice(registries.indexOf(registry), 1); + await saveCustomRegistriesNonsensitive(registries); + await refresh(); } } @@ -97,7 +95,7 @@ function getUsernamePwdKey(registryUrl: string): string { } export async function getCustomRegistries(): Promise { - let nonsensitive = ext.context.workspaceState.get(customRegistriesKey) || []; + let nonsensitive = ext.context.globalState.get(customRegistriesKey) || []; let registries: CustomRegistry[] = []; for (let reg of nonsensitive) { @@ -105,14 +103,15 @@ export async function getCustomRegistries(): Promise { this.suppressTelemetry = true; try { - let key = getUsernamePwdKey(reg.url); - let credentialsString = await ext.keytar.getPassword(keytarConstants.serviceId, key); - let credentials: CustomRegistryCredentials = JSON.parse(credentialsString); - registries.push({ - url: reg.url, - credentials - }); - registries.push() + if (ext.keytar) { + let key = getUsernamePwdKey(reg.url); + let credentialsString = await ext.keytar.getPassword(keytarConstants.serviceId, key); + let credentials: CustomRegistryCredentials = JSON.parse(credentialsString); + registries.push({ + url: reg.url, + credentials + }); + } } catch (error) { throw new Error(`Unable to retrieve password for container registry ${reg.url}: ${parseError(error).message}`); } diff --git a/explorer/models/customRegistryNodes.ts b/explorer/models/customRegistryNodes.ts index 187164e35d..5f71a3cff8 100644 --- a/explorer/models/customRegistryNodes.ts +++ b/explorer/models/customRegistryNodes.ts @@ -13,8 +13,10 @@ import { RegistryType } from './registryType'; export class CustomRegistryNode extends NodeBase { public type: RegistryType = RegistryType.Custom; - public static readonly contextValue: string = 'customRegistry'; + + public static readonly contextValue: string = 'customRegistryNode'; public contextValue: string = CustomRegistryNode.contextValue; + public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Registry_16x.svg'), dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Registry_16x.svg') @@ -37,15 +39,9 @@ export class CustomRegistryNode extends NodeBase { } // Returns undefined if it's valid, otherwise returns an error message - public static async isValidRegistryUrl(registry: CustomRegistry): Promise { - try { - let response = await registryRequest<{}>(registry.url, 'v2', registry.credentials); - - // If the call succeeded, it's a V2 registry - return undefined; - } catch (error) { - return parseError(error).message; - } + public static async verifyIsValidRegistryUrl(registry: CustomRegistry): Promise { + // If the call succeeded, it's a V2 registry + await registryRequest<{}>(registry.url, 'v2', registry.credentials); } public async getChildren(element: CustomRegistryNode): Promise { @@ -110,7 +106,7 @@ export class CustomRepositoryNode extends NodeBase { } export class CustomImageTagNode extends NodeBase { - public static contextValue: string = 'customImageTag'; + public static contextValue: string = 'customImageTagNode'; public contextValue: string = CustomImageTagNode.contextValue; constructor( diff --git a/explorer/models/dockerHubNodes.ts b/explorer/models/dockerHubNodes.ts index 0cfce360fc..3491420ab0 100644 --- a/explorer/models/dockerHubNodes.ts +++ b/explorer/models/dockerHubNodes.ts @@ -20,7 +20,9 @@ export class DockerHubOrgNode extends NodeBase { super(label); } - public readonly contextValue: string = 'dockerHubNamespace'; + public static readonly contextValue: string = 'dockerHubOrgNode'; + public readonly contextValue: string = DockerHubOrgNode.contextValue; + public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Registry_16x.svg'), dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Registry_16x.svg') @@ -71,7 +73,9 @@ export class DockerHubRepositoryNode extends NodeBase { super(label); } - public readonly contextValue: string = 'dockerHubRepository'; + public static readonly contextValue: string = 'dockerHubRepositoryNode'; + public readonly contextValue: string = DockerHubRepositoryNode.contextValue; + public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } = { light: path.join(__filename, '..', '..', '..', '..', 'images', 'light', 'Repository_16x.svg'), dark: path.join(__filename, '..', '..', '..', '..', 'images', 'dark', 'Repository_16x.svg') @@ -115,7 +119,8 @@ export class DockerHubImageTagNode extends NodeBase { super(`${repositoryName}:${tag}`); } - public readonly contextValue: string = 'dockerHubImageTag'; + public static readonly contextValue: string = 'dockerHubImageTagNode'; + public readonly contextValue: string = DockerHubImageTagNode.contextValue; // this needs to be empty string for Docker Hub public serverUrl: string = ''; diff --git a/explorer/models/registryRootNode.ts b/explorer/models/registryRootNode.ts index 6395451317..01f1a6a611 100644 --- a/explorer/models/registryRootNode.ts +++ b/explorer/models/registryRootNode.ts @@ -74,14 +74,16 @@ export class RegistryRootNode extends NodeBase { let id: { username: string, password: string, token: string } = { username: null, password: null, token: null }; - id.token = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); - id.username = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); - id.password = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); + if (ext.keytar) { + id.token = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); + id.username = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); + id.password = await ext.keytar.getPassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); + } if (!id.token) { id = await dockerHub.dockerHubLogin(); - if (id && id.token) { + if (id && id.token && ext.keytar) { await ext.keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey, id.token); await ext.keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey, id.password); await ext.keytar.setPassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey, id.username); diff --git a/explorer/utils/dockerHubUtils.ts b/explorer/utils/dockerHubUtils.ts index 8007802a7c..72f7e5ce03 100644 --- a/explorer/utils/dockerHubUtils.ts +++ b/explorer/utils/dockerHubUtils.ts @@ -7,7 +7,7 @@ import * as keytarType from 'keytar'; import * as opn from 'opn'; import request = require('request-promise'); import * as vscode from 'vscode'; -import { keytarConstants } from '../../constants'; +import { keytarConstants, PAGE_SIZE } from '../../constants'; import { ext } from '../../extensionVariables'; import { DockerHubImageTagNode, DockerHubOrgNode, DockerHubRepositoryNode } from '../models/dockerHubNodes'; @@ -106,9 +106,11 @@ export interface Manifest { } export async function dockerHubLogout(): Promise { - await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); - await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); - await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); + if (ext.keytar) { + await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubTokenKey); + await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubPasswordKey); + await ext.keytar.deletePassword(keytarConstants.serviceId, keytarConstants.dockerHubUserNameKey); + } _token = null; } @@ -229,7 +231,7 @@ export async function getRepositoryTags(repository: Repository): Promise let options = { method: 'GET', - uri: `https://hub.docker.com/v2/repositories/${repository.namespace}/${repository.name}/tags?page_size=100&page=1`, + uri: `https://hub.docker.com/v2/repositories/${repository.namespace}/${repository.name}/tags?page_size=${PAGE_SIZE}&page=1`, headers: { Authorization: 'JWT ' + _token.token }, @@ -252,13 +254,13 @@ export function browseDockerHub(context?: DockerHubImageTagNode | DockerHubRepos let url: string = 'https://hub.docker.com/'; const repo: RepositoryInfo = context.repository; switch (context.contextValue) { - case 'dockerHubNamespace': + case DockerHubOrgNode.contextValue: url = `${url}u/${context.userName}`; break; - case 'dockerHubRepository': + case DockerHubRepositoryNode.contextValue: url = `${url}r/${context.repository.namespace}/${context.repository.name}`; break; - case 'dockerHubImageTag': + case DockerHubImageTagNode.contextValue: url = `${url}r/${context.repository.namespace}/${context.repository.name}/tags`; break; default: diff --git a/extensionVariables.ts b/extensionVariables.ts index 3443eeb784..dde2c2c673 100644 --- a/extensionVariables.ts +++ b/extensionVariables.ts @@ -17,5 +17,5 @@ export namespace ext { export let ui: IAzureUserInput; export let reporter: ITelemetryReporter; export let terminalProvider: ITerminalProvider; - export let keytar: IKeytar; + export let keytar: IKeytar | undefined; } diff --git a/package.json b/package.json index 5c23c9f68a..f277cc1d73 100644 --- a/package.json +++ b/package.json @@ -187,7 +187,7 @@ }, { "command": "vscode-docker.createWebApp", - "when": "view == dockerExplorer && viewItem =~ /^(azureImageTagNode|dockerHubImageTag|customImageTag)$/" + "when": "view == dockerExplorer && viewItem =~ /^(azureImageTagNode|dockerHubImageTagNode|customImageTagNode)$/" }, { "command": "vscode-docker.dockerHubLogout", @@ -195,7 +195,7 @@ }, { "command": "vscode-docker.browseDockerHub", - "when": "view == dockerExplorer && viewItem =~ /^(dockerHubImageTag|dockerHubRepository|dockerHubNamespace)$/" + "when": "view == dockerExplorer && viewItem =~ /^(dockerHubImageTagNode|dockerHubRepositoryNode|dockerHubOrgNode)$/" }, { "command": "vscode-docker.browseAzurePortal", @@ -207,7 +207,7 @@ }, { "command": "vscode-docker.disconnectCustomRegistry", - "when": "view == dockerExplorer && viewItem =~ /^(customRegistry)$/" + "when": "view == dockerExplorer && viewItem =~ /^(customRegistryNode)$/" } ] }, diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index cb3bb3a3ce..04050cf7b1 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -58,7 +58,6 @@ suite("Custom registries", async function (this: Suite): Promise { suiteTeardown(async function (this: Context): Promise { await stopRegistry(); - ext.keytar = undefined; }); test("Connect, no auth", async function (this: Context) { @@ -73,6 +72,32 @@ suite("Custom registries", async function (this: Suite): Promise { // TODO: Verify the node is there (have to start using common tree provider first) }); + test("Connect, no auth - keytar not available", async function (this: Context) { + // Make sure extension activated + await commands.executeCommand('vscode-docker.refresh'); + + let oldKeytar = ext.keytar; + try { + ext.keytar = undefined; + + let input = new TestUserInput([ + 'http://localhost:5900', + 'fake username', // TODO: TestUserInput doesn't currently allow '' as an input + 'fake password' + ]); + ext.ui = input; + await commands.executeCommand('vscode-docker.connectCustomRegistry'); + + // TODO: Verify the node is there (have to start using common tree provider first) + } finally { + ext.keytar = oldKeytar; + } + }); + + test("Connect with credentials"); + test("Publish to Azure app service with credentials"); + test("Disconnect"); + test("Connect with credentials"); test("Publish to Azure app service with credentials"); test("Disconnect"); diff --git a/test/global.test.ts b/test/global.test.ts index 02b69d09c1..88828faccf 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -9,7 +9,7 @@ import * as fse from "fs-extra"; import mocha = require("mocha"); import * as assert from 'assert'; import { ext } from "../extensionVariables"; -import { TestKeytar } from "../utils/keytar"; +import { TestKeytar } from "../test/testKeytar"; export namespace constants { export const testOutputName = 'testOutput'; diff --git a/test/testKeytar.ts b/test/testKeytar.ts new file mode 100644 index 0000000000..b8700b2554 --- /dev/null +++ b/test/testKeytar.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IKeytar } from "../utils/keytar"; + +export class TestKeytar implements IKeytar { + private _services: Map> = new Map>(); + + public async getPassword(service: string, account: string): Promise { + await this.delay(); + let foundService = this._services.get(service); + if (foundService) { + return foundService.get(account); + } + + return undefined; + } + + public async setPassword(service: string, account: string, password: string): Promise { + await this.delay(); + let foundService = this._services.get(service); + if (!foundService) { + foundService = new Map(); + this._services.set(service, foundService); + } + + foundService.set(account, password); + } + + public async deletePassword(service: string, account: string): Promise { + await this.delay(); + let foundService = this._services.get(service); + if (foundService) { + if (foundService.has(account)) { + foundService.delete(account); + return true; + } + } + + return false; + } + + private async delay(): Promise { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1); + }); + } +} diff --git a/utils/keytar.ts b/utils/keytar.ts index ed72f727bf..9fef9e17d7 100644 --- a/utils/keytar.ts +++ b/utils/keytar.ts @@ -52,63 +52,27 @@ function getKeytarModule(): typeof keytarType { } export class Keytar implements IKeytar { - private _keytar: typeof keytarType = getKeytarModule(); - - public async getPassword(service: string, account: string): Promise { - return await this._keytar.getPassword(service, account); - } - - public async setPassword(service: string, account: string, password: string): Promise { - await this._keytar.setPassword(service, account, password); + private constructor(private _keytar: typeof keytarType) { } - public async deletePassword(service: string, account: string): Promise { - return await this._keytar.deletePassword(service, account); + public static tryCreate(): Keytar | undefined { + let keytar: typeof keytarType = getKeytarModule(); + if (keytar) { + return new Keytar(keytar); + } else { + return undefined; + } } -} - -export class TestKeytar implements IKeytar { - private _services: Map> = new Map>(); public async getPassword(service: string, account: string): Promise { - await this.delay(); - let foundService = this._services.get(service); - if (foundService) { - return foundService.get(account); - } - - return undefined; + return await this._keytar.getPassword(service, account); } public async setPassword(service: string, account: string, password: string): Promise { - await this.delay(); - let foundService = this._services.get(service); - if (!foundService) { - foundService = new Map(); - this._services.set(service, foundService); - } - - foundService.set(account, password); + await this._keytar.setPassword(service, account, password); } public async deletePassword(service: string, account: string): Promise { - await this.delay(); - let foundService = this._services.get(service); - if (foundService) { - if (foundService.has(account)) { - foundService.delete(account); - return true; - } - } - - return false; - } - - private async delay(): Promise { - return new Promise(resolve => { - setTimeout(() => { - resolve(); - }, 1); - }); + return await this._keytar.deletePassword(service, account); } } From f038aa05fc48097081226511d1cf5e6ae7e33818 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 23 Aug 2018 17:41:43 -0700 Subject: [PATCH 14/16] Fix --- explorer/models/customRegistries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explorer/models/customRegistries.ts b/explorer/models/customRegistries.ts index f1fb610d14..77b701fdf9 100644 --- a/explorer/models/customRegistries.ts +++ b/explorer/models/customRegistries.ts @@ -127,5 +127,5 @@ async function refresh(): Promise { async function saveCustomRegistriesNonsensitive(registries: CustomRegistry[]): Promise { let minimal: CustomRegistryNonsensitive[] = registries.map(reg => { url: reg.url }); - await ext.context.workspaceState.update(customRegistriesKey, minimal); + await ext.context.globalState.update(customRegistriesKey, minimal); } From 17b1f92bc657c6dd0e575920253902188aa69bc2 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Fri, 24 Aug 2018 11:13:45 -0700 Subject: [PATCH 15/16] Fix test --- test/customRegistries.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/customRegistries.test.ts b/test/customRegistries.test.ts index 04050cf7b1..73aa38958a 100644 --- a/test/customRegistries.test.ts +++ b/test/customRegistries.test.ts @@ -74,7 +74,7 @@ suite("Custom registries", async function (this: Suite): Promise { test("Connect, no auth - keytar not available", async function (this: Context) { // Make sure extension activated - await commands.executeCommand('vscode-docker.refresh'); + await commands.executeCommand('vscode-docker.explorer.refresh'); let oldKeytar = ext.keytar; try { From 14080443acf615be946ad590efaab9b60bc26492 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Fri, 24 Aug 2018 11:19:50 -0700 Subject: [PATCH 16/16] Increase build tests timeout --- test/buildAndRun.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/buildAndRun.test.ts b/test/buildAndRun.test.ts index fd2dad1871..d10aa420fa 100644 --- a/test/buildAndRun.test.ts +++ b/test/buildAndRun.test.ts @@ -64,7 +64,7 @@ async function extractFolderTo(zip: AdmZip, sourceFolderInZip: string, outputFol } suite("Build Image", function (this: Suite): void { - this.timeout(Math.max(60 * 1000, this.timeout())); + this.timeout(Math.max(2 * 60 * 1000, this.timeout())); const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel('Docker extension tests'); ext.outputChannel = outputChannel;