Skip to content

Commit 5963a93

Browse files
authored
Add default naming logic to the deployWorkspaceProject command (#448)
1 parent 13924e8 commit 5963a93

File tree

8 files changed

+493
-26
lines changed

8 files changed

+493
-26
lines changed

extension.bundle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
// At runtime the tests live in dist/tests and will therefore pick up the main webpack bundle at dist/extension.bundle.js.
1717
export * from '@microsoft/vscode-azext-utils';
1818
// Export activate/deactivate for main.js
19+
export * from './src/commands/deployWorkspaceProject/DeployWorkspaceProjectContext';
20+
export * from './src/commands/deployWorkspaceProject/getDefaultValues/DefaultResourcesNameStep';
1921
export { activate, deactivate } from './src/extension';
2022
export * from './src/extensionVariables';
2123
export * from './src/utils/validateUtils';

src/commands/createContainerApp/ContainerAppNameStep.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
77
import { getResourceGroupFromId } from "@microsoft/vscode-azext-azureutils";
8-
import { AzureWizardPromptStep, nonNullProp } from "@microsoft/vscode-azext-utils";
8+
import { AzureWizardPromptStep, ISubscriptionActionContext, nonNullProp } from "@microsoft/vscode-azext-utils";
99
import { createContainerAppsAPIClient } from '../../utils/azureClients';
1010
import { localize } from "../../utils/localize";
1111
import { ICreateContainerAppContext } from './ICreateContainerAppContext';
@@ -14,22 +14,22 @@ export class ContainerAppNameStep extends AzureWizardPromptStep<ICreateContainer
1414
public hideStepCount: boolean = true;
1515

1616
public async prompt(context: ICreateContainerAppContext): Promise<void> {
17-
const prompt: string = localize('containerAppNamePrompt', 'Enter a name for the new container app.');
17+
const prompt: string = localize('containerAppNamePrompt', 'Enter a container app name.');
1818
context.newContainerAppName = (await context.ui.showInputBox({
1919
prompt,
20-
validateInput: async (value: string | undefined): Promise<string | undefined> => await this.validateInput(context, value)
20+
validateInput: this.validateInput,
21+
asyncValidationTask: (name: string) => this.validateNameAvailable(context, name)
2122
})).trim();
2223

2324
context.valuesToMask.push(context.newContainerAppName);
2425
}
2526

2627
public shouldPrompt(context: ICreateContainerAppContext): boolean {
27-
return !context.newContainerAppName;
28+
return !context.containerApp && !context.newContainerAppName;
2829
}
2930

30-
private async validateInput(context: ICreateContainerAppContext, name: string | undefined): Promise<string | undefined> {
31+
private validateInput(name: string | undefined): string | undefined {
3132
name = name ? name.trim() : '';
32-
// to prevent showing an error when the character types the first letter
3333

3434
const { minLength, maxLength } = { minLength: 1, maxLength: 32 };
3535
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) {
@@ -38,17 +38,24 @@ export class ContainerAppNameStep extends AzureWizardPromptStep<ICreateContainer
3838
return localize('invalidLength', 'The name must be between {0} and {1} characters.', minLength, maxLength);
3939
}
4040

41-
// do the API call last
42-
try {
43-
const client: ContainerAppsAPIClient = await createContainerAppsAPIClient(context);
44-
const managedEnvironmentRg = getResourceGroupFromId(nonNullProp(context, 'managedEnvironmentId'));
45-
await client.containerApps.get(managedEnvironmentRg, name);
46-
return localize('containerAppExists', 'The container app "{0}" already exists in resource group "{1}". Please enter a unique name.', name, managedEnvironmentRg);
47-
} catch (err) {
48-
// do nothing
49-
}
50-
41+
return undefined;
42+
}
5143

44+
private async validateNameAvailable(context: ICreateContainerAppContext, name: string): Promise<string | undefined> {
45+
const resourceGroupName: string = getResourceGroupFromId(nonNullProp(context, 'managedEnvironmentId'));
46+
if (!await ContainerAppNameStep.isNameAvailable(context, resourceGroupName, name)) {
47+
return localize('containerAppExists', 'The container app "{0}" already exists in resource group "{1}".', name, resourceGroupName);
48+
}
5249
return undefined;
5350
}
51+
52+
public static async isNameAvailable(context: ISubscriptionActionContext, resourceGroupName: string, containerAppName: string): Promise<boolean> {
53+
const client: ContainerAppsAPIClient = await createContainerAppsAPIClient(context);
54+
try {
55+
await client.containerApps.get(resourceGroupName, containerAppName);
56+
return false;
57+
} catch (_e) {
58+
return true;
59+
}
60+
}
5461
}

src/commands/createManagedEnvironment/ManagedEnvironmentNameStep.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,29 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { AzureWizardPromptStep } from "@microsoft/vscode-azext-utils";
6+
import { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
7+
import { AzureWizardPromptStep, ISubscriptionActionContext, nonNullValueAndProp } from "@microsoft/vscode-azext-utils";
8+
import { createContainerAppsAPIClient } from "../../utils/azureClients";
79
import { localize } from "../../utils/localize";
810
import { IManagedEnvironmentContext } from './IManagedEnvironmentContext';
911

1012
export class ManagedEnvironmentNameStep extends AzureWizardPromptStep<IManagedEnvironmentContext> {
1113
public async prompt(context: IManagedEnvironmentContext): Promise<void> {
12-
const prompt: string = localize('containerAppNamePrompt', 'Enter a name for the new Container Apps environment.');
14+
const prompt: string = localize('containerAppNamePrompt', 'Enter a container apps environment name.');
1315
context.newManagedEnvironmentName = (await context.ui.showInputBox({
1416
prompt,
15-
validateInput: async (value: string | undefined): Promise<string | undefined> => await this.validateInput(value)
17+
validateInput: this.validateInput,
18+
asyncValidationTask: (name: string) => this.validateNameAvailable(context, name)
1619
})).trim();
1720

1821
context.valuesToMask.push(context.newManagedEnvironmentName);
1922
}
2023

2124
public shouldPrompt(context: IManagedEnvironmentContext): boolean {
22-
return !context.managedEnvironment;
25+
return !context.managedEnvironment && !context.newManagedEnvironmentName;
2326
}
2427

25-
private async validateInput(name: string | undefined): Promise<string | undefined> {
28+
private validateInput(name: string | undefined): string | undefined {
2629
name = name ? name.trim() : '';
2730

2831
const { minLength, maxLength } = { minLength: 4, maxLength: 20 };
@@ -34,4 +37,28 @@ export class ManagedEnvironmentNameStep extends AzureWizardPromptStep<IManagedEn
3437

3538
return undefined;
3639
}
40+
41+
private async validateNameAvailable(context: IManagedEnvironmentContext, name: string): Promise<string | undefined> {
42+
if (!context.resourceGroup) {
43+
// If a new resource group will house the managed environment, we can skip the name check
44+
return undefined;
45+
}
46+
47+
const resourceGroupName: string = nonNullValueAndProp(context.resourceGroup, 'name');
48+
if (!await ManagedEnvironmentNameStep.isNameAvailable(context, resourceGroupName, name)) {
49+
return localize('managedEnvironmentExists', 'The container apps environment "{0}" already exists in resource group "{1}".', name, resourceGroupName);
50+
}
51+
52+
return undefined;
53+
}
54+
55+
public static async isNameAvailable(context: ISubscriptionActionContext, resourceGroupName: string, environmentName: string): Promise<boolean> {
56+
const client: ContainerAppsAPIClient = await createContainerAppsAPIClient(context);
57+
try {
58+
await client.managedEnvironments.get(resourceGroupName, environmentName);
59+
return false;
60+
} catch (_e) {
61+
return true;
62+
}
63+
}
3764
}

src/commands/deployImage/imageSource/containerRegistry/acr/createAcr/RegistryNameStep.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { ContainerRegistryManagementClient, RegistryNameStatus } from "@azure/arm-containerregistry";
7-
import { AzureWizardPromptStep } from "@microsoft/vscode-azext-utils";
7+
import { AzureWizardPromptStep, ISubscriptionActionContext } from "@microsoft/vscode-azext-utils";
88
import { createContainerRegistryManagementClient } from "../../../../../../utils/azureClients";
99
import { localize } from "../../../../../../utils/localize";
1010
import { CreateAcrContext } from "./CreateAcrContext";
@@ -36,12 +36,16 @@ export class RegistryNameStep extends AzureWizardPromptStep<CreateAcrContext> {
3636
}
3737

3838
private async validateNameAvalability(context: CreateAcrContext, name: string) {
39-
const client: ContainerRegistryManagementClient = await createContainerRegistryManagementClient(context);
40-
const nameResponse: RegistryNameStatus = await client.registries.checkNameAvailability({ name: name, type: "Microsoft.ContainerRegistry/registries" });
41-
if (nameResponse.nameAvailable === false) {
39+
if (await RegistryNameStep.isNameAvailable(context, name)) {
4240
return localize('validateInputError', `The registry name ${name} is already in use.`);
4341
}
4442

4543
return undefined;
4644
}
45+
46+
public static async isNameAvailable(context: ISubscriptionActionContext, name: string): Promise<boolean> {
47+
const client: ContainerRegistryManagementClient = await createContainerRegistryManagementClient(context);
48+
const nameResponse: RegistryNameStatus = await client.registries.checkNameAvailability({ name: name, type: "Microsoft.ContainerRegistry/registries" });
49+
return !!nameResponse.nameAvailable;
50+
}
4751
}

src/commands/deployWorkspaceProject/deployWorkspaceProject.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ext } from "../../extensionVariables";
1010
import { createActivityContext } from "../../utils/activityUtils";
1111
import { localize } from "../../utils/localize";
1212
import type { DeployWorkspaceProjectContext } from "./DeployWorkspaceProjectContext";
13+
import { DefaultResourcesNameStep } from "./getDefaultValues/DefaultResourcesNameStep";
1314
import { getDefaultContextValues } from "./getDefaultValues/getDefaultContextValues";
1415

1516
export async function deployWorkspaceProject(context: IActionContext): Promise<void> {
@@ -39,7 +40,7 @@ export async function deployWorkspaceProject(context: IActionContext): Promise<v
3940

4041
const promptSteps: AzureWizardPromptStep<DeployWorkspaceProjectContext>[] = [
4142
// new DeployWorkspaceProjectConfirmStep(),
42-
// new DefaultResourcesNameStep()
43+
new DefaultResourcesNameStep()
4344
];
4445

4546
const executeSteps: AzureWizardExecuteStep<DeployWorkspaceProjectContext>[] = [
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { ResourceGroupListStep } from "@microsoft/vscode-azext-azureutils";
7+
import { AzureWizardPromptStep, nonNullValueAndProp } from "@microsoft/vscode-azext-utils";
8+
import { ProgressLocation, window } from "vscode";
9+
import { ext } from "../../../extensionVariables";
10+
import { localize } from "../../../utils/localize";
11+
import { validateUtils } from "../../../utils/validateUtils";
12+
import { ContainerAppNameStep } from "../../createContainerApp/ContainerAppNameStep";
13+
import { ManagedEnvironmentNameStep } from "../../createManagedEnvironment/ManagedEnvironmentNameStep";
14+
import { RegistryNameStep } from "../../deployImage/imageSource/containerRegistry/acr/createAcr/RegistryNameStep";
15+
import type { DeployWorkspaceProjectContext } from "../DeployWorkspaceProjectContext";
16+
17+
export class DefaultResourcesNameStep extends AzureWizardPromptStep<DeployWorkspaceProjectContext> {
18+
public async prompt(context: DeployWorkspaceProjectContext): Promise<void> {
19+
ext.outputChannel.appendLog(localize('resourceNameUnavailable',
20+
'Info: Some container app resources matching the workspace name "{0}" were invalid or unavailable.',
21+
cleanWorkspaceName(nonNullValueAndProp(context.rootFolder, 'name')))
22+
);
23+
24+
const resourceBaseName: string = (await context.ui.showInputBox({
25+
prompt: localize('resourceBaseNamePrompt', 'Enter a name for new container app resources.'),
26+
validateInput: this.validateInput,
27+
asyncValidationTask: (name: string) => this.validateNameAvailability(context, name)
28+
})).trim();
29+
30+
ext.outputChannel.appendLog(localize('usingResourceName', 'User provided the new resource name "{0}" as the default for resource creation.', resourceBaseName))
31+
32+
!context.resourceGroup && (context.newResourceGroupName = resourceBaseName);
33+
!context.managedEnvironment && (context.newManagedEnvironmentName = resourceBaseName);
34+
!context.registry && (context.newRegistryName = resourceBaseName.replace(/[^a-z0-9]+/g, ''));
35+
!context.containerApp && (context.newContainerAppName = resourceBaseName);
36+
context.imageName = `${resourceBaseName}:latest`;
37+
}
38+
39+
public async configureBeforePrompt(context: DeployWorkspaceProjectContext): Promise<void> {
40+
const workspaceName: string = cleanWorkspaceName(nonNullValueAndProp(context.rootFolder, 'name'));
41+
if (this.validateInput(workspaceName) !== undefined) {
42+
return;
43+
}
44+
45+
if (!await this.isWorkspaceNameAvailable(context, workspaceName)) {
46+
return;
47+
}
48+
49+
if (!context.resourceGroup || !context.managedEnvironment || !context.registry || !context.containerApp) {
50+
ext.outputChannel.appendLog(localize('usingWorkspaceName', 'Using workspace name "{0}" as the default for remaining resource creation.', workspaceName));
51+
}
52+
53+
!context.resourceGroup && (context.newResourceGroupName = workspaceName);
54+
!context.managedEnvironment && (context.newManagedEnvironmentName = workspaceName);
55+
!context.registry && (context.newRegistryName = workspaceName.replace(/[^a-z0-9]+/g, ''));
56+
!context.containerApp && (context.newContainerAppName = workspaceName);
57+
context.imageName = `${context.containerApp?.name || workspaceName}:latest`;
58+
}
59+
60+
public shouldPrompt(context: DeployWorkspaceProjectContext): boolean {
61+
return (!context.resourceGroup && !context.newResourceGroupName) ||
62+
(!context.managedEnvironment && !context.newManagedEnvironmentName) ||
63+
(!context.registry && !context.newRegistryName) ||
64+
(!context.containerApp && !context.newContainerAppName);
65+
}
66+
67+
private validateInput(name: string | undefined): string | undefined {
68+
name ??= '';
69+
70+
// No symbols are allowed for ACR - we will strip out any offending characters from the base name, but still need to ensure this version has an appropriate length
71+
const nameWithoutSymbols: string = name.replace(/[^a-z0-9]+/g, '');
72+
if (!validateUtils.isValidLength(nameWithoutSymbols, 5, 20)) {
73+
return localize('invalidLength', 'The alphanumeric portion of the name must be least 5 characters but no more than 20 characters.');
74+
}
75+
76+
const symbols: string = '-';
77+
if (!validateUtils.isLowerCaseAlphanumericWithSymbols(name, symbols, false /** canSymbolsRepeat */)) {
78+
return validateUtils.getInvalidLowerCaseAlphanumericWithSymbolsMessage(symbols);
79+
}
80+
81+
return undefined;
82+
}
83+
84+
protected async validateNameAvailability(context: DeployWorkspaceProjectContext, name: string): Promise<string | undefined> {
85+
return await window.withProgress({
86+
location: ProgressLocation.Notification,
87+
cancellable: false,
88+
title: localize('verifyingAvailabilityTitle', 'Verifying resource name availability...')
89+
}, async () => {
90+
const resourceNameUnavailable: string = localize('resourceNameUnavailable', 'Resource name "{0}" is already taken.', name);
91+
92+
const registryAvailable: boolean = !!context.registry || await RegistryNameStep.isNameAvailable(context, name.replace(/[^a-zA-Z0-9]+/g, ''));
93+
if (!registryAvailable) {
94+
return resourceNameUnavailable;
95+
}
96+
97+
const resourceGroupAvailable: boolean = !!context.resourceGroup || await ResourceGroupListStep.isNameAvailable(context, name);
98+
if (!resourceGroupAvailable) {
99+
return resourceNameUnavailable;
100+
}
101+
102+
if (context.resourceGroup) {
103+
const managedEnvironmentAvailable: boolean = !!context.managedEnvironment || await ManagedEnvironmentNameStep.isNameAvailable(context, name, name);
104+
if (!managedEnvironmentAvailable) {
105+
return resourceNameUnavailable;
106+
}
107+
} else {
108+
// Skip check - new resource group means unique managed environment
109+
}
110+
111+
if (context.managedEnvironment) {
112+
const containerAppAvailable: boolean = !!context.containerApp || await ContainerAppNameStep.isNameAvailable(context, name, name);
113+
if (!containerAppAvailable) {
114+
return resourceNameUnavailable;
115+
}
116+
} else {
117+
// Skip check - new managed environment means unique container app
118+
}
119+
120+
return undefined;
121+
});
122+
}
123+
124+
protected async isWorkspaceNameAvailable(context: DeployWorkspaceProjectContext, workspaceName: string): Promise<boolean> {
125+
const isAvailable: Record<string, boolean> = {};
126+
127+
if (context.resourceGroup || await ResourceGroupListStep.isNameAvailable(context, workspaceName)) {
128+
isAvailable['resourceGroup'] = true;
129+
}
130+
131+
if (context.managedEnvironment || await ManagedEnvironmentNameStep.isNameAvailable(context, workspaceName, workspaceName)) {
132+
isAvailable['managedEnvironment'] = true;
133+
}
134+
135+
if (context.registry || await RegistryNameStep.isNameAvailable(context, workspaceName.replace(/[^a-z0-9]+/g, ''))) {
136+
isAvailable['containerRegistry'] = true;
137+
}
138+
139+
if (context.containerApp || await ContainerAppNameStep.isNameAvailable(context, workspaceName, workspaceName)) {
140+
isAvailable['containerApp'] = true;
141+
}
142+
143+
return isAvailable['resourceGroup'] && isAvailable['managedEnvironment'] && isAvailable['containerRegistry'] && isAvailable['containerApp'];
144+
}
145+
}
146+
147+
export function cleanWorkspaceName(workspaceName: string): string {
148+
// Only alphanumeric characters or hyphens
149+
let cleanedWorkspaceName: string = workspaceName.toLowerCase().replace(/[^a-z0-9-]+/g, '');
150+
151+
// Remove any consecutive hyphens
152+
cleanedWorkspaceName = cleanedWorkspaceName.replace(/-+/g, '-');
153+
154+
// Remove any leading or ending hyphens
155+
if (cleanedWorkspaceName.startsWith('-')) {
156+
cleanedWorkspaceName = cleanedWorkspaceName.slice(1);
157+
}
158+
if (cleanedWorkspaceName.endsWith('-')) {
159+
cleanedWorkspaceName = cleanedWorkspaceName.slice(0, -1);
160+
}
161+
162+
return cleanedWorkspaceName;
163+
}

0 commit comments

Comments
 (0)