Skip to content

Commit

Permalink
Enable flex consumption creation (preview) (#4057)
Browse files Browse the repository at this point in the history
* Create flex asp SKU and function app

* Add try/catch and feature flag

* Remove extra steps

* Fix some build errors

* Remove unneeded custom deploy and PR feedback

* Minor changes to fix creation

* Update appservice for flex deploy & use locations API

* Remove FUNCTIONS_EXTENSION_VERSION from flex app config

* Bump appservice and fix lint

* Remove geolocation constant

* Fix constants file

* Only add the FunctionAppHostingPlan for advanced creation
  • Loading branch information
nturinski authored Mar 29, 2024
1 parent 31e733e commit 2931e3c
Show file tree
Hide file tree
Showing 10 changed files with 292 additions and 68 deletions.
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,11 @@
"type": "string",
"default": "src/functions",
"description": "%azureFunctions.functionSubpath%"
},
"azureFunctions.enableFlexConsumption": {
"type": "boolean",
"description": "%azureFunctions.enableFlexConsumption%",
"default": false
}
}
}
Expand Down Expand Up @@ -1180,7 +1185,7 @@
"@azure/core-client": "^1.7.3",
"@azure/core-rest-pipeline": "^1.11.0",
"@azure/storage-blob": "^12.5.0",
"@microsoft/vscode-azext-azureappservice": "^3.1.2",
"@microsoft/vscode-azext-azureappservice": "^3.2.1",
"@microsoft/vscode-azext-azureappsettings": "^0.2.1",
"@microsoft/vscode-azext-azureutils": "^3.0.0",
"@microsoft/vscode-azext-serviceconnector": "^0.1.3",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"azureFunctions.viewDeploymentLogs": "View Deployment Logs",
"azureFunctions.viewProperties": "View Properties",
"azureFunctions.eventGrid.sendMockRequest": "Save and execute...",
"azureFunctions.enableFlexConsumption": "Enables to create a Flex Consumption Function App. This is a preview feature and may not be available in all regions.",
"azureFunctions.walkthrough.functionsStart.create.description": "If you're just getting started, you will need to create an Azure Functions project. Follow along with the [Visual Studio Code developer guide](https://aka.ms/functions-getstarted-vscode) for step-by-step instructions.\n[Create New Project](command:azureFunctions.createNewProject)",
"azureFunctions.walkthrough.functionsStart.create.title": "Create a new Azure Functions project",
"azureFunctions.walkthrough.functionsStart.description": "Learn about Azure Functions and the Azure Functions extension for Visual Studio Code",
Expand Down
167 changes: 146 additions & 21 deletions src/commands/createFunctionApp/FunctionAppCreateStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
*--------------------------------------------------------------------------------------------*/

import { type NameValuePair, type Site, type SiteConfig, type WebSiteManagementClient } from '@azure/arm-appservice';
import { createHttpHeaders, createPipelineRequest, type RequestBodyType } from '@azure/core-rest-pipeline';
import { ParsedSite, WebsiteOS, type CustomLocation, type IAppServiceWizardContext } from '@microsoft/vscode-azext-azureappservice';
import { LocationListStep } from '@microsoft/vscode-azext-azureutils';
import { AzureWizardExecuteStep, parseError } from '@microsoft/vscode-azext-utils';
import { LocationListStep, createGenericClient, type AzExtPipelineResponse, type AzExtRequestPrepareOptions } from '@microsoft/vscode-azext-azureutils';
import { AzureWizardExecuteStep, parseError, randomUtils } from '@microsoft/vscode-azext-utils';
import { type AppResource } from '@microsoft/vscode-azext-utils/hostapi';
import { type Progress } from 'vscode';
import { FuncVersion, getMajorVersion } from '../../FuncVersion';
Expand All @@ -27,13 +28,15 @@ export class FunctionAppCreateStep extends AzureWizardExecuteStep<IFunctionAppWi

public async execute(context: IFunctionAppWizardContext, progress: Progress<{ message?: string; increment?: number }>): Promise<void> {
const os: WebsiteOS = nonNullProp(context, 'newSiteOS');
const stack: FullFunctionAppStack = nonNullProp(context, 'newSiteStack');
const stack: FullFunctionAppStack | undefined = context.newSiteStack

context.telemetry.properties.newSiteOS = os;
context.telemetry.properties.newSiteStack = stack.stack.value;
context.telemetry.properties.newSiteMajorVersion = stack.majorVersion.value;
context.telemetry.properties.newSiteMinorVersion = stack.minorVersion.value;
context.telemetry.properties.planSkuTier = context.plan?.sku?.tier;
if (stack) {
context.telemetry.properties.newSiteOS = os;
context.telemetry.properties.newSiteStack = stack.stack.value;
context.telemetry.properties.newSiteMajorVersion = stack.majorVersion.value;
context.telemetry.properties.newSiteMinorVersion = stack.minorVersion.value;
context.telemetry.properties.planSkuTier = context.plan?.sku?.tier;
}

const message: string = localize('creatingNewApp', 'Creating new function app "{0}"...', context.newSiteName);
ext.outputChannel.appendLog(message);
Expand All @@ -42,8 +45,8 @@ export class FunctionAppCreateStep extends AzureWizardExecuteStep<IFunctionAppWi
const siteName: string = nonNullProp(context, 'newSiteName');
const rgName: string = nonNullProp(nonNullProp(context, 'resourceGroup'), 'name');

const client: WebSiteManagementClient = await createWebSiteClient(context);
context.site = await client.webApps.beginCreateOrUpdateAndWait(rgName, siteName, await this.getNewSite(context, stack));
// TODO: Because we don't have the stack API, assume no stack means it's a flex app
context.site = stack ? await this.createFunctionApp(context, rgName, siteName, stack) : await this.createFlexFunctionApp(context, rgName, siteName);
context.activityResult = context.site as AppResource;

const site = new ParsedSite(context.site, context);
Expand Down Expand Up @@ -97,24 +100,71 @@ export class FunctionAppCreateStep extends AzureWizardExecuteStep<IFunctionAppWi
site.extendedLocation = { name: customLocation.id, type: 'customLocation' };
}

private async getNewSiteConfig(context: IFunctionAppWizardContext, stack: FullFunctionAppStack): Promise<SiteConfig> {
const stackSettings: FunctionAppRuntimeSettings = nonNullProp(stack.minorVersion.stackSettings, context.newSiteOS === WebsiteOS.linux ? 'linuxRuntimeSettings' : 'windowsRuntimeSettings');
const newSiteConfig: SiteConfig = stackSettings.siteConfigPropertiesDictionary;
const storageConnectionString: string = (await getStorageConnectionString(context)).connectionString;
private async getNewFlexSite(context: IFunctionAppWizardContext): Promise<Site> {
const location = await LocationListStep.getLocation(context, webProvider);
const site: Site & { properties: FlexFunctionAppProperties } = {
name: context.newSiteName,
kind: getSiteKind(context),
location: nonNullProp(location, 'name'),
properties: {
name: context.newSiteName,
serverFarmId: context.plan?.id,
clientAffinityEnabled: false,
siteConfig: await this.getNewSiteConfig(context)
},
};

site.properties.sku = 'FlexConsumption';
site.properties.functionAppConfig = {
deployment: {
storage: {
type: 'blobContainer',
value: `${context.storageAccount?.primaryEndpoints?.blob}app-package-${context.newSiteName?.substring(0, 32)}-${randomUtils.getRandomHexString(7)}`,
authentication: {
userAssignedIdentityResourceId: null,
type: 'StorageAccountConnectionString',
storageAccountConnectionStringName: 'DEPLOYMENT_STORAGE_CONNECTION_STRING'
}
}
},
runtime: {
name: context.newSiteStackFlex?.runtime,
version: context.newSiteStackFlex?.version
},
scaleAndConcurrency: {
maximumInstanceCount: 100,
instanceMemoryMB: 2048,
alwaysReady: [],
triggers: null
},
}

return site;
}

private async getNewSiteConfig(context: IFunctionAppWizardContext, stack?: FullFunctionAppStack): Promise<SiteConfig> {
let newSiteConfig: SiteConfig = {};

const storageConnectionString: string = (await getStorageConnectionString(context)).connectionString;
const appSettings: NameValuePair[] = [
{
name: ConnectionKey.Storage,
value: storageConnectionString
},
{
name: extensionVersionKey,
value: '~' + getMajorVersion(context.version)
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
...Object.entries(stackSettings.appSettingsDictionary).map(([name, value]) => { return { name, value }; })
}
];

if (stack) {
const stackSettings: FunctionAppRuntimeSettings = nonNullProp(stack.minorVersion.stackSettings, context.newSiteOS === WebsiteOS.linux ? 'linuxRuntimeSettings' : 'windowsRuntimeSettings');
newSiteConfig = stackSettings.siteConfigPropertiesDictionary;
appSettings.concat(
{
name: extensionVersionKey,
value: '~' + getMajorVersion(context.version)
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
...Object.entries(stackSettings.appSettingsDictionary).map(([name, value]) => { return { name, value }; }));
}

// This setting only applies for v1 https://github.com/Microsoft/vscode-azurefunctions/issues/640
if (context.version === FuncVersion.v1) {
appSettings.push({
Expand All @@ -125,6 +175,8 @@ export class FunctionAppCreateStep extends AzureWizardExecuteStep<IFunctionAppWi

const isElasticPremium: boolean = context.plan?.sku?.family?.toLowerCase() === 'ep';
const isConsumption: boolean = context.plan?.sku?.family?.toLowerCase() === 'y';
// no stack means it's a flex app
const isFlex: boolean = !stack;
if (isConsumption || isElasticPremium) {
// WEBSITE_CONTENT* settings are added for consumption/premium plans, but not dedicated
// https://github.com/microsoft/vscode-azurefunctions/issues/1702
Expand All @@ -136,6 +188,11 @@ export class FunctionAppCreateStep extends AzureWizardExecuteStep<IFunctionAppWi
name: contentShareKey,
value: getNewFileShareName(nonNullProp(context, 'newSiteName'))
});
} else if (isFlex) {
appSettings.push({
name: 'DEPLOYMENT_STORAGE_CONNECTION_STRING',
value: storageConnectionString
})
}

// This setting is not required, but we will set it since it has many benefits https://docs.microsoft.com/en-us/azure/azure-functions/run-functions-from-deployment-package
Expand Down Expand Up @@ -167,6 +224,38 @@ export class FunctionAppCreateStep extends AzureWizardExecuteStep<IFunctionAppWi
newSiteConfig.appSettings = appSettings;
return newSiteConfig;
}

async createFunctionApp(context: IFunctionAppWizardContext, rgName: string, siteName: string, stack: FullFunctionAppStack): Promise<Site> {
const client: WebSiteManagementClient = await createWebSiteClient(context);
return await client.webApps.beginCreateOrUpdateAndWait(rgName, siteName, await this.getNewSite(context, stack));
}

async createFlexFunctionApp(context: IFunctionAppWizardContext, rgName: string, siteName: string): Promise<Site> {
const headers = createHttpHeaders({
'Content-Type': 'application/json',
});

const options: AzExtRequestPrepareOptions = {
url: `https://management.azure.com/subscriptions/${context.subscriptionId}/resourceGroups/${rgName}/providers/Microsoft.Web/sites/${siteName}?api-version=2023-12-01`,
method: 'PUT',
body: JSON.stringify(await this.getNewFlexSite(context)) as unknown as RequestBodyType,
headers
};

const client = await createGenericClient(context, context);
const result = await client.sendRequest(createPipelineRequest(options)) as AzExtPipelineResponse;
if (result && result.status >= 200 && result.status < 300) {
const client: WebSiteManagementClient = await createWebSiteClient(context);
// the payload for the new API version "2023-12-01" is incompatiable with our current SiteClient so get the old payload
try {
return await client.webApps.get(rgName, siteName);
} catch (_) {
// ignore error and fall thru to throw
}
}

throw new Error(parseError(result.parsedBody).message || localize('failedToCreateFlexFunctionApp', 'Failed to create flex function app "{0}".', siteName));
}
}

function getNewFileShareName(siteName: string): string {
Expand All @@ -185,3 +274,39 @@ function getSiteKind(context: IAppServiceWizardContext): string {
}
return kind;
}

type FlexFunctionAppProperties = {
containerSize?: number,
sku?: 'FlexConsumption',
name?: string,
serverFarmId?: string,
clientAffinityEnabled?: boolean,
siteConfig: SiteConfig,
reserved?: boolean,
functionAppConfig?: FunctionAppConfig
};

// TODO: Temporary until we can get the SDK updated
export type FunctionAppConfig = {
deployment: {
storage: {
type: string;
value: string;
authentication: {
type: string;
userAssignedIdentityResourceId: string | null;
storageAccountConnectionStringName: string | null;
};
}
},
runtime: {
name?: string,
version?: string
},
scaleAndConcurrency: {
alwaysReady: number[],
maximumInstanceCount: number,
instanceMemoryMB: number,
triggers: null
}
};
41 changes: 40 additions & 1 deletion src/commands/createFunctionApp/FunctionAppHostingPlanStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,38 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { AppServicePlanListStep, setLocationsTask } from '@microsoft/vscode-azext-azureappservice';
import { type Location } from '@azure/arm-resources-subscriptions';
import { createHttpHeaders, createPipelineRequest } from '@azure/core-rest-pipeline';
import { AppServicePlanListStep, setLocationsTask, WebsiteOS, type IAppServiceWizardContext } from '@microsoft/vscode-azext-azureappservice';
import { createGenericClient, LocationListStep, type AzExtPipelineResponse, type AzExtRequestPrepareOptions } from '@microsoft/vscode-azext-azureutils';
import { AzureWizardPromptStep, type IAzureQuickPickItem, type IWizardOptions } from '@microsoft/vscode-azext-utils';
import { localize } from '../../localize';
import { getRandomHexString } from '../../utils/fs';
import { nonNullProp } from '../../utils/nonNull';
import { getWorkspaceSetting } from '../../vsCodeConfig/settings';
import { type IFunctionAppWizardContext } from './IFunctionAppWizardContext';

export class FunctionAppHostingPlanStep extends AzureWizardPromptStep<IFunctionAppWizardContext> {
public async prompt(context: IFunctionAppWizardContext): Promise<void> {
const placeHolder: string = localize('selectHostingPlan', 'Select a hosting plan.');
const enableFlexSetting = await getWorkspaceSetting('enableFlexConsumption');
const picks: IAzureQuickPickItem<[boolean, RegExp | undefined]>[] = [
{ label: localize('consumption', 'Consumption'), data: [true, undefined] },
{ label: localize('premium', 'Premium'), data: [false, /^EP$/i] },
{ label: localize('dedicated', 'App Service Plan'), data: [false, /^((?!EP|Y).)*$/i] }
];

if (enableFlexSetting) {
picks.splice(1, 0, { label: localize('flexConsumption', 'Flex Consumption (Preview)'), data: [false, undefined] });
}

[context.useConsumptionPlan, context.planSkuFamilyFilter] = (await context.ui.showQuickPick(picks, { placeHolder })).data;
await setLocationsTask(context);
if (context.useConsumptionPlan) {
setConsumptionPlanProperties(context);
} else if (!context.useConsumptionPlan && !context.planSkuFamilyFilter) {
// if it's not consumption and has no filter, then it's flex consumption
setFlexConsumptionPlanProperties(context);
}
}

Expand All @@ -42,3 +54,30 @@ export function setConsumptionPlanProperties(context: IFunctionAppWizardContext)
context.newPlanName = `ASP-${nonNullProp(context, 'newSiteName')}-${getRandomHexString(4)}`;
context.newPlanSku = { name: 'Y1', tier: 'Dynamic', size: 'Y1', family: 'Y', capacity: 0 };
}

export function setFlexConsumptionPlanProperties(context: IAppServiceWizardContext): void {
context.newPlanName = `FLEXASP-${nonNullProp(context, 'newSiteName')}-${getRandomHexString(4)}`;
context.newPlanSku = { name: 'FC1', tier: 'FlexConsumption', size: 'FC', family: 'FC' };
// flex consumption only supports linux
context.newSiteOS = WebsiteOS.linux;
LocationListStep.setLocationSubset(context, getFlexLocations(context), 'Microsoft.WebFlex');
}

async function getFlexLocations(context: IAppServiceWizardContext): Promise<string[]> {
const headers = createHttpHeaders({
'Content-Type': 'application/json',
});

const options: AzExtRequestPrepareOptions = {
url: `https://management.azure.com/subscriptions/${context.subscriptionId}/providers/Microsoft.Web/geoRegions?api-version=2023-01-01&sku=FlexConsumption`,
method: 'GET',
headers
};

const client = await createGenericClient(context, context);
const result = await client.sendRequest(createPipelineRequest(options)) as AzExtPipelineResponse;
const locations = ((result.parsedBody as { value: Location[] }).value.map(loc => loc.name) as string[])
// TODO: hardcoding these locations for now because they are the only ones that work
locations.push(...['North Central US (Stage)', 'East US 2 EUAP']);
return locations;
}
Loading

0 comments on commit 2931e3c

Please sign in to comment.