From 3436e1c319cb4e6a36c79fb1571ccfb89e05ffc7 Mon Sep 17 00:00:00 2001 From: Alex Weininger Date: Wed, 20 Nov 2024 10:27:31 -0800 Subject: [PATCH 01/10] Add tsaoptions.json (#790) --- .azure-pipelines/compliance/tsaoptions.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .azure-pipelines/compliance/tsaoptions.json diff --git a/.azure-pipelines/compliance/tsaoptions.json b/.azure-pipelines/compliance/tsaoptions.json new file mode 100644 index 000000000..fbe2ca1d1 --- /dev/null +++ b/.azure-pipelines/compliance/tsaoptions.json @@ -0,0 +1,18 @@ +{ + "tsaVersion": "TsaV2", + "codeBase": "NewOrUpdate", + "codeBaseName": "vscode-azurecontainerapps", + "tsaStamp": "DevDiv", + "notificationAliases": [ + "AzCode@microsoft.com" + ], + "codebaseAdmins": [ + "REDMOND\\jinglou", + "REDMOND\\AzCode" + ], + "instanceUrl": "https://devdiv.visualstudio.com", + "projectName": "DevDiv", + "areaPath": "DevDiv\\VS Azure Tools\\AzCode Extensions", + "iterationPath": "DevDiv", + "allTools": true +} From 77301cb2bab0fe341fc1f23f4f9981369a5edbea Mon Sep 17 00:00:00 2001 From: Matthew Fisher <40250218+MicroFish91@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:55:12 -0800 Subject: [PATCH 02/10] Update container item descendants so that they indicate any unsaved changes (#767) --- src/commands/registerCommands.ts | 2 +- src/tree/containers/ContainerItem.ts | 50 ++++++++-- src/tree/containers/ContainersItem.ts | 45 ++++++--- .../containers/EnvironmentVariableItem.ts | 82 ++++++++++++---- .../containers/EnvironmentVariablesItem.ts | 56 ++++++++--- src/tree/containers/ImageItem.ts | 96 +++++++++++++++---- src/tree/revisionManagement/RevisionItem.ts | 9 +- 7 files changed, 261 insertions(+), 79 deletions(-) diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 3f9331a56..00b99b187 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -59,7 +59,7 @@ export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping('containerApps.editContainerApp', editContainerApp); registerCommandWithTreeNodeUnwrapping('containerApps.openConsoleInPortal', openConsoleInPortal); registerCommandWithTreeNodeUnwrapping('containerApps.updateImage', updateImage); - registerCommandWithTreeNodeUnwrapping('containerapps.toggleEnvironmentVariableVisibility', + registerCommandWithTreeNodeUnwrapping('containerApps.toggleEnvironmentVariableVisibility', async (context: IActionContext, item: EnvironmentVariableItem) => { await item.toggleValueVisibility(context); }); diff --git a/src/tree/containers/ContainerItem.ts b/src/tree/containers/ContainerItem.ts index 3a539e0de..c836ef18b 100644 --- a/src/tree/containers/ContainerItem.ts +++ b/src/tree/containers/ContainerItem.ts @@ -3,31 +3,42 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type Container, type Revision } from "@azure/arm-appcontainers"; -import { nonNullProp, nonNullValue, type TreeElementBase } from "@microsoft/vscode-azext-utils"; +import { KnownActiveRevisionsMode, type Container, type Revision } from "@azure/arm-appcontainers"; +import { nonNullProp, type TreeElementBase } from "@microsoft/vscode-azext-utils"; import { type AzureSubscription, type ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; +import * as deepEqual from "deep-eql"; import { TreeItemCollapsibleState, type TreeItem } from "vscode"; import { getParentResource } from "../../utils/revisionDraftUtils"; import { type ContainerAppModel } from "../ContainerAppItem"; -import { type RevisionsItemModel } from "../revisionManagement/RevisionItem"; +import { RevisionDraftDescendantBase } from "../revisionManagement/RevisionDraftDescendantBase"; +import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; import { EnvironmentVariablesItem } from "./EnvironmentVariablesItem"; import { ImageItem } from "./ImageItem"; -export class ContainerItem implements RevisionsItemModel { +export class ContainerItem extends RevisionDraftDescendantBase { id: string; label: string; + static readonly contextValue: string = 'containerItem'; static readonly contextValueRegExp: RegExp = new RegExp(ContainerItem.contextValue); - constructor(readonly subscription: AzureSubscription, readonly containerApp: ContainerAppModel, readonly revision: Revision, readonly container: Container) { + constructor( + subscription: AzureSubscription, + containerApp: ContainerAppModel, + revision: Revision, + readonly containersIdx: number, + + // Used as the basis for the view; can reflect either the original or the draft changes + readonly container: Container, + ) { + super(subscription, containerApp, revision); this.id = `${this.parentResource.id}/${container.name}`; - this.label = nonNullValue(this.container.name); } getTreeItem(): TreeItem { return { id: this.id, - label: `${this.container.name}`, + label: this.label, contextValue: ContainerItem.contextValue, collapsibleState: TreeItemCollapsibleState.Collapsed, } @@ -35,8 +46,8 @@ export class ContainerItem implements RevisionsItemModel { getChildren(): TreeElementBase[] { return [ - new ImageItem(this.subscription, this.containerApp, this.revision, this.id, this.container), - new EnvironmentVariablesItem(this.subscription, this.containerApp, this.revision, this.id, this.container) + RevisionDraftDescendantBase.createTreeItem(ImageItem, this.subscription, this.containerApp, this.revision, this.containersIdx, this.container), + RevisionDraftDescendantBase.createTreeItem(EnvironmentVariablesItem, this.subscription, this.containerApp, this.revision, this.containersIdx, this.container), ]; } @@ -48,4 +59,25 @@ export class ContainerItem implements RevisionsItemModel { data: this.container, label: nonNullProp(this.container, 'name'), } + + protected setProperties(): void { + this.label = this.container.name ?? ''; + } + + protected setDraftProperties(): void { + this.label = `${this.container.name}*`; + } + + hasUnsavedChanges(): boolean { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return false; + } + + const currentContainers: Container[] = this.parentResource.template?.containers ?? []; + const currentContainer: Container | undefined = currentContainers[this.containersIdx]; + + return !currentContainer || !deepEqual(this.container, currentContainer); + } } + diff --git a/src/tree/containers/ContainersItem.ts b/src/tree/containers/ContainersItem.ts index 0bdbd16f3..43a3a3452 100644 --- a/src/tree/containers/ContainersItem.ts +++ b/src/tree/containers/ContainersItem.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { KnownActiveRevisionsMode, type Container, type Revision } from "@azure/arm-appcontainers"; -import { nonNullValue, nonNullValueAndProp, type TreeElementBase } from "@microsoft/vscode-azext-utils"; +import { nonNullValueAndProp, type TreeElementBase } from "@microsoft/vscode-azext-utils"; import { type AzureSubscription, type ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; import * as deepEqual from 'deep-eql'; import { TreeItemCollapsibleState, type TreeItem } from "vscode"; @@ -19,26 +19,36 @@ import { ContainerItem } from "./ContainerItem"; import { EnvironmentVariablesItem } from "./EnvironmentVariablesItem"; import { ImageItem } from "./ImageItem"; +export const container: string = localize('container', 'Container'); +export const containers: string = localize('containers', 'Containers'); + export class ContainersItem extends RevisionDraftDescendantBase { id: string; label: string; - private containers: Container[] = []; - constructor(public readonly subscription: AzureSubscription, - public readonly containerApp: ContainerAppModel, - public readonly revision: Revision,) { + static readonly contextValue: string = 'containersItem'; + static readonly contextValueRegExp: RegExp = new RegExp(ContainersItem.contextValue); + + constructor( + subscription: AzureSubscription, + containerApp: ContainerAppModel, + revision: Revision, + + // Used as the basis for the view; can reflect either the original or the draft changes + private containers: Container[], + ) { super(subscription, containerApp, revision); this.id = `${this.parentResource.id}/containers`; - this.containers = nonNullValue(revision.template?.containers); - this.label = this.containers.length === 1 ? localize('container', 'Container') : localize('containers', 'Containers'); } getChildren(): TreeElementBase[] { if (this.containers.length === 1) { - return [new ImageItem(this.subscription, this.containerApp, this.revision, this.id, this.containers[0]), - new EnvironmentVariablesItem(this.subscription, this.containerApp, this.revision, this.id, this.containers[0])]; + return [ + RevisionDraftDescendantBase.createTreeItem(ImageItem, this.subscription, this.containerApp, this.revision, 0, this.containers[0]), + RevisionDraftDescendantBase.createTreeItem(EnvironmentVariablesItem, this.subscription, this.containerApp, this.revision, 0, this.containers[0]), + ]; } - return nonNullValue(this.containers?.map(container => new ContainerItem(this.subscription, this.containerApp, this.revision, container))); + return this.containers?.map((container, idx) => RevisionDraftDescendantBase.createTreeItem(ContainerItem, this.subscription, this.containerApp, this.revision, idx, container)) ?? []; } getTreeItem(): TreeItem { @@ -46,22 +56,27 @@ export class ContainersItem extends RevisionDraftDescendantBase { id: this.id, label: this.label, iconPath: treeUtils.getIconPath('containers'), + contextValue: this.contextValue, collapsibleState: TreeItemCollapsibleState.Collapsed } } + private get contextValue(): string { + return ContainersItem.contextValue; + } + private get parentResource(): ContainerAppModel | Revision { return getParentResource(this.containerApp, this.revision); } protected setProperties(): void { - this.label = this.containers.length === 1 ? localize('container', 'Container') : localize('containers', 'Containers'); this.containers = nonNullValueAndProp(this.parentResource.template, 'containers'); + this.label = this.containers.length === 1 ? container : containers; } protected setDraftProperties(): void { - this.label = this.containers.length === 1 ? localize('container*', 'Container*') : localize('containers*', 'Containers*'); this.containers = nonNullValueAndProp(ext.revisionDraftFileSystem.parseRevisionDraft(this), 'containers'); + this.label = this.containers.length === 1 ? `${container}*` : `${containers}*`; } viewProperties: ViewPropertiesModel = { @@ -71,6 +86,12 @@ export class ContainersItem extends RevisionDraftDescendantBase { } } + static isContainersItem(item: unknown): item is ContainersItem { + return typeof item === 'object' && + typeof (item as ContainersItem).id === 'string' && + (item as ContainersItem).id.endsWith('/containers'); + } + hasUnsavedChanges(): boolean { // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { diff --git a/src/tree/containers/EnvironmentVariableItem.ts b/src/tree/containers/EnvironmentVariableItem.ts index e6f07627c..719b73316 100644 --- a/src/tree/containers/EnvironmentVariableItem.ts +++ b/src/tree/containers/EnvironmentVariableItem.ts @@ -3,52 +3,94 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type Container, type EnvironmentVar, type Revision } from "@azure/arm-appcontainers"; +import { KnownActiveRevisionsMode, type Container, type EnvironmentVar, type Revision } from "@azure/arm-appcontainers"; import { type IActionContext } from "@microsoft/vscode-azext-utils"; import { type AzureSubscription } from "@microsoft/vscode-azureresources-api"; +import * as deepEqual from "deep-eql"; import { ThemeIcon, type TreeItem } from "vscode"; import { ext } from "../../extensionVariables"; import { localize } from "../../utils/localize"; import { getParentResource } from "../../utils/revisionDraftUtils"; import { type ContainerAppModel } from "../ContainerAppItem"; -import { type RevisionsItemModel } from "../revisionManagement/RevisionItem"; - -export class EnvironmentVariableItem implements RevisionsItemModel { - _hideValue: boolean; - constructor(public readonly subscription: AzureSubscription, - public readonly containerApp: ContainerAppModel, - public readonly revision: Revision, - readonly containerId: string, +import { RevisionDraftDescendantBase } from "../revisionManagement/RevisionDraftDescendantBase"; +import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; + +const clickToView: string = localize('clickToView', 'Hidden value. Click to view.'); + +export class EnvironmentVariableItem extends RevisionDraftDescendantBase { + static readonly contextValue: string = 'environmentVariableItem'; + static readonly contextValueRegExp: RegExp = new RegExp(EnvironmentVariableItem.contextValue); + + id: string = `${this.parentResource.id}/${this.container.image}/${this.envVariable.name}`; + + private hideValue: boolean = true; + private hiddenMessage: string; // Shown when 'hideValue' is true + private hiddenValue: string; // Shown when 'hideValue' is false + + constructor( + subscription: AzureSubscription, + containerApp: ContainerAppModel, + revision: Revision, + readonly containersIdx: number, + + // Used as the basis for the view; can reflect either the original or the draft changes readonly container: Container, - readonly envVariable: EnvironmentVar) { - this._hideValue = true; + readonly envVariable: EnvironmentVar, + ) { + super(subscription, containerApp, revision); } - id: string = `${this.parentResource.id}/${this.container.image}/${this.envVariable.name}` getTreeItem(): TreeItem { return { - label: this._hideValue ? localize('viewHidden', '{0}=Hidden value. Click to view.', this.envVariable.name) : `${this.envVariable.name}=${this.envOutput}`, - description: this.envVariable.secretRef && !this._hideValue ? localize('secretRef', 'Secret reference') : undefined, - contextValue: 'environmentVariableItem', + label: this.label, + contextValue: EnvironmentVariableItem.contextValue, + description: this.envVariable.secretRef && !this.hideValue ? localize('secretRef', 'Secret reference') : undefined, iconPath: new ThemeIcon('symbol-constant'), command: { - command: 'containerapps.toggleEnvironmentVariableVisibility', + command: 'containerApps.toggleEnvironmentVariableVisibility', title: localize('commandtitle', 'Toggle Environment Variable Visibility'), - arguments: [this, this._hideValue,] + arguments: [this, this.hideValue,] } } } public async toggleValueVisibility(_: IActionContext): Promise { - this._hideValue = !this._hideValue; + this.hideValue = !this.hideValue; ext.branchDataProvider.refresh(this); } + public get label(): string { + return this.hideValue ? this.hiddenMessage : this.hiddenValue; + } + + private get envOutput(): string { + return this.envVariable.value || this.envVariable.secretRef || ''; + } + private get parentResource(): ContainerAppModel | Revision { return getParentResource(this.containerApp, this.revision); } - private get envOutput(): string { - return this.envVariable.value ?? this.envVariable.secretRef ?? ''; + protected setProperties(): void { + this.hiddenMessage = `${this.envVariable.name}=${clickToView}`; + this.hiddenValue = `${this.envVariable.name}=${this.envOutput}`; + } + + protected setDraftProperties(): void { + this.hiddenMessage = `${this.envVariable.name}=${clickToView} *`; + this.hiddenValue = `${this.envVariable.name}=${this.envOutput} *`; + } + + hasUnsavedChanges(): boolean { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return false; + } + + const currentContainers: Container[] = this.parentResource.template?.containers ?? []; + const currentContainer: Container | undefined = currentContainers[this.containersIdx]; + const currentEnv: EnvironmentVar | undefined = currentContainer.env?.find(env => env.name === this.envVariable.name); + + return !currentEnv || !deepEqual(this.envVariable, currentEnv); } } diff --git a/src/tree/containers/EnvironmentVariablesItem.ts b/src/tree/containers/EnvironmentVariablesItem.ts index 14c522e02..e591de771 100644 --- a/src/tree/containers/EnvironmentVariablesItem.ts +++ b/src/tree/containers/EnvironmentVariablesItem.ts @@ -3,46 +3,74 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type Container, type Revision } from "@azure/arm-appcontainers"; +import { KnownActiveRevisionsMode, type Container, type Revision } from "@azure/arm-appcontainers"; import { type TreeElementBase } from "@microsoft/vscode-azext-utils"; import { type AzureSubscription } from "@microsoft/vscode-azureresources-api"; +import * as deepEqual from "deep-eql"; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from "vscode"; import { localize } from "../../utils/localize"; import { getParentResource } from "../../utils/revisionDraftUtils"; import { type ContainerAppModel } from "../ContainerAppItem"; -import { type RevisionsItemModel } from "../revisionManagement/RevisionItem"; +import { RevisionDraftDescendantBase } from "../revisionManagement/RevisionDraftDescendantBase"; +import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; import { EnvironmentVariableItem } from "./EnvironmentVariableItem"; -export class EnvironmentVariablesItem implements RevisionsItemModel { +const environmentVariables: string = localize('environmentVariables', 'Environment Variables'); + +export class EnvironmentVariablesItem extends RevisionDraftDescendantBase { static readonly contextValue: string = 'environmentVariablesItem'; static readonly contextValueRegExp: RegExp = new RegExp(EnvironmentVariablesItem.contextValue); - constructor(public readonly subscription: AzureSubscription, - public readonly containerApp: ContainerAppModel, - public readonly revision: Revision, - readonly containerId: string, - readonly container: Container) { + constructor( + subscription: AzureSubscription, + containerApp: ContainerAppModel, + revision: Revision, + readonly containersIdx: number, + + // Used as the basis for the view; can reflect either the original or the draft changes + readonly container: Container, + ) { + super(subscription, containerApp, revision); } + id: string = `${this.parentResource.id}/environmentVariables/${this.container.image}`; + label: string; getTreeItem(): TreeItem { return { id: this.id, - label: localize('environmentVariables', 'Environment Variables'), + label: this.label, iconPath: new ThemeIcon('settings'), contextValue: EnvironmentVariablesItem.contextValue, collapsibleState: TreeItemCollapsibleState.Collapsed } } - getChildren(): TreeElementBase[] | undefined { - if (!this.container.env) { - return; - } - return this.container.env?.map(env => new EnvironmentVariableItem(this.subscription, this.containerApp, this.revision, this.id, this.container, env)); + getChildren(): TreeElementBase[] { + return this.container.env?.map(env => RevisionDraftDescendantBase.createTreeItem(EnvironmentVariableItem, this.subscription, this.containerApp, this.revision, this.containersIdx, this.container, env)) ?? []; } private get parentResource(): ContainerAppModel | Revision { return getParentResource(this.containerApp, this.revision); } + + protected setProperties(): void { + this.label = environmentVariables; + } + + protected setDraftProperties(): void { + this.label = `${environmentVariables}*`; + } + + hasUnsavedChanges(): boolean { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return false; + } + + const currentContainers: Container[] = this.parentResource.template?.containers ?? []; + const currentContainer: Container | undefined = currentContainers[this.containersIdx]; + + return !deepEqual(this.container.env ?? [], currentContainer?.env ?? []); + } } diff --git a/src/tree/containers/ImageItem.ts b/src/tree/containers/ImageItem.ts index 20b43dd9b..ee0f21ecb 100644 --- a/src/tree/containers/ImageItem.ts +++ b/src/tree/containers/ImageItem.ts @@ -3,52 +3,76 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type Container, type Revision } from "@azure/arm-appcontainers"; -import { createGenericElement, nonNullProp, nonNullValue, type TreeElementBase } from "@microsoft/vscode-azext-utils"; +import { KnownActiveRevisionsMode, type Container, type Revision } from "@azure/arm-appcontainers"; +import { createGenericElement, nonNullValue, nonNullValueAndProp, type TreeElementBase } from "@microsoft/vscode-azext-utils"; import { type AzureSubscription, type ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from "vscode"; import { localize } from "../../utils/localize"; import { getParentResource } from "../../utils/revisionDraftUtils"; import { type ContainerAppModel } from "../ContainerAppItem"; -import { type RevisionsItemModel } from "../revisionManagement/RevisionItem"; +import { RevisionDraftDescendantBase } from "../revisionManagement/RevisionDraftDescendantBase"; +import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem"; -export class ImageItem implements RevisionsItemModel { +export class ImageItem extends RevisionDraftDescendantBase { static readonly contextValue: string = 'imageItem'; static readonly contextValueRegExp: RegExp = new RegExp(ImageItem.contextValue); - readonly loginServer = this.container.image?.split('/')[0]; - readonly imageAndTag = this.container.image?.substring(nonNullValue(this.loginServer?.length) + 1, this.container.image?.length); constructor( - readonly subscription: AzureSubscription, - readonly containerApp: ContainerAppModel, - readonly revision: Revision, - readonly containerId: string, - readonly container: Container) { } - id: string = `${this.parentResource.id}/image/${this.imageAndTag}` + subscription: AzureSubscription, + containerApp: ContainerAppModel, + revision: Revision, + readonly containersIdx: number, + + // Used as the basis for the view; can reflect either the original or the draft changes + readonly container: Container, + ) { + super(subscription, containerApp, revision); + } + + id: string = `${this.parentResource.id}/image/${this.container.image}`; + label: string; + + viewProperties: ViewPropertiesModel = { + data: nonNullValueAndProp(this.container, 'image'), + label: this.container.name ?? '', + } + + private getImageName(image?: string): string { + const loginServer: string = this.getLoginServer(image); + if (!loginServer) return ''; + + return image?.substring(nonNullValue(loginServer.length) + 1, image?.length) ?? ''; + } + + private getLoginServer(image?: string): string { + return image?.split('/')[0] ?? ''; + } getTreeItem(): TreeItem { return { id: this.id, - label: localize('image', 'Image'), + label: this.label, iconPath: new ThemeIcon('window'), contextValue: ImageItem.contextValue, collapsibleState: TreeItemCollapsibleState.Collapsed, } } - getChildren(): TreeElementBase[] { + async getChildren(): Promise { + const { imageNameItem: isImageNameUnsaved, imageRegistryItem: isImageRegistryUnsaved } = this.doChildrenHaveUnsavedChanges(); + return [ createGenericElement({ id: `${this.id}/imageName`, - label: localize('containerImage', 'Name:'), + label: isImageNameUnsaved ? localize('imageNameDraft', 'Name*:') : localize('imageName', 'Name:'), contextValue: 'containerImageNameItem', - description: `${this.imageAndTag}`, + description: `${this.getImageName(this.container.image)}`, }), createGenericElement({ id: `${this.id}/imageRegistry`, - label: localize('containerImageRegistryItem', 'Registry:'), + label: isImageRegistryUnsaved ? localize('imageRegistryDraft', 'Registry*:') : localize('imageRegistry', 'Registry:'), contextValue: 'containerImageRegistryItem', - description: `${this.loginServer}`, + description: `${this.getLoginServer(this.container.image)}`, }) ]; } @@ -57,8 +81,38 @@ export class ImageItem implements RevisionsItemModel { return getParentResource(this.containerApp, this.revision); } - viewProperties: ViewPropertiesModel = { - data: this.container, - label: nonNullProp(this.container, 'name'), + protected setProperties(): void { + this.label = 'Image'; + } + + protected setDraftProperties(): void { + this.label = 'Image*'; + } + + private doChildrenHaveUnsavedChanges(): { imageNameItem: boolean, imageRegistryItem: boolean } { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return { imageNameItem: false, imageRegistryItem: false }; + } + + const currentContainers: Container[] = this.parentResource.template?.containers ?? []; + const currentContainer: Container = currentContainers[this.containersIdx]; + + return { + imageNameItem: this.getImageName(currentContainer.image) !== this.getImageName(this.container.image), + imageRegistryItem: this.getLoginServer(currentContainer.image) !== this.getLoginServer(this.container.image), + }; + } + + hasUnsavedChanges(): boolean { + // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode + if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple && !RevisionDraftItem.hasDescendant(this)) { + return false; + } + + const currentContainers: Container[] = this.parentResource.template?.containers ?? []; + const currentContainer: Container = currentContainers[this.containersIdx]; + + return this.container.image !== currentContainer.image; } } diff --git a/src/tree/revisionManagement/RevisionItem.ts b/src/tree/revisionManagement/RevisionItem.ts index f49b88542..bc0eb8b87 100644 --- a/src/tree/revisionManagement/RevisionItem.ts +++ b/src/tree/revisionManagement/RevisionItem.ts @@ -7,7 +7,7 @@ import { KnownActiveRevisionsMode, KnownRevisionProvisioningState, type Revision import { createContextValue, nonNullProp, type TreeItemIconPath } from "@microsoft/vscode-azext-utils"; import { type AzureSubscription, type ViewPropertiesModel } from "@microsoft/vscode-azureresources-api"; import { ThemeColor, ThemeIcon, TreeItemCollapsibleState, type TreeItem } from "vscode"; -import { revisionDraftFalseContextValue, revisionDraftTrueContextValue, revisionModeMultipleContextValue, revisionModeSingleContextValue } from "../../constants"; +import { revisionDraftFalseContextValue, revisionDraftTrueContextValue } from "../../constants"; import { ext } from "../../extensionVariables"; import { localize } from "../../utils/localize"; import { type ContainerAppModel } from "../ContainerAppItem"; @@ -43,7 +43,6 @@ export class RevisionItem implements RevisionsItemModel { values.push(ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(this) ? revisionDraftTrueContextValue : revisionDraftFalseContextValue); values.push(this.revision.active ? revisionStateActiveContextValue : revisionStateInactiveContextValue); - values.push(this.revisionsMode === KnownActiveRevisionsMode.Single ? revisionModeSingleContextValue : revisionModeMultipleContextValue); return createContextValue(values); } @@ -120,4 +119,10 @@ export class RevisionItem implements RevisionsItemModel { return new ThemeIcon(id, colorId ? new ThemeColor(colorId) : undefined); } + + static isRevisionItem(item: unknown): item is RevisionItem { + return typeof item === 'object' && + typeof (item as RevisionItem).contextValue === 'string' && + RevisionItem.contextValueRegExp.test((item as RevisionItem).contextValue); + } } From 94c41d67178f4076186363afbbed0530a0b3520a Mon Sep 17 00:00:00 2001 From: Matthew Fisher <40250218+MicroFish91@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:32:13 -0800 Subject: [PATCH 03/10] Add an `Edit Container...` command (#769) --- package.json | 30 ++---- package.nls.json | 2 +- .../editContainer/ContainerEditContext.ts | 16 ++++ .../editContainer/ContainerEditDraftStep.ts | 74 +++++++++++++++ .../RegistryAndSecretsUpdateStep.ts} | 19 ++-- src/commands/editContainer/editContainer.ts | 70 ++++++++++++++ .../ContainerRegistryImageConfigureStep.ts | 13 ++- .../image/updateImage/UpdateImageDraftStep.ts | 44 --------- src/commands/image/updateImage/updateImage.ts | 80 ---------------- src/commands/registerCommands.ts | 6 +- .../scaling/scaleRange/editScaleRange.ts | 4 +- src/telemetry/commandTelemetryProps.ts | 2 +- src/tree/containers/ContainersItem.ts | 2 +- src/utils/pickItem/pickContainer.ts | 91 +++++++++++++++++++ src/utils/revisionDraftUtils.ts | 6 +- 15 files changed, 291 insertions(+), 168 deletions(-) create mode 100644 src/commands/editContainer/ContainerEditContext.ts create mode 100644 src/commands/editContainer/ContainerEditDraftStep.ts rename src/commands/{image/updateImage/UpdateRegistryAndSecretsStep.ts => editContainer/RegistryAndSecretsUpdateStep.ts} (83%) create mode 100644 src/commands/editContainer/editContainer.ts delete mode 100644 src/commands/image/updateImage/UpdateImageDraftStep.ts delete mode 100644 src/commands/image/updateImage/updateImage.ts create mode 100644 src/utils/pickItem/pickContainer.ts diff --git a/package.json b/package.json index b837aea4d..053609bd2 100644 --- a/package.json +++ b/package.json @@ -80,11 +80,6 @@ "title": "%containerApps.editContainerApp%", "category": "Azure Container Apps" }, - { - "command": "containerApps.updateImage", - "title": "%containerApps.updateImage%", - "category": "Azure Container Apps" - }, { "command": "containerApps.deployImageApi", "title": "%containerApps.deployImageApi%", @@ -203,6 +198,11 @@ "title": "%containerApps.openConsoleInPortal%", "category": "Azure Container Apps" }, + { + "command": "containerApps.editContainer", + "title": "%containerApps.editContainer%", + "category": "Azure Container Apps" + }, { "command": "containerApps.editScaleRange", "title": "%containerApps.editScaleRange%", @@ -373,11 +373,6 @@ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single(.*)unsavedChanges:true/i", "group": "3@2" }, - { - "command": "containerApps.updateImage", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single/i", - "group": "4@1" - }, { "command": "containerApps.editContainerApp", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single/i", @@ -438,11 +433,6 @@ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionItem(.*)revisionState:active/i", "group": "2@2" }, - { - "command": "containerApps.updateImage", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraft:false(.*)revisionItem/i", - "group": "3@1" - }, { "command": "containerApps.deployRevisionDraft", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem(.*)unsavedChanges:true/i", @@ -463,16 +453,16 @@ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem/i", "group": "1@2" }, - { - "command": "containerApps.updateImage", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem/i", - "group": "2@1" - }, { "command": "containerApps.editRevisionDraft", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem/i", "group": "3@1" }, + { + "command": "containerApps.editContainer", + "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerItem/i", + "group": "1@1" + }, { "command": "containerApps.editScaleRange", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /scaleItem/i", diff --git a/package.nls.json b/package.nls.json index b7b548693..ebde104d0 100644 --- a/package.nls.json +++ b/package.nls.json @@ -9,7 +9,7 @@ "containerApps.createContainerApp": "Create Container App...", "containerApps.createContainerAppFromWorkspace": "Create Container App from Workspace...", "containerApps.editContainerApp": "Edit Container App (Advanced)...", - "containerApps.updateImage": "Update Container Image...", + "containerApps.editContainer": "Edit Container...", "containerApps.deployImageApi": "Deploy Image to Container App (API)...", "containerApps.deployWorkspaceProject": "Deploy Project from Workspace...", "containerApps.deployWorkspaceProjectApi": "Deploy Project from Workspace (API)...", diff --git a/src/commands/editContainer/ContainerEditContext.ts b/src/commands/editContainer/ContainerEditContext.ts new file mode 100644 index 000000000..f169f0f11 --- /dev/null +++ b/src/commands/editContainer/ContainerEditContext.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ExecuteActivityContext } from "@microsoft/vscode-azext-utils"; +import { type SetTelemetryProps } from "../../telemetry/SetTelemetryProps"; +import { type ContainerUpdateTelemetryProps as TelemetryProps } from "../../telemetry/commandTelemetryProps"; +import { type IContainerAppContext } from "../IContainerAppContext"; +import { type ImageSourceBaseContext } from "../image/imageSource/ImageSourceContext"; + +export interface ContainerEditBaseContext extends IContainerAppContext, ImageSourceBaseContext, ExecuteActivityContext { + containersIdx: number; +} + +export type ContainerEditContext = ContainerEditBaseContext & SetTelemetryProps; diff --git a/src/commands/editContainer/ContainerEditDraftStep.ts b/src/commands/editContainer/ContainerEditDraftStep.ts new file mode 100644 index 000000000..5e5ce1d23 --- /dev/null +++ b/src/commands/editContainer/ContainerEditDraftStep.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Container, type Revision } from "@azure/arm-appcontainers"; +import { activityFailContext, activityFailIcon, activityProgressContext, activityProgressIcon, activitySuccessContext, activitySuccessIcon, createUniversallyUniqueContextValue, GenericParentTreeItem, GenericTreeItem, nonNullProp, type ExecuteActivityOutput } from "@microsoft/vscode-azext-utils"; +import { type Progress } from "vscode"; +import { type ContainerAppItem, type ContainerAppModel } from "../../tree/ContainerAppItem"; +import { type RevisionsItemModel } from "../../tree/revisionManagement/RevisionItem"; +import { localize } from "../../utils/localize"; +import { getParentResourceFromItem } from "../../utils/revisionDraftUtils"; +import { getContainerNameForImage } from "../image/imageSource/containerRegistry/getContainerNameForImage"; +import { RevisionDraftUpdateBaseStep } from "../revisionDraft/RevisionDraftUpdateBaseStep"; +import { type ContainerEditContext } from "./ContainerEditContext"; + +export class ContainerEditDraftStep extends RevisionDraftUpdateBaseStep { + public priority: number = 590; + + constructor(baseItem: ContainerAppItem | RevisionsItemModel) { + super(baseItem); + } + + public async execute(context: T, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { + progress.report({ message: localize('editingContainer', 'Editing container (draft)...') }); + this.revisionDraftTemplate.containers ??= []; + + const container: Container = this.revisionDraftTemplate.containers[context.containersIdx] ?? {}; + container.name = getContainerNameForImage(nonNullProp(context, 'image')); + container.image = context.image; + container.env = context.environmentVariables; + + await this.updateRevisionDraftWithTemplate(context); + } + + public shouldExecute(context: T): boolean { + return context.containersIdx !== undefined && !!context.image; + } + + public createSuccessOutput(): ExecuteActivityOutput { + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(this.baseItem); + return { + item: new GenericTreeItem(undefined, { + contextValue: createUniversallyUniqueContextValue(['containerEditDraftStepSuccessItem', activitySuccessContext]), + label: localize('editContainer', 'Edit container profile for container app "{0}" (draft)', parentResource.name), + iconPath: activitySuccessIcon, + }), + message: localize('editContainerSuccess', 'Successfully edited container profile for container app "{0}" (draft).', parentResource.name), + }; + } + + public createProgressOutput(): ExecuteActivityOutput { + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(this.baseItem); + return { + item: new GenericTreeItem(undefined, { + contextValue: createUniversallyUniqueContextValue(['containerEditDraftStepProgressItem', activityProgressContext]), + label: localize('editContainer', 'Edit container profile for container app "{0}" (draft)', parentResource.name), + iconPath: activityProgressIcon, + }), + }; + } + + public createFailOutput(): ExecuteActivityOutput { + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(this.baseItem); + return { + item: new GenericParentTreeItem(undefined, { + contextValue: createUniversallyUniqueContextValue(['containerEditDraftStepFailItem', activityFailContext]), + label: localize('editContainer', 'Edit container profile for container app "{0}" (draft)', parentResource.name), + iconPath: activityFailIcon, + }), + message: localize('editContainerFail', 'Failed to edit container profile for container app "{0}" (draft).', parentResource.name), + }; + } +} diff --git a/src/commands/image/updateImage/UpdateRegistryAndSecretsStep.ts b/src/commands/editContainer/RegistryAndSecretsUpdateStep.ts similarity index 83% rename from src/commands/image/updateImage/UpdateRegistryAndSecretsStep.ts rename to src/commands/editContainer/RegistryAndSecretsUpdateStep.ts index 56eed3af0..9d141ecf5 100644 --- a/src/commands/image/updateImage/UpdateRegistryAndSecretsStep.ts +++ b/src/commands/editContainer/RegistryAndSecretsUpdateStep.ts @@ -7,16 +7,16 @@ import { type RegistryCredentials, type Secret } from "@azure/arm-appcontainers" import { AzureWizardExecuteStep, nonNullProp } from "@microsoft/vscode-azext-utils"; import * as deepEqual from "deep-eql"; import { type Progress } from "vscode"; -import { ext } from "../../../extensionVariables"; -import { getContainerEnvelopeWithSecrets, type ContainerAppModel } from "../../../tree/ContainerAppItem"; -import { localize } from "../../../utils/localize"; -import { updateContainerApp } from "../../updateContainerApp"; -import { type UpdateImageContext } from "./updateImage"; +import { ext } from "../../extensionVariables"; +import { getContainerEnvelopeWithSecrets, type ContainerAppModel } from "../../tree/ContainerAppItem"; +import { localize } from "../../utils/localize"; +import { updateContainerApp } from "../updateContainerApp"; +import { type ContainerEditContext } from "./ContainerEditContext"; -export class UpdateRegistryAndSecretsStep extends AzureWizardExecuteStep { +export class RegistryAndSecretsUpdateStep extends AzureWizardExecuteStep { public priority: number = 580; - public async execute(context: UpdateImageContext, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { + public async execute(context: T, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { const containerApp: ContainerAppModel = nonNullProp(context, 'containerApp'); const containerAppEnvelope = await getContainerEnvelopeWithSecrets(context, context.subscription, containerApp); @@ -28,20 +28,17 @@ export class UpdateRegistryAndSecretsStep extends AzureWizardExecuteStep { + const item: ContainerItem | ContainersItem = node ?? await pickContainer(context, { autoSelectDraft: true }); + const { containerApp, subscription } = item; + + if (!isTemplateItemEditable(item)) { + throw new TemplateItemNotEditableError(item); + } + + const subscriptionContext: ISubscriptionContext = createSubscriptionContext(subscription); + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(item); + + let containersIdx: number; + if (ContainersItem.isContainersItem(item)) { + // The 'editContainer' command should only show up on a 'ContainersItem' when it only has one container, else the command would show up on the 'ContainerItem' + containersIdx = 0; + } else { + containersIdx = item.containersIdx; + } + + const wizardContext: ContainerEditContext = { + ...context, + ...subscriptionContext, + ...await createActivityContext(true), + subscription, + managedEnvironment: await getManagedEnvironmentFromContainerApp({ ...context, ...subscriptionContext }, containerApp), + containerApp, + containersIdx, + }; + wizardContext.telemetry.properties.revisionMode = containerApp.revisionsMode; + + const wizard: AzureWizard = new AzureWizard(wizardContext, { + title: localize('editContainer', 'Edit container profile for container app "{0}" (draft)', parentResource.name), + promptSteps: [ + new ImageSourceListStep(), + new RevisionDraftDeployPromptStep(), + ], + executeSteps: [ + getVerifyProvidersStep(), + new RegistryAndSecretsUpdateStep(), + new ContainerEditDraftStep(item), + ], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); +} diff --git a/src/commands/image/imageSource/containerRegistry/ContainerRegistryImageConfigureStep.ts b/src/commands/image/imageSource/containerRegistry/ContainerRegistryImageConfigureStep.ts index c197dace6..3b54f14f2 100644 --- a/src/commands/image/imageSource/containerRegistry/ContainerRegistryImageConfigureStep.ts +++ b/src/commands/image/imageSource/containerRegistry/ContainerRegistryImageConfigureStep.ts @@ -3,15 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizardExecuteStep } from "@microsoft/vscode-azext-utils"; import { parseImageName } from "../../../../utils/imageNameUtils"; +import { localize } from "../../../../utils/localize"; +import { AzureWizardActivityOutputExecuteStep } from "../../../AzureWizardActivityOutputExecuteStep"; import { type ContainerRegistryImageSourceContext } from "./ContainerRegistryImageSourceContext"; import { getLoginServer } from "./getLoginServer"; -export class ContainerRegistryImageConfigureStep extends AzureWizardExecuteStep { +export class ContainerRegistryImageConfigureStep extends AzureWizardActivityOutputExecuteStep { public priority: number = 570; + public stepName: string = 'containerRegistryImageConfigureStep'; + protected getSuccessString = (context: T) => localize('successOutput', 'Successfully set container app image to "{0}".', context.image); + protected getFailString = (context: T) => localize('failOutput', 'Failed to set container app image to "{0}".', context.image); + protected getTreeItemLabel = (context: T) => localize('treeItemLabel', 'Set container app image to "{0}"', context.image); - public async execute(context: ContainerRegistryImageSourceContext): Promise { + public async execute(context: T): Promise { context.image = `${getLoginServer(context)}/${context.repositoryName}:${context.tag}`; const { registryName, registryDomain } = parseImageName(context.image); @@ -19,7 +24,7 @@ export class ContainerRegistryImageConfigureStep extends AzureWizardExecuteStep< context.telemetry.properties.registryDomain = registryDomain ?? 'other'; } - public shouldExecute(context: ContainerRegistryImageSourceContext): boolean { + public shouldExecute(context: T): boolean { return !context.image; } } diff --git a/src/commands/image/updateImage/UpdateImageDraftStep.ts b/src/commands/image/updateImage/UpdateImageDraftStep.ts deleted file mode 100644 index e06331eb7..000000000 --- a/src/commands/image/updateImage/UpdateImageDraftStep.ts +++ /dev/null @@ -1,44 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type Revision } from "@azure/arm-appcontainers"; -import { nonNullProp } from "@microsoft/vscode-azext-utils"; -import { type Progress } from "vscode"; -import { ext } from "../../../extensionVariables"; -import { type ContainerAppItem, type ContainerAppModel } from "../../../tree/ContainerAppItem"; -import { type RevisionsItemModel } from "../../../tree/revisionManagement/RevisionItem"; -import { localize } from "../../../utils/localize"; -import { getParentResourceFromItem } from "../../../utils/revisionDraftUtils"; -import { RevisionDraftUpdateBaseStep } from "../../revisionDraft/RevisionDraftUpdateBaseStep"; -import { getContainerNameForImage } from "../imageSource/containerRegistry/getContainerNameForImage"; -import { type UpdateImageContext } from "./updateImage"; - -export class UpdateImageDraftStep extends RevisionDraftUpdateBaseStep { - public priority: number = 590; - - constructor(baseItem: ContainerAppItem | RevisionsItemModel) { - super(baseItem); - } - - public async execute(context: T, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { - progress.report({ message: localize('updatingImage', 'Updating image (draft)...') }); - - this.revisionDraftTemplate.containers = []; - this.revisionDraftTemplate.containers.push({ - env: context.environmentVariables, - image: context.image, - name: getContainerNameForImage(nonNullProp(context, 'image')), - }); - - await this.updateRevisionDraftWithTemplate(context); - - const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(this.baseItem); - ext.outputChannel.appendLog(localize('updatedImage', 'Updated container app "{0}" with image "{1}" (draft).', parentResource.name, context.image)); - } - - public shouldExecute(context: T): boolean { - return !!context.containerApp && !!context.image; - } -} diff --git a/src/commands/image/updateImage/updateImage.ts b/src/commands/image/updateImage/updateImage.ts deleted file mode 100644 index b9b108add..000000000 --- a/src/commands/image/updateImage/updateImage.ts +++ /dev/null @@ -1,80 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Microsoft Corporation. All rights reserved. -* Licensed under the MIT License. See License.md in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import { KnownActiveRevisionsMode, type Revision } from "@azure/arm-appcontainers"; -import { AzureWizard, createSubscriptionContext, type ExecuteActivityContext, type IActionContext, type ISubscriptionContext } from "@microsoft/vscode-azext-utils"; -import { ext } from "../../../extensionVariables"; -import { type SetTelemetryProps } from "../../../telemetry/SetTelemetryProps"; -import { type UpdateImageTelemetryProps as TelemetryProps } from "../../../telemetry/commandTelemetryProps"; -import { type ContainerAppItem, type ContainerAppModel } from "../../../tree/ContainerAppItem"; -import { type RevisionDraftItem } from "../../../tree/revisionManagement/RevisionDraftItem"; -import { type RevisionItem } from "../../../tree/revisionManagement/RevisionItem"; -import { createActivityContext } from "../../../utils/activityUtils"; -import { getManagedEnvironmentFromContainerApp } from "../../../utils/getResourceUtils"; -import { getVerifyProvidersStep } from "../../../utils/getVerifyProvidersStep"; -import { localize } from "../../../utils/localize"; -import { pickContainerApp } from "../../../utils/pickItem/pickContainerApp"; -import { pickRevision, pickRevisionDraft } from "../../../utils/pickItem/pickRevision"; -import { getParentResourceFromItem } from "../../../utils/revisionDraftUtils"; -import { RevisionDraftDeployPromptStep } from "../../revisionDraft/RevisionDraftDeployPromptStep"; -import { type ImageSourceBaseContext } from "../imageSource/ImageSourceContext"; -import { ImageSourceListStep } from "../imageSource/ImageSourceListStep"; -import { UpdateImageDraftStep } from "./UpdateImageDraftStep"; -import { UpdateRegistryAndSecretsStep } from "./UpdateRegistryAndSecretsStep"; - -export type UpdateImageContext = ImageSourceBaseContext & ExecuteActivityContext & SetTelemetryProps; - -/** - * An ACA exclusive command that updates the container app or revision's container image via revision draft. - * The draft must be deployed for the changes to take effect and can be used to bundle together template changes. - */ -export async function updateImage(context: IActionContext, node?: ContainerAppItem | RevisionItem): Promise { - let item: ContainerAppItem | RevisionItem | RevisionDraftItem | undefined = node; - if (!item) { - const containerAppItem: ContainerAppItem = await pickContainerApp(context); - - if (containerAppItem.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) { - item = containerAppItem; - } else { - if (ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppItem)) { - item = await pickRevisionDraft(context, containerAppItem); - } else { - item = await pickRevision(context, containerAppItem); - } - } - } - - const { subscription, containerApp } = item; - const subscriptionContext: ISubscriptionContext = createSubscriptionContext(subscription); - - const wizardContext: UpdateImageContext = { - ...context, - ...subscriptionContext, - ...await createActivityContext(), - subscription, - managedEnvironment: await getManagedEnvironmentFromContainerApp({ ...context, ...subscriptionContext }, containerApp), - containerApp - }; - - wizardContext.telemetry.properties.revisionMode = containerApp.revisionsMode; - - const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(item); - const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize('updateImage', 'Update container image for "{0}" (draft)', parentResource.name), - promptSteps: [ - new ImageSourceListStep(), - new RevisionDraftDeployPromptStep(), - ], - executeSteps: [ - getVerifyProvidersStep(), - new UpdateRegistryAndSecretsStep(), - new UpdateImageDraftStep(item), - ], - showLoadingPrompt: true - }); - - await wizard.prompt(); - await wizard.execute(); -} diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 00b99b187..f4d17f1e0 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -11,6 +11,7 @@ import { createManagedEnvironment } from './createManagedEnvironment/createManag import { deleteContainerApp } from './deleteContainerApp/deleteContainerApp'; import { deleteManagedEnvironment } from './deleteManagedEnvironment/deleteManagedEnvironment'; import { deployWorkspaceProject } from './deployWorkspaceProject/deployWorkspaceProject'; +import { editContainer } from './editContainer/editContainer'; import { editContainerApp } from './editContainerApp'; import { connectToGitHub } from './gitHub/connectToGitHub/connectToGitHub'; import { disconnectRepo } from './gitHub/disconnectRepo/disconnectRepo'; @@ -18,7 +19,6 @@ import { openGitHubRepo } from './gitHub/openGitHubRepo'; import { deployImageApi } from './image/deployImageApi/deployImageApi'; import { createAcr } from './image/imageSource/containerRegistry/acr/createAcr/createAcr'; import { openAcrBuildLogs } from './image/openAcrBuildLogs'; -import { updateImage } from './image/updateImage/updateImage'; import { disableIngress } from './ingress/disableIngress/disableIngress'; import { editTargetPort } from './ingress/editTargetPort/editTargetPort'; import { enableIngress } from './ingress/enableIngress/enableIngress'; @@ -58,12 +58,14 @@ export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping('containerApps.deleteContainerApp', deleteContainerApp); registerCommandWithTreeNodeUnwrapping('containerApps.editContainerApp', editContainerApp); registerCommandWithTreeNodeUnwrapping('containerApps.openConsoleInPortal', openConsoleInPortal); - registerCommandWithTreeNodeUnwrapping('containerApps.updateImage', updateImage); registerCommandWithTreeNodeUnwrapping('containerApps.toggleEnvironmentVariableVisibility', async (context: IActionContext, item: EnvironmentVariableItem) => { await item.toggleValueVisibility(context); }); + // containers + registerCommandWithTreeNodeUnwrapping('containerApps.editContainer', editContainer); + // deploy registerCommandWithTreeNodeUnwrapping('containerApps.deployImageApi', deployImageApi); registerCommandWithTreeNodeUnwrapping('containerApps.deployRevisionDraft', deployRevisionDraft); diff --git a/src/commands/scaling/scaleRange/editScaleRange.ts b/src/commands/scaling/scaleRange/editScaleRange.ts index a0a09a381..75c9c4394 100644 --- a/src/commands/scaling/scaleRange/editScaleRange.ts +++ b/src/commands/scaling/scaleRange/editScaleRange.ts @@ -11,7 +11,7 @@ import { type ScaleItem } from "../../../tree/scaling/ScaleItem"; import { createActivityContext } from "../../../utils/activityUtils"; import { localize } from "../../../utils/localize"; import { pickScale } from "../../../utils/pickItem/pickScale"; -import { getParentResource, isTemplateItemEditable, throwTemplateItemNotEditable } from "../../../utils/revisionDraftUtils"; +import { getParentResource, isTemplateItemEditable, TemplateItemNotEditableError } from "../../../utils/revisionDraftUtils"; import { RevisionDraftDeployPromptStep } from "../../revisionDraft/RevisionDraftDeployPromptStep"; import { type ScaleRangeContext } from "./ScaleRangeContext"; import { ScaleRangePromptStep } from "./ScaleRangePromptStep"; @@ -22,7 +22,7 @@ export async function editScaleRange(context: IActionContext, node?: ScaleItem): const { containerApp, revision, subscription } = item; if (!isTemplateItemEditable(item)) { - throwTemplateItemNotEditable(item); + throw new TemplateItemNotEditableError(item); } const parentResource: ContainerAppModel | Revision = getParentResource(containerApp, revision); diff --git a/src/telemetry/commandTelemetryProps.ts b/src/telemetry/commandTelemetryProps.ts index adca1a261..531cfa236 100644 --- a/src/telemetry/commandTelemetryProps.ts +++ b/src/telemetry/commandTelemetryProps.ts @@ -19,7 +19,7 @@ export interface DeployRevisionDraftTelemetryProps extends AzdTelemetryProps, Ov directUpdatesCount?: string; // Direct updates via 'editContainerApp' & 'editDraft' } -export interface UpdateImageTelemetryProps extends AzdTelemetryProps, ImageSourceTelemetryProps { +export interface ContainerUpdateTelemetryProps extends AzdTelemetryProps, ImageSourceTelemetryProps { revisionMode?: KnownActiveRevisionsMode; skippedRegistryCredentialUpdate?: 'true' | 'false'; } diff --git a/src/tree/containers/ContainersItem.ts b/src/tree/containers/ContainersItem.ts index 43a3a3452..dc76977a5 100644 --- a/src/tree/containers/ContainersItem.ts +++ b/src/tree/containers/ContainersItem.ts @@ -62,7 +62,7 @@ export class ContainersItem extends RevisionDraftDescendantBase { } private get contextValue(): string { - return ContainersItem.contextValue; + return this.parentResource.template?.containers?.length === 1 ? ContainerItem.contextValue : ContainersItem.contextValue; } private get parentResource(): ContainerAppModel | Revision { diff --git a/src/utils/pickItem/pickContainer.ts b/src/utils/pickItem/pickContainer.ts new file mode 100644 index 000000000..5839935e0 --- /dev/null +++ b/src/utils/pickItem/pickContainer.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { KnownActiveRevisionsMode, type Container } from "@azure/arm-appcontainers"; +import { AzureWizardPromptStep, ContextValueQuickPickStep, runQuickPickWizard, type AzureResourceQuickPickWizardContext, type IActionContext, type IWizardOptions, type QuickPickWizardContext } from "@microsoft/vscode-azext-utils"; +import { ext } from "../../extensionVariables"; +import { ContainerAppItem } from "../../tree/ContainerAppItem"; +import { ContainerItem } from "../../tree/containers/ContainerItem"; +import { ContainersItem } from "../../tree/containers/ContainersItem"; +import { RevisionDraftItem } from "../../tree/revisionManagement/RevisionDraftItem"; +import { RevisionItem } from "../../tree/revisionManagement/RevisionItem"; +import { localize } from "../localize"; +import { pickContainerApp } from "./pickContainerApp"; +import { type RevisionDraftPickItemOptions } from "./PickItemOptions"; +import { getPickRevisionDraftStep, getPickRevisionStep, getPickRevisionsStep } from "./pickRevision"; + +function getPickContainerStep(): AzureWizardPromptStep { + return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { + contextValueFilter: { include: ContainerItem.contextValueRegExp }, + skipIfOne: true, + }, { + placeHolder: localize('selectContainer', 'Select a container'), + }); +} + +function getPickContainersStep(): AzureWizardPromptStep { + return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { + contextValueFilter: { include: ContainersItem.contextValueRegExp }, + skipIfOne: true, + }); +} + +export async function pickContainer(context: IActionContext, options?: RevisionDraftPickItemOptions): Promise { + const containerAppItem: ContainerAppItem = await pickContainerApp(context); + return await runQuickPickWizard(context, { + promptSteps: getPickContainerSteps(containerAppItem, { autoSelectDraft: options?.autoSelectDraft }), + title: options?.title, + }, containerAppItem); +} + +/** + * Assumes starting from the ContainerAppItem + */ +export function getPickContainerSteps(containerAppItem: ContainerAppItem, options?: RevisionDraftPickItemOptions): AzureWizardPromptStep[] { + const promptSteps: AzureWizardPromptStep[] = []; + if (containerAppItem.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple) { + promptSteps.push(getPickRevisionsStep()); + + if (options?.autoSelectDraft && ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppItem)) { + promptSteps.push(getPickRevisionDraftStep()); + } else { + promptSteps.push(getPickRevisionStep()); + } + } + + promptSteps.push(new ContainerItemPickSteps()); + return promptSteps; +} + +export class ContainerItemPickSteps extends AzureWizardPromptStep { + public async prompt(): Promise { + // Nothing to prompt, just need to use the subwizard + } + + public shouldPrompt(): boolean { + return false; + } + + public async getSubWizard(context: T): Promise | undefined> { + const lastNode: unknown = context.pickedNodes.at(-1); + + let containers: Container[] = []; + if (ContainerAppItem.isContainerAppItem(lastNode)) { + containers = lastNode.containerApp.template?.containers ?? []; + } else if (RevisionItem.isRevisionItem(lastNode)) { + containers = lastNode.revision.template?.containers ?? []; + } else if (RevisionDraftItem.isRevisionDraftItem(lastNode)) { + containers = lastNode.revision.template?.containers ?? []; + } + + const promptSteps: AzureWizardPromptStep[] = []; + if (containers.length > 1) { + promptSteps.push(getPickContainersStep()); + } + promptSteps.push(getPickContainerStep()); + + return { promptSteps }; + } +} diff --git a/src/utils/revisionDraftUtils.ts b/src/utils/revisionDraftUtils.ts index 8e34b71e1..9de6f3bed 100644 --- a/src/utils/revisionDraftUtils.ts +++ b/src/utils/revisionDraftUtils.ts @@ -47,6 +47,8 @@ export function isTemplateItemEditable(item: RevisionsItemModel): boolean { /** * If a template item is not editable, throw this error to cancel and alert the user */ -export function throwTemplateItemNotEditable(item: RevisionsItemModel) { - throw new Error(localize('itemNotEditable', 'Action cannot be performed on revision "{0}" because a draft is currently active.', item.revision.name)); +export class TemplateItemNotEditableError extends Error { + constructor(item: RevisionsItemModel) { + super(localize('itemNotEditable', 'Action cannot be performed on revision "{0}" because a draft is currently active.', item.revision.name)); + } } From 5aa0a42e32eb465504fdfe65d1413d6e047d3a14 Mon Sep 17 00:00:00 2001 From: Matthew Fisher <40250218+MicroFish91@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:05:37 -0800 Subject: [PATCH 04/10] Add an `Edit Container Image...` command (#778) --- package.json | 11 +++ package.nls.json | 2 + .../ContainerImageEditDraftStep.ts | 73 +++++++++++++++++++ .../editContainerImage/editContainerImage.ts | 63 ++++++++++++++++ .../image/imageSource/ImageSourceListStep.ts | 12 ++- .../ContainerRegistryImageConfigureStep.ts | 6 +- src/commands/registerCommands.ts | 2 + src/tree/containers/ImageItem.ts | 10 +-- .../revisionManagement/RevisionDraftItem.ts | 4 +- src/utils/pickItem/parentResourcePickSteps.ts | 53 ++++++++++++++ src/utils/pickItem/pickContainer.ts | 54 +++++++------- src/utils/pickItem/pickImage.ts | 27 +++++++ 12 files changed, 277 insertions(+), 40 deletions(-) create mode 100644 src/commands/editContainer/editContainerImage/ContainerImageEditDraftStep.ts create mode 100644 src/commands/editContainer/editContainerImage/editContainerImage.ts create mode 100644 src/utils/pickItem/parentResourcePickSteps.ts create mode 100644 src/utils/pickItem/pickImage.ts diff --git a/package.json b/package.json index 053609bd2..fdb5cfde0 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,12 @@ "title": "%containerApps.editContainer%", "category": "Azure Container Apps" }, + { + "command": "containerApps.editContainerImage", + "title": "%containerApps.editContainerImage.title%", + "shortTitle": "%containerApps.editContainerImage.shortTitle%", + "category": "Azure Container Apps" + }, { "command": "containerApps.editScaleRange", "title": "%containerApps.editScaleRange%", @@ -463,6 +469,11 @@ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerItem/i", "group": "1@1" }, + { + "command": "containerApps.editContainerImage", + "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /imageItem/i", + "group": "1@1" + }, { "command": "containerApps.editScaleRange", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /scaleItem/i", diff --git a/package.nls.json b/package.nls.json index ebde104d0..b0fbaed65 100644 --- a/package.nls.json +++ b/package.nls.json @@ -10,6 +10,8 @@ "containerApps.createContainerAppFromWorkspace": "Create Container App from Workspace...", "containerApps.editContainerApp": "Edit Container App (Advanced)...", "containerApps.editContainer": "Edit Container...", + "containerApps.editContainerImage.title": "Edit Container Image...", + "containerApps.editContainerImage.shortTitle": "Edit Image...", "containerApps.deployImageApi": "Deploy Image to Container App (API)...", "containerApps.deployWorkspaceProject": "Deploy Project from Workspace...", "containerApps.deployWorkspaceProjectApi": "Deploy Project from Workspace (API)...", diff --git a/src/commands/editContainer/editContainerImage/ContainerImageEditDraftStep.ts b/src/commands/editContainer/editContainerImage/ContainerImageEditDraftStep.ts new file mode 100644 index 000000000..e9e1d41fd --- /dev/null +++ b/src/commands/editContainer/editContainerImage/ContainerImageEditDraftStep.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Container, type Revision } from "@azure/arm-appcontainers"; +import { activityFailContext, activityFailIcon, activityProgressContext, activityProgressIcon, activitySuccessContext, activitySuccessIcon, createUniversallyUniqueContextValue, GenericParentTreeItem, GenericTreeItem, nonNullProp, type ExecuteActivityOutput } from "@microsoft/vscode-azext-utils"; +import { type Progress } from "vscode"; +import { type ContainerAppItem, type ContainerAppModel } from "../../../tree/ContainerAppItem"; +import { type RevisionsItemModel } from "../../../tree/revisionManagement/RevisionItem"; +import { localize } from "../../../utils/localize"; +import { getParentResourceFromItem } from "../../../utils/revisionDraftUtils"; +import { getContainerNameForImage } from "../../image/imageSource/containerRegistry/getContainerNameForImage"; +import { RevisionDraftUpdateBaseStep } from "../../revisionDraft/RevisionDraftUpdateBaseStep"; +import { type ContainerEditUpdateContext } from "./editContainerImage"; + +export class ContainerImageEditDraftStep extends RevisionDraftUpdateBaseStep { + public priority: number = 590; + + constructor(baseItem: ContainerAppItem | RevisionsItemModel) { + super(baseItem); + } + + public async execute(context: T, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { + progress.report({ message: localize('editingImage', 'Editing image (draft)...') }); + this.revisionDraftTemplate.containers ??= []; + + const container: Container = this.revisionDraftTemplate.containers[context.containersIdx] ?? {}; + container.name = getContainerNameForImage(nonNullProp(context, 'image')); + container.image = context.image; + + await this.updateRevisionDraftWithTemplate(context); + } + + public shouldExecute(context: T): boolean { + return context.containersIdx !== undefined && !!context.image; + } + + public createSuccessOutput(context: T): ExecuteActivityOutput { + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(this.baseItem); + return { + item: new GenericTreeItem(undefined, { + contextValue: createUniversallyUniqueContextValue(['containerImageEditDraftStepSuccessItem', activitySuccessContext]), + label: localize('editImage', 'Edit container image for app "{0}" (draft)', parentResource.name), + iconPath: activitySuccessIcon, + }), + message: localize('editImageSuccess', 'Successfully added image "{0}" to container app "{1}" (draft).', context.image, parentResource.name), + }; + } + + public createProgressOutput(): ExecuteActivityOutput { + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(this.baseItem); + return { + item: new GenericTreeItem(undefined, { + contextValue: createUniversallyUniqueContextValue(['containerImageEditDraftStepProgressItem', activityProgressContext]), + label: localize('editImage', 'Edit container image for app "{0}" (draft)', parentResource.name), + iconPath: activityProgressIcon, + }), + }; + } + + public createFailOutput(context: T): ExecuteActivityOutput { + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(this.baseItem); + return { + item: new GenericParentTreeItem(undefined, { + contextValue: createUniversallyUniqueContextValue(['containerImageEditDraftStepFailItem', activityFailContext]), + label: localize('editImage', 'Edit container image for app "{0}" (draft)', parentResource.name), + iconPath: activityFailIcon, + }), + message: localize('editImageFail', 'Failed to add image "{0}" to container app "{1}" (draft).', context.image, parentResource.name), + }; + } +} diff --git a/src/commands/editContainer/editContainerImage/editContainerImage.ts b/src/commands/editContainer/editContainerImage/editContainerImage.ts new file mode 100644 index 000000000..bed0555b4 --- /dev/null +++ b/src/commands/editContainer/editContainerImage/editContainerImage.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.md in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { type Revision } from "@azure/arm-appcontainers"; +import { AzureWizard, createSubscriptionContext, type IActionContext, type ISubscriptionContext } from "@microsoft/vscode-azext-utils"; +import { type ContainerAppModel } from "../../../tree/ContainerAppItem"; +import { type ImageItem } from "../../../tree/containers/ImageItem"; +import { createActivityContext } from "../../../utils/activityUtils"; +import { getManagedEnvironmentFromContainerApp } from "../../../utils/getResourceUtils"; +import { getVerifyProvidersStep } from "../../../utils/getVerifyProvidersStep"; +import { localize } from "../../../utils/localize"; +import { pickImage } from "../../../utils/pickItem/pickImage"; +import { getParentResourceFromItem, isTemplateItemEditable, TemplateItemNotEditableError } from "../../../utils/revisionDraftUtils"; +import { ImageSourceListStep } from "../../image/imageSource/ImageSourceListStep"; +import { RevisionDraftDeployPromptStep } from "../../revisionDraft/RevisionDraftDeployPromptStep"; +import { type ContainerEditContext } from "../ContainerEditContext"; +import { RegistryAndSecretsUpdateStep } from "../RegistryAndSecretsUpdateStep"; +import { ContainerImageEditDraftStep } from "./ContainerImageEditDraftStep"; + +export type ContainerEditUpdateContext = ContainerEditContext; + +// Edits only the 'image' portion of the container profile +export async function editContainerImage(context: IActionContext, node?: ImageItem): Promise { + const item: ImageItem = node ?? await pickImage(context, { autoSelectDraft: true }); + const { subscription, containerApp } = item; + + if (!isTemplateItemEditable(item)) { + throw new TemplateItemNotEditableError(item); + } + + const subscriptionContext: ISubscriptionContext = createSubscriptionContext(subscription); + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(item); + + const wizardContext: ContainerEditUpdateContext = { + ...context, + ...subscriptionContext, + ...await createActivityContext(true), + subscription, + managedEnvironment: await getManagedEnvironmentFromContainerApp({ ...context, ...subscriptionContext }, containerApp), + containerApp, + containersIdx: item.containersIdx, + }; + wizardContext.telemetry.properties.revisionMode = containerApp.revisionsMode; + + const wizard: AzureWizard = new AzureWizard(wizardContext, { + title: localize('editContainerImage', 'Edit container image for app "{0}" (draft)', parentResource.name), + promptSteps: [ + new ImageSourceListStep({ suppressEnvPrompt: true }), + new RevisionDraftDeployPromptStep(), + ], + executeSteps: [ + getVerifyProvidersStep(), + new RegistryAndSecretsUpdateStep(), + new ContainerImageEditDraftStep(item), + ], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); +} diff --git a/src/commands/image/imageSource/ImageSourceListStep.ts b/src/commands/image/imageSource/ImageSourceListStep.ts index b064f6aea..62451fc05 100644 --- a/src/commands/image/imageSource/ImageSourceListStep.ts +++ b/src/commands/image/imageSource/ImageSourceListStep.ts @@ -24,7 +24,15 @@ import { ContainerRegistryImageConfigureStep } from "./containerRegistry/Contain import { ContainerRegistryListStep } from "./containerRegistry/ContainerRegistryListStep"; import { AcrListStep } from "./containerRegistry/acr/AcrListStep"; +interface ImageSourceListStepOptions { + suppressEnvPrompt?: boolean; +} + export class ImageSourceListStep extends AzureWizardPromptStep { + constructor(private readonly options?: ImageSourceListStepOptions) { + super(); + } + public async prompt(context: ImageSourceContext): Promise { const imageSourceLabels: string[] = [ localize('containerRegistryLabel', 'Container Registry'), @@ -81,7 +89,9 @@ export class ImageSourceListStep extends AzureWizardPromptStep extends AzureWizardActivityOutputExecuteStep { public priority: number = 570; public stepName: string = 'containerRegistryImageConfigureStep'; - protected getSuccessString = (context: T) => localize('successOutput', 'Successfully set container app image to "{0}".', context.image); - protected getFailString = (context: T) => localize('failOutput', 'Failed to set container app image to "{0}".', context.image); - protected getTreeItemLabel = (context: T) => localize('treeItemLabel', 'Set container app image to "{0}"', context.image); + protected getSuccessString = (context: T) => localize('successOutput', 'Successfully set container image to "{0}".', context.image); + protected getFailString = (context: T) => localize('failOutput', 'Failed to set container image to "{0}".', context.image); + protected getTreeItemLabel = (context: T) => localize('treeItemLabel', 'Set container image to "{0}"', context.image); public async execute(context: T): Promise { context.image = `${getLoginServer(context)}/${context.repositoryName}:${context.tag}`; diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index f4d17f1e0..4e3a6cc91 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -12,6 +12,7 @@ import { deleteContainerApp } from './deleteContainerApp/deleteContainerApp'; import { deleteManagedEnvironment } from './deleteManagedEnvironment/deleteManagedEnvironment'; import { deployWorkspaceProject } from './deployWorkspaceProject/deployWorkspaceProject'; import { editContainer } from './editContainer/editContainer'; +import { editContainerImage } from './editContainer/editContainerImage/editContainerImage'; import { editContainerApp } from './editContainerApp'; import { connectToGitHub } from './gitHub/connectToGitHub/connectToGitHub'; import { disconnectRepo } from './gitHub/disconnectRepo/disconnectRepo'; @@ -65,6 +66,7 @@ export function registerCommands(): void { // containers registerCommandWithTreeNodeUnwrapping('containerApps.editContainer', editContainer); + registerCommandWithTreeNodeUnwrapping('containerApps.editContainerImage', editContainerImage); // deploy registerCommandWithTreeNodeUnwrapping('containerApps.deployImageApi', deployImageApi); diff --git a/src/tree/containers/ImageItem.ts b/src/tree/containers/ImageItem.ts index ee0f21ecb..a057dd531 100644 --- a/src/tree/containers/ImageItem.ts +++ b/src/tree/containers/ImageItem.ts @@ -96,11 +96,11 @@ export class ImageItem extends RevisionDraftDescendantBase { } const currentContainers: Container[] = this.parentResource.template?.containers ?? []; - const currentContainer: Container = currentContainers[this.containersIdx]; + const currentContainer: Container | undefined = currentContainers[this.containersIdx]; return { - imageNameItem: this.getImageName(currentContainer.image) !== this.getImageName(this.container.image), - imageRegistryItem: this.getLoginServer(currentContainer.image) !== this.getLoginServer(this.container.image), + imageNameItem: this.getImageName(currentContainer?.image) !== this.getImageName(this.container.image), + imageRegistryItem: this.getLoginServer(currentContainer?.image) !== this.getLoginServer(this.container.image), }; } @@ -111,8 +111,8 @@ export class ImageItem extends RevisionDraftDescendantBase { } const currentContainers: Container[] = this.parentResource.template?.containers ?? []; - const currentContainer: Container = currentContainers[this.containersIdx]; + const currentContainer: Container | undefined = currentContainers[this.containersIdx]; - return this.container.image !== currentContainer.image; + return this.container.image !== currentContainer?.image; } } diff --git a/src/tree/revisionManagement/RevisionDraftItem.ts b/src/tree/revisionManagement/RevisionDraftItem.ts index 79229891d..9214a6726 100644 --- a/src/tree/revisionManagement/RevisionDraftItem.ts +++ b/src/tree/revisionManagement/RevisionDraftItem.ts @@ -23,7 +23,7 @@ export interface RevisionsDraftModel { } export class RevisionDraftItem implements RevisionsItemModel, RevisionsDraftModel { - static readonly idSuffix: string = '/revisionDraft'; + static readonly idSuffix: string = 'revisionDraft'; static readonly contextValue: string = 'revisionDraftItem'; static readonly contextValueRegExp: RegExp = new RegExp(RevisionDraftItem.contextValue); @@ -58,7 +58,7 @@ export class RevisionDraftItem implements RevisionsItemModel, RevisionsDraftMode static isRevisionDraftItem(item: unknown): item is RevisionDraftItem { return typeof item === 'object' && - (item as RevisionDraftItem).id === 'string' && + typeof (item as RevisionDraftItem).id === 'string' && (item as RevisionDraftItem).id.split('/').at(-1) === RevisionDraftItem.idSuffix; } diff --git a/src/utils/pickItem/parentResourcePickSteps.ts b/src/utils/pickItem/parentResourcePickSteps.ts new file mode 100644 index 000000000..f8548a16f --- /dev/null +++ b/src/utils/pickItem/parentResourcePickSteps.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- +* 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 { AzureWizardPromptStep, type AzureResourceQuickPickWizardContext, type IWizardOptions, type QuickPickWizardContext } from "@microsoft/vscode-azext-utils"; +import { type ResourceModelBase } from "@microsoft/vscode-azureresources-api"; +import { ext } from "../../extensionVariables"; +import { ContainerAppItem } from "../../tree/ContainerAppItem"; +import { localize } from "../localize"; +import { type RevisionDraftPickItemOptions } from "./PickItemOptions"; +import { getPickRevisionDraftStep, getPickRevisionsStep, getPickRevisionStep } from "./pickRevision"; + +/** + * Use to add pick steps that automatically select down to the appropriate parent resource (`ContainerAppItem`, `RevisionItem`, or `RevisionDraftItem`) + * given that the last node picked was a `ContainerAppItem`. + */ +export class ParentResourceItemPickSteps extends AzureWizardPromptStep { + constructor(readonly options?: RevisionDraftPickItemOptions) { + super(); + } + + public async prompt(): Promise { + // Nothing to prompt, just need to use the subwizard + } + + public shouldPrompt(): boolean { + return false; + } + + public async getSubWizard(context: T): Promise | undefined> { + const lastNode: unknown = context.pickedNodes.at(-1); + const containerAppItem: unknown = (lastNode as { branchItem?: ResourceModelBase })?.branchItem ?? lastNode; + + if (!ContainerAppItem.isContainerAppItem(containerAppItem)) { + throw new Error(localize('expectedContainerAppItem', 'Internal error: Expected last picked item to be a "ContainerAppItem".')); + } + + const promptSteps: AzureWizardPromptStep[] = []; + if (containerAppItem.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple) { + promptSteps.push(getPickRevisionsStep()); + + if (this.options?.autoSelectDraft && ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppItem)) { + promptSteps.push(getPickRevisionDraftStep()); + } else { + promptSteps.push(getPickRevisionStep()); + } + } + + return { promptSteps }; + } +} diff --git a/src/utils/pickItem/pickContainer.ts b/src/utils/pickItem/pickContainer.ts index 5839935e0..aa4f55ad8 100644 --- a/src/utils/pickItem/pickContainer.ts +++ b/src/utils/pickItem/pickContainer.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { KnownActiveRevisionsMode, type Container } from "@azure/arm-appcontainers"; +import { type Container } from "@azure/arm-appcontainers"; import { AzureWizardPromptStep, ContextValueQuickPickStep, runQuickPickWizard, type AzureResourceQuickPickWizardContext, type IActionContext, type IWizardOptions, type QuickPickWizardContext } from "@microsoft/vscode-azext-utils"; +import { type ResourceModelBase } from "@microsoft/vscode-azureresources-api"; import { ext } from "../../extensionVariables"; import { ContainerAppItem } from "../../tree/ContainerAppItem"; import { ContainerItem } from "../../tree/containers/ContainerItem"; @@ -12,9 +13,9 @@ import { ContainersItem } from "../../tree/containers/ContainersItem"; import { RevisionDraftItem } from "../../tree/revisionManagement/RevisionDraftItem"; import { RevisionItem } from "../../tree/revisionManagement/RevisionItem"; import { localize } from "../localize"; -import { pickContainerApp } from "./pickContainerApp"; +import { ParentResourceItemPickSteps } from "./parentResourcePickSteps"; +import { getPickContainerAppSteps } from "./pickContainerApp"; import { type RevisionDraftPickItemOptions } from "./PickItemOptions"; -import { getPickRevisionDraftStep, getPickRevisionStep, getPickRevisionsStep } from "./pickRevision"; function getPickContainerStep(): AzureWizardPromptStep { return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { @@ -33,32 +34,24 @@ function getPickContainersStep(): AzureWizardPromptStep } export async function pickContainer(context: IActionContext, options?: RevisionDraftPickItemOptions): Promise { - const containerAppItem: ContainerAppItem = await pickContainerApp(context); return await runQuickPickWizard(context, { - promptSteps: getPickContainerSteps(containerAppItem, { autoSelectDraft: options?.autoSelectDraft }), + promptSteps: getPickContainerSteps(options), title: options?.title, - }, containerAppItem); + }); } -/** - * Assumes starting from the ContainerAppItem - */ -export function getPickContainerSteps(containerAppItem: ContainerAppItem, options?: RevisionDraftPickItemOptions): AzureWizardPromptStep[] { - const promptSteps: AzureWizardPromptStep[] = []; - if (containerAppItem.containerApp.revisionsMode === KnownActiveRevisionsMode.Multiple) { - promptSteps.push(getPickRevisionsStep()); - - if (options?.autoSelectDraft && ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppItem)) { - promptSteps.push(getPickRevisionDraftStep()); - } else { - promptSteps.push(getPickRevisionStep()); - } - } - - promptSteps.push(new ContainerItemPickSteps()); - return promptSteps; +export function getPickContainerSteps(options?: RevisionDraftPickItemOptions): AzureWizardPromptStep[] { + return [ + ...getPickContainerAppSteps(), + new ParentResourceItemPickSteps(options), + new ContainerItemPickSteps(), + ]; } +/** + * Use to add pick steps that select down to the `ContainerItem` given that the last node picked was + * either a `ContainerAppItem`, `RevisionItem` or `RevisionDraftItem` + */ export class ContainerItemPickSteps extends AzureWizardPromptStep { public async prompt(): Promise { // Nothing to prompt, just need to use the subwizard @@ -70,14 +63,17 @@ export class ContainerItemPickSteps | undefined> { const lastNode: unknown = context.pickedNodes.at(-1); + const lastItem: unknown = (lastNode as { branchItem?: ResourceModelBase })?.branchItem ?? lastNode; let containers: Container[] = []; - if (ContainerAppItem.isContainerAppItem(lastNode)) { - containers = lastNode.containerApp.template?.containers ?? []; - } else if (RevisionItem.isRevisionItem(lastNode)) { - containers = lastNode.revision.template?.containers ?? []; - } else if (RevisionDraftItem.isRevisionDraftItem(lastNode)) { - containers = lastNode.revision.template?.containers ?? []; + if (ContainerAppItem.isContainerAppItem(lastItem)) { + containers = lastItem.containerApp.template?.containers ?? []; + } else if (RevisionItem.isRevisionItem(lastItem)) { + containers = lastItem.revision.template?.containers ?? []; + } else if (RevisionDraftItem.isRevisionDraftItem(lastItem)) { + containers = lastItem.revision.template?.containers ?? []; + } else { + throw new Error(localize('expectedItem', 'Internal error: Expected last picked item to be a "ContainerAppItem", "RevisionItem", or "RevisionDraftItem".')); } const promptSteps: AzureWizardPromptStep[] = []; diff --git a/src/utils/pickItem/pickImage.ts b/src/utils/pickItem/pickImage.ts new file mode 100644 index 000000000..5205a958a --- /dev/null +++ b/src/utils/pickItem/pickImage.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { ContextValueQuickPickStep, runQuickPickWizard, type AzureWizardPromptStep, type IActionContext, type QuickPickWizardContext } from "@microsoft/vscode-azext-utils"; +import { ext } from "../../extensionVariables"; +import { ImageItem } from "../../tree/containers/ImageItem"; +import { getPickContainerSteps } from "./pickContainer"; +import { type RevisionDraftPickItemOptions } from "./PickItemOptions"; + +function getPickImageStep(): AzureWizardPromptStep { + return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { + contextValueFilter: { include: ImageItem.contextValueRegExp }, + skipIfOne: true, + }); +} + +export async function pickImage(context: IActionContext, options?: RevisionDraftPickItemOptions): Promise { + return await runQuickPickWizard(context, { + promptSteps: [ + ...getPickContainerSteps(options), + getPickImageStep(), + ], + title: options?.title, + }); +} From 1064e44586b1b5980d0b2d8d305559b8c1b13ab3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:22:06 -0800 Subject: [PATCH 05/10] Bump path-to-regexp and express (#799) Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) to 0.1.12 and updates ancestor dependency [express](https://github.com/expressjs/express). These dependencies need to be updated together. Updates `path-to-regexp` from 0.1.10 to 0.1.12 - [Release notes](https://github.com/pillarjs/path-to-regexp/releases) - [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md) - [Commits](https://github.com/pillarjs/path-to-regexp/compare/v0.1.10...v0.1.12) Updates `express` from 4.21.1 to 4.21.2 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/4.21.2/History.md) - [Commits](https://github.com/expressjs/express/compare/4.21.1...4.21.2) --- updated-dependencies: - dependency-name: path-to-regexp dependency-type: indirect - dependency-name: express dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../src/package-lock.json | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/test/testProjects/containerapps-albumapi-javascript/src/package-lock.json b/test/testProjects/containerapps-albumapi-javascript/src/package-lock.json index 9ba820f10..41727c95a 100644 --- a/test/testProjects/containerapps-albumapi-javascript/src/package-lock.json +++ b/test/testProjects/containerapps-albumapi-javascript/src/package-lock.json @@ -260,9 +260,9 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -283,7 +283,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -298,6 +298,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/on-finished": { @@ -628,9 +632,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -1050,9 +1054,9 @@ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, "express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1073,7 +1077,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -1306,9 +1310,9 @@ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "proxy-addr": { "version": "2.0.7", From b64b3b67d9d3b10b551eb4c0e8781eab42a0741c Mon Sep 17 00:00:00 2001 From: Matthew Fisher <40250218+MicroFish91@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:47:35 -0800 Subject: [PATCH 06/10] Misc. improvements to ingress logic (#783) --- .../imageSource/ContainerAppUpdateStep.ts | 21 +++++++++++- src/commands/ingress/IngressPromptStep.ts | 33 ++++++++++--------- .../enableIngress/EnableIngressStep.ts | 23 ++++++++----- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/commands/image/imageSource/ContainerAppUpdateStep.ts b/src/commands/image/imageSource/ContainerAppUpdateStep.ts index 41161068b..409570144 100644 --- a/src/commands/image/imageSource/ContainerAppUpdateStep.ts +++ b/src/commands/image/imageSource/ContainerAppUpdateStep.ts @@ -3,22 +3,41 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type Ingress } from "@azure/arm-appcontainers"; import { AzureWizardExecuteStep, GenericParentTreeItem, GenericTreeItem, activityFailContext, activityFailIcon, activitySuccessContext, activitySuccessIcon, createUniversallyUniqueContextValue, nonNullProp, type ExecuteActivityOutput } from "@microsoft/vscode-azext-utils"; import { type Progress } from "vscode"; import { ext } from "../../../extensionVariables"; import { getContainerEnvelopeWithSecrets, type ContainerAppModel } from "../../../tree/ContainerAppItem"; import { localize } from "../../../utils/localize"; +import { type IngressContext } from "../../ingress/IngressContext"; +import { enabledIngressDefaults } from "../../ingress/enableIngress/EnableIngressStep"; import { updateContainerApp } from "../../updateContainerApp"; import { type ImageSourceContext } from "./ImageSourceContext"; import { getContainerNameForImage } from "./containerRegistry/getContainerNameForImage"; -export class ContainerAppUpdateStep extends AzureWizardExecuteStep { +export class ContainerAppUpdateStep extends AzureWizardExecuteStep { public priority: number = 680; public async execute(context: T, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { const containerApp: ContainerAppModel = nonNullProp(context, 'containerApp'); const containerAppEnvelope = await getContainerEnvelopeWithSecrets(context, context.subscription, containerApp); + let ingress: Ingress | undefined; + if (context.enableIngress) { + ingress = { + ...enabledIngressDefaults, + ...containerAppEnvelope.configuration.ingress ?? {}, // Overwrite any default settings if we already have previous configurations set + external: context.enableExternal ?? containerAppEnvelope.configuration.ingress?.external, + targetPort: context.targetPort ?? containerAppEnvelope.configuration.ingress?.targetPort, + }; + } else if (context.enableIngress === false) { + ingress = undefined; + } else { + // If enableIngress is not set, just default to the previous settings if they exist + ingress = containerAppEnvelope.configuration.ingress; + } + + containerAppEnvelope.configuration.ingress = ingress; containerAppEnvelope.configuration.secrets = context.secrets; containerAppEnvelope.configuration.registries = context.registryCredentials; diff --git a/src/commands/ingress/IngressPromptStep.ts b/src/commands/ingress/IngressPromptStep.ts index b160f0a14..15daa5c15 100644 --- a/src/commands/ingress/IngressPromptStep.ts +++ b/src/commands/ingress/IngressPromptStep.ts @@ -59,28 +59,31 @@ export async function tryConfigureIngressUsingDockerfile(context: IngressContext return; } - if (!context.dockerfileExposePorts) { - context.enableIngress = false; - context.enableExternal = false; - } else if (context.dockerfileExposePorts) { + if (context.dockerfileExposePorts) { context.enableIngress = true; context.enableExternal = true; context.targetPort = getDefaultPort(context); + } else { + context.enableIngress = false; + context.enableExternal = false; } - // If a container app already exists, activity children will be added automatically in later execute steps - if (!context.containerApp) { - context.activityChildren?.push( - new GenericTreeItem(undefined, { - contextValue: createUniversallyUniqueContextValue(['ingressPromptStepSuccessItem', activitySuccessContext]), - label: context.enableIngress ? - localize('ingressEnableLabel', 'Enable ingress on port {0} (from Dockerfile configuration)', context.targetPort) : - localize('ingressDisableLabel', 'Disable ingress (from Dockerfile configuration)'), - iconPath: activitySuccessIcon - }) - ); + const currentExternalEnabled: boolean | undefined = context.containerApp?.configuration?.ingress?.external; + const currentTargetPort: number | undefined = context.containerApp?.configuration?.ingress?.targetPort; + if (currentExternalEnabled === context.enableExternal && currentTargetPort === context.targetPort) { + return; } + context.activityChildren?.push( + new GenericTreeItem(undefined, { + contextValue: createUniversallyUniqueContextValue(['ingressPromptStepSuccessItem', activitySuccessContext]), + label: context.enableIngress ? + localize('ingressEnableLabel', 'Enable ingress on port {0} (from Dockerfile configuration)', context.targetPort) : + localize('ingressDisableLabel', 'Disable ingress (from Dockerfile configuration)'), + iconPath: activitySuccessIcon + }) + ); + ext.outputChannel.appendLog(context.enableIngress ? localize('ingressEnabledLabel', 'Detected ingress on port {0} using Dockerfile configuration.', context.targetPort) : localize('ingressDisabledLabel', 'Detected no ingress using Dockerfile configuration.') diff --git a/src/commands/ingress/enableIngress/EnableIngressStep.ts b/src/commands/ingress/enableIngress/EnableIngressStep.ts index b55496dee..fc81d97c1 100644 --- a/src/commands/ingress/enableIngress/EnableIngressStep.ts +++ b/src/commands/ingress/enableIngress/EnableIngressStep.ts @@ -10,6 +10,17 @@ import { localize } from "../../../utils/localize"; import { updateContainerApp } from "../../updateContainerApp"; import { type IngressBaseContext } from "../IngressContext"; +export const enabledIngressDefaults = { + transport: 'auto', + allowInsecure: false, + traffic: [ + { + weight: 100, + latestRevision: true + } + ], +}; + export class EnableIngressStep extends AzureWizardExecuteStep { public priority: number = 750; @@ -18,19 +29,13 @@ export class EnableIngressStep extends AzureWizardExecuteStep Date: Fri, 6 Dec 2024 13:51:26 -0800 Subject: [PATCH 07/10] Add an ingress prompt when more than the image name tag changes (#800) --- extension.bundle.ts | 1 + .../image/deployImageApi/deployImage.ts | 19 +++--- src/utils/imageNameUtils.ts | 4 ++ test/imageNameUtils.test.ts | 61 +++++++++++++++++++ 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 test/imageNameUtils.test.ts diff --git a/extension.bundle.ts b/extension.bundle.ts index f27dde214..5f27d2195 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -31,6 +31,7 @@ export * from './src/commands/ingress/tryGetDockerfileExposePorts'; export { activate, deactivate } from './src/extension'; export * from './src/extensionVariables'; export * from './src/utils/azureClients'; +export * from './src/utils/imageNameUtils'; export * from './src/utils/settingUtils'; export * from './src/utils/validateUtils'; diff --git a/src/commands/image/deployImageApi/deployImage.ts b/src/commands/image/deployImageApi/deployImage.ts index 33432a520..35b138483 100644 --- a/src/commands/image/deployImageApi/deployImage.ts +++ b/src/commands/image/deployImageApi/deployImage.ts @@ -3,14 +3,16 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizard, createSubscriptionContext, type AzureWizardExecuteStep, type AzureWizardPromptStep, type IActionContext, type ISubscriptionContext } from "@microsoft/vscode-azext-utils"; +import { AzureWizard, createSubscriptionContext, type AzureWizardPromptStep, type IActionContext, type ISubscriptionContext } from "@microsoft/vscode-azext-utils"; import { type ContainerAppItem } from "../../../tree/ContainerAppItem"; import { createActivityContext } from "../../../utils/activityUtils"; import { getManagedEnvironmentFromContainerApp } from "../../../utils/getResourceUtils"; import { getVerifyProvidersStep } from "../../../utils/getVerifyProvidersStep"; +import { getImageNameWithoutTag } from "../../../utils/imageNameUtils"; import { localize } from "../../../utils/localize"; import { ContainerAppOverwriteConfirmStep } from "../../ContainerAppOverwriteConfirmStep"; import { showContainerAppNotification } from "../../createContainerApp/showContainerAppNotification"; +import { IngressPromptStep } from "../../ingress/IngressPromptStep"; import { ContainerAppUpdateStep } from "../imageSource/ContainerAppUpdateStep"; import { ImageSourceListStep } from "../imageSource/ImageSourceListStep"; import { type ContainerRegistryImageSourceContext } from "../imageSource/containerRegistry/ContainerRegistryImageSourceContext"; @@ -33,18 +35,21 @@ export async function deployImage(context: IActionContext & Partial[] = [ new ImageSourceListStep(), - new ContainerAppOverwriteConfirmStep(), ]; - const executeSteps: AzureWizardExecuteStep[] = [ - getVerifyProvidersStep(), - new ContainerAppUpdateStep() - ]; + // If more than the image tag changed, prompt for ingress again + if (getImageNameWithoutTag(wizardContext.containerApp?.template?.containers?.[0].image ?? '') !== getImageNameWithoutTag(wizardContext.image ?? '')) { + promptSteps.push(new IngressPromptStep()); + } + promptSteps.push(new ContainerAppOverwriteConfirmStep()); const wizard: AzureWizard = new AzureWizard(wizardContext, { title: localize('deploy', 'Deploy image to container app "{0}"', containerApp.name), promptSteps, - executeSteps, + executeSteps: [ + getVerifyProvidersStep(), + new ContainerAppUpdateStep(), + ], showLoadingPrompt: true }); diff --git a/src/utils/imageNameUtils.ts b/src/utils/imageNameUtils.ts index b5508652a..01db907f5 100644 --- a/src/utils/imageNameUtils.ts +++ b/src/utils/imageNameUtils.ts @@ -51,6 +51,10 @@ export function parseImageName(imageName?: string): ParsedImageName { }; } +export function getImageNameWithoutTag(imageName: string): string { + return imageName.replace(/:[^:]*$/, ''); +} + /** * @param registryName When parsed from a full image name, everything before the first slash */ diff --git a/test/imageNameUtils.test.ts b/test/imageNameUtils.test.ts new file mode 100644 index 000000000..5577449e6 --- /dev/null +++ b/test/imageNameUtils.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from "assert"; +import { getImageNameWithoutTag } from "../extension.bundle"; + +suite('imageNameUtils', () => { + test('getImageNameWithoutTag', () => { + const testValues: string[] = [ + '', + 'myimage:latest', + 'repository/name:1.0.0', + 'custom-registry.com/myimage:v2', + 'library/ubuntu:20.04', + 'project/service-name:dev-build', + 'docker.io/library/nginx:stable', + 'my-repo/my-service:release-candidate', + 'anotherrepo/anotherimage:test', + 'image_without_tag', + 'my-image:with:multiple:colons', + 'some-registry.io/nested/repo/image:12345', + 'edgecase-without-tag:', + 'dockerhub.com/image-name:alpha-beta', + 'registry.example.com:5000/repo/image:tagname', + 'private-repo/myimage:', + 'test-image-with-special-characters:beta@123', + 'path/to/image:no-colon-in-name', + 'simple-image:another:tag', + 'example.com:8080/repo/image:v3' + ]; + + const expectedValues: string[] = [ + '', + 'myimage', + 'repository/name', + 'custom-registry.com/myimage', + 'library/ubuntu', + 'project/service-name', + 'docker.io/library/nginx', + 'my-repo/my-service', + 'anotherrepo/anotherimage', + 'image_without_tag', + 'my-image:with:multiple', + 'some-registry.io/nested/repo/image', + 'edgecase-without-tag', + 'dockerhub.com/image-name', + 'registry.example.com:5000/repo/image', + 'private-repo/myimage', + 'test-image-with-special-characters', + 'path/to/image', + 'simple-image:another', + 'example.com:8080/repo/image' + ]; + + for (let i = 0; i < testValues.length; i++) { + assert.equal(getImageNameWithoutTag(testValues[i]), expectedValues[i]); + } + }); +}); From cd1e5629ccb218caebaadcfca0d1946f8b22075b Mon Sep 17 00:00:00 2001 From: Matthew Fisher <40250218+MicroFish91@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:24:26 -0600 Subject: [PATCH 08/10] Add `addEnvironmentVariable` command (#781) --- package.json | 10 ++++ package.nls.json | 1 + src/commands/editContainer/editContainer.ts | 2 +- .../editContainerImage/editContainerImage.ts | 2 +- .../EnvironmentVariablesContext.ts | 11 ++++ .../EnvironmentVariableAddContext.ts | 19 ++++++ .../EnvironmentVariableAddDraftStep.ts | 39 ++++++++++++ .../EnvironmentVariableManualInputStep.ts | 21 +++++++ .../EnvironmentVariableNameStep.ts | 58 ++++++++++++++++++ .../EnvironmentVariableTypeListStep.ts | 55 +++++++++++++++++ .../addEnvironmentVariable.ts | 60 +++++++++++++++++++ src/commands/registerCommands.ts | 4 ++ src/commands/secret/SecretListStep.ts | 31 ++++++++-- src/utils/pickItem/pickContainer.ts | 2 +- src/utils/pickItem/pickContainerApp.ts | 3 + .../pickItem/pickEnvironmentVariables.ts | 31 ++++++++++ 16 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 src/commands/environmentVariables/EnvironmentVariablesContext.ts create mode 100644 src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddContext.ts create mode 100644 src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddDraftStep.ts create mode 100644 src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableManualInputStep.ts create mode 100644 src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableNameStep.ts create mode 100644 src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableTypeListStep.ts create mode 100644 src/commands/environmentVariables/addEnvironmentVariable/addEnvironmentVariable.ts create mode 100644 src/utils/pickItem/pickEnvironmentVariables.ts diff --git a/package.json b/package.json index fdb5cfde0..b3365b7d9 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,11 @@ "shortTitle": "%containerApps.editContainerImage.shortTitle%", "category": "Azure Container Apps" }, + { + "command": "containerApps.addEnvironmentVariable", + "title": "%containerApps.addEnvironmentVariable%", + "category": "Azure Container Apps" + }, { "command": "containerApps.editScaleRange", "title": "%containerApps.editScaleRange%", @@ -474,6 +479,11 @@ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /imageItem/i", "group": "1@1" }, + { + "command": "containerApps.addEnvironmentVariable", + "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /environmentVariablesItem/i", + "group": "1@1" + }, { "command": "containerApps.editScaleRange", "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /scaleItem/i", diff --git a/package.nls.json b/package.nls.json index b0fbaed65..86c83680e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -12,6 +12,7 @@ "containerApps.editContainer": "Edit Container...", "containerApps.editContainerImage.title": "Edit Container Image...", "containerApps.editContainerImage.shortTitle": "Edit Image...", + "containerApps.addEnvironmentVariable": "Add Environment Variable...", "containerApps.deployImageApi": "Deploy Image to Container App (API)...", "containerApps.deployWorkspaceProject": "Deploy Project from Workspace...", "containerApps.deployWorkspaceProjectApi": "Deploy Project from Workspace (API)...", diff --git a/src/commands/editContainer/editContainer.ts b/src/commands/editContainer/editContainer.ts index 7b34f105b..add277ae0 100644 --- a/src/commands/editContainer/editContainer.ts +++ b/src/commands/editContainer/editContainer.ts @@ -52,7 +52,7 @@ export async function editContainer(context: IActionContext, node?: ContainersIt wizardContext.telemetry.properties.revisionMode = containerApp.revisionsMode; const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize('editContainer', 'Edit container profile for container app "{0}" (draft)', parentResource.name), + title: localize('editContainer', 'Edit container profile for "{0}" (draft)', parentResource.name), promptSteps: [ new ImageSourceListStep(), new RevisionDraftDeployPromptStep(), diff --git a/src/commands/editContainer/editContainerImage/editContainerImage.ts b/src/commands/editContainer/editContainerImage/editContainerImage.ts index bed0555b4..4ab224e00 100644 --- a/src/commands/editContainer/editContainerImage/editContainerImage.ts +++ b/src/commands/editContainer/editContainerImage/editContainerImage.ts @@ -45,7 +45,7 @@ export async function editContainerImage(context: IActionContext, node?: ImageIt wizardContext.telemetry.properties.revisionMode = containerApp.revisionsMode; const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize('editContainerImage', 'Edit container image for app "{0}" (draft)', parentResource.name), + title: localize('editContainerImage', 'Edit container image for "{0}" (draft)', parentResource.name), promptSteps: [ new ImageSourceListStep({ suppressEnvPrompt: true }), new RevisionDraftDeployPromptStep(), diff --git a/src/commands/environmentVariables/EnvironmentVariablesContext.ts b/src/commands/environmentVariables/EnvironmentVariablesContext.ts new file mode 100644 index 000000000..1606abec9 --- /dev/null +++ b/src/commands/environmentVariables/EnvironmentVariablesContext.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.md in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { type ContainerUpdateTelemetryProps as TelemetryProps } from "../../telemetry/commandTelemetryProps"; +import { type SetTelemetryProps } from "../../telemetry/SetTelemetryProps"; +import { type ContainerEditBaseContext } from "../editContainer/ContainerEditContext"; + +export type EnvironmentVariablesBaseContext = ContainerEditBaseContext; +export type EnvironmentVariablesContext = EnvironmentVariablesBaseContext & SetTelemetryProps; diff --git a/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddContext.ts b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddContext.ts new file mode 100644 index 000000000..4cfee5bde --- /dev/null +++ b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddContext.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.md in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { type SetTelemetryProps } from "../../../telemetry/SetTelemetryProps"; +import { type ContainerUpdateTelemetryProps as TelemetryProps } from "../../../telemetry/commandTelemetryProps"; +import { type ISecretContext } from "../../secret/ISecretContext"; +import { type EnvironmentVariablesBaseContext } from "../EnvironmentVariablesContext"; +import { type EnvironmentVariableType } from "./EnvironmentVariableTypeListStep"; + +export interface EnvironmentVariableAddBaseContext extends EnvironmentVariablesBaseContext, Pick { + newEnvironmentVariableType?: EnvironmentVariableType; + newEnvironmentVariableName?: string; + newEnvironmentVariableManualInput?: string; + // secretName +} + +export type EnvironmentVariableAddContext = EnvironmentVariableAddBaseContext & SetTelemetryProps; diff --git a/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddDraftStep.ts b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddDraftStep.ts new file mode 100644 index 000000000..f9615c84c --- /dev/null +++ b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableAddDraftStep.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Container } from "@azure/arm-appcontainers"; +import { type Progress } from "vscode"; +import { type ContainerAppItem } from "../../../tree/ContainerAppItem"; +import { type RevisionsItemModel } from "../../../tree/revisionManagement/RevisionItem"; +import { localize } from "../../../utils/localize"; +import { RevisionDraftUpdateBaseStep } from "../../revisionDraft/RevisionDraftUpdateBaseStep"; +import { type EnvironmentVariableAddContext } from "./EnvironmentVariableAddContext"; + +export class EnvironmentVariableAddDraftStep extends RevisionDraftUpdateBaseStep { + public priority: number = 590; + + constructor(baseItem: ContainerAppItem | RevisionsItemModel) { + super(baseItem); + } + + public async execute(context: T, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise { + progress.report({ message: localize('addingEnv', 'Adding environment variable (draft)...') }); + this.revisionDraftTemplate.containers ??= []; + + const container: Container = this.revisionDraftTemplate.containers[context.containersIdx] ?? {}; + container.env ??= []; + container.env.push({ + name: context.newEnvironmentVariableName, + value: context.newEnvironmentVariableManualInput ?? '', // The server doesn't allow this value to be undefined + secretRef: context.secretName, + }); + + await this.updateRevisionDraftWithTemplate(context); + } + + public shouldExecute(context: T): boolean { + return context.containersIdx !== undefined && !!context.newEnvironmentVariableName; + } +} diff --git a/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableManualInputStep.ts b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableManualInputStep.ts new file mode 100644 index 000000000..b78e8ca85 --- /dev/null +++ b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableManualInputStep.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * 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 EnvironmentVariableAddContext } from "./EnvironmentVariableAddContext"; + +export class EnvironmentVariableManualInputStep extends AzureWizardPromptStep { + public async prompt(context: T): Promise { + context.newEnvironmentVariableManualInput = (await context.ui.showInputBox({ + prompt: localize('envManualPrompt', 'Enter a value for the environment variable'), + })).trim(); + context.valuesToMask.push(context.newEnvironmentVariableManualInput); + } + + public shouldPrompt(context: T): boolean { + return !context.newEnvironmentVariableManualInput; + } +} diff --git a/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableNameStep.ts b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableNameStep.ts new file mode 100644 index 000000000..be50e6dcb --- /dev/null +++ b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableNameStep.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Container, type EnvironmentVar } from "@azure/arm-appcontainers"; +import { AzureWizardPromptStep, validationUtils } from "@microsoft/vscode-azext-utils"; +import { ext } from "../../../extensionVariables"; +import { type EnvironmentVariableItem } from "../../../tree/containers/EnvironmentVariableItem"; +import { type EnvironmentVariablesItem } from "../../../tree/containers/EnvironmentVariablesItem"; +import { localize } from "../../../utils/localize"; +import { getParentResourceFromItem } from "../../../utils/revisionDraftUtils"; +import { type EnvironmentVariableAddContext } from "./EnvironmentVariableAddContext"; + +export class EnvironmentVariableNameStep extends AzureWizardPromptStep { + constructor(readonly baseItem: EnvironmentVariableItem | EnvironmentVariablesItem) { + super(); + } + + public async prompt(context: T): Promise { + context.newEnvironmentVariableName = (await context.ui.showInputBox({ + prompt: localize('envNamePrompt', 'Enter a name for the environment variable'), + validateInput: (value: string) => this.validateInput(context, value), + })).trim(); + context.valuesToMask.push(context.newEnvironmentVariableName); + } + + public shouldPrompt(context: T): boolean { + return !context.newEnvironmentVariableName; + } + + private validateInput(context: T, value: string): string | undefined { + if (!validationUtils.hasValidCharLength(value)) { + return validationUtils.getInvalidCharLengthMessage(); + } + + // This is the same regex used by the portal with similar warning verbiage + const rule = /^[-._a-zA-z][-._a-zA-Z0-9]*$/; + if (!rule.test(value)) { + return localize('invalidEnvName', 'Name contains invalid character. Regex used for validation is "{0}".', String(rule)); + } + + // Check for duplicates + let container: Container | undefined; + if (ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(this.baseItem)) { + container = ext.revisionDraftFileSystem.parseRevisionDraft(this.baseItem)?.containers?.[context.containersIdx]; + } else { + container = getParentResourceFromItem(this.baseItem).template?.containers?.[context.containersIdx]; + } + + const envs: EnvironmentVar[] = container?.env ?? []; + if (envs.some(env => env.name === value)) { + return localize('duplicateEnv', 'Environment variable with name "{0}" already exists for this container.', value); + } + + return undefined; + } +} diff --git a/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableTypeListStep.ts b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableTypeListStep.ts new file mode 100644 index 000000000..8d98da083 --- /dev/null +++ b/src/commands/environmentVariables/addEnvironmentVariable/EnvironmentVariableTypeListStep.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, type IAzureQuickPickItem, type IWizardOptions } from "@microsoft/vscode-azext-utils"; +import { localize } from "../../../utils/localize"; +import { SecretListStep } from "../../secret/SecretListStep"; +import { type EnvironmentVariableAddContext } from "./EnvironmentVariableAddContext"; +import { EnvironmentVariableManualInputStep } from "./EnvironmentVariableManualInputStep"; + +export enum EnvironmentVariableType { + ManualInput = 'manual', + SecretRef = 'secretRef', +} + +export class EnvironmentVariableTypeListStep extends AzureWizardPromptStep { + public async prompt(context: T): Promise { + const placeHolder: string = localize('environmentVariableTypePrompt', 'Select an environment variable type'); + const picks: IAzureQuickPickItem[] = [ + { + label: localize('manualLabel', 'Manual entry'), + data: EnvironmentVariableType.ManualInput, + }, + { + label: localize('secretRefLabel', 'Reference a secret'), + data: EnvironmentVariableType.SecretRef, + }, + ]; + context.newEnvironmentVariableType = (await context.ui.showQuickPick(picks, { + placeHolder, + suppressPersistence: true, + })).data; + } + + public shouldPrompt(context: T): boolean { + return !context.newEnvironmentVariableType; + } + + public async getSubWizard(context: T): Promise | undefined> { + const promptSteps: AzureWizardPromptStep[] = []; + + switch (context.newEnvironmentVariableType) { + case EnvironmentVariableType.ManualInput: + promptSteps.push(new EnvironmentVariableManualInputStep()); + break; + case EnvironmentVariableType.SecretRef: + promptSteps.push(new SecretListStep({ suppressCreatePick: true })); + break; + default: + } + + return { promptSteps }; + } +} diff --git a/src/commands/environmentVariables/addEnvironmentVariable/addEnvironmentVariable.ts b/src/commands/environmentVariables/addEnvironmentVariable/addEnvironmentVariable.ts new file mode 100644 index 000000000..b3868fb73 --- /dev/null +++ b/src/commands/environmentVariables/addEnvironmentVariable/addEnvironmentVariable.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.md in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { type Revision } from "@azure/arm-appcontainers"; +import { AzureWizard, createSubscriptionContext, type IActionContext, type ISubscriptionContext } from "@microsoft/vscode-azext-utils"; +import { type ContainerAppModel } from "../../../tree/ContainerAppItem"; +import { type EnvironmentVariablesItem } from "../../../tree/containers/EnvironmentVariablesItem"; +import { createActivityContext } from "../../../utils/activityUtils"; +import { getManagedEnvironmentFromContainerApp } from "../../../utils/getResourceUtils"; +import { getVerifyProvidersStep } from "../../../utils/getVerifyProvidersStep"; +import { localize } from "../../../utils/localize"; +import { pickEnvironmentVariables } from "../../../utils/pickItem/pickEnvironmentVariables"; +import { getParentResourceFromItem, isTemplateItemEditable, TemplateItemNotEditableError } from "../../../utils/revisionDraftUtils"; +import { RevisionDraftDeployPromptStep } from "../../revisionDraft/RevisionDraftDeployPromptStep"; +import { type EnvironmentVariableAddContext } from "./EnvironmentVariableAddContext"; +import { EnvironmentVariableAddDraftStep } from "./EnvironmentVariableAddDraftStep"; +import { EnvironmentVariableNameStep } from "./EnvironmentVariableNameStep"; +import { EnvironmentVariableTypeListStep } from "./EnvironmentVariableTypeListStep"; + +export async function addEnvironmentVariable(context: IActionContext, node?: EnvironmentVariablesItem): Promise { + const item: EnvironmentVariablesItem = node ?? await pickEnvironmentVariables(context, { autoSelectDraft: true }); + const { subscription, containerApp } = item; + + if (!isTemplateItemEditable(item)) { + throw new TemplateItemNotEditableError(item); + } + + const subscriptionContext: ISubscriptionContext = createSubscriptionContext(subscription); + const parentResource: ContainerAppModel | Revision = getParentResourceFromItem(item); + + const wizardContext: EnvironmentVariableAddContext = { + ...context, + ...subscriptionContext, + ...await createActivityContext(), + subscription, + managedEnvironment: await getManagedEnvironmentFromContainerApp({ ...context, ...subscriptionContext }, containerApp), + containerApp, + containersIdx: item.containersIdx, + }; + wizardContext.telemetry.properties.revisionMode = containerApp.revisionsMode; + + const wizard: AzureWizard = new AzureWizard(wizardContext, { + title: localize('updateEnvironmentVariables', 'Add environment variable to "{0}" (draft)', parentResource.name), + promptSteps: [ + new EnvironmentVariableNameStep(item), + new EnvironmentVariableTypeListStep(), + new RevisionDraftDeployPromptStep(), + ], + executeSteps: [ + getVerifyProvidersStep(), + new EnvironmentVariableAddDraftStep(item), + ], + }); + + await wizard.prompt(); + wizardContext.activityTitle = localize('updateEnvironmentVariables', 'Add environment variable "{0}" to "{1}" (draft)', wizardContext.newEnvironmentVariableName, parentResource.name); + await wizard.execute(); +} diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 4e3a6cc91..fb88beb5c 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -14,6 +14,7 @@ import { deployWorkspaceProject } from './deployWorkspaceProject/deployWorkspace import { editContainer } from './editContainer/editContainer'; import { editContainerImage } from './editContainer/editContainerImage/editContainerImage'; import { editContainerApp } from './editContainerApp'; +import { addEnvironmentVariable } from './environmentVariables/addEnvironmentVariable/addEnvironmentVariable'; import { connectToGitHub } from './gitHub/connectToGitHub/connectToGitHub'; import { disconnectRepo } from './gitHub/disconnectRepo/disconnectRepo'; import { openGitHubRepo } from './gitHub/openGitHubRepo'; @@ -68,6 +69,9 @@ export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping('containerApps.editContainer', editContainer); registerCommandWithTreeNodeUnwrapping('containerApps.editContainerImage', editContainerImage); + // environment variables + registerCommandWithTreeNodeUnwrapping('containerApps.addEnvironmentVariable', addEnvironmentVariable); + // deploy registerCommandWithTreeNodeUnwrapping('containerApps.deployImageApi', deployImageApi); registerCommandWithTreeNodeUnwrapping('containerApps.deployRevisionDraft', deployRevisionDraft); diff --git a/src/commands/secret/SecretListStep.ts b/src/commands/secret/SecretListStep.ts index 863d755fa..011a9100e 100644 --- a/src/commands/secret/SecretListStep.ts +++ b/src/commands/secret/SecretListStep.ts @@ -5,6 +5,7 @@ import { type Secret } from '@azure/arm-appcontainers'; import { AzureWizardPromptStep, nonNullProp, type IAzureQuickPickItem, type IWizardOptions } from '@microsoft/vscode-azext-utils'; +import { noMatchingResources, noMatchingResourcesQp } from '../../constants'; import { getContainerEnvelopeWithSecrets, type ContainerAppModel } from '../../tree/ContainerAppItem'; import { localize } from '../../utils/localize'; import { type ISecretContext } from './ISecretContext'; @@ -12,24 +13,42 @@ import { SecretCreateStep } from './addSecret/SecretCreateStep'; import { SecretNameStep } from './addSecret/SecretNameStep'; import { SecretValueStep } from './addSecret/SecretValueStep'; +export interface SecretListStepOptions { + suppressCreatePick?: boolean; +} + export class SecretListStep extends AzureWizardPromptStep { + constructor(private readonly options?: SecretListStepOptions) { + super(); + } + public async prompt(context: ISecretContext): Promise { const containerApp: ContainerAppModel = nonNullProp(context, 'containerApp'); const containerAppWithSecrets = await getContainerEnvelopeWithSecrets(context, context.subscription, containerApp); const secrets: Secret[] = containerAppWithSecrets.configuration.secrets ?? []; + const picks: IAzureQuickPickItem[] = []; - const picks: IAzureQuickPickItem[] = [ - { label: localize('createSecret', '$(plus) Create a secret'), data: undefined }, + if (!this.options?.suppressCreatePick) { + picks.push({ label: localize('createSecret', '$(plus) Create a secret'), data: undefined }); + } + + picks.push( ...secrets.map((secret) => { const secretName: string = nonNullProp(secret, "name"); return { label: secretName, data: secretName }; }) - ] + ); + + if (!picks.length) { + picks.push(noMatchingResourcesQp); + } - context.secretName = (await context.ui.showQuickPick(picks, { - placeHolder: localize('chooseSecretRef', 'Choose a secret') - })).data; + do { + context.secretName = (await context.ui.showQuickPick(picks, { + placeHolder: localize('chooseSecretRef', 'Choose a secret') + })).data; + } while (context.secretName === noMatchingResources); } public shouldPrompt(context: ISecretContext): boolean { diff --git a/src/utils/pickItem/pickContainer.ts b/src/utils/pickItem/pickContainer.ts index aa4f55ad8..cd07a55bf 100644 --- a/src/utils/pickItem/pickContainer.ts +++ b/src/utils/pickItem/pickContainer.ts @@ -50,7 +50,7 @@ export function getPickContainerSteps(options?: RevisionDraftPickItemOptions): A /** * Use to add pick steps that select down to the `ContainerItem` given that the last node picked was - * either a `ContainerAppItem`, `RevisionItem` or `RevisionDraftItem` + * either a `ContainerAppItem`, `RevisionItem` or `RevisionDraftItem` (i.e. a parent resource item) */ export class ContainerItemPickSteps extends AzureWizardPromptStep { public async prompt(): Promise { diff --git a/src/utils/pickItem/pickContainerApp.ts b/src/utils/pickItem/pickContainerApp.ts index 6b38ee087..3e4a7b095 100644 --- a/src/utils/pickItem/pickContainerApp.ts +++ b/src/utils/pickItem/pickContainerApp.ts @@ -28,6 +28,9 @@ export function getPickContainerAppStep(containerAppName?: string | RegExp): Azu }); } +/** + * Get all the steps required to pick a `ContainerAppItem` + */ export function getPickContainerAppSteps(): AzureWizardPromptStep[] { return [ ...getPickEnvironmentSteps(), diff --git a/src/utils/pickItem/pickEnvironmentVariables.ts b/src/utils/pickItem/pickEnvironmentVariables.ts new file mode 100644 index 000000000..50acabf8e --- /dev/null +++ b/src/utils/pickItem/pickEnvironmentVariables.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { ContextValueQuickPickStep, runQuickPickWizard, type AzureWizardPromptStep, type IActionContext, type QuickPickWizardContext } from "@microsoft/vscode-azext-utils"; +import { ext } from "../../extensionVariables"; +import { EnvironmentVariablesItem } from "../../tree/containers/EnvironmentVariablesItem"; +import { ParentResourceItemPickSteps } from "./parentResourcePickSteps"; +import { ContainerItemPickSteps } from "./pickContainer"; +import { getPickContainerAppSteps } from "./pickContainerApp"; +import { type RevisionDraftPickItemOptions } from "./PickItemOptions"; + +function getPickEnvironmentVariablesStep(): AzureWizardPromptStep { + return new ContextValueQuickPickStep(ext.rgApiV2.resources.azureResourceTreeDataProvider, { + contextValueFilter: { include: EnvironmentVariablesItem.contextValueRegExp }, + skipIfOne: true, + }); +} + +export async function pickEnvironmentVariables(context: IActionContext, options?: RevisionDraftPickItemOptions): Promise { + return await runQuickPickWizard(context, { + promptSteps: [ + ...getPickContainerAppSteps(), + new ParentResourceItemPickSteps(options), + new ContainerItemPickSteps(), + getPickEnvironmentVariablesStep(), + ], + title: options?.title, + }); +} From 05bee27746f69855dd22a9b6190e24583d5b06d0 Mon Sep 17 00:00:00 2001 From: Matthew Fisher <40250218+MicroFish91@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:39:09 -0600 Subject: [PATCH 09/10] Fix and re-enable long running tests (#786) --- extension.bundle.ts | 1 + package-lock.json | 28 +++++++++---------- package.json | 4 +-- .../createManagedEnvironment.ts | 4 ++- test/global.test.ts | 8 ++---- .../deployWorkspaceProject.test.ts | 25 +++++++++++++++-- .../testCases/albumApiJavaScriptTestCases.ts | 4 +++ .../testCases/monoRepoBasicTestCases.ts | 4 +++ test/test.code-workspace | 4 --- 9 files changed, 53 insertions(+), 29 deletions(-) diff --git a/extension.bundle.ts b/extension.bundle.ts index 5f27d2195..3239525ad 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -16,6 +16,7 @@ // At runtime the tests live in dist/tests and will therefore pick up the main webpack bundle at dist/extension.bundle.js. export * from '@microsoft/vscode-azext-utils'; // Export activate/deactivate for main.js +export * from './src/commands/createManagedEnvironment/createManagedEnvironment'; export * from './src/commands/deployWorkspaceProject/deployWorkspaceProject'; export * from './src/commands/deployWorkspaceProject/getDeployWorkspaceProjectResults'; export * from './src/commands/deployWorkspaceProject/internal/DeployWorkspaceProjectInternalContext'; diff --git a/package-lock.json b/package-lock.json index b11400b91..b19c676fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@azure/storage-blob": "^12.4.1", "@microsoft/vscode-azext-azureutils": "^3.1.1", "@microsoft/vscode-azext-github": "^1.0.0", - "@microsoft/vscode-azext-utils": "^2.5.10", + "@microsoft/vscode-azext-utils": "^2.5.11", "@microsoft/vscode-azureresources-api": "^2.0.2", "buffer": "^6.0.3", "dayjs": "^1.11.3", @@ -35,7 +35,7 @@ "devDependencies": { "@azure/ms-rest-azure-env": "^2.0.0", "@microsoft/eslint-config-azuretools": "^0.2.2", - "@microsoft/vscode-azext-dev": "^2.0.4", + "@microsoft/vscode-azext-dev": "^2.0.5", "@types/deep-eql": "^4.0.0", "@types/fs-extra": "^8.1.1", "@types/gulp": "^4.0.6", @@ -1117,9 +1117,9 @@ } }, "node_modules/@microsoft/vscode-azext-dev": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-dev/-/vscode-azext-dev-2.0.4.tgz", - "integrity": "sha512-+XZenjPrfsEc3OPMOdPBVCgPsTvx6h1BVItf5mUoRC3lfN3Gv8OZF80VIETnC9Gj5nB+9nDpQWDzj9V/+2ZS/g==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-dev/-/vscode-azext-dev-2.0.5.tgz", + "integrity": "sha512-521fy+fWc7u4GT5lhQX5B0XK8kwbE/3Kt2c6QBtMcXKAa8EYxNhNOaLkRdSbLf0nSg2FhOCum+hE6a6H4y77Sw==", "dev": true, "dependencies": { "assert": "^2.0.0", @@ -1210,9 +1210,9 @@ } }, "node_modules/@microsoft/vscode-azext-utils": { - "version": "2.5.10", - "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-utils/-/vscode-azext-utils-2.5.10.tgz", - "integrity": "sha512-b+wN2A7eGLilPyVFoV2ICu2t8259Fr8sqSrQsLcjWZL1MsETFe5C/7HWNB3dQjkFZmMGsxcZ5hfTBT2Ukq18mg==", + "version": "2.5.11", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-utils/-/vscode-azext-utils-2.5.11.tgz", + "integrity": "sha512-3Hq5JB+e/iDPRF4VDvjBpjYm7O68AFE8ZX/dUEcuoMZEeaKK320cuIBaQxl8RePlb7FqqEA5E9kCSsXFYpfFZA==", "dependencies": { "@microsoft/vscode-azureresources-api": "^2.3.1", "@vscode/extension-telemetry": "^0.9.6", @@ -10296,9 +10296,9 @@ } }, "@microsoft/vscode-azext-dev": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-dev/-/vscode-azext-dev-2.0.4.tgz", - "integrity": "sha512-+XZenjPrfsEc3OPMOdPBVCgPsTvx6h1BVItf5mUoRC3lfN3Gv8OZF80VIETnC9Gj5nB+9nDpQWDzj9V/+2ZS/g==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-dev/-/vscode-azext-dev-2.0.5.tgz", + "integrity": "sha512-521fy+fWc7u4GT5lhQX5B0XK8kwbE/3Kt2c6QBtMcXKAa8EYxNhNOaLkRdSbLf0nSg2FhOCum+hE6a6H4y77Sw==", "dev": true, "requires": { "assert": "^2.0.0", @@ -10379,9 +10379,9 @@ } }, "@microsoft/vscode-azext-utils": { - "version": "2.5.10", - "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-utils/-/vscode-azext-utils-2.5.10.tgz", - "integrity": "sha512-b+wN2A7eGLilPyVFoV2ICu2t8259Fr8sqSrQsLcjWZL1MsETFe5C/7HWNB3dQjkFZmMGsxcZ5hfTBT2Ukq18mg==", + "version": "2.5.11", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-utils/-/vscode-azext-utils-2.5.11.tgz", + "integrity": "sha512-3Hq5JB+e/iDPRF4VDvjBpjYm7O68AFE8ZX/dUEcuoMZEeaKK320cuIBaQxl8RePlb7FqqEA5E9kCSsXFYpfFZA==", "requires": { "@microsoft/vscode-azureresources-api": "^2.3.1", "@vscode/extension-telemetry": "^0.9.6", diff --git a/package.json b/package.json index b3365b7d9..30140620e 100644 --- a/package.json +++ b/package.json @@ -773,7 +773,7 @@ "devDependencies": { "@azure/ms-rest-azure-env": "^2.0.0", "@microsoft/eslint-config-azuretools": "^0.2.2", - "@microsoft/vscode-azext-dev": "^2.0.4", + "@microsoft/vscode-azext-dev": "^2.0.5", "@types/deep-eql": "^4.0.0", "@types/fs-extra": "^8.1.1", "@types/gulp": "^4.0.6", @@ -810,7 +810,7 @@ "@azure/storage-blob": "^12.4.1", "@microsoft/vscode-azext-azureutils": "^3.1.1", "@microsoft/vscode-azext-github": "^1.0.0", - "@microsoft/vscode-azext-utils": "^2.5.10", + "@microsoft/vscode-azext-utils": "^2.5.11", "@microsoft/vscode-azureresources-api": "^2.0.2", "buffer": "^6.0.3", "dayjs": "^1.11.3", diff --git a/src/commands/createManagedEnvironment/createManagedEnvironment.ts b/src/commands/createManagedEnvironment/createManagedEnvironment.ts index 9e15f12b1..e73657a8d 100644 --- a/src/commands/createManagedEnvironment/createManagedEnvironment.ts +++ b/src/commands/createManagedEnvironment/createManagedEnvironment.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type ManagedEnvironment } from "@azure/arm-appcontainers"; import { LocationListStep, ResourceGroupCreateStep } from "@microsoft/vscode-azext-azureutils"; import { AzureWizard, createSubscriptionContext, nonNullProp, subscriptionExperience, type AzureWizardExecuteStep, type AzureWizardPromptStep, type IActionContext } from "@microsoft/vscode-azext-utils"; import { type AzureSubscription } from "@microsoft/vscode-azureresources-api"; @@ -16,7 +17,7 @@ import { type ManagedEnvironmentCreateContext } from "./ManagedEnvironmentCreate import { ManagedEnvironmentCreateStep } from "./ManagedEnvironmentCreateStep"; import { ManagedEnvironmentNameStep } from "./ManagedEnvironmentNameStep"; -export async function createManagedEnvironment(context: IActionContext, node?: { subscription: AzureSubscription }): Promise { +export async function createManagedEnvironment(context: IActionContext, node?: { subscription: AzureSubscription }): Promise { const subscription = node?.subscription ?? await subscriptionExperience(context, ext.rgApiV2.resources.azureResourceTreeDataProvider); const wizardContext: ManagedEnvironmentCreateContext = { @@ -54,4 +55,5 @@ export async function createManagedEnvironment(context: IActionContext, node?: { await wizard.execute(); ext.branchDataProvider.refresh(); + return nonNullProp(wizardContext, 'managedEnvironment'); } diff --git a/test/global.test.ts b/test/global.test.ts index b86c906be..08463702e 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -9,12 +9,10 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import { ext, registerOnActionStartHandler, registerUIExtensionVariables } from '../extension.bundle'; -// const longRunningLocalTestsEnabled: boolean = !/^(false|0)?$/i.test(process.env.AzCode_EnableLongRunningTestsLocal || ''); -// const longRunningRemoteTestsEnabled: boolean = !/^(false|0)?$/i.test(process.env.AzCode_UseAzureFederatedCredentials || ''); +const longRunningLocalTestsEnabled: boolean = !/^(false|0)?$/i.test(process.env.AzCode_EnableLongRunningTestsLocal || ''); +const longRunningRemoteTestsEnabled: boolean = !/^(false|0)?$/i.test(process.env.AzCode_UseAzureFederatedCredentials || ''); -// export const longRunningTestsEnabled: boolean = longRunningLocalTestsEnabled || longRunningRemoteTestsEnabled; - -export const longRunningTestsEnabled: boolean = false; +export const longRunningTestsEnabled: boolean = longRunningLocalTestsEnabled || longRunningRemoteTestsEnabled; // Runs before all tests suiteSetup(async function (this: Mocha.Context): Promise { diff --git a/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts b/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts index 312d8e61e..e840da5ac 100644 --- a/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts +++ b/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type ManagedEnvironment } from '@azure/arm-appcontainers'; import { runWithTestActionContext } from '@microsoft/vscode-azext-dev'; -import { parseError, type IParsedError } from "@microsoft/vscode-azext-utils"; +import { nonNullProp, parseError, randomUtils, type IParsedError } from "@microsoft/vscode-azext-utils"; import * as assert from 'assert'; import * as path from 'path'; import { workspace, type Uri, type WorkspaceFolder } from 'vscode'; -import { AzExtFsExtra, deployWorkspaceProject, dwpSettingUtilsV2, settingUtils, type DeploymentConfigurationSettings, type DeployWorkspaceProjectResults } from '../../../extension.bundle'; +import { AzExtFsExtra, createManagedEnvironment, deployWorkspaceProject, dwpSettingUtilsV2, settingUtils, type DeploymentConfigurationSettings, type DeployWorkspaceProjectResults } from '../../../extension.bundle'; import { longRunningTestsEnabled } from '../../global.test'; import { assertStringPropsMatch, getWorkspaceFolderUri } from '../../testUtils'; import { resourceGroupsToDelete } from '../global.nightly.test'; @@ -17,10 +18,17 @@ import { dwpTestScenarios } from './dwpTestScenarios'; suite('deployWorkspaceProject', function (this: Mocha.Suite) { this.timeout(15 * 60 * 1000); - suiteSetup(function (this: Mocha.Context) { + suiteSetup(async function (this: Mocha.Context) { if (!longRunningTestsEnabled) { this.skip(); } + + // Create a managed environment first so that we can guarantee one is always built before workspace deployment tests start. + // This is crucial for test consistency because the managed environment prompt will skip if no managed environment + // resources are available yet. Creating at least one environment first ensures consistent reproduceability. + const managedEnvironment: ManagedEnvironment | undefined = await setupManagedEnvironment(); + assert.ok(managedEnvironment, 'Failed to create managed environment - skipping "deployWorkspaceProject" tests.'); + resourceGroupsToDelete.add(nonNullProp(managedEnvironment, 'name')); }); for (const scenario of dwpTestScenarios) { @@ -83,6 +91,17 @@ suite('deployWorkspaceProject', function (this: Mocha.Suite) { } }); +async function setupManagedEnvironment(): Promise { + let managedEnvironment: ManagedEnvironment | undefined; + await runWithTestActionContext('createManagedEnvironment', async context => { + const resourceName: string = 'dwp' + randomUtils.getRandomHexString(6); + await context.ui.runWithInputs([resourceName, 'East US'], async () => { + managedEnvironment = await createManagedEnvironment(context); + }); + }); + return managedEnvironment; +} + function getMethodCleanWorkspaceFolderSettings(rootFolder: WorkspaceFolder) { return async function cleanWorkspaceFolderSettings(): Promise { const settingsPath: string = settingUtils.getDefaultRootWorkspaceSettingsPath(rootFolder); diff --git a/test/nightly/deployWorkspaceProject/testCases/albumApiJavaScriptTestCases.ts b/test/nightly/deployWorkspaceProject/testCases/albumApiJavaScriptTestCases.ts index fb662aab2..9c25415fd 100644 --- a/test/nightly/deployWorkspaceProject/testCases/albumApiJavaScriptTestCases.ts +++ b/test/nightly/deployWorkspaceProject/testCases/albumApiJavaScriptTestCases.ts @@ -27,6 +27,8 @@ export function generateAlbumApiJavaScriptTestCases(): DeployWorkspaceProjectTes sharedResourceName.slice(0, -1), // Isolate by using a different resource group name since we expect this case to fail appResourceName, `.${path.sep}src`, + 'Docker Login Credentials', + 'Enable', 'East US', 'Save' ], @@ -45,6 +47,8 @@ export function generateAlbumApiJavaScriptTestCases(): DeployWorkspaceProjectTes sharedResourceName, appResourceName, `.${path.sep}src`, + 'Docker Login Credentials', + 'Enable', 'East US', 'Save' ], diff --git a/test/nightly/deployWorkspaceProject/testCases/monoRepoBasicTestCases.ts b/test/nightly/deployWorkspaceProject/testCases/monoRepoBasicTestCases.ts index 92332a499..220eebd68 100644 --- a/test/nightly/deployWorkspaceProject/testCases/monoRepoBasicTestCases.ts +++ b/test/nightly/deployWorkspaceProject/testCases/monoRepoBasicTestCases.ts @@ -26,6 +26,8 @@ export function generateMonoRepoBasicTestCases(): DeployWorkspaceProjectTestCase sharedResourceName, 'app1', `.${path.sep}app1`, + 'Docker Login Credentials', + 'Enable', path.join('app1', '.env.example'), 'East US', 'Save' @@ -49,6 +51,7 @@ export function generateMonoRepoBasicTestCases(): DeployWorkspaceProjectTestCase 'Continue', 'app2', `.${path.sep}app2`, + 'Docker Login Credentials', path.join('app2', '.env.example'), 'Save' ], @@ -71,6 +74,7 @@ export function generateMonoRepoBasicTestCases(): DeployWorkspaceProjectTestCase 'Continue', 'app3', `.${path.sep}app3`, + 'Docker Login Credentials', path.join('app3', '.env.example'), 'Save' ], diff --git a/test/test.code-workspace b/test/test.code-workspace index e80fdda16..89ee2890c 100644 --- a/test/test.code-workspace +++ b/test/test.code-workspace @@ -1,9 +1,5 @@ { "folders": [ - { - "name": "albumapi-go", - "path": "./testProjects/containerapps-albumapi-go" - }, { "name": "albumapi-js", "path": "./testProjects/containerapps-albumapi-javascript" From 874ff43e27a3309bbee5bbfd964dda12a8f6ff54 Mon Sep 17 00:00:00 2001 From: Matthew Fisher <40250218+MicroFish91@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:53:46 -0600 Subject: [PATCH 10/10] Setup workspace project tests to run in parallel (#788) --- .../buildParallelScenarios.ts | 97 ++++++++++++++ .../deployWorkspaceProject.test.ts | 118 +++++------------- .../dwpTestScenarios.ts | 2 +- 3 files changed, 129 insertions(+), 88 deletions(-) create mode 100644 test/nightly/deployWorkspaceProject/buildParallelScenarios.ts diff --git a/test/nightly/deployWorkspaceProject/buildParallelScenarios.ts b/test/nightly/deployWorkspaceProject/buildParallelScenarios.ts new file mode 100644 index 000000000..680b16b94 --- /dev/null +++ b/test/nightly/deployWorkspaceProject/buildParallelScenarios.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { runWithTestActionContext } from "@microsoft/vscode-azext-dev"; +import * as assert from "assert"; +import * as path from "path"; +import { workspace, type Uri, type WorkspaceFolder } from "vscode"; +import { AzExtFsExtra, deployWorkspaceProject, dwpSettingUtilsV2, ext, parseError, settingUtils, type DeploymentConfigurationSettings, type DeployWorkspaceProjectResults, type IParsedError } from "../../../extension.bundle"; +import { assertStringPropsMatch, getWorkspaceFolderUri } from "../../testUtils"; +import { resourceGroupsToDelete } from "../global.nightly.test"; +import { dwpTestScenarios, type DeployWorkspaceProjectTestScenario } from "./dwpTestScenarios"; + +export interface DwpParallelTestScenario { + title: string; + callback(setupTask: Promise): Promise; + scenario?: Promise; +} + +export function buildParallelTestScenarios(): DwpParallelTestScenario[] { + return dwpTestScenarios.map(scenario => { + return { + title: scenario.label, + callback: buildParallelScenarioCallback(scenario), + }; + }); +} + +function buildParallelScenarioCallback(scenario: DeployWorkspaceProjectTestScenario): DwpParallelTestScenario['callback'] { + return async (setupTask: Promise) => { + await setupTask; + + const workspaceFolderUri: Uri = getWorkspaceFolderUri(scenario.folderName); + const rootFolder: WorkspaceFolder | undefined = workspace.getWorkspaceFolder(workspaceFolderUri); + assert.ok(rootFolder, 'Could not retrieve root workspace folder.'); + + await cleanWorkspaceFolderSettings(rootFolder); + + for (const testCase of scenario.testCases) { + ext.outputChannel.appendLog(`[[[ *** ${scenario.label} - ${testCase.label} *** ]]]`); + await runWithTestActionContext('deployWorkspaceProject', async context => { + await context.ui.runWithInputs(testCase.inputs, async () => { + let results: DeployWorkspaceProjectResults; + let perr: IParsedError | undefined; + try { + results = await deployWorkspaceProject(context); + } catch (e) { + results = {}; + + perr = parseError(e); + console.log(perr); + } + + if (testCase.resourceGroupToDelete) { + resourceGroupsToDelete.add(testCase.resourceGroupToDelete); + } + + // Verify 'expectedErrMsg' + if (perr || testCase.expectedErrMsg) { + if (testCase.expectedErrMsg instanceof RegExp) { + assert.match(perr?.message ?? "", testCase.expectedErrMsg, 'DeployWorkspaceProject thrown and expected error message did not match.'); + } else { + assert.strictEqual(perr?.message ?? "", testCase.expectedErrMsg, 'DeployWorkspaceProject thrown and expected error message did not match.'); + } + } + + // Verify 'expectedResults' + assertStringPropsMatch(results as Partial>, (testCase.expectedResults ?? {}) as Record, 'DeployWorkspaceProject results mismatch.'); + + // Verify 'expectedVSCodeSettings' + const deploymentConfigurationsV2: DeploymentConfigurationSettings[] = await dwpSettingUtilsV2.getWorkspaceDeploymentConfigurations(rootFolder) ?? []; + const expectedDeploymentConfigurations = testCase.expectedVSCodeSettings?.deploymentConfigurations ?? []; + assert.strictEqual(deploymentConfigurationsV2.length, expectedDeploymentConfigurations.length, 'DeployWorkspaceProject ".vscode" saved settings mismatch.'); + + for (const [i, expectedDeploymentConfiguration] of expectedDeploymentConfigurations.entries()) { + const deploymentConfiguration: DeploymentConfigurationSettings = deploymentConfigurationsV2[i] ?? {}; + assertStringPropsMatch(deploymentConfiguration as Partial>, expectedDeploymentConfiguration, 'DeployWorkspaceProject ".vscode" saved settings mismatch.'); + } + + // Verify 'postTestAssertion' + await testCase.postTestAssertion?.(context, results, 'DeployWorkspaceProject resource settings mismatch.'); + }); + }); + } + + await cleanWorkspaceFolderSettings(rootFolder); + } +} + +async function cleanWorkspaceFolderSettings(rootFolder: WorkspaceFolder) { + const settingsPath: string = settingUtils.getDefaultRootWorkspaceSettingsPath(rootFolder); + const vscodeFolderPath: string = path.dirname(settingsPath); + if (await AzExtFsExtra.pathExists(vscodeFolderPath)) { + await AzExtFsExtra.deleteResource(vscodeFolderPath, { recursive: true }); + } +} diff --git a/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts b/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts index e840da5ac..86a5d5246 100644 --- a/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts +++ b/test/nightly/deployWorkspaceProject/deployWorkspaceProject.test.ts @@ -3,19 +3,19 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type ManagedEnvironment } from '@azure/arm-appcontainers'; -import { runWithTestActionContext } from '@microsoft/vscode-azext-dev'; -import { nonNullProp, parseError, randomUtils, type IParsedError } from "@microsoft/vscode-azext-utils"; -import * as assert from 'assert'; -import * as path from 'path'; -import { workspace, type Uri, type WorkspaceFolder } from 'vscode'; -import { AzExtFsExtra, createManagedEnvironment, deployWorkspaceProject, dwpSettingUtilsV2, settingUtils, type DeploymentConfigurationSettings, type DeployWorkspaceProjectResults } from '../../../extension.bundle'; +import { type ManagedEnvironment } from "@azure/arm-appcontainers"; +import { runWithTestActionContext } from "@microsoft/vscode-azext-dev"; +import { nonNullProp, randomUtils } from "@microsoft/vscode-azext-utils"; +import * as assert from "assert"; +import { createManagedEnvironment } from "../../../extension.bundle"; import { longRunningTestsEnabled } from '../../global.test'; -import { assertStringPropsMatch, getWorkspaceFolderUri } from '../../testUtils'; -import { resourceGroupsToDelete } from '../global.nightly.test'; -import { dwpTestScenarios } from './dwpTestScenarios'; +import { resourceGroupsToDelete } from "../global.nightly.test"; +import { buildParallelTestScenarios, type DwpParallelTestScenario } from './buildParallelScenarios'; -suite('deployWorkspaceProject', function (this: Mocha.Suite) { +let setupTask: Promise; +const testScenarios: DwpParallelTestScenario[] = buildParallelTestScenarios(); + +suite('deployWorkspaceProject', async function (this: Mocha.Suite) { this.timeout(15 * 60 * 1000); suiteSetup(async function (this: Mocha.Context) { @@ -26,89 +26,33 @@ suite('deployWorkspaceProject', function (this: Mocha.Suite) { // Create a managed environment first so that we can guarantee one is always built before workspace deployment tests start. // This is crucial for test consistency because the managed environment prompt will skip if no managed environment // resources are available yet. Creating at least one environment first ensures consistent reproduceability. - const managedEnvironment: ManagedEnvironment | undefined = await setupManagedEnvironment(); - assert.ok(managedEnvironment, 'Failed to create managed environment - skipping "deployWorkspaceProject" tests.'); - resourceGroupsToDelete.add(nonNullProp(managedEnvironment, 'name')); - }); - - for (const scenario of dwpTestScenarios) { - suite(scenario.label, function () { - const workspaceFolderUri: Uri = getWorkspaceFolderUri(scenario.folderName); - const rootFolder: WorkspaceFolder | undefined = workspace.getWorkspaceFolder(workspaceFolderUri); - assert.ok(rootFolder, 'Could not retrieve root workspace folder.'); - - suiteSetup(getMethodCleanWorkspaceFolderSettings(rootFolder)); - suiteTeardown(getMethodCleanWorkspaceFolderSettings(rootFolder)); - - for (const testCase of scenario.testCases) { - test(testCase.label, async function () { - await runWithTestActionContext('deployWorkspaceProject', async context => { - await context.ui.runWithInputs(testCase.inputs, async () => { - let results: DeployWorkspaceProjectResults; - let perr: IParsedError | undefined; - try { - results = await deployWorkspaceProject(context); - } catch (e) { - results = {}; - - perr = parseError(e); - console.log(perr); - } - - if (testCase.resourceGroupToDelete) { - resourceGroupsToDelete.add(testCase.resourceGroupToDelete); - } - - // Verify 'expectedErrMsg' - if (perr || testCase.expectedErrMsg) { - if (testCase.expectedErrMsg instanceof RegExp) { - assert.match(perr?.message ?? "", testCase.expectedErrMsg, 'DeployWorkspaceProject thrown and expected error message did not match.'); - } else { - assert.strictEqual(perr?.message ?? "", testCase.expectedErrMsg, 'DeployWorkspaceProject thrown and expected error message did not match.'); - } - } - - // Verify 'expectedResults' - assertStringPropsMatch(results as Partial>, (testCase.expectedResults ?? {}) as Record, 'DeployWorkspaceProject results mismatch.'); + setupTask = setupManagedEnvironment(); - // Verify 'expectedVSCodeSettings' - const deploymentConfigurationsV2: DeploymentConfigurationSettings[] = await dwpSettingUtilsV2.getWorkspaceDeploymentConfigurations(rootFolder) ?? []; - const expectedDeploymentConfigurations = testCase.expectedVSCodeSettings?.deploymentConfigurations ?? []; - assert.strictEqual(deploymentConfigurationsV2.length, expectedDeploymentConfigurations.length, 'DeployWorkspaceProject ".vscode" saved settings mismatch.'); - - for (const [i, expectedDeploymentConfiguration] of expectedDeploymentConfigurations.entries()) { - const deploymentConfiguration: DeploymentConfigurationSettings = deploymentConfigurationsV2[i] ?? {}; - assertStringPropsMatch(deploymentConfiguration as Partial>, expectedDeploymentConfiguration, 'DeployWorkspaceProject ".vscode" saved settings mismatch.'); - } + for (const s of testScenarios) { + s.scenario = s.callback(setupTask); + } + }); - // Verify 'postTestAssertion' - await testCase.postTestAssertion?.(context, results, 'DeployWorkspaceProject resource settings mismatch.'); - }); - }); - }); - } + for (const s of testScenarios) { + test(s.title, async function () { + await nonNullProp(s, 'scenario'); }); } }); -async function setupManagedEnvironment(): Promise { +async function setupManagedEnvironment(): Promise { let managedEnvironment: ManagedEnvironment | undefined; - await runWithTestActionContext('createManagedEnvironment', async context => { - const resourceName: string = 'dwp' + randomUtils.getRandomHexString(6); - await context.ui.runWithInputs([resourceName, 'East US'], async () => { - managedEnvironment = await createManagedEnvironment(context); + try { + await runWithTestActionContext('createManagedEnvironment', async context => { + const resourceName: string = 'dwp' + randomUtils.getRandomHexString(6); + await context.ui.runWithInputs([resourceName, 'East US'], async () => { + managedEnvironment = await createManagedEnvironment(context); + }); }); - }); - return managedEnvironment; -} - -function getMethodCleanWorkspaceFolderSettings(rootFolder: WorkspaceFolder) { - return async function cleanWorkspaceFolderSettings(): Promise { - const settingsPath: string = settingUtils.getDefaultRootWorkspaceSettingsPath(rootFolder); - const vscodeFolderPath: string = path.dirname(settingsPath); - if (await AzExtFsExtra.pathExists(vscodeFolderPath)) { - await AzExtFsExtra.deleteResource(vscodeFolderPath, { recursive: true }); - } + } catch (e) { + console.error(e); } -} + assert.ok(managedEnvironment, 'Failed to create managed environment - skipping "deployWorkspaceProject" tests.'); + resourceGroupsToDelete.add(nonNullProp(managedEnvironment, 'name')); +} diff --git a/test/nightly/deployWorkspaceProject/dwpTestScenarios.ts b/test/nightly/deployWorkspaceProject/dwpTestScenarios.ts index 09abd8980..38e0643fe 100644 --- a/test/nightly/deployWorkspaceProject/dwpTestScenarios.ts +++ b/test/nightly/deployWorkspaceProject/dwpTestScenarios.ts @@ -7,7 +7,7 @@ import { generateAlbumApiJavaScriptTestCases } from "./testCases/albumApiJavaScr import { type DeployWorkspaceProjectTestCase } from "./testCases/DeployWorkspaceProjectTestCase"; import { generateMonoRepoBasicTestCases } from "./testCases/monoRepoBasicTestCases"; -interface DeployWorkspaceProjectTestScenario { +export interface DeployWorkspaceProjectTestScenario { label: string; folderName: string; testCases: DeployWorkspaceProjectTestCase[];