|
| 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 { type Site, type WebSiteManagementClient } from '@azure/arm-appservice'; |
| 7 | +import { LocationListStep } from '@microsoft/vscode-azext-azureutils'; |
| 8 | +import { AzureWizardExecuteStepWithActivityOutput, nonNullProp } from '@microsoft/vscode-azext-utils'; |
| 9 | +import { type AppResource } from '@microsoft/vscode-azext-utils/hostapi'; |
| 10 | +import * as fs from 'fs'; |
| 11 | +import * as os from 'os'; |
| 12 | +import * as path from 'path'; |
| 13 | +import { type Progress, Uri, ViewColumn, window, workspace } from 'vscode'; |
| 14 | +import { webProvider } from '../../../constants'; |
| 15 | +import { ext } from '../../../extensionVariables'; |
| 16 | +import { localize } from '../../../localize'; |
| 17 | +import { createWebSiteClient } from '../../../utils/azureClients'; |
| 18 | +import { cpUtils } from '../../../utils/cpUtils'; |
| 19 | +import { type IFlexFunctionAppWizardContext, type IFunctionAppWizardContext } from '../IFunctionAppWizardContext'; |
| 20 | +import { generateAzdYaml } from './generateAzdYaml'; |
| 21 | +import { generateBicepTemplate } from './generateBicepTemplate'; |
| 22 | + |
| 23 | +/** |
| 24 | + * An execute step that: |
| 25 | + * 1. Generates a Bicep template from the wizard context |
| 26 | + * 2. Writes it (along with azure.yaml) to a temporary directory |
| 27 | + * 3. Runs `azd provision` to deploy the infrastructure |
| 28 | + * 4. Retrieves the created Function App Site object from ARM and sets it on context |
| 29 | + * |
| 30 | + * This replaces the individual ResourceGroupCreateStep, StorageAccountCreateStep, |
| 31 | + * AppServicePlanCreateStep, LogAnalyticsCreateStep, AppInsightsCreateStep, |
| 32 | + * FunctionAppCreateStep, UserAssignedIdentityCreateStep, and RoleAssignmentExecuteStep |
| 33 | + * for the standard (non-Docker) flow. |
| 34 | + */ |
| 35 | +export class AzdProvisionExecuteStep extends AzureWizardExecuteStepWithActivityOutput<IFunctionAppWizardContext> { |
| 36 | + // This step replaces ALL individual ARM create steps, so it runs at the end |
| 37 | + public priority: number = 50; |
| 38 | + public stepName: string = 'azdProvisionStep'; |
| 39 | + |
| 40 | + public async execute(context: IFlexFunctionAppWizardContext, progress: Progress<{ message?: string; increment?: number }>): Promise<void> { |
| 41 | + const siteName = nonNullProp(context, 'newSiteName'); |
| 42 | + const rgName = nonNullProp(context, 'newResourceGroupName'); |
| 43 | + const location = await LocationListStep.getLocation(context, webProvider); |
| 44 | + const locationName = nonNullProp(location, 'name'); |
| 45 | + |
| 46 | + // 1. Generate the Bicep template and azure.yaml from wizard context |
| 47 | + progress.report({ message: localize('generatingInfra', 'Generating infrastructure template...') }); |
| 48 | + const { bicepContent, resourcesBicepContent, parametersContent } = generateBicepTemplate(context); |
| 49 | + const azdYaml = generateAzdYaml(context); |
| 50 | + |
| 51 | + // 2. Write to a temporary AZD project directory |
| 52 | + const tmpDir = await this.createTempAzdProject(siteName, bicepContent, resourcesBicepContent, parametersContent, azdYaml); |
| 53 | + |
| 54 | + try { |
| 55 | + // 2b. Open the generated Bicep file in the editor so the user can see the template |
| 56 | + const mainBicepPath = path.join(tmpDir, 'infra', 'main.bicep'); |
| 57 | + const doc = await workspace.openTextDocument(Uri.file(mainBicepPath)); |
| 58 | + await window.showTextDocument(doc, { viewColumn: ViewColumn.Beside, preview: true, preserveFocus: true }); |
| 59 | + |
| 60 | + // 3. Set up the AZD environment by writing files directly (avoids shell quoting issues) |
| 61 | + progress.report({ message: localize('initAzdEnv', 'Initializing AZD environment...') }); |
| 62 | + const envName = sanitizeEnvName(siteName); |
| 63 | + |
| 64 | + await this.writeAzdEnvironment(tmpDir, envName, { |
| 65 | + AZURE_LOCATION: locationName, |
| 66 | + AZURE_SUBSCRIPTION_ID: context.subscriptionId, |
| 67 | + }); |
| 68 | + |
| 69 | + // 4. Run azd provision |
| 70 | + progress.report({ message: localize('provisioning', 'Provisioning resources with AZD for "{0}"...', siteName) }); |
| 71 | + |
| 72 | + await cpUtils.executeCommand( |
| 73 | + ext.outputChannel, |
| 74 | + tmpDir, |
| 75 | + 'azd', |
| 76 | + ['provision', '--no-prompt'], |
| 77 | + ); |
| 78 | + |
| 79 | + // 5. Retrieve the created Function App from ARM to populate context.site |
| 80 | + progress.report({ message: localize('retrievingSite', 'Retrieving created function app...') }); |
| 81 | + const client: WebSiteManagementClient = await createWebSiteClient(context); |
| 82 | + const site: Site = await client.webApps.get(rgName, siteName); |
| 83 | + |
| 84 | + context.site = site; |
| 85 | + context.activityResult = site as AppResource; |
| 86 | + |
| 87 | + ext.outputChannel.appendLog(localize('azdProvisionSuccess', 'Successfully provisioned function app "{0}" via AZD.', siteName)); |
| 88 | + } finally { |
| 89 | + // 6. Clean up the temp directory |
| 90 | + this.cleanupTempDir(tmpDir); |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + public shouldExecute(context: IFunctionAppWizardContext): boolean { |
| 95 | + return !context.site; |
| 96 | + } |
| 97 | + |
| 98 | + /** |
| 99 | + * Creates a temporary directory with the AZD project structure: |
| 100 | + * tmpDir/ |
| 101 | + * azure.yaml |
| 102 | + * infra/ |
| 103 | + * main.bicep |
| 104 | + * resources.bicep |
| 105 | + * main.parameters.json |
| 106 | + */ |
| 107 | + private async createTempAzdProject( |
| 108 | + siteName: string, |
| 109 | + bicepContent: string, |
| 110 | + resourcesBicepContent: string, |
| 111 | + parametersContent: string, |
| 112 | + azdYaml: string, |
| 113 | + ): Promise<string> { |
| 114 | + const tmpDir = path.join(os.tmpdir(), `azfunc-azd-${siteName}-${Date.now()}`); |
| 115 | + const infraDir = path.join(tmpDir, 'infra'); |
| 116 | + |
| 117 | + await fs.promises.mkdir(infraDir, { recursive: true }); |
| 118 | + await Promise.all([ |
| 119 | + fs.promises.writeFile(path.join(tmpDir, 'azure.yaml'), azdYaml, 'utf-8'), |
| 120 | + fs.promises.writeFile(path.join(infraDir, 'main.bicep'), bicepContent, 'utf-8'), |
| 121 | + fs.promises.writeFile(path.join(infraDir, 'resources.bicep'), resourcesBicepContent, 'utf-8'), |
| 122 | + fs.promises.writeFile(path.join(infraDir, 'main.parameters.json'), parametersContent, 'utf-8'), |
| 123 | + ]); |
| 124 | + |
| 125 | + return tmpDir; |
| 126 | + } |
| 127 | + |
| 128 | + /** |
| 129 | + * Writes AZD environment config files directly, avoiding shell quoting issues |
| 130 | + * with `azd env set`. Creates: |
| 131 | + * .azure/config.json — sets default environment |
| 132 | + * .azure/<envName>/.env — environment variable values |
| 133 | + */ |
| 134 | + private async writeAzdEnvironment( |
| 135 | + projectDir: string, |
| 136 | + envName: string, |
| 137 | + envVars: Record<string, string>, |
| 138 | + ): Promise<void> { |
| 139 | + const azureDir = path.join(projectDir, '.azure'); |
| 140 | + const envDir = path.join(azureDir, envName); |
| 141 | + |
| 142 | + await fs.promises.mkdir(envDir, { recursive: true }); |
| 143 | + |
| 144 | + // Write .azure/config.json to set the default environment |
| 145 | + const configJson = JSON.stringify({ version: 1, defaultEnvironment: envName }, null, 2); |
| 146 | + await fs.promises.writeFile(path.join(azureDir, 'config.json'), configJson, 'utf-8'); |
| 147 | + |
| 148 | + // Write .azure/<envName>/.env with all environment variables |
| 149 | + const envFileContent = Object.entries(envVars) |
| 150 | + .map(([key, value]) => `${key}="${value}"`) |
| 151 | + .join('\n'); |
| 152 | + await fs.promises.writeFile(path.join(envDir, '.env'), envFileContent, 'utf-8'); |
| 153 | + } |
| 154 | + |
| 155 | + /** |
| 156 | + * Best-effort cleanup of the temporary AZD project directory. |
| 157 | + */ |
| 158 | + private cleanupTempDir(tmpDir: string): void { |
| 159 | + try { |
| 160 | + fs.rmSync(tmpDir, { recursive: true, force: true }); |
| 161 | + } catch { |
| 162 | + // Ignore cleanup errors — the OS will clean temp eventually |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + protected getTreeItemLabel(context: IFunctionAppWizardContext): string { |
| 167 | + return localize('provisionWithAzd', 'Provision function app "{0}" via AZD', nonNullProp(context, 'newSiteName')); |
| 168 | + } |
| 169 | + |
| 170 | + protected getOutputLogSuccess(context: IFunctionAppWizardContext): string { |
| 171 | + return localize('azdProvisionSuccess', 'Successfully provisioned function app "{0}" via AZD.', nonNullProp(context, 'newSiteName')); |
| 172 | + } |
| 173 | + |
| 174 | + protected getOutputLogFail(context: IFunctionAppWizardContext): string { |
| 175 | + return localize('azdProvisionFail', 'Failed to provision function app "{0}" via AZD.', nonNullProp(context, 'newSiteName')); |
| 176 | + } |
| 177 | + |
| 178 | + protected getOutputLogProgress(context: IFunctionAppWizardContext): string { |
| 179 | + return localize('azdProvisionProgress', 'Provisioning function app "{0}" via AZD...', nonNullProp(context, 'newSiteName')); |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +/** |
| 184 | + * Sanitizes a Function App name into a valid AZD environment name. |
| 185 | + * AZD env names must be alphanumeric with hyphens, 1-64 chars. |
| 186 | + */ |
| 187 | +function sanitizeEnvName(name: string): string { |
| 188 | + return name.replace(/[^a-zA-Z0-9-]/g, '-').substring(0, 64); |
| 189 | +} |
0 commit comments