diff --git a/package.json b/package.json index f26bdb1d2..44564ecaa 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,12 @@ "title": "%containerApps.editRevisionDraft%", "category": "Azure Container Apps" }, + { + "command": "containerApps.deployRevisionDraft", + "title": "%containerApps.deployRevisionDraft%", + "category": "Azure Container Apps", + "icon": "$(cloud-upload)" + }, { "command": "containerApps.discardRevisionDraft", "title": "%containerApps.discardRevisionDraft%", @@ -241,6 +247,16 @@ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem/i", "group": "2@2" }, + { + "command": "containerApps.deployRevisionDraft", + "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single(.*)unsavedChanges:true/i", + "group": "inline@1" + }, + { + "command": "containerApps.deployRevisionDraft", + "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single(.*)unsavedChanges:true/i", + "group": "3@1" + }, { "command": "containerApps.discardRevisionDraft", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single(.*)unsavedChanges:true/i", @@ -306,6 +322,16 @@ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionItem(.*)revisionState:active/i", "group": "2@4" }, + { + "command": "containerApps.deployRevisionDraft", + "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem(.*)unsavedChanges:true/i", + "group": "inline@1" + }, + { + "command": "containerApps.deployRevisionDraft", + "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem(.*)unsavedChanges:true/i", + "group": "1@1" + }, { "command": "containerApps.discardRevisionDraft", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem/i", diff --git a/package.nls.json b/package.nls.json index 48d23e026..2fb26e471 100644 --- a/package.nls.json +++ b/package.nls.json @@ -18,6 +18,7 @@ "containerApps.chooseRevisionMode": "Choose Revision Mode...", "containerApps.createRevisionDraft": "Create Draft...", "containerApps.editRevisionDraft": "Edit Draft (Advanced)...", + "containerApps.deployRevisionDraft": "Deploy Changes...", "containerApps.discardRevisionDraft": "Discard Changes...", "containerApps.activateRevision": "Activate Revision", "containerApps.deactivateRevision": "Deactivate Revision", diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index c80d76e06..e7625d496 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -27,6 +27,7 @@ import { chooseRevisionMode } from './revision/chooseRevisionMode'; import { deactivateRevision } from './revision/deactivateRevision'; import { restartRevision } from './revision/restartRevision'; import { createRevisionDraft } from './revisionDraft/createRevisionDraft'; +import { deployRevisionDraft } from './revisionDraft/deployRevisionDraft/deployRevisionDraft'; import { discardRevisionDraft } from './revisionDraft/discardRevisionDraft'; import { editRevisionDraft } from './revisionDraft/editRevisionDraft'; import { addScaleRule } from './scaling/addScaleRule/addScaleRule'; @@ -70,6 +71,7 @@ export function registerCommands(): void { // revision draft registerCommandWithTreeNodeUnwrapping('containerApps.createRevisionDraft', createRevisionDraft); registerCommandWithTreeNodeUnwrapping('containerApps.editRevisionDraft', editRevisionDraft); + registerCommandWithTreeNodeUnwrapping('containerApps.deployRevisionDraft', deployRevisionDraft); registerCommandWithTreeNodeUnwrapping('containerApps.discardRevisionDraft', discardRevisionDraft); // scaling diff --git a/src/commands/revisionDraft/deployRevisionDraft/DeployRevisionDraftConfirmStep.ts b/src/commands/revisionDraft/deployRevisionDraft/DeployRevisionDraftConfirmStep.ts new file mode 100644 index 000000000..6039c86e3 --- /dev/null +++ b/src/commands/revisionDraft/deployRevisionDraft/DeployRevisionDraftConfirmStep.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from "@microsoft/vscode-azext-utils"; +import { localize } from "../../../utils/localize"; +import type { IDeployRevisionDraftContext } from "./IDeployRevisionDraftContext"; + +export class DeployRevisionDraftConfirmStep extends AzureWizardPromptStep { + public async prompt(context: IDeployRevisionDraftContext): Promise { + await context.ui.showWarningMessage( + localize('deployRevisionWarning', 'This will deploy any unsaved changes to container app "{0}".', context.containerApp?.name), + { modal: true }, + { title: localize('continue', 'Continue') } + ); + } + + public shouldPrompt(context: IDeployRevisionDraftContext): boolean { + return !!context.template; + } +} diff --git a/src/commands/revisionDraft/deployRevisionDraft/DeployRevisionDraftStep.ts b/src/commands/revisionDraft/deployRevisionDraft/DeployRevisionDraftStep.ts new file mode 100644 index 000000000..417d51749 --- /dev/null +++ b/src/commands/revisionDraft/deployRevisionDraft/DeployRevisionDraftStep.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KnownActiveRevisionsMode } from "@azure/arm-appcontainers"; +import { AzureWizardExecuteStep, nonNullProp } from "@microsoft/vscode-azext-utils"; +import type { Progress } from "vscode"; +import { ext } from "../../../extensionVariables"; +import { ContainerAppItem, ContainerAppModel, getContainerEnvelopeWithSecrets } from "../../../tree/ContainerAppItem"; +import { RevisionDraftItem } from "../../../tree/revisionManagement/RevisionDraftItem"; +import { localize } from "../../../utils/localize"; +import { updateContainerApp } from "../../../utils/updateContainerApp"; +import type { IDeployRevisionDraftContext } from "./IDeployRevisionDraftContext"; + +export class DeployRevisionDraftStep extends AzureWizardExecuteStep { + public priority: number = 260; + + public async execute(context: IDeployRevisionDraftContext, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { + const containerApp: ContainerAppModel = nonNullProp(context, 'containerApp'); + const containerAppEnvelope = await getContainerEnvelopeWithSecrets(context, context.subscription, containerApp); + containerAppEnvelope.template = nonNullProp(context, 'template'); + + const creatingRevision: string = localize('creatingRevision', 'Creating revision...'); + progress.report({ message: creatingRevision }); + + const id: string = containerApp.revisionsMode === KnownActiveRevisionsMode.Single ? containerApp.id : `${containerApp.id}/${RevisionDraftItem.idSuffix}`; + + await ext.state.runWithTemporaryDescription(id, creatingRevision, async () => { + await updateContainerApp(context, context.subscription, containerAppEnvelope); + const updatedContainerApp = await ContainerAppItem.Get(context, context.subscription, containerApp.resourceGroup, containerApp.name); + + if (containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple) { + // Display the name of the newly created revision when in multiple revisions mode + context.activityTitle = localize('deployRevision', 'Deploy revision "{0}" to container app "{1}"', updatedContainerApp.latestRevisionName, containerApp.name); + } + }); + } + + public shouldExecute(context: IDeployRevisionDraftContext): boolean { + return !!context.template; + } +} diff --git a/src/commands/revisionDraft/deployRevisionDraft/IDeployRevisionDraftContext.ts b/src/commands/revisionDraft/deployRevisionDraft/IDeployRevisionDraftContext.ts new file mode 100644 index 000000000..b03db4cba --- /dev/null +++ b/src/commands/revisionDraft/deployRevisionDraft/IDeployRevisionDraftContext.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Template } from "@azure/arm-appcontainers"; +import type { ExecuteActivityContext } from "@microsoft/vscode-azext-utils"; +import type { IContainerAppContext } from "../../IContainerAppContext"; + +export interface IDeployRevisionDraftContext extends IContainerAppContext, ExecuteActivityContext { + template: Template | undefined; +} diff --git a/src/commands/revisionDraft/deployRevisionDraft/deployRevisionDraft.ts b/src/commands/revisionDraft/deployRevisionDraft/deployRevisionDraft.ts new file mode 100644 index 000000000..543f050e6 --- /dev/null +++ b/src/commands/revisionDraft/deployRevisionDraft/deployRevisionDraft.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ContainerAppsAPIClient, KnownActiveRevisionsMode, Revision } from "@azure/arm-appcontainers"; +import { uiUtils } from "@microsoft/vscode-azext-azureutils"; +import { AzureWizard, AzureWizardExecuteStep, AzureWizardPromptStep, IActionContext, createSubscriptionContext, nonNullProp } from "@microsoft/vscode-azext-utils"; +import * as deepEqual from "deep-eql"; +import { ext } from "../../../extensionVariables"; +import type { ContainerAppItem, ContainerAppModel } from "../../../tree/ContainerAppItem"; +import { RevisionDraftItem } from "../../../tree/revisionManagement/RevisionDraftItem"; +import { createActivityContext } from "../../../utils/activityUtils"; +import { createContainerAppsAPIClient } from "../../../utils/azureClients"; +import { delay } from "../../../utils/delay"; +import { localize } from "../../../utils/localize"; +import { pickContainerApp } from "../../../utils/pickContainerApp"; +import { DeployRevisionDraftConfirmStep } from "./DeployRevisionDraftConfirmStep"; +import { DeployRevisionDraftStep } from "./DeployRevisionDraftStep"; +import type { IDeployRevisionDraftContext } from "./IDeployRevisionDraftContext"; + +export async function deployRevisionDraft(context: IActionContext, node?: ContainerAppItem | RevisionDraftItem): Promise { + const item = node ?? await pickContainerApp(context); + + const { subscription, containerApp } = item; + + const wizardContext: IDeployRevisionDraftContext = { + ...context, + ...createSubscriptionContext(subscription), + ...(await createActivityContext()), + subscription, + containerApp, + template: ext.revisionDraftFileSystem.parseRevisionDraft(item), + }; + + if (!await hasUnsavedChanges(wizardContext, item)) { + throw new Error(localize('noUnsavedChanges', 'No unsaved changes detected to deploy to container app "{0}".', containerApp.name)); + } + + const promptSteps: AzureWizardPromptStep[] = [ + new DeployRevisionDraftConfirmStep() + ]; + + const executeSteps: AzureWizardExecuteStep[] = [ + new DeployRevisionDraftStep() + ]; + + const wizard: AzureWizard = new AzureWizard(wizardContext, { + title: localize('deploy', 'Deploy changes to container app "{0}"', containerApp.name), + promptSteps, + executeSteps, + }); + + await wizard.prompt(); + await wizard.execute(); + + if (item.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { + ext.revisionDraftFileSystem.discardRevisionDraft(item); + } else { + await ext.state.showDeleting( + `${item.containerApp.id}/${RevisionDraftItem.idSuffix}`, + async () => { + // Add a short delay to display the deleting message + await delay(5); + ext.revisionDraftFileSystem.discardRevisionDraft(item); + } + ); + } + + ext.state.notifyChildrenChanged(item.containerApp.managedEnvironmentId); +} + +async function hasUnsavedChanges(context: IDeployRevisionDraftContext, item: ContainerAppItem | RevisionDraftItem): Promise { + const containerApp: ContainerAppModel = nonNullProp(context, 'containerApp'); + + if (context.containerApp?.revisionsMode === KnownActiveRevisionsMode.Single) { + return !!containerApp.template && !!context.template && !deepEqual(containerApp.template, context.template); + } else { + const client: ContainerAppsAPIClient = await createContainerAppsAPIClient(context); + const revisions: Revision[] = await uiUtils.listAllIterator(client.containerAppsRevisions.listRevisions(containerApp.resourceGroup, containerApp.name)); + + const baseRevisionName: string | undefined = ext.revisionDraftFileSystem.getRevisionDraftFile(item)?.baseRevisionName; + const baseRevision: Revision | undefined = revisions.find(revision => baseRevisionName && revision.name === baseRevisionName); + + return !!baseRevision?.template && !!context.template && !deepEqual(baseRevision.template, context.template); + } +} diff --git a/src/constants.ts b/src/constants.ts index c3356feb7..7d2bef58b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -74,3 +74,6 @@ export const DOCKERFILE_GLOB_PATTERN = '**/{*.[dD][oO][cC][kK][eE][rR][fF][iI][l export const revisionModeSingleContextValue: string = 'revisionMode:single'; export const revisionModeMultipleContextValue: string = 'revisionMode:multiple'; + +export const unsavedChangesTrueContextValue: string = 'unsavedChanges:true'; +export const unsavedChangesFalseContextValue: string = 'unsavedChanges:false'; diff --git a/src/tree/ContainerAppItem.ts b/src/tree/ContainerAppItem.ts index f92f1bb60..bd4377b68 100644 --- a/src/tree/ContainerAppItem.ts +++ b/src/tree/ContainerAppItem.ts @@ -11,7 +11,7 @@ import * as deepEqual from "deep-eql"; import { TreeItem, TreeItemCollapsibleState, Uri } from "vscode"; import { DeleteAllContainerAppsStep } from "../commands/deleteContainerApp/DeleteAllContainerAppsStep"; import { IDeleteContainerAppWizardContext } from "../commands/deleteContainerApp/IDeleteContainerAppWizardContext"; -import { revisionModeMultipleContextValue, revisionModeSingleContextValue } from "../constants"; +import { revisionModeMultipleContextValue, revisionModeSingleContextValue, unsavedChangesFalseContextValue, unsavedChangesTrueContextValue } from "../constants"; import { ext } from "../extensionVariables"; import { createActivityContext } from "../utils/activityUtils"; import { createContainerAppsAPIClient, createContainerAppsClient } from "../utils/azureClients"; @@ -21,12 +21,10 @@ import { treeUtils } from "../utils/treeUtils"; import type { ContainerAppsItem, TreeElementBase } from "./ContainerAppsBranchDataProvider"; import { LogsGroupItem } from "./LogsGroupItem"; import { ConfigurationItem } from "./configurations/ConfigurationItem"; +import { RevisionsDraftModel } from "./revisionManagement/RevisionDraftItem"; import { RevisionItem } from "./revisionManagement/RevisionItem"; import { RevisionsItem } from "./revisionManagement/RevisionsItem"; -const unsavedChangesTrueContextValue: string = 'unsavedChanges:true'; -const unsavedChangesFalseContextValue: string = 'unsavedChanges:false'; - export interface ContainerAppModel extends ContainerApp { id: string; name: string; @@ -35,7 +33,7 @@ export interface ContainerAppModel extends ContainerApp { revisionsMode: KnownActiveRevisionsMode; } -export class ContainerAppItem implements ContainerAppsItem { +export class ContainerAppItem implements ContainerAppsItem, RevisionsDraftModel { static readonly contextValue: string = 'containerAppItem'; static readonly contextValueRegExp: RegExp = new RegExp(ContainerAppItem.contextValue); @@ -164,7 +162,7 @@ export class ContainerAppItem implements ContainerAppsItem { ext.state.notifyChildrenChanged(this.containerApp.managedEnvironmentId); } - private hasUnsavedChanges(): boolean { + hasUnsavedChanges(): boolean { const draftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this); if (!this.containerApp.template || !draftTemplate) { return false; diff --git a/src/tree/revisionManagement/RevisionDraftItem.ts b/src/tree/revisionManagement/RevisionDraftItem.ts index 33056be0e..77e543002 100644 --- a/src/tree/revisionManagement/RevisionDraftItem.ts +++ b/src/tree/revisionManagement/RevisionDraftItem.ts @@ -3,17 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { KnownActiveRevisionsMode, Revision } from "@azure/arm-appcontainers"; -import { TreeElementBase, nonNullProp } from "@microsoft/vscode-azext-utils"; +import { ContainerAppsAPIClient, KnownActiveRevisionsMode, Revision, Template } from "@azure/arm-appcontainers"; +import { uiUtils } from "@microsoft/vscode-azext-azureutils"; +import { IActionContext, TreeElementBase, callWithTelemetryAndErrorHandling, createContextValue, createSubscriptionContext, nonNullProp } from "@microsoft/vscode-azext-utils"; import type { AzureSubscription } from "@microsoft/vscode-azureresources-api"; +import * as deepEqual from "deep-eql"; import { TreeItem, TreeItemCollapsibleState } from "vscode"; +import { unsavedChangesFalseContextValue, unsavedChangesTrueContextValue } from "../../constants"; import { ext } from "../../extensionVariables"; +import { createContainerAppsAPIClient } from "../../utils/azureClients"; import { localize } from "../../utils/localize"; import { treeUtils } from "../../utils/treeUtils"; import type { ContainerAppModel } from "../ContainerAppItem"; import { RevisionItem, type RevisionsItemModel } from "./RevisionItem"; -export class RevisionDraftItem implements RevisionsItemModel { +// For tree items that depend on the container app's revision draft template +export interface RevisionsDraftModel { + hasUnsavedChanges: () => boolean | Promise; +} + +export class RevisionDraftItem implements RevisionsItemModel, RevisionsDraftModel { static readonly idSuffix: string = 'revisionDraft'; static readonly contextValue: string = 'revisionDraftItem'; static readonly contextValueRegExp: RegExp = new RegExp(RevisionDraftItem.contextValue); @@ -37,6 +46,12 @@ export class RevisionDraftItem implements RevisionsItemModel { return this.revisionName.split('--').pop() ?? ''; } + private async getContextValues(): Promise { + const values: string[] = [RevisionDraftItem.contextValue]; + values.push(await this.hasUnsavedChanges() ? unsavedChangesTrueContextValue : unsavedChangesFalseContextValue); + return createContextValue(values); + } + static hasDescendant(item: RevisionsItemModel): boolean { if (item instanceof RevisionDraftItem) { return false; @@ -46,7 +61,7 @@ export class RevisionDraftItem implements RevisionsItemModel { return item.revision.name === revisionDraftBaseName; } - getTreeItem(): TreeItem { + async getTreeItem(): Promise { return { id: this.id, label: localize('draft', 'Draft'), @@ -54,7 +69,7 @@ export class RevisionDraftItem implements RevisionsItemModel { description: this.containerApp.latestRevisionName === this.revisionName ? localize('basedOnLatestRevision', 'Based on "{0}" (Latest)', this.baseRevisionName) : localize('basedOnRevision', 'Based on "{0}"', this.baseRevisionName), - contextValue: RevisionDraftItem.contextValue, + contextValue: await this.getContextValues(), collapsibleState: TreeItemCollapsibleState.Expanded }; } @@ -62,4 +77,21 @@ export class RevisionDraftItem implements RevisionsItemModel { getChildren(): TreeElementBase[] { return RevisionItem.getTemplateChildren(this.subscription, this.containerApp, this.revision); } + + async hasUnsavedChanges(): Promise { + const revisions: Revision[] | undefined = await callWithTelemetryAndErrorHandling('revisionDraftItem.hasUnsavedChanges.getRevisions', async (context: IActionContext) => { + const client: ContainerAppsAPIClient = await createContainerAppsAPIClient([context, createSubscriptionContext(this.subscription)]); + return uiUtils.listAllIterator(client.containerAppsRevisions.listRevisions(this.containerApp.resourceGroup, this.containerApp.name)); + }); + + const baseRevisionName: string | undefined = ext.revisionDraftFileSystem.getRevisionDraftFile(this)?.baseRevisionName; + const baseRevision: Revision | undefined = revisions?.find(revision => baseRevisionName && revision.name === baseRevisionName); + const draftTemplate: Template | undefined = ext.revisionDraftFileSystem.parseRevisionDraft(this); + + if (!baseRevision?.template || !draftTemplate) { + return false; + } + + return !deepEqual(baseRevision.template, draftTemplate); + } } diff --git a/src/tree/scaling/ScaleItem.ts b/src/tree/scaling/ScaleItem.ts index e917fc256..05502d1bc 100644 --- a/src/tree/scaling/ScaleItem.ts +++ b/src/tree/scaling/ScaleItem.ts @@ -13,7 +13,7 @@ import { localize } from "../../utils/localize"; import { treeUtils } from "../../utils/treeUtils"; import type { ContainerAppModel } from "../ContainerAppItem"; import type { TreeElementBase } from "../ContainerAppsBranchDataProvider"; -import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; +import { RevisionDraftItem, RevisionsDraftModel } from "../revisionManagement/RevisionDraftItem"; import type { RevisionsItemModel } from "../revisionManagement/RevisionItem"; import { createScaleRuleGroupItem } from "./ScaleRuleGroupItem"; @@ -21,7 +21,7 @@ const minMaxReplicaItemContextValue: string = 'minMaxReplicaItem'; const scaling: string = localize('scaling', 'Scaling'); -export class ScaleItem implements RevisionsItemModel { +export class ScaleItem implements RevisionsItemModel, RevisionsDraftModel { static readonly contextValue: string = 'scaleItem'; static readonly contextValueRegExp: RegExp = new RegExp(ScaleItem.contextValue); @@ -77,7 +77,7 @@ export class ScaleItem implements RevisionsItemModel { ]; } - private hasUnsavedChanges(): boolean { + hasUnsavedChanges(): boolean { const scaleDraftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale; if (!scaleDraftTemplate) { return false;