From 58c6be6dfc518dd8f658cdf3370dfb46b88ab407 Mon Sep 17 00:00:00 2001 From: "Brandon Waterloo [MSFT]" <36966225+bwateratmsft@users.noreply.github.com> Date: Wed, 8 Jul 2020 12:08:50 -0400 Subject: [PATCH] Support creating an ACI context from palette / contexts view (#2135) * Create ACI from command * Add VSCode context for command visibility * Don't show start/stop/restart --- package.json | 25 ++++- package.nls.json | 1 + src/commands/context/aci/createAciContext.ts | 103 +++++++++++++++++++ src/commands/registerCommands.ts | 2 + src/docker/ContextManager.ts | 48 ++++++++- 5 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 src/commands/context/aci/createAciContext.ts diff --git a/package.json b/package.json index f8121608a5..a3f8917fdc 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "onCommand:vscode-docker.contexts.configureExplorer", "onCommand:vscode-docker.contexts.refresh", "onCommand:vscode-docker.contexts.help", + "onCommand:vscode-docker.contexts.create.aci", "onCommand:workbench.action.tasks.runTask", "onDebugInitialConfigurations", "onDebugResolve:docker-coreclr", @@ -140,6 +141,10 @@ { "command": "vscode-docker.registries.reconnectRegistry", "when": "never" + }, + { + "command": "vscode-docker.contexts.create.aci", + "when": "vscode-docker:newCliPresent" } ], "editor/context": [ @@ -302,6 +307,11 @@ "when": "view == dockerVolumes", "group": "navigation@9" }, + { + "command": "vscode-docker.contexts.create.aci", + "when": "view == vscode-docker.views.dockerContexts && vscode-docker:newCliPresent", + "group": "navigation@1" + }, { "command": "vscode-docker.contexts.configureExplorer", "when": "view == vscode-docker.views.dockerContexts", @@ -346,17 +356,17 @@ }, { "command": "vscode-docker.containers.start", - "when": "view == dockerContainers && viewItem =~ /^(created|dead|exited|paused|terminated)Container$/i", + "when": "view == dockerContainers && viewItem =~ /^(created|dead|exited|paused|terminated)Container$/i && vscode-docker:aciContext != true", "group": "containers_1_general@5" }, { "command": "vscode-docker.containers.stop", - "when": "view == dockerContainers && viewItem =~ /^(paused|restarting|running)Container$/i", + "when": "view == dockerContainers && viewItem =~ /^(paused|restarting|running)Container$/i && vscode-docker:aciContext != true", "group": "containers_1_general@6" }, { "command": "vscode-docker.containers.restart", - "when": "view == dockerContainers && viewItem =~ /^runningContainer$/i", + "when": "view == dockerContainers && viewItem =~ /^runningContainer$/i && vscode-docker:aciContext != true", "group": "containers_1_general@7" }, { @@ -2527,6 +2537,15 @@ "title": "%vscode-docker.commands.contexts.help%", "category": "%vscode-docker.commands.category.contexts%", "icon": "$(question)" + }, + { + "command": "vscode-docker.contexts.create.aci", + "title": "%vscode-docker.commands.contexts.create.aci%", + "category": "%vscode-docker.commands.category.contexts%", + "icon": { + "light": "resources/light/add.svg", + "dark": "resources/dark/add.svg" + } } ], "views": { diff --git a/package.nls.json b/package.nls.json index 69024c37fc..4bce7d731e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -245,6 +245,7 @@ "vscode-docker.commands.contexts.configureExplorer": "Configure Explorer...", "vscode-docker.commands.contexts.refresh": "Refresh", "vscode-docker.commands.contexts.help": "Docker Context Help", + "vscode-docker.commands.contexts.create.aci": "Create Azure Container Instances Context...", "vscode-docker.commands.help": "Docker Help", "vscode-docker.commands.category.docker": "Docker", "vscode-docker.commands.category.dockerContainers": "Docker Containers", diff --git a/src/commands/context/aci/createAciContext.ts b/src/commands/context/aci/createAciContext.ts new file mode 100644 index 0000000000..89feb4898f --- /dev/null +++ b/src/commands/context/aci/createAciContext.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Progress } from 'vscode'; +import { AzureWizard, AzureWizardExecuteStep, AzureWizardPromptStep, IActionContext, IResourceGroupWizardContext, LocationListStep, parseError, ResourceGroupListStep } from 'vscode-azureextensionui'; +import { ext } from '../../../extensionVariables'; +import { localize } from '../../../localize'; +import { RegistryApi } from '../../../tree/registries/all/RegistryApi'; +import { AzureAccountTreeItem } from '../../../tree/registries/azure/AzureAccountTreeItem'; +import { azureRegistryProviderId } from '../../../tree/registries/azure/azureRegistryProvider'; +import { execAsync } from '../../../utils/spawnAsync'; + +interface IAciWizardContext extends IResourceGroupWizardContext { + contextName: string; +} + +export async function createAciContext(actionContext: IActionContext): Promise { + const wizardContext: IActionContext & Partial = { + ...actionContext, + }; + + // Set up the prompt steps + const promptSteps: AzureWizardPromptStep[] = [ + new ContextNameStep(), + ]; + + // Create a temporary azure account tree item since Azure might not be connected + const azureAccountTreeItem = new AzureAccountTreeItem(ext.registriesRoot, { id: azureRegistryProviderId, api: RegistryApi.DockerV2 }); + + // Add a subscription prompt step (skipped if there is exactly one subscription) + const subscriptionStep = await azureAccountTreeItem.getSubscriptionPromptStep(wizardContext); + if (subscriptionStep) { + promptSteps.push(subscriptionStep); + } + + // Add additional prompt steps + promptSteps.push(new ResourceGroupListStep()); + LocationListStep.addStep(wizardContext, promptSteps); + + // Set up the execute steps + const executeSteps: AzureWizardExecuteStep[] = [ + new AciContextCreateStep(), + ]; + + const title = localize('vscode-docker.commands.contexts.create.aci.title', 'Create new Azure Container Instances context'); + + const wizard = new AzureWizard(wizardContext, { title, promptSteps, executeSteps }); + await wizard.prompt(); + await wizard.execute(); +} + +class ContextNameStep extends AzureWizardPromptStep { + public async prompt(context: IAciWizardContext): Promise { + context.contextName = await ext.ui.showInputBox({ prompt: localize('vscode-docker.commands.contexts.create.aci.enterContextName', 'Enter context name'), validateInput: validateContextName }); + } + + public shouldPrompt(wizardContext: IActionContext): boolean { + return true; + } +} + +class AciContextCreateStep extends AzureWizardExecuteStep { + public priority: number = 200; + + public async execute(wizardContext: IAciWizardContext, progress: Progress<{ message?: string; increment?: number }>): Promise { + const creatingNewContext: string = localize('vscode-docker.commands.contexts.create.aci.creatingContext', 'Creating ACI context "{0}"...', wizardContext.contextName); + ext.outputChannel.appendLine(creatingNewContext); + progress.report({ message: creatingNewContext }); + + const command = `docker context create aci ${wizardContext.contextName} --subscription-id ${wizardContext.subscriptionId} --location ${wizardContext.location.name} --resource-group ${wizardContext.resourceGroup.name}`; + + try { + await execAsync(command); + } catch (err) { + const error = parseError(err); + + if (error.errorType === '5' || /not logged in/i.test(error.message)) { + // If error is due to being not logged in, we'll go through login and try again + await execAsync('docker login azure'); + await execAsync(command); + } else { + // Otherwise rethrow + throw err; + } + } + } + + public shouldExecute(context: IAciWizardContext): boolean { + return true; + } +} + +// Slightly more strict than CLI +const contextNameRegex = /^[a-z0-9][a-z0-9_-]+$/i; +function validateContextName(value: string | undefined): string | undefined { + if (!contextNameRegex.test(value)) { + return localize('vscode-docker.tree.contexts.create.aci.contextNameValidation', 'Context names must be start with an alphanumeric character and can only contain alphanumeric characters, underscores, and dashes.'); + } else { + return undefined; + } +} diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 15f59022cd..b99abf86a1 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -22,6 +22,7 @@ import { selectContainer } from "./containers/selectContainer"; import { startContainer } from "./containers/startContainer"; import { stopContainer } from "./containers/stopContainer"; import { viewContainerLogs } from "./containers/viewContainerLogs"; +import { createAciContext } from "./context/aci/createAciContext"; import { configureDockerContextsExplorer, dockerContextsHelp } from "./context/DockerContextsViewCommands"; import { inspectDockerContext } from "./context/inspectDockerContext"; import { removeDockerContext } from "./context/removeDockerContext"; @@ -163,6 +164,7 @@ export function registerCommands(): void { registerCommand('vscode-docker.contexts.inspect', inspectDockerContext); registerCommand('vscode-docker.contexts.remove', removeDockerContext); registerCommand('vscode-docker.contexts.use', useDockerContext); + registerCommand('vscode-docker.contexts.create.aci', createAciContext); registerLocalCommand('vscode-docker.installDocker', installDocker); diff --git a/src/docker/ContextManager.ts b/src/docker/ContextManager.ts index f7c859dc44..253821cccf 100644 --- a/src/docker/ContextManager.ts +++ b/src/docker/ContextManager.ts @@ -9,7 +9,7 @@ import * as fse from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import { URL } from 'url'; -import { Event, EventEmitter, workspace } from 'vscode'; +import { commands, Event, EventEmitter, workspace } from 'vscode'; import { Disposable } from 'vscode'; import { callWithTelemetryAndErrorHandling, IActionContext } from 'vscode-azureextensionui'; import { LineSplitter } from '../debugging/coreclr/lineSplitter'; @@ -40,6 +40,8 @@ const defaultContext: Partial = { Description: 'Current DOCKER_HOST based configuration', }; +type VSCodeContext = 'vscode-docker:aciContext' | 'vscode-docker:newSdkContext' | 'vscode-docker:newCliPresent'; + export interface ContextManager { readonly onContextChanged: Event; refresh(): Promise; @@ -48,6 +50,8 @@ export interface ContextManager { inspect(actionContext: IActionContext, contextName: string): Promise; use(actionContext: IActionContext, contextName: string): Promise; remove(actionContext: IActionContext, contextName: string): Promise; + + isNewCli(): Promise; } // TODO: consider a periodic refresh as a catch-all; but make sure it compares old data to new before firing a change event @@ -55,6 +59,7 @@ export interface ContextManager { export class DockerContextManager implements ContextManager, Disposable { private readonly emitter: EventEmitter = new EventEmitter(); private readonly contextsCache: AsyncLazy; + private readonly newCli: AsyncLazy; private readonly configFileWatcher: fs.FSWatcher; private readonly contextFolderWatcher: fs.FSWatcher; private refreshing: boolean = false; @@ -62,6 +67,8 @@ export class DockerContextManager implements ContextManager, Disposable { public constructor() { this.contextsCache = new AsyncLazy(async () => this.loadContexts()); + this.newCli = new AsyncLazy(async () => this.getCliVersion()); + /* eslint-disable @typescript-eslint/tslint/config */ this.configFileWatcher = fs.watch(dockerConfigFile, async () => this.refresh()); this.contextFolderWatcher = fs.watch(dockerContextsFolder, async () => this.refresh()); @@ -96,8 +103,14 @@ export class DockerContextManager implements ContextManager, Disposable { // Create a new client if (currentContext.Type === 'aci') { + // Currently vscode-docker:aciContext vscode-docker:newSdkContext mean the same thing + // But that probably won't be true in the future, so define both as separate concepts now + await this.setVsCodeContext('vscode-docker:aciContext', true); + await this.setVsCodeContext('vscode-docker:newSdkContext', true); ext.dockerClient = new DockerServeClient(); } else { + await this.setVsCodeContext('vscode-docker:aciContext', false); + await this.setVsCodeContext('vscode-docker:newSdkContext', false); ext.dockerClient = new DockerodeApiClient(currentContext); } @@ -106,6 +119,9 @@ export class DockerContextManager implements ContextManager, Disposable { } finally { this.refreshing = false; } + + // Lastly, trigger a CLI version check but don't wait + void this.newCli.getValue(); } public async getContexts(): Promise { @@ -130,6 +146,10 @@ export class DockerContextManager implements ContextManager, Disposable { await spawnAsync(removeCmd, ContextCmdExecOptions); } + public async isNewCli(): Promise { + return this.newCli.getValue(); + } + private async loadContexts(): Promise { let loadResult = await callWithTelemetryAndErrorHandling(ext.dockerClient ? 'docker-context.change' : 'docker-context.initialize', async (actionContext: IActionContext) => { try { @@ -214,4 +234,30 @@ export class DockerContextManager implements ContextManager, Disposable { return loadResult; } + + private async getCliVersion(): Promise { + let result: boolean = false; + const contexts = await this.contextsCache.getValue(); + + if (contexts.some(c => c.Type === 'aci')) { + // If there are any ACI contexts we automatically know it's the new CLI + result = true; + } else { + // Otherwise we look at the output of `docker serve --help` + // TODO: this is not a very good heuristic + const { stdout } = await execAsync('docker serve --help'); + + if (/^\s*Start an api server/i.test(stdout)) { + result = true; + } + } + + // Set the VSCode context to the result (which may expose commands, etc.) + await this.setVsCodeContext('vscode-docker:newCliPresent', result); + return result; + } + + private async setVsCodeContext(vsCodeContext: VSCodeContext, value: boolean): Promise { + return commands.executeCommand('setContext', vsCodeContext, value); + } }