Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

appservice: Update steps and validation to support new domain name label scopes #1882

Merged
merged 26 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
368 changes: 156 additions & 212 deletions appservice/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions appservice/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@microsoft/vscode-azext-azureappservice",
"author": "Microsoft Corporation",
"version": "3.3.1",
"version": "3.4.0",
"description": "Common tools for developing Azure App Service extensions for VS Code",
"tags": [
"azure",
Expand Down Expand Up @@ -42,7 +42,7 @@
"@azure/storage-blob": "^12.3.0",
"@microsoft/vscode-azext-azureutils": "^3.0.0",
"@microsoft/vscode-azext-github": "^1.0.0",
"@microsoft/vscode-azext-utils": "^2.5.0",
"@microsoft/vscode-azext-utils": "^2.5.13",
"dayjs": "^1.11.2",
"fs-extra": "^10.0.0",
"p-retry": "^3.0.1",
Expand Down
7 changes: 7 additions & 0 deletions appservice/src/createAppService/IAppServiceWizardContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AppServicePlan, Site, SkuDescription } from '@azure/arm-appservice
import type { Workspace } from '@azure/arm-operationalinsights';
import { IResourceGroupWizardContext, IStorageAccountWizardContext } from '@microsoft/vscode-azext-azureutils';
import { AppKind, WebsiteOS } from './AppKind';
import { DomainNameLabelScope } from './SiteDomainNameLabelScopeStep';

export interface IAppServiceWizardContext extends IResourceGroupWizardContext, IStorageAccountWizardContext {
newSiteKind: AppKind;
Expand All @@ -30,6 +31,12 @@ export interface IAppServiceWizardContext extends IResourceGroupWizardContext, I
*/
newSiteName?: string;

/**
* The domain name label scope for the new site
* This will be defined after `SiteDomainNameLabelScopeStep.prompt` occurs.
*/
newSiteDomainNameLabelScope?: DomainNameLabelScope;

/**
* The App Service plan to use.
* If an existing plan is picked, this value will be defined after `AppServicePlanListStep.prompt` occurs
Expand Down
47 changes: 47 additions & 0 deletions appservice/src/createAppService/SiteDomainNameLabelScopeStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { AzureWizardPromptStep, IAzureQuickPickItem, openUrl } from '@microsoft/vscode-azext-utils';
import * as vscode from 'vscode';
import { IAppServiceWizardContext } from './IAppServiceWizardContext';

export enum DomainNameLabelScope {
ResourceGroup = 'ResourceGroupReuse',
Subscription = 'SubscriptionReuse',
Tenant = 'TenantReuse',
Global = 'NoReuse',
}

export class SiteDomainNameLabelScopeStep<T extends IAppServiceWizardContext> extends AzureWizardPromptStep<T> {
public async prompt(context: T): Promise<void> {
const picks: IAzureQuickPickItem<DomainNameLabelScope | undefined>[] = [
// Matching the portal which doesn't yet offer ResourceGroup and Subscription level domain scope
{ label: vscode.l10n.t('Secure unique default hostname'), description: vscode.l10n.t('Tenant Scope'), data: DomainNameLabelScope.Tenant },
{ label: vscode.l10n.t('Global default hostname'), description: vscode.l10n.t('Global'), data: DomainNameLabelScope.Global },
{ label: vscode.l10n.t('$(link-external) Learn more about unique default hostname'), data: undefined },
];
const learnMoreUrl: string = 'https://aka.ms/AAu7lhs';

let result: DomainNameLabelScope | undefined;
do {
result = (await context.ui.showQuickPick(picks, {
placeHolder: vscode.l10n.t('Select default hostname format'),
suppressPersistence: true,
learnMoreLink: learnMoreUrl,
})).data;

if (!result) {
await openUrl(learnMoreUrl);
}
} while (!result);

context.telemetry.properties.siteDomainNameLabelScope = result;
context.newSiteDomainNameLabelScope = result;
}

public shouldPrompt(context: T): boolean {
return !context.newSiteDomainNameLabelScope;
}
}
132 changes: 118 additions & 14 deletions appservice/src/createAppService/SiteNameStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import type { ResourceNameAvailability, WebSiteManagementClient } from '@azure/arm-appservice';
import { ResourceGroupListStep, StorageAccountListStep, resourceGroupNamingRules, storageAccountNamingRules } from '@microsoft/vscode-azext-azureutils';
import { AgentInputBoxOptions, AzureNameStep, IAzureAgentInput, IAzureNamingRules } from '@microsoft/vscode-azext-utils';
import type { ResourceNameAvailability, Site, WebSiteManagementClient } from '@azure/arm-appservice';
import { createHttpHeaders, createPipelineRequest } from '@azure/core-rest-pipeline';
import { AzExtLocation, AzExtPipelineResponse, AzExtRequestPrepareOptions, LocationListStep, ResourceGroupListStep, StorageAccountListStep, createGenericClient, resourceGroupNamingRules, storageAccountNamingRules } from '@microsoft/vscode-azext-azureutils';
import { AgentInputBoxOptions, AzureNameStep, IAzureAgentInput, IAzureNamingRules, nonNullValue, nonNullValueAndProp } from '@microsoft/vscode-azext-utils';
import * as vscode from 'vscode';
import { createWebSiteClient } from '../utils/azureClients';
import { appInsightsNamingRules } from './AppInsightsListStep';
import { AppKind } from './AppKind';
import { AppServicePlanListStep } from './AppServicePlanListStep';
import { appServicePlanNamingRules } from './AppServicePlanNameStep';
import { IAppServiceWizardContext } from './IAppServiceWizardContext';
import { DomainNameLabelScope } from './SiteDomainNameLabelScopeStep';

interface SiteNameStepWizardContext extends IAppServiceWizardContext {
ui: IAzureAgentInput;
Expand All @@ -24,6 +26,11 @@ const siteNamingRules: IAzureNamingRules = {
invalidCharsRegExp: /[^a-zA-Z0-9\-]/
};

// Selecting a regional domain name label scope actually fails if over 43 chars long, even though the CNA validation would otherwise say it's fine.
// Setting the limit to 43 chars seems to completely fix the issue and is the same number the portal is using.
// See: https://github.com/microsoft/vscode-azuretools/pull/1882#issue-2828801875
const regionalCNAMaxLength: number = 43;

export class SiteNameStep extends AzureNameStep<SiteNameStepWizardContext> {
private _siteFor: "functionApp" | "containerizedFunctionApp" | undefined;

Expand Down Expand Up @@ -56,7 +63,7 @@ export class SiteNameStep extends AzureNameStep<SiteNameStepWizardContext> {
} else if (context.newSiteKind?.includes(AppKind.workflowapp)) {
prompt = vscode.l10n.t('Enter a globally unique name for the new logic app.');
} else {
prompt = vscode.l10n.t('Enter a globally unique name for the new web app.');
prompt = vscode.l10n.t('Enter a name for the new web app.');
}

const agentMetadata = this._siteFor === ("functionApp") || this._siteFor === ("containerizedFunctionApp") ?
Expand All @@ -70,14 +77,17 @@ export class SiteNameStep extends AzureNameStep<SiteNameStepWizardContext> {
const options: AgentInputBoxOptions = {
prompt,
placeHolder,
validateInput: (name: string): string | undefined => this.validateSiteName(name),
asyncValidationTask: async (name: string): Promise<string | undefined> => await this.asyncValidateSiteName(client, name),
validateInput: (name: string): string | undefined => this.validateSiteName(context, name),
asyncValidationTask: async (name: string): Promise<string | undefined> => await this.asyncValidateSiteName(context, client, name),
agentMetadata: agentMetadata
};

context.newSiteName = (await context.ui.showInputBox(options)).trim();
context.valuesToMask.push(context.newSiteName);
context.relatedNameTask ??= this.generateRelatedName(context, context.newSiteName, this.getRelatedResourceNamingRules(context));
}

private getRelatedResourceNamingRules(context: SiteNameStepWizardContext): IAzureNamingRules[] {
const namingRules: IAzureNamingRules[] = [resourceGroupNamingRules];
if (context.newSiteKind === AppKind.functionapp) {
namingRules.push(storageAccountNamingRules);
Expand All @@ -86,18 +96,18 @@ export class SiteNameStep extends AzureNameStep<SiteNameStepWizardContext> {
}

namingRules.push(appInsightsNamingRules);
context.relatedNameTask = this.generateRelatedName(context, context.newSiteName, namingRules);
return namingRules;
}

public async getRelatedName(context: IAppServiceWizardContext, name: string): Promise<string | undefined> {
public async getRelatedName(context: SiteNameStepWizardContext, name: string): Promise<string | undefined> {
return await this.generateRelatedName(context, name, appServicePlanNamingRules);
}

public shouldPrompt(context: IAppServiceWizardContext): boolean {
public shouldPrompt(context: SiteNameStepWizardContext): boolean {
return !context.newSiteName;
}

protected async isRelatedNameAvailable(context: IAppServiceWizardContext, name: string): Promise<boolean> {
protected async isRelatedNameAvailable(context: SiteNameStepWizardContext, name: string): Promise<boolean> {
const tasks: Promise<boolean>[] = [ResourceGroupListStep.isNameAvailable(context, name)];
if (context.newSiteKind === AppKind.functionapp) {
tasks.push(StorageAccountListStep.isNameAvailable(context, name));
Expand All @@ -108,11 +118,16 @@ export class SiteNameStep extends AzureNameStep<SiteNameStepWizardContext> {
return (await Promise.all(tasks)).every((v: boolean) => v);
}

private validateSiteName(name: string): string | undefined {
private validateSiteName(context: SiteNameStepWizardContext, name: string): string | undefined {
name = name.trim();

if (name.length < siteNamingRules.minLength || name.length > siteNamingRules.maxLength) {
return vscode.l10n.t('The name must be between {0} and {1} characters.', siteNamingRules.minLength, siteNamingRules.maxLength);
let maxLength: number = siteNamingRules.maxLength;
if (context.newSiteDomainNameLabelScope && context.newSiteDomainNameLabelScope !== DomainNameLabelScope.Global) {
maxLength = regionalCNAMaxLength;
}

if (name.length < siteNamingRules.minLength || name.length > maxLength) {
return vscode.l10n.t('The name must be between {0} and {1} characters.', siteNamingRules.minLength, maxLength);
} else if (this._siteFor === "containerizedFunctionApp" && (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name))) {
return vscode.l10n.t("A name must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character and cannot have '--'.");
} else if (siteNamingRules.invalidCharsRegExp.test(name)) {
Expand All @@ -122,12 +137,101 @@ export class SiteNameStep extends AzureNameStep<SiteNameStepWizardContext> {
return undefined;
}

private async asyncValidateSiteName(client: WebSiteManagementClient, name: string): Promise<string | undefined> {
// For comprehensive breakdown of validation logic, please refer to: https://github.com/microsoft/vscode-azuretools/pull/1882#issue-2828801875
private async asyncValidateSiteName(context: SiteNameStepWizardContext, sdkClient: WebSiteManagementClient, name: string): Promise<string | undefined> {
name = name.trim();

let validationMessage: string | undefined;
if (!context.newSiteDomainNameLabelScope || context.newSiteDomainNameLabelScope === DomainNameLabelScope.Global) {
validationMessage ??= await this.asyncValidateGlobalCNA(sdkClient, name);
}

if (context.newSiteDomainNameLabelScope) {
validationMessage ??= await this.asyncValidateRegionalCNA(context, context.newSiteDomainNameLabelScope, name, context.resourceGroup?.name ?? context.newResourceGroupName);
validationMessage ??= await this.asyncValidateUniqueARMId(context, sdkClient, name, context.resourceGroup?.name ?? context.newResourceGroupName);
}

return validationMessage;
}

private async asyncValidateGlobalCNA(client: WebSiteManagementClient, name: string): Promise<string | undefined> {
const nameAvailability: ResourceNameAvailability = await client.checkNameAvailability(name, 'Site');
if (!nameAvailability.nameAvailable) {
return nameAvailability.message;
} else {
return undefined;
}
}

private async asyncValidateRegionalCNA(context: SiteNameStepWizardContext, domainNameScope: DomainNameLabelScope, siteName: string, resourceGroupName?: string): Promise<string | undefined> {
if (!LocationListStep.hasLocation(context)) {
throw new Error(vscode.l10n.t('Internal Error: A location is required when validating a site name with regional CNA.'));
} else if (domainNameScope === DomainNameLabelScope.ResourceGroup && !resourceGroupName) {
throw new Error(vscode.l10n.t('Internal Error: A resource group name is required for validating this level of domain name scope.'));
}

const apiVersion: string = '2024-04-01';
const location: AzExtLocation = await LocationListStep.getLocation(context);
const authToken: string = nonNullValueAndProp((await context.credentials.getToken() as { token?: string }), 'token');

// Todo: Can replace with call using SDK once the update is available
const options: AzExtRequestPrepareOptions = {
url: `${context.environment.resourceManagerEndpointUrl}subscriptions/${context.subscriptionId}/providers/Microsoft.Web/locations/${location.name}/checknameavailability?api-version=${apiVersion}`,
method: 'POST',
headers: createHttpHeaders({
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
}),
body: JSON.stringify({
name: siteName,
type: 'Site',
autoGeneratedDomainNameLabelScope: domainNameScope,
resourceGroupName: domainNameScope === DomainNameLabelScope.ResourceGroup ? resourceGroupName : undefined,
}),
};

const client = await createGenericClient(context, undefined);
const pipelineResponse = await client.sendRequest(createPipelineRequest(options)) as AzExtPipelineResponse;
const checkNameResponse = pipelineResponse.parsedBody as {
hostName?: string;
message?: string;
nameAvailable?: boolean;
reason?: string;
};

if (!checkNameResponse.nameAvailable) {
// For global domain scope, if site name input is greater than regionalCNAMaxLength, ignore result of regional CNA because it inherently has a shorter character limit than Global CNA
if (domainNameScope === DomainNameLabelScope.Global && siteName.length > regionalCNAMaxLength) {
// Ensure the error message is the expected character validation error message before ignoring it
if (checkNameResponse.message && /must be less than \d{2} chars/i.test(checkNameResponse.message)) {
return undefined;
}
}
return checkNameResponse.message;
}

return undefined;
}

private async asyncValidateUniqueARMId(context: SiteNameStepWizardContext, client: WebSiteManagementClient, siteName: string, resourceGroupName?: string): Promise<string | undefined> {
if (!resourceGroupName) {
context.relatedNameTask ??= this.generateRelatedName(context, siteName, this.getRelatedResourceNamingRules(context));
resourceGroupName = await context.relatedNameTask;
}

try {
const rgName: string = nonNullValue(resourceGroupName, vscode.l10n.t('Internal Error: A resource group name must be provided to verify unique site ID.'));
const site: Site = await client.webApps.get(rgName, siteName);
if (site) {
return vscode.l10n.t('A site with name "{0}" already exists in resource group "{1}".', siteName, rgName);
}
} catch (e) {
const statusCode = (e as { statusCode?: number })?.statusCode;
if (statusCode !== 404) {
return vscode.l10n.t('Failed to validate name availability for "{0}". Please try another name.', siteName);
}
}

return undefined;
}
}
16 changes: 9 additions & 7 deletions appservice/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

export * from './KuduModels';
export * from './SiteClient';
export * from './TunnelProxy';
export * from './confirmOverwriteSettings';
export * from './createAppService/AppInsightsCreateStep';
export * from './createAppService/AppInsightsListStep';
Expand All @@ -17,23 +14,27 @@ export * from './createAppService/AppServicePlanSkuStep';
export * from './createAppService/CustomLocationListStep';
export * from './createAppService/IAppServiceWizardContext';
export * from './createAppService/LogAnalyticsCreateStep';
export * from './createAppService/setLocationsTask';
export * from './createAppService/SiteDomainNameLabelScopeStep';
export * from './createAppService/SiteNameStep';
export * from './createAppService/SiteOSStep';
export * from './createAppService/setLocationsTask';
export * from './createSlot';
export * from './deleteSite/DeleteLastServicePlanStep';
export * from './deleteSite/DeleteSiteStep';
export * from './deleteSite/IDeleteSiteWizardContext';
export * from './deploy/IDeployContext';
export * from './deploy/deploy';
export * from './deploy/getDeployFsPath';
export * from './deploy/getDeployNode';
export * from './deploy/IDeployContext';
export * from './deploy/localGitDeploy';
export { IPreDeployTaskResult, handleFailedPreDeployTask, runPreDeployTask, tryRunPreDeployTask } from './deploy/runDeployTask';
export { handleFailedPreDeployTask, IPreDeployTaskResult, runPreDeployTask, tryRunPreDeployTask } from './deploy/runDeployTask';
export * from './deploy/showDeployConfirmation';
export { disconnectRepo } from './disconnectRepo';
export * from './editScmType';
export { registerAppServiceExtensionVariables } from './extensionVariables';
export * from './KuduModels';
export * from './SiteClient';
export * from './TunnelProxy';
// export { IConnectToGitHubWizardContext } from './github/IConnectToGitHubWizardContext';
export * from './pingFunctionApp';
export * from './registerSiteCommand';
Expand All @@ -42,11 +43,12 @@ export * from './remoteDebug/startRemoteDebug';
export * from './siteFiles';
export * from './startStreamingLogs';
export * from './swapSlot';
export * from './tree/DeploymentTreeItem';
export * from './tree/DeploymentsTreeItem';
export * from './tree/DeploymentTreeItem';
export * from './tree/FileTreeItem';
export * from './tree/FolderTreeItem';
export * from './tree/LogFilesTreeItem';
export * from './tree/SiteFilesTreeItem';
export * from './tryGetSiteResource';
export * from './utils/azureClients';