diff --git a/package.json b/package.json index 6796efe60e..93e723f8d7 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "onCommand:vscode-docker.registries.copyImageDigest", "onCommand:vscode-docker.registries.deleteImage", "onCommand:vscode-docker.registries.deployImageToAzure", + "onCommand:vscode-docker.registries.deployImageToAci", "onCommand:vscode-docker.registries.disconnectRegistry", "onCommand:vscode-docker.registries.dockerHub.openInBrowser", "onCommand:vscode-docker.registries.help", @@ -145,6 +146,10 @@ { "command": "vscode-docker.contexts.create.aci", "when": "vscode-docker:newCliPresent" + }, + { + "command": "vscode-docker.registries.deployImageToAci", + "when": "vscode-docker:newCliPresent" } ], "editor/context": [ @@ -356,17 +361,17 @@ }, { "command": "vscode-docker.containers.start", - "when": "view == dockerContainers && viewItem =~ /^(created|dead|exited|paused|terminated)Container$/i && vscode-docker:aciContext != true", + "when": "view == dockerContainers && viewItem =~ /^(created|dead|exited|paused|terminated)Container$/i", "group": "containers_1_general@5" }, { "command": "vscode-docker.containers.stop", - "when": "view == dockerContainers && viewItem =~ /^(paused|restarting|running)Container$/i && vscode-docker:aciContext != true", + "when": "view == dockerContainers && viewItem =~ /^(paused|restarting|running)Container$/i", "group": "containers_1_general@6" }, { "command": "vscode-docker.containers.restart", - "when": "view == dockerContainers && viewItem =~ /^runningContainer$/i && vscode-docker:aciContext != true", + "when": "view == dockerContainers && viewItem =~ /^runningContainer$/i", "group": "containers_1_general@7" }, { @@ -469,6 +474,11 @@ "when": "view == dockerRegistries && viewItem =~ /(DockerV2|DockerHubV2);Tag;/ && isAzureAccountInstalled", "group": "regs_tag_1_general@3" }, + { + "command": "vscode-docker.registries.deployImageToAci", + "when": "view == dockerRegistries && viewItem =~ /(DockerV2|DockerHubV2);Tag;/ && vscode-docker:newCliPresent", + "group": "regs_tag_1_general@4" + }, { "command": "vscode-docker.registries.azure.untagImage", "when": "view == dockerRegistries && viewItem == azure;DockerV2;Tag;", @@ -551,17 +561,17 @@ }, { "command": "vscode-docker.contexts.inspect", - "when": "view == vscode-docker.views.dockerContexts && viewItem =~ /Context$/i", + "when": "view == vscode-docker.views.dockerContexts && viewItem =~ /Context/i", "group": "contexts_1_general@1" }, { "command": "vscode-docker.contexts.use", - "when": "view == vscode-docker.views.dockerContexts && viewItem =~ /Context$/i", + "when": "view == vscode-docker.views.dockerContexts && viewItem =~ /Context/i", "group": "contexts_1_general@2" }, { "command": "vscode-docker.contexts.remove", - "when": "view == vscode-docker.views.dockerContexts && viewItem =~ /^customContext$/i", + "when": "view == vscode-docker.views.dockerContexts && viewItem =~ /^customContext/i", "group": "contexts_2_destructive@1" } ] @@ -2498,6 +2508,11 @@ "title": "%vscode-docker.commands.registries.deployImageToAzure%", "category": "%vscode-docker.commands.category.dockerRegistries%" }, + { + "command": "vscode-docker.registries.deployImageToAci", + "title": "%vscode-docker.commands.registries.deployImageToAci%", + "category": "%vscode-docker.commands.category.dockerRegistries%" + }, { "command": "vscode-docker.registries.disconnectRegistry", "title": "%vscode-docker.commands.registries.disconnectRegistry%", diff --git a/package.nls.json b/package.nls.json index ab3dfb3565..a7d341b774 100644 --- a/package.nls.json +++ b/package.nls.json @@ -225,6 +225,7 @@ "vscode-docker.commands.registries.copyImageDigest": "Copy Image Digest", "vscode-docker.commands.registries.deleteImage": "Delete Image...", "vscode-docker.commands.registries.deployImageToAzure": "Deploy Image to Azure App Service...", + "vscode-docker.commands.registries.deployImageToAci": "Deploy Image to Azure Container Instances...", "vscode-docker.commands.registries.disconnectRegistry": "Disconnect", "vscode-docker.commands.registries.dockerHub.openInBrowser": "Open in Browser", "vscode-docker.commands.registries.help": "Registries Help", diff --git a/src/commands/containers/confirmAllAffectedContainers.ts b/src/commands/containers/confirmAllAffectedContainers.ts new file mode 100644 index 0000000000..759759a961 --- /dev/null +++ b/src/commands/containers/confirmAllAffectedContainers.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DialogResponses, IActionContext } from 'vscode-azureextensionui'; +import { ext } from '../../extensionVariables'; +import { localize } from '../../localize'; +import { getComposeProjectName, NonComposeGroupName } from '../../tree/containers/ContainersTreeItem'; +import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; + +export async function confirmAllAffectedContainers(context: IActionContext, nodes: ContainerTreeItem[]): Promise { + if ((await ext.dockerContextManager.getCurrentContext()).Type !== 'aci' || + nodes.every(n => getComposeProjectName(n.containerItem) === NonComposeGroupName)) { + // If we're not in an ACI context, or every node in the list is not part of any ACI container group, return unchanged + return nodes.map(n => n.containerId); + } + + const groupsSet = new Set(); + + nodes.forEach(n => { + const groupName = getComposeProjectName(n.containerItem); + + groupsSet.add(groupName === NonComposeGroupName ? n.containerId : groupName); + }); + + const groupsList = Array.from(groupsSet); + const groupsConfirm = groupsList.map(g => `\'${g}\'`).join(', '); + + const confirm = localize('vscode-docker.commands.containers.aciContainerActionWarning.confirm', 'ACI containers can only be started or stopped in a group. This action will apply to all containers in {0}. Do you want to proceed?', groupsConfirm); + + // No need to check result - cancel will throw a UserCancelledError + await ext.ui.showWarningMessage(confirm, { modal: true }, DialogResponses.yes); + + return groupsList; +} diff --git a/src/commands/containers/restartContainer.ts b/src/commands/containers/restartContainer.ts index 6fe114d62b..6e04ffbf1d 100644 --- a/src/commands/containers/restartContainer.ts +++ b/src/commands/containers/restartContainer.ts @@ -9,6 +9,7 @@ import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; import { multiSelectNodes } from '../../utils/multiSelectNodes'; +import { confirmAllAffectedContainers } from './confirmAllAffectedContainers'; export async function restartContainer(context: IActionContext, node?: ContainerTreeItem, nodes?: ContainerTreeItem[]): Promise { nodes = await multiSelectNodes( @@ -19,9 +20,11 @@ export async function restartContainer(context: IActionContext, node?: Container nodes ); + const references = await confirmAllAffectedContainers(context, nodes); + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: localize('vscode-docker.commands.containers.restart.restarting', 'Restarting Container(s)...') }, async () => { - await Promise.all(nodes.map(async n => { - await ext.dockerClient.restartContainer(context, n.containerId); + await Promise.all(references.map(async ref => { + await ext.dockerClient.restartContainer(context, ref); })); }); } diff --git a/src/commands/containers/startContainer.ts b/src/commands/containers/startContainer.ts index bc5657e392..c8a469a543 100644 --- a/src/commands/containers/startContainer.ts +++ b/src/commands/containers/startContainer.ts @@ -9,6 +9,7 @@ import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; import { multiSelectNodes } from '../../utils/multiSelectNodes'; +import { confirmAllAffectedContainers } from './confirmAllAffectedContainers'; export async function startContainer(context: IActionContext, node?: ContainerTreeItem, nodes?: ContainerTreeItem[]): Promise { nodes = await multiSelectNodes( @@ -19,9 +20,11 @@ export async function startContainer(context: IActionContext, node?: ContainerTr nodes ); + const references = await confirmAllAffectedContainers(context, nodes); + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: localize('vscode-docker.commands.containers.start.starting', 'Starting Container(s)...') }, async () => { - await Promise.all(nodes.map(async n => { - await ext.dockerClient.startContainer(context, n.containerId); + await Promise.all(references.map(async ref => { + await ext.dockerClient.startContainer(context, ref); })); }); } diff --git a/src/commands/containers/stopContainer.ts b/src/commands/containers/stopContainer.ts index 100dd27643..f42d79d8d6 100644 --- a/src/commands/containers/stopContainer.ts +++ b/src/commands/containers/stopContainer.ts @@ -9,6 +9,7 @@ import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; import { multiSelectNodes } from '../../utils/multiSelectNodes'; +import { confirmAllAffectedContainers } from './confirmAllAffectedContainers'; export async function stopContainer(context: IActionContext, node?: ContainerTreeItem, nodes?: ContainerTreeItem[]): Promise { nodes = await multiSelectNodes( @@ -19,9 +20,11 @@ export async function stopContainer(context: IActionContext, node?: ContainerTre nodes ); + const references = await confirmAllAffectedContainers(context, nodes); + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: localize('vscode-docker.commands.containers.stop.stopping', 'Stopping Container(s)...') }, async () => { - await Promise.all(nodes.map(async n => { - await ext.dockerClient.stopContainer(context, n.containerId); + await Promise.all(references.map(async ref => { + await ext.dockerClient.stopContainer(context, ref); })); }); } diff --git a/src/commands/context/aci/createAciContext.ts b/src/commands/context/aci/createAciContext.ts index 13c132b282..0b16c836f5 100644 --- a/src/commands/context/aci/createAciContext.ts +++ b/src/commands/context/aci/createAciContext.ts @@ -3,104 +3,9 @@ * 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, parseError, ResourceGroupListStep } from 'vscode-azureextensionui'; +import { IActionContext } 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()); - - // 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); - const createdContext: string = localize('vscode-docker.commands.contexts.create.aci.createdContext', 'Created 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} --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; - } - } - - ext.outputChannel.appendLine(createdContext); - progress.report({ message: createdContext }); - } - - 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; - } + await ext.contextsRoot.createChild(actionContext); } diff --git a/src/commands/context/inspectDockerContext.ts b/src/commands/context/inspectDockerContext.ts index 3e09ea7048..c99129d91e 100644 --- a/src/commands/context/inspectDockerContext.ts +++ b/src/commands/context/inspectDockerContext.ts @@ -12,7 +12,8 @@ export async function inspectDockerContext(actionContext: IActionContext, node?: if (!node) { node = await ext.contextsTree.showTreeItemPicker(ContextTreeItem.allContextRegExp, { ...actionContext, - noItemFoundErrorMessage: localize('vscode-docker.commands.contexts.inspect.noContexts', 'No Docker contexts are available to inspect') + noItemFoundErrorMessage: localize('vscode-docker.commands.contexts.inspect.noContexts', 'No Docker contexts are available to inspect'), + suppressCreatePick: true, }); } diff --git a/src/commands/context/removeDockerContext.ts b/src/commands/context/removeDockerContext.ts index 1d195b9b9a..e9c9383f77 100644 --- a/src/commands/context/removeDockerContext.ts +++ b/src/commands/context/removeDockerContext.ts @@ -13,7 +13,8 @@ export async function removeDockerContext(actionContext: IActionContext, node?: if (!node) { node = await ext.contextsTree.showTreeItemPicker(ContextTreeItem.removableContextRegExp, { ...actionContext, - noItemFoundErrorMessage: localize('vscode-docker.commands.contexts.remove.noContexts', 'No Docker contexts are available to remove') + noItemFoundErrorMessage: localize('vscode-docker.commands.contexts.remove.noContexts', 'No Docker contexts are available to remove'), + suppressCreatePick: true, }); } diff --git a/src/commands/context/useDockerContext.ts b/src/commands/context/useDockerContext.ts index 53810b79a5..da16e39b2f 100644 --- a/src/commands/context/useDockerContext.ts +++ b/src/commands/context/useDockerContext.ts @@ -15,7 +15,8 @@ export async function useDockerContext(actionContext: IActionContext, node?: Con if (!node) { node = await ext.contextsTree.showTreeItemPicker(ContextTreeItem.allContextRegExp, { ...actionContext, - noItemFoundErrorMessage: localize('vscode-docker.commands.contexts.use.noContexts', 'No Docker contexts are available to use') + noItemFoundErrorMessage: localize('vscode-docker.commands.contexts.use.noContexts', 'No Docker contexts are available to use'), + suppressCreatePick: !(await ext.dockerContextManager.isNewCli()), }); invokedFromCommandPalette = true; } diff --git a/src/commands/images/tagImage.ts b/src/commands/images/tagImage.ts index 77a4aa0606..36993d1318 100644 --- a/src/commands/images/tagImage.ts +++ b/src/commands/images/tagImage.ts @@ -67,7 +67,6 @@ const KnownRegistries: { type: string, regex: RegExp }[] = [ export function addImageTaggingTelemetry(context: IActionContext, fullImageName: string, propertyPostfix: '.before' | '.after' | ''): void { try { - let defaultRegistryPath: string = vscode.workspace.getConfiguration('docker').get('defaultRegistryPath', ''); let properties: TelemetryProperties = {}; let [repository, tag] = extractRegExGroups(fullImageName, /^(.*):(.*)$/, [fullImageName, '']); @@ -77,8 +76,6 @@ export function addImageTaggingTelemetry(context: IActionContext, fullImageName: } properties.hasTag = String(!!tag); properties.numSlashes = String(numberMatches(repository.match(/\//g))); - properties.isDefaultRegistryPathInName = String(repository.startsWith(`${defaultRegistryPath}/`)); - properties.isDefaultRegistryPathSet = String(!!defaultRegistryPath); let knownRegistry = KnownRegistries.find(kr => !!repository.match(kr.regex)); if (knownRegistry) { diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index b99abf86a1..ad9549b275 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -51,6 +51,7 @@ import { registerWorkspaceCommand } from "./registerWorkspaceCommand"; import { createAzureRegistry } from "./registries/azure/createAzureRegistry"; import { deleteAzureRegistry } from "./registries/azure/deleteAzureRegistry"; import { deleteAzureRepository } from "./registries/azure/deleteAzureRepository"; +import { deployImageToAci } from "./registries/azure/deployImageToAci"; import { openInAzurePortal } from "./registries/azure/openInAzurePortal"; import { buildImageInAzure } from "./registries/azure/tasks/buildImageInAzure"; import { runAzureTask } from "./registries/azure/tasks/runAzureTask"; @@ -132,6 +133,7 @@ export function registerCommands(): void { registerCommand('vscode-docker.registries.copyImageDigest', copyRemoteImageDigest); registerCommand('vscode-docker.registries.deleteImage', deleteRemoteImage); registerCommand('vscode-docker.registries.deployImageToAzure', deployImageToAzure); + registerCommand('vscode-docker.registries.deployImageToAci', deployImageToAci); registerCommand('vscode-docker.registries.disconnectRegistry', disconnectRegistry); registerCommand('vscode-docker.registries.help', registryHelp); registerWorkspaceCommand('vscode-docker.registries.logInToDockerCli', logInToDockerCli); diff --git a/src/commands/registries/azure/deployImageToAci.ts b/src/commands/registries/azure/deployImageToAci.ts new file mode 100644 index 0000000000..adb9314387 --- /dev/null +++ b/src/commands/registries/azure/deployImageToAci.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IActionContext, parseError } from 'vscode-azureextensionui'; +import { ext } from '../../../extensionVariables'; +import { localize } from '../../../localize'; +import { ContextTreeItem } from '../../../tree/contexts/ContextTreeItem'; +import { registryExpectedContextValues } from '../../../tree/registries/registryContextValues'; +import { RemoteTagTreeItem } from '../../../tree/registries/RemoteTagTreeItem'; +import { executeAsTask } from '../../../utils/executeAsTask'; +import { execAsync } from '../../../utils/spawnAsync'; +import { addImageTaggingTelemetry } from '../../images/tagImage'; + +export async function deployImageToAci(context: IActionContext, node?: RemoteTagTreeItem): Promise { + if (!node) { + node = await ext.registriesTree.showTreeItemPicker([registryExpectedContextValues.dockerHub.tag, registryExpectedContextValues.dockerV2.tag], context); + } + + const aciContext = await ext.contextsTree.showTreeItemPicker([/aciContext;/i], context); + + // Switch to the other context if needed + if (!aciContext.current) { + await vscode.commands.executeCommand('vscode-docker.contexts.use', aciContext); + } + + // Log in to the registry to ensure the run actually succeeds + // If a registry was found/chosen and is still the same as the final tag's registry, try logging in + await vscode.commands.executeCommand('vscode-docker.registries.logInToDockerCli', node.parent.parent); + + const progressOptions = { + location: vscode.ProgressLocation.Notification, + title: localize('vscode-docker.commands.registries.deployImageToAci.gettingPorts', 'Determining ports from image...'), + }; + const ports = await vscode.window.withProgress(progressOptions, async () => { + return getImagePorts(node.fullTag); + }); + const portsArg = ports.map(port => `-p ${port}:${port}`).join(' '); + + addImageTaggingTelemetry(context, node.fullTag, ''); + + const command = `docker --context ${aciContext.name} run -d ${portsArg} ${node.fullTag}`; + const title = localize('vscode-docker.commands.registries.deployImageToAci.deploy', 'Deploy to ACI'); + const options = { + addDockerEnv: false, + }; + + try { + await executeAsTask(context, command, title, options); + } catch { + // If it fails, try logging in and make one more attempt + await executeAsTask(context, 'docker login azure', title, options); + await executeAsTask(context, command, title, options); + } +} + +async function getImagePorts(fullTag: string): Promise { + try { + const result: number[] = []; + + // 1. Pull the image to the default context + await execAsync(`docker --context default pull ${fullTag}`); + + // 2. Inspect it in the default context to find out the ports to map + const { stdout } = await execAsync(`docker --context default inspect ${fullTag} --format="{{ json .Config.ExposedPorts }}"`); + + try { + const portsJson = <{ [key: string]: never }>JSON.parse(stdout); + + for (const portAndProtocol of Object.keys(portsJson)) { + const portParts = portAndProtocol.split('/'); + result.push(Number.parseInt(portParts[0], 10)); + } + } catch { } // Best effort + + return result; + } catch (err) { + const error = parseError(err); + throw new Error(localize('vscode-docker.commands.registries.deployImageToAci.portsError', 'Unable to determine ports to expose. The error is: {0}', error.message)); + } +} diff --git a/src/commands/registries/azure/deployImageToAzure.ts b/src/commands/registries/azure/deployImageToAzure.ts index 7739d4ec01..fcca3dc17a 100644 --- a/src/commands/registries/azure/deployImageToAzure.ts +++ b/src/commands/registries/azure/deployImageToAzure.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { WebSiteManagementClient, WebSiteManagementModels } from '@azure/arm-appservice'; -import { NameValuePair } from 'request'; import { env, Progress, Uri, window } from "vscode"; import { AppKind, AppServicePlanListStep, IAppServiceWizardContext, SiteNameStep, WebsiteOS } from "vscode-azureappservice"; import { AzureWizard, AzureWizardExecuteStep, AzureWizardPromptStep, createAzureClient, IActionContext, LocationListStep, ResourceGroupListStep } from "vscode-azureextensionui"; @@ -82,7 +81,7 @@ async function getNewSiteConfig(node: RemoteTagTreeItem): Promise { throw new NotSupportedError(context); } + // #endregion Not supported by the Docker SDK yet public async startContainer(context: IActionContext, ref: string, token?: CancellationToken): Promise { - throw new NotSupportedError(context); + const request = new StartRequest() + .setId(ref); + + await this.promisify(context, this.containersClient, this.containersClient.start, request, token); } public async restartContainer(context: IActionContext, ref: string, token?: CancellationToken): Promise { - throw new NotSupportedError(context); + await this.stopContainer(context, ref, token); + await this.startContainer(context, ref, token); } public async stopContainer(context: IActionContext, ref: string, token?: CancellationToken): Promise { - // Supported by SDK, but is not really the same thing; containers in ACI must stop/start as a group - throw new NotSupportedError(context); + const request = new StopRequest() + .setId(ref); + + await this.promisify(context, this.containersClient, this.containersClient.stop, request, token); } - // #endregion Not supported by the Docker SDK yet public async removeContainer(context: IActionContext, ref: string, token?: CancellationToken): Promise { const request = new DeleteRequest() diff --git a/src/tree/containers/ContainerTreeItem.ts b/src/tree/containers/ContainerTreeItem.ts index 4dd24be5a0..5727b6ea91 100644 --- a/src/tree/containers/ContainerTreeItem.ts +++ b/src/tree/containers/ContainerTreeItem.ts @@ -56,6 +56,10 @@ export class ContainerTreeItem extends AzExtTreeItem { return this._item.Ports; } + public get containerItem(): DockerContainer { + return this._item; + } + /** * @deprecated This is only kept for backwards compatability with the "Remote Containers" extension * They add a context menu item "Attach Visual Studio Code" to our container nodes that relies on containerDesc diff --git a/src/tree/containers/ContainersTreeItem.ts b/src/tree/containers/ContainersTreeItem.ts index a9b888a5e0..398505a7dd 100644 --- a/src/tree/containers/ContainersTreeItem.ts +++ b/src/tree/containers/ContainersTreeItem.ts @@ -121,9 +121,9 @@ export class ContainersTreeItem extends LocalRootTreeItemBase ({ label: label, value: container.Labels[label] })); diff --git a/src/tree/contexts/ContextTreeItem.ts b/src/tree/contexts/ContextTreeItem.ts index 31b4fad8c9..40d2ba937b 100644 --- a/src/tree/contexts/ContextTreeItem.ts +++ b/src/tree/contexts/ContextTreeItem.ts @@ -10,8 +10,8 @@ import { getThemedIconPath, IconPath } from '../IconPath'; import { getTreeId } from "../LocalRootTreeItemBase"; export class ContextTreeItem extends AzExtTreeItem { - public static allContextRegExp: RegExp = /Context$/; - public static removableContextRegExp: RegExp = /^customContext$/i; + public static allContextRegExp: RegExp = /Context;/; + public static removableContextRegExp: RegExp = /^customContext;/i; private readonly _item: DockerContext; @@ -21,13 +21,21 @@ export class ContextTreeItem extends AzExtTreeItem { } public get contextValue(): string { + let result: string; + if (this.name === 'default') { - return 'defaultContext'; + result = 'defaultContext;'; } else if (this.current) { - return 'currentCustomContext'; + result = 'currentCustomContext;'; + } else { + result = 'customContext;'; + } + + if (this._item.Type === 'aci') { + result += 'aciContext;'; } - return 'customContext'; + return result; } public get createdTime(): number { diff --git a/src/tree/contexts/ContextsTreeItem.ts b/src/tree/contexts/ContextsTreeItem.ts index 66261c2d6c..1c13ec9d68 100644 --- a/src/tree/contexts/ContextsTreeItem.ts +++ b/src/tree/contexts/ContextsTreeItem.ts @@ -3,14 +3,20 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzExtTreeItem, IActionContext } from 'vscode-azureextensionui'; +import { AzExtTreeItem, AzureWizard, AzureWizardExecuteStep, AzureWizardPromptStep, IActionContext, ICreateChildImplContext, ResourceGroupListStep } from 'vscode-azureextensionui'; import { DockerContext } from '../../docker/Contexts'; import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; import { descriptionKey, labelKey, LocalChildGroupType, LocalChildType, LocalRootTreeItemBase } from "../LocalRootTreeItemBase"; +import { RegistryApi } from '../registries/all/RegistryApi'; +import { AzureAccountTreeItem } from '../registries/azure/AzureAccountTreeItem'; +import { azureRegistryProviderId } from '../registries/azure/azureRegistryProvider'; import { CommonGroupBy, groupByNoneProperty } from "../settings/CommonProperties"; import { ITreeArraySettingInfo, ITreeSettingInfo } from "../settings/ITreeSettingInfo"; import { ITreeSettingWizardInfo } from '../settings/ITreeSettingsWizardContext'; +import { AciContextCreateStep } from './aci/AciContextCreateStep'; +import { ContextNameStep } from './aci/ContextNameStep'; +import { IAciWizardContext } from './aci/IAciWizardContext'; import { ContextGroupTreeItem } from './ContextGroupTreeItem'; import { contextProperties, ContextProperty } from "./ContextProperties"; import { ContextTreeItem } from './ContextTreeItem'; @@ -21,6 +27,7 @@ export class ContextsTreeItem extends LocalRootTreeItemBase = ContextTreeItem; public childGroupType: LocalChildGroupType = ContextGroupTreeItem; + public createNewLabel: string = localize('vscode-docker.tree.Contexts.createNewLabel', 'Create new ACI context...'); public labelSettingInfo: ITreeSettingInfo = { properties: contextProperties, @@ -82,4 +89,47 @@ export class ContextsTreeItem extends LocalRootTreeItemBase { + 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()); + + // 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(); + + return new ContextTreeItem(this, { + Id: wizardContext.contextName, + Name: wizardContext.contextName, + Current: false, + DockerEndpoint: undefined, + CreatedTime: undefined, + Type: 'aci', + }); + } } diff --git a/src/tree/contexts/aci/AciContextCreateStep.ts b/src/tree/contexts/aci/AciContextCreateStep.ts new file mode 100644 index 0000000000..f40eaa114b --- /dev/null +++ b/src/tree/contexts/aci/AciContextCreateStep.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AzureWizardExecuteStep, parseError } from 'vscode-azureextensionui'; +import { ext } from '../../../extensionVariables'; +import { localize } from '../../../localize'; +import { execAsync } from '../../../utils/spawnAsync'; +import { IAciWizardContext } from './IAciWizardContext'; + +export 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); + const createdContext: string = localize('vscode-docker.commands.contexts.create.aci.createdContext', 'Created 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} --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; + } + } + + ext.outputChannel.appendLine(createdContext); + progress.report({ message: createdContext }); + } + + public shouldExecute(context: IAciWizardContext): boolean { + return true; + } +} diff --git a/src/tree/contexts/aci/ContextNameStep.ts b/src/tree/contexts/aci/ContextNameStep.ts new file mode 100644 index 0000000000..3fe3b04fdf --- /dev/null +++ b/src/tree/contexts/aci/ContextNameStep.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from 'vscode-azureextensionui'; +import { ext } from '../../../extensionVariables'; +import { localize } from '../../../localize'; +import { IAciWizardContext } from './IAciWizardContext'; + +export class ContextNameStep extends AzureWizardPromptStep { + public async prompt(context: IAciWizardContext): Promise { + context.contextName = await ext.ui.showInputBox({ prompt: localize('vscode-docker.tree.contexts.create.aci.enterContextName', 'Enter context name'), validateInput: validateContextName }); + } + + public shouldPrompt(wizardContext: IAciWizardContext): boolean { + return !wizardContext.contextName; + } +} + +// 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/tree/contexts/aci/IAciWizardContext.ts b/src/tree/contexts/aci/IAciWizardContext.ts new file mode 100644 index 0000000000..75be61c3b1 --- /dev/null +++ b/src/tree/contexts/aci/IAciWizardContext.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IResourceGroupWizardContext } from 'vscode-azureextensionui'; + +export interface IAciWizardContext extends IResourceGroupWizardContext { + contextName: string; +} diff --git a/src/tree/registries/RemoteTagTreeItem.ts b/src/tree/registries/RemoteTagTreeItem.ts index d18d2f8282..465910d1bc 100644 --- a/src/tree/registries/RemoteTagTreeItem.ts +++ b/src/tree/registries/RemoteTagTreeItem.ts @@ -35,6 +35,10 @@ export class RemoteTagTreeItem extends AzExtTreeItem { return this.parent.repoName + ':' + this.tag; } + public get fullTag(): string { + return `${this.parent.parent.baseImagePath}/${this.repoNameAndTag}`; + } + public get description(): string { return moment(this.time).fromNow(); } diff --git a/src/utils/executeAsTask.ts b/src/utils/executeAsTask.ts index 7af50185da..ce92c8ccc6 100644 --- a/src/utils/executeAsTask.ts +++ b/src/utils/executeAsTask.ts @@ -36,10 +36,15 @@ export async function executeAsTask(context: IActionContext, command: string, na const taskExecution = await vscode.tasks.executeTask(task); - const taskEndPromise = new Promise((resolve) => { + const taskEndPromise = new Promise((resolve, reject) => { const disposable = vscode.tasks.onDidEndTaskProcess(e => { if (e.execution === taskExecution) { disposable.dispose(); + + if (e.exitCode) { + reject(e.exitCode); + } + resolve(); } });