From 2d9e62b1b8a273c9481c21ac1a9adfe721645639 Mon Sep 17 00:00:00 2001 From: "Stephen Weatherford (MSFT)" Date: Fri, 24 Aug 2018 12:33:16 -0700 Subject: [PATCH] Support private registries (#381) * Support private registries * Password flag * headers: * Fix test * increase timeout * test fixes * test fixes * comment * mock keytar * test fixes * test fixes * test fixes * PR fixes * Fix * Fix test * Increase build tests timeout --- .gitignore | 2 + commands/system-prune.ts | 2 +- commands/utils/TerminalProvider.ts | 102 +++++++++-- constants.ts | 3 + dockerExtension.ts | 42 +++-- explorer/deploy/webAppCreator.ts | 16 +- explorer/models/azureRegistryNodes.ts | 228 ++++++++++--------------- explorer/models/commonRegistryUtils.ts | 115 +++++++++++++ explorer/models/customRegistries.ts | 131 ++++++++++++++ explorer/models/customRegistryNodes.ts | 145 ++++++++++++++++ explorer/models/dockerHubNodes.ts | 63 +++---- explorer/models/registryRootNode.ts | 62 +++---- explorer/models/registryType.ts | 1 + explorer/models/rootNode.ts | 4 +- explorer/utils/azureUtils.ts | 6 +- explorer/utils/dockerHubUtils.ts | 51 ++++-- explorer/utils/utils.ts | 19 --- extensionVariables.ts | 2 + package.json | 26 ++- test/assertEx.ts | 6 +- test/buildAndRun.test.ts | 14 +- test/customRegistries.test.ts | 105 ++++++++++++ test/global.test.ts | 6 + test/test.code-workspace | 3 +- test/testKeytar.ts | 52 ++++++ tsconfig.json | 4 +- utils/getCoreNodeModule.ts | 23 +++ utils/keytar.ts | 78 +++++++++ 28 files changed, 1021 insertions(+), 290 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 create mode 100644 test/testKeytar.ts create mode 100644 utils/getCoreNodeModule.ts create mode 100644 utils/keytar.ts 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/commands/system-prune.ts b/commands/system-prune.ts index dce57276b3..3c23e5aaa7 100644 --- a/commands/system-prune.ts +++ b/commands/system-prune.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ 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/commands/utils/TerminalProvider.ts b/commands/utils/TerminalProvider.ts index 11d459787e..5a5530835d 100644 --- a/commands/utils/TerminalProvider.ts +++ b/commands/utils/TerminalProvider.ts @@ -34,7 +34,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; @@ -47,11 +47,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(); @@ -63,28 +65,96 @@ 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. 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') { + commands = [commands]; + } + + this.show(); + for (let command of commands) { + this.sendText(command); + } + + let results = await this.waitForCompletionCore(options); + + 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) => { @@ -98,10 +168,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); } @@ -111,16 +186,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/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 117c808c99..5889105df2 100644 --- a/dockerExtension.ts +++ b/dockerExtension.ts @@ -35,14 +35,16 @@ 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"; 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,25 +66,29 @@ const DOCUMENT_SELECTOR: DocumentSelector = [ { language: 'dockerfile', scheme: 'file' } ]; -// tslint:disable-next-line:max-func-body-length -export async function activate(ctx: vscode.ExtensionContext): Promise { - const installedExtensions: any[] = vscode.extensions.all; - const outputChannel = util.getOutputChannel(); - let azureAccount: AzureAccount | undefined; - - // 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 = Keytar.tryCreate(); + } +} + +export async function activate(ctx: vscode.ExtensionContext): Promise { + const installedExtensions: any[] = vscode.extensions.all; + let azureAccount: AzureAccount | undefined; + + initializeExtensionVariables(ctx); // tslint:disable-next-line:prefer-for-of // Grandfathered in for (let i = 0; i < installedExtensions.length; i++) { @@ -131,11 +137,11 @@ 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 (node?: AzureImageNode | DockerHubImageNode) => { - if (node) { + registerCommand('vscode-docker.createWebApp', async (context?: AzureImageTagNode | DockerHubImageTagNode) => { + if (context) { if (azureAccount) { const azureAccountWrapper = new AzureAccountWrapper(ctx, azureAccount); - const wizard = new WebAppCreator(outputChannel, azureAccountWrapper, node); + const wizard = new WebAppCreator(ext.outputChannel, azureAccountWrapper, context); const result = await wizard.run(); if (result.status === 'Faulted') { throw result.error; @@ -152,12 +158,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } }); registerCommand('vscode-docker.dockerHubLogout', dockerHubLogout); - registerCommand('vscode-docker.browseDockerHub', (node?: DockerHubImageNode | DockerHubRepositoryNode | DockerHubOrgNode) => { - browseDockerHub(node); + registerCommand('vscode-docker.browseDockerHub', (context?: DockerHubImageTagNode | DockerHubRepositoryNode | DockerHubOrgNode) => { + browseDockerHub(context); }); - registerCommand('vscode-docker.browseAzurePortal', (node?: AzureRegistryNode | AzureRepositoryNode | AzureImageNode) => { - browseAzurePortal(node); + 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 39a226f767..f758a4fef8 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 45740a8d6d..0e311af238 100644 --- a/explorer/models/azureRegistryNodes.ts +++ b/explorer/models/azureRegistryNodes.ts @@ -13,25 +13,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 { @@ -47,11 +48,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) { @@ -88,51 +89,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 { @@ -143,121 +137,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..fcde7cc385 --- /dev/null +++ b/explorer/models/commonRegistryUtils.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +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, 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'; +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=${PAGE_SIZE}&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..77b701fdf9 --- /dev/null +++ b/explorer/models/customRegistries.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +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 { CustomRegistryNode } from './customRegistryNodes'; + +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({ + 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", + password: true + }); + } + + let newRegistry: CustomRegistry = { + url, + credentials: { userName, password } + }; + + await CustomRegistryNode.verifyIsValidRegistryUrl(newRegistry); + + // Save + 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(); +} + +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 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(); + } +} + +function getUsernamePwdKey(registryUrl: string): string { + return `usernamepwd_${registryUrl}`; +} + +export async function getCustomRegistries(): Promise { + let nonsensitive = ext.context.globalState.get(customRegistriesKey) || []; + let registries: CustomRegistry[] = []; + + for (let reg of nonsensitive) { + await callWithTelemetryAndErrorHandling('getCustomRegistryUsernamePwd', async function (this: IActionContext): Promise { + this.suppressTelemetry = true; + + try { + 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}`); + } + }); + } + + 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.globalState.update(customRegistriesKey, minimal); +} diff --git a/explorer/models/customRegistryNodes.ts b/explorer/models/customRegistryNodes.ts new file mode 100644 index 0000000000..5f71a3cff8 --- /dev/null +++ b/explorer/models/customRegistryNodes.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +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 = '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') + }; + + 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 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 { + 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 = 'customImageTagNode'; + 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 34b3a0ac01..3491420ab0 100644 --- a/explorer/models/dockerHubNodes.ts +++ b/explorer/models/dockerHubNodes.ts @@ -9,18 +9,25 @@ 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 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') + }; + public repository: string; public userName: string; public password: string; @@ -46,11 +53,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; @@ -65,13 +68,18 @@ 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 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') + }; public repository: any; public userName: string; public password: string; @@ -85,48 +93,45 @@ 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 static readonly contextValue: string = 'dockerHubImageTagNode'; + public readonly contextValue: string = DockerHubImageTagNode.contextValue; + // 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 a2d10ce9c9..01f1a6a611 100644 --- a/explorer/models/registryRootNode.ts +++ b/explorer/models/registryRootNode.ts @@ -3,38 +3,36 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ +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 { ext } from '../../extensionVariables'; import { AzureAccount } from '../../typings/azure-account.api'; import { AsyncPool } from '../../utils/asyncpool'; import * as dockerHub from '../utils/dockerHubUtils' -import { getCoreNodeModule } 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'; // tslint:disable-next-line:no-var-requires const ContainerRegistryManagement = require('azure-arm-containerregistry'); export class RegistryRootNode extends NodeBase { - private _keytar: typeof keytarType; private _azureAccount: AzureAccount; 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._azureAccount = azureAccount; @@ -66,7 +64,8 @@ export class RegistryRootNode extends NodeBase { } else if (element.contextValue === 'dockerHubRootNode') { return this.getDockerHubOrgs(); } else { - return []; + assert(element.contextValue === 'customRootNode'); + return await this.getCustomRegistryNodes(); } } @@ -75,21 +74,19 @@ 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); + 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 (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); - } + 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); } else { return orgNodes; } @@ -101,11 +98,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; @@ -115,6 +108,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) { @@ -166,14 +169,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 e38737a3a3..3cdf09a9ee 100644 --- a/explorer/models/registryType.ts +++ b/explorer/models/registryType.ts @@ -6,5 +6,6 @@ export enum RegistryType { DockerHub, Azure, + Custom, Unknown } diff --git a/explorer/models/rootNode.ts b/explorer/models/rootNode.ts index f8347ca487..3579232de1 100644 --- a/explorer/models/rootNode.ts +++ b/explorer/models/rootNode.ts @@ -36,7 +36,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 ) { @@ -275,6 +275,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 bb75dab635..6c7b56a8a7 100644 --- a/explorer/utils/azureUtils.ts +++ b/explorer/utils/azureUtils.ts @@ -5,15 +5,15 @@ 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(node?: AzureRegistryNode | AzureRepositoryNode | AzureImageNode): void { +export function browseAzurePortal(node?: AzureRegistryNode | AzureRepositoryNode | AzureImageTagNode): void { if (node) { const tenantId: string = node.subscription.tenantId; const session: AzureSession = node.azureAccount.sessions.find((s, i, array) => s.tenantId.toLowerCase() === tenantId.toLowerCase()); let url: string = `${session.environment.portalUrl}/${tenantId}/#resource${node.registry.id}`; - if (node.contextValue === 'azureImageNode' || node.contextValue === 'azureRepositoryNode') { + if (node.contextValue === AzureImageTagNode.contextValue || node.contextValue === AzureRepositoryNode.contextValue) { url = `${url}/repository`; } opn(url); diff --git a/explorer/utils/dockerHubUtils.ts b/explorer/utils/dockerHubUtils.ts index 862acad93d..c3ffe8e456 100644 --- a/explorer/utils/dockerHubUtils.ts +++ b/explorer/utils/dockerHubUtils.ts @@ -7,9 +7,9 @@ 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 { DockerHubImageNode, DockerHubOrgNode, DockerHubRepositoryNode } from '../models/dockerHubNodes'; -import { getCoreNodeModule } from './utils'; +import { keytarConstants, PAGE_SIZE } from '../../constants'; +import { ext } from '../../extensionVariables'; +import { DockerHubImageTagNode, DockerHubOrgNode, DockerHubRepositoryNode } from '../models/dockerHubNodes'; let _token: Token; @@ -84,13 +84,32 @@ export interface Image { variant: any } -export async function dockerHubLogout(): Promise { +export interface ManifestFsLayer { + blobSum: string; +} + +export interface ManifestHistory { + v1Compatibility: string; // stringified ManifestHistoryV1Compatibility +} - 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); +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 { + 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; } @@ -109,7 +128,6 @@ export async function dockerHubLogin(): Promise<{ username: string, password: st } return; - } export function setDockerHubToken(token: string): void { @@ -137,7 +155,6 @@ async function login(username: string, password: string): Promise { } return t; - } export async function getUser(): Promise { @@ -162,7 +179,6 @@ export async function getUser(): Promise { } return u; - } export async function getRepositories(username: string): Promise { @@ -215,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 }, @@ -230,22 +246,21 @@ export async function getRepositoryTags(repository: Repository): Promise } return tagsPage.results; - } -export function browseDockerHub(node?: DockerHubImageNode | DockerHubRepositoryNode | DockerHubOrgNode): void { +export function browseDockerHub(node?: DockerHubImageTagNode | DockerHubRepositoryNode | DockerHubOrgNode): void { if (node) { let url: string = 'https://hub.docker.com/'; const repo: RepositoryInfo = node.repository; switch (node.contextValue) { - case 'dockerHubNamespace': + case DockerHubOrgNode.contextValue: url = `${url}u/${node.userName}`; break; - case 'dockerHubRepository': + case DockerHubRepositoryNode.contextValue: url = `${url}r/${node.repository.namespace}/${node.repository.name}`; break; - case 'dockerHubImageTag': + case DockerHubImageTagNode.contextValue: url = `${url}r/${node.repository.namespace}/${node.repository.name}/tags`; break; default: diff --git a/explorer/utils/utils.ts b/explorer/utils/utils.ts index 0b23afe2a3..a7dcce84ac 100644 --- a/explorer/utils/utils.ts +++ b/explorer/utils/utils.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; - export function trimWithElipsis(str: string, max: number = 10): string { const elipsis: string = "..."; const len: number = str.length; @@ -18,20 +16,3 @@ export function trimWithElipsis(str: string, max: number = 10): string { return front + elipsis + back; } - -/** - * Returns a node module installed with VSCode, or null 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 null; -} diff --git a/extensionVariables.ts b/extensionVariables.ts index 2eb5fe845a..dde2c2c673 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 | undefined; } diff --git a/package.json b/package.json index a02f823f82..f22014779c 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,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" ], @@ -190,7 +192,7 @@ }, { "command": "vscode-docker.createWebApp", - "when": "view == dockerExplorer && viewItem =~ /^(azureImageNode|dockerHubImageTag)$/" + "when": "view == dockerExplorer && viewItem =~ /^(azureImageTagNode|dockerHubImageTagNode|customImageTagNode)$/" }, { "command": "vscode-docker.dockerHubLogout", @@ -198,11 +200,19 @@ }, { "command": "vscode-docker.browseDockerHub", - "when": "view == dockerExplorer && viewItem =~ /^(dockerHubImageTag|dockerHubRepository|dockerHubNamespace)$/" + "when": "view == dockerExplorer && viewItem =~ /^(dockerHubImageTagNode|dockerHubRepositoryNode|dockerHubOrgNode)$/" }, { "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 =~ /^(customRegistryNode)$/" } ] }, @@ -543,6 +553,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/assertEx.ts b/test/assertEx.ts index a207a9d78a..5a3714dc58 100644 --- a/test/assertEx.ts +++ b/test/assertEx.ts @@ -7,6 +7,7 @@ import * as assert from 'assert'; import { parseError } from 'vscode-azureextensionui'; import * as fse from 'fs-extra'; import * as path from 'path'; +import { getTestRootFolder } from './global.test'; // Provides additional assertion-style functions for use in tests. @@ -70,11 +71,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 { @@ -89,4 +90,3 @@ export function assertFileContains(filePath: string, text: string): void { export function assertNotFileContains(filePath: string, text: string): void { assert(!fileContains(filePath, text), `Unexpected text '${text}' found in file ${filePath}`); } - diff --git a/test/buildAndRun.test.ts b/test/buildAndRun.test.ts index df5f86f794..cae6b81a52 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(60 * 1000); + this.timeout(2 * 60 * 1000); const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel('Docker extension tests'); ext.outputChannel = outputChannel; @@ -99,14 +99,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') @@ -122,9 +114,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..73aa38958a --- /dev/null +++ b/test/customRegistries.test.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { TestTerminalProvider } from '../commands/utils/TerminalProvider'; +import { TestUserInput } from 'vscode-azureextensionui'; + +const registryContainerName = 'test-registry'; + +suite("Custom registries", async function (this: Suite): Promise { + this.timeout(Math.max(60 * 1000 * 3, 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 * 2, this.timeout())); + + 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 5900:5000 registry`); + + 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 { + await stopRegistry(); + }); + + test("Connect, no auth", async function (this: Context) { + 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) + }); + + test("Connect, no auth - keytar not available", async function (this: Context) { + // Make sure extension activated + await commands.executeCommand('vscode-docker.explorer.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 1ba05a9495..42467e4bb8 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -8,6 +8,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 "../test/testKeytar"; export namespace constants { export const testOutputName = 'testOutput'; @@ -61,11 +63,15 @@ export function testInEmptyFolder(name: string, func?: () => Promise): voi // Runs before all tests suiteSetup(function (this: mocha.IHookCallbackContext): void { console.log('global.test.ts: suiteSetup'); + + // Otherwise the app can blocking asking for keychain access + ext.keytar = new TestKeytar(); }); // Runs after all tests suiteTeardown(function (this: mocha.IHookCallbackContext): void { console.log('global.test.ts: suiteTestdown'); + if (testRootFolder && path.basename(testRootFolder) === constants.testOutputName) { fse.emptyDir(testRootFolder); } 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": {} } 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/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 +} 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..9fef9e17d7 --- /dev/null +++ b/utils/keytar.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * 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 constructor(private _keytar: typeof keytarType) { + } + + public static tryCreate(): Keytar | undefined { + let keytar: typeof keytarType = getKeytarModule(); + if (keytar) { + return new Keytar(keytar); + } else { + return undefined; + } + } + + 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); + } +}