apps/vs-code-designer/src/app/commands/deploy/deploy.ts (369 lines of code) (raw):

/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { LogicAppResolver } from '../../../LogicAppResolver'; import { logicAppKind, deploySubpathSetting, connectionsFileName, parametersFileName, webhookRedirectHostUri, workflowAppAADClientId, workflowAppAADClientSecret, workflowAppAADObjectId, workflowAppAADTenantId, kubernetesKind, showDeployConfirmationSetting, logicAppFilter, parameterizeConnectionsInProjectLoadSetting, } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { LogicAppResourceTree } from '../../tree/LogicAppResourceTree'; import { SlotTreeItem } from '../../tree/slotsTree/SlotTreeItem'; import { SubscriptionTreeItem } from '../../tree/subscriptionTree/SubscriptionTreeItem'; import { createAclInConnectionIfNeeded, getConnectionsJson } from '../../utils/codeless/connection'; import { getParametersJson } from '../../utils/codeless/parameter'; import { isPathEqual, writeFormattedJson } from '../../utils/fs'; import { addLocalFuncTelemetry } from '../../utils/funcCoreTools/funcVersion'; import { getWorkspaceSetting, getGlobalSetting } from '../../utils/vsCodeConfig/settings'; import { verifyInitForVSCode } from '../../utils/vsCodeConfig/verifyInitForVSCode'; import { createLogicAppAdvanced, createLogicApp } from '../createLogicApp/createLogicApp'; import { AdvancedIdentityObjectIdStep, AdvancedIdentityClientIdStep, AdvancedIdentityTenantIdStep, AdvancedIdentityClientSecretStep, } from '../createLogicApp/createLogicAppSteps/AdvancedIdentityPromptSteps'; import { notifyDeployComplete } from './notifyDeployComplete'; import { updateAppSettingsWithIdentityDetails } from './updateAppSettings'; import { verifyAppSettings } from './verifyAppSettings'; import type { SiteConfigResource, StringDictionary, Site } from '@azure/arm-appservice'; import { deploy as innerDeploy, getDeployFsPath, runPreDeployTask, getDeployNode } from '@microsoft/vscode-azext-azureappservice'; import type { IDeployContext } from '@microsoft/vscode-azext-azureappservice'; import { ScmType } from '@microsoft/vscode-azext-azureappservice/out/src/ScmType'; import type { AzExtParentTreeItem, IActionContext, IAzureQuickPickItem, ISubscriptionContext } from '@microsoft/vscode-azext-utils'; import { AzureWizard, DialogResponses } from '@microsoft/vscode-azext-utils'; import { resolveConnectionsReferences, type ConnectionsData, type FuncVersion, type IIdentityWizardContext, type ProjectLanguage, } from '@microsoft/vscode-extension-logic-apps'; import * as fse from 'fs-extra'; import * as path from 'path'; import type { Uri, MessageItem, WorkspaceFolder } from 'vscode'; import { deployHybridLogicApp } from './hybridLogicApp'; import { createContainerClient } from '../../utils/azureClients'; export async function deployProductionSlot( context: IActionContext, target?: Uri | string | SlotTreeItem, functionAppId?: string | Record<string, any> ): Promise<void> { await deploy(context, target, functionAppId); } export async function deploySlot( context: IActionContext, target?: Uri | string | SlotTreeItem, functionAppId?: string | Record<string, any> ): Promise<void> { await deploy(context, target, functionAppId, new RegExp(LogicAppResourceTree.pickSlotContextValue)); } async function deploy( actionContext: IActionContext, target: Uri | string | SlotTreeItem | undefined, functionAppId: string | Record<string, any> | undefined, expectedContextValue?: string | RegExp ): Promise<void> { addLocalFuncTelemetry(actionContext); let deployProjectPathForWorkflowApp: string | undefined; const settingsToExclude: string[] = [webhookRedirectHostUri]; const deployPaths = await getDeployFsPath(actionContext, target); const context: IDeployContext = Object.assign(actionContext, deployPaths, { defaultAppSetting: 'defaultFunctionAppToDeploy' }); const { originalDeployFsPath, effectiveDeployFsPath, workspaceFolder } = deployPaths; ext.deploymentFolderPath = originalDeployFsPath; let node: SlotTreeItem; if (expectedContextValue) { node = await getDeployNode(context, ext.rgApi.appResourceTree, target, functionAppId, async () => ext.rgApi.pickAppResource( { ...context, suppressCreatePick: false }, { filter: logicAppFilter, expectedChildContextValue: expectedContextValue, } ) ); } else { node = await getDeployNode(context, ext.rgApi.appResourceTree, target, functionAppId, async () => getDeployLogicAppNode(actionContext)); } const isHybridLogicApp = !!node.isHybridLogicApp; const nodeKind = (isHybridLogicApp ? node.hybridSite.type : node.site.kind).toLowerCase(); const isWorkflowApp = nodeKind?.includes(logicAppKind); const isDeployingToKubernetes = nodeKind && nodeKind.indexOf(kubernetesKind) !== -1; const [language, version]: [ProjectLanguage, FuncVersion] = await verifyInitForVSCode(context, effectiveDeployFsPath); context.telemetry.properties.projectLanguage = language; context.telemetry.properties.projectRuntime = version; const identityWizardContext: IIdentityWizardContext = { clientId: undefined, clientSecret: undefined, objectId: undefined, tenantId: undefined, useAdvancedIdentity: undefined, ...context, }; if (isDeployingToKubernetes) { const managedApiConnectionExists = await managedApiConnectionsExists(workspaceFolder); if (managedApiConnectionExists) { const aadDetailsExist = await checkAADDetailsExistsInAppSettings(node, identityWizardContext); if (!aadDetailsExist) { const wizard: AzureWizard<IIdentityWizardContext> = new AzureWizard(identityWizardContext, { promptSteps: [ new AdvancedIdentityObjectIdStep(), new AdvancedIdentityClientIdStep(), new AdvancedIdentityTenantIdStep(), new AdvancedIdentityClientSecretStep(), ], title: localize('aadDetails', 'Provide your AAD identity details to use with your Azure connections.'), }); await wizard.prompt(); } identityWizardContext.useAdvancedIdentity = true; } } identityWizardContext?.useAdvancedIdentity ? await updateAppSettingsWithIdentityDetails(context, node, identityWizardContext) : undefined; let isZipDeploy = false; if (!isHybridLogicApp) { await verifyAppSettings(context, node, version, language, originalDeployFsPath, !context.isNewApp); const client = await node.site.createClient(actionContext); const siteConfig: SiteConfigResource = await client.getSiteConfig(); isZipDeploy = siteConfig.scmType !== ScmType.LocalGit && siteConfig.scmType !== ScmType.GitHub; if (getWorkspaceSetting<boolean>(showDeployConfirmationSetting, workspaceFolder.uri.fsPath) && !context.isNewApp && isZipDeploy) { const warning: string = localize( 'confirmDeploy', 'Are you sure you want to deploy to "{0}"? This will overwrite any previous deployment and cannot be undone.', client.fullName ); context.telemetry.properties.cancelStep = 'confirmDestructiveDeployment'; const deployButton: MessageItem = { title: localize('deploy', 'Deploy') }; await context.ui.showWarningMessage(warning, { modal: true }, deployButton, DialogResponses.cancel); context.telemetry.properties.cancelStep = ''; } await runPreDeployTask(context, effectiveDeployFsPath, siteConfig.scmType); if (isZipDeploy) { validateGlobSettings(context, effectiveDeployFsPath); } } await node.runWithTemporaryDescription(context, localize('deploying', 'Deploying...'), async () => { // preDeploy tasks are only required for zipdeploy so subpath may not exist let deployFsPath: string = effectiveDeployFsPath; if (!isZipDeploy && !isPathEqual(effectiveDeployFsPath, originalDeployFsPath)) { deployFsPath = originalDeployFsPath; const noSubpathWarning = `WARNING: Ignoring deploySubPath "${getWorkspaceSetting( deploySubpathSetting, originalDeployFsPath )}" for non-zip deploy.`; ext.outputChannel.appendLog(noSubpathWarning); } if (isWorkflowApp) { await cleanupPublishBinPath(context, effectiveDeployFsPath); } deployProjectPathForWorkflowApp = isWorkflowApp ? await getProjectPathToDeploy(node, workspaceFolder, settingsToExclude, deployFsPath, identityWizardContext, actionContext) : undefined; try { if (isHybridLogicApp) { await deployHybridLogicApp(context, node); } else { await innerDeploy( node.site, deployProjectPathForWorkflowApp !== undefined ? deployProjectPathForWorkflowApp : deployFsPath, context ); } } finally { if (deployProjectPathForWorkflowApp !== undefined && !isHybridLogicApp) { await cleanAndRemoveDeployFolder(deployProjectPathForWorkflowApp); } } }); if (!isHybridLogicApp) { await node.loadAllChildren(context); } await notifyDeployComplete(node, context.workspaceFolder, isHybridLogicApp, settingsToExclude); } /** * Shows tree item picker to select Logic App or create a new one. * @param {IActionContext} context - Command context. * @returns {Promise<SlotTreeItem>} Logic App slot tree item. */ async function getDeployLogicAppNode(context: IActionContext): Promise<SlotTreeItem> { const placeHolder: string = localize('selectLogicApp', 'Select Logic App (Standard) in Azure'); const sub = await ext.rgApi.appResourceTree.showTreeItemPicker<AzExtParentTreeItem>(SubscriptionTreeItem.contextValue, context); let [site, isAdvance] = (await context.ui.showQuickPick(getLogicAppsPicks(context, sub.subscription), { placeHolder })).data; if (!site) { if (isAdvance) { return await createLogicAppAdvanced(context, sub); } return await createLogicApp(context, sub); } if (site.id.includes('Microsoft.App')) { // NOTE(anandgmenon): Getting latest metadata for hybrid app as the one loaded from the cache can have outdateed definition and cause deployment to fail. const clientContainer = await createContainerClient({ ...context, ...sub.subscription }); site = (await clientContainer.containerApps.get(site.id.split('/')[4], site.name)) as undefined as Site; } const resourceTree = new LogicAppResourceTree(sub.subscription, site); return new SlotTreeItem(sub, resourceTree); } async function getLogicAppsPicks( context: IActionContext, subContext: ISubscriptionContext ): Promise<IAzureQuickPickItem<[Site | undefined, boolean]>[]> { const logicAppsResolver = new LogicAppResolver(); const sites = await logicAppsResolver.getAppResourceSiteBySubscription(context, subContext); const picks: { label: string; data: [Site, boolean]; description?: string }[] = []; Array.from(sites.logicApps).forEach(([_id, site]) => { picks.push({ label: site.name, data: [site, false] }); }); Array.from(sites.hybridLogicApps).forEach(([_id, site]) => { picks.push({ label: `${site.name} (Hybrid)`, data: [site as unknown as Site, false] }); }); picks.sort((a, b) => a.label.localeCompare(b.label)); picks.unshift({ label: localize('selectLogicApp', '$(plus) Create new Logic App (Standard) in Azure...'), data: [undefined, true], description: localize('advanced', 'Advanced'), }); picks.unshift({ label: localize('selectLogicApp', '$(plus) Create new Logic App (Standard) in Azure...'), data: [undefined, false] }); return picks; } /** * Azure functions task `_GenerateFunctionsExtensionsMetadataPostPublish` moves `NetFxWorker` * in `bin/` of publish path, It needs to be reverted as it's a special case where we have a * Azure Function Extension inside a Logic App Extension. * @param context {@link IActionContext} * @param fsPath publish path for logic app extension */ async function cleanupPublishBinPath(_context: IActionContext, fsPath: string): Promise<void> { const netFxWorkerBinPath = path.join(fsPath, 'bin', 'NetFxWorker'); const netFxWorkerAssetPath = path.join(fsPath, 'NetFxWorker'); if (await fse.pathExists(netFxWorkerBinPath)) { return fse.move(netFxWorkerBinPath, netFxWorkerAssetPath, { overwrite: true }); } } async function validateGlobSettings(context: IActionContext, fsPath: string): Promise<void> { const includeKey = 'zipGlobPattern'; const excludeKey = 'zipIgnorePattern'; const includeSetting: string | undefined = getWorkspaceSetting(includeKey, fsPath); const excludeSetting: string | string[] | undefined = getWorkspaceSetting(excludeKey, fsPath); if (includeSetting || excludeSetting) { context.telemetry.properties.hasOldGlobSettings = 'true'; const message: string = localize( 'globSettingRemoved', '"{0}" and "{1}" settings are no longer supported. Instead, place a ".funcignore" file at the root of your repo, using the same syntax as a ".gitignore" file.', includeKey, excludeKey ); await context.ui.showWarningMessage(message); } } async function managedApiConnectionsExists(workspaceFolder: WorkspaceFolder): Promise<boolean> { const workspaceFolderPath = workspaceFolder.uri.fsPath; const connectionsJson = await getConnectionsJson(workspaceFolderPath); let connectionsData: ConnectionsData; try { connectionsData = JSON.parse(connectionsJson); } catch { return false; } return !!connectionsData.managedApiConnections && Object.keys(connectionsData.managedApiConnections).length > 0; } async function getProjectPathToDeploy( node: SlotTreeItem, workspaceFolder: WorkspaceFolder, settingsToExclude: string[], originalDeployFsPath: string, identityWizardContext: IIdentityWizardContext, actionContext: IActionContext ): Promise<string | undefined> { const workspaceFolderPath = workspaceFolder.uri.fsPath; const connectionsJson = await getConnectionsJson(workspaceFolderPath); const parametersJson = await getParametersJson(workspaceFolderPath); const targetAppSettings = await node.getApplicationSettings(identityWizardContext as IDeployContext); const parameterizeConnectionsSetting = getGlobalSetting(parameterizeConnectionsInProjectLoadSetting); let resolvedConnections: ConnectionsData; let connectionsData: ConnectionsData; function updateAuthenticationParameters(authValue: any): void { if (connectionsData.managedApiConnections && Object.keys(connectionsData.managedApiConnections).length) { for (const referenceKey of Object.keys(connectionsData.managedApiConnections)) { parametersJson[`${referenceKey}-Authentication`].value = authValue; actionContext.telemetry.properties.updateAuth = `updated "${referenceKey}-Authentication" parameter to ManagedServiceIdentity`; } } } function updateAuthenticationInConnections(authValue: any): void { if (connectionsData.managedApiConnections && Object.keys(connectionsData.managedApiConnections).length) { for (const referenceKey of Object.keys(connectionsData.managedApiConnections)) { connectionsData.managedApiConnections[referenceKey].authentication = authValue; actionContext.telemetry.properties.updateAuth = `updated "${referenceKey}" connection authentication to ManagedServiceIdentity`; } } } try { connectionsData = JSON.parse(connectionsJson); const authValue = { type: 'ManagedServiceIdentity' }; const advancedIdentityAuthValue = { type: 'ActiveDirectoryOAuth', audience: 'https://management.core.windows.net/', credentialType: 'Secret', clientId: `@appsetting('${workflowAppAADClientId}')`, tenant: `@appsetting('${workflowAppAADTenantId}')`, secret: `@appsetting('${workflowAppAADClientSecret}')`, }; if (parameterizeConnectionsSetting) { identityWizardContext?.useAdvancedIdentity ? updateAuthenticationParameters(advancedIdentityAuthValue) : updateAuthenticationParameters(authValue); } else { identityWizardContext?.useAdvancedIdentity ? updateAuthenticationInConnections(advancedIdentityAuthValue) : updateAuthenticationInConnections(authValue); } resolvedConnections = resolveConnectionsReferences(connectionsJson, parametersJson, targetAppSettings); } catch { actionContext.telemetry.properties.noAuthUpdate = 'No authentication update was made'; return undefined; } if (connectionsData.managedApiConnections && Object.keys(connectionsData.managedApiConnections).length) { const deployProjectPath = path.join(path.dirname(workspaceFolderPath), `${path.basename(workspaceFolderPath)}-deploytemp`); const connectionsFilePathDeploy = path.join(deployProjectPath, connectionsFileName); const parametersFilePathDeploy = path.join(deployProjectPath, parametersFileName); if (await fse.pathExists(deployProjectPath)) { await cleanAndRemoveDeployFolder(deployProjectPath); } fse.mkdirSync(deployProjectPath); await fse.copy(originalDeployFsPath, deployProjectPath, { overwrite: true }); for (const referenceKey of Object.keys(connectionsData.managedApiConnections)) { try { const connection = resolvedConnections.managedApiConnections[referenceKey].connection; await createAclInConnectionIfNeeded(identityWizardContext, connection.id, node.site); if (node.site.isSlot) { const parentTreeItem = node.parent?.parent as SlotTreeItem; await createAclInConnectionIfNeeded(identityWizardContext, connection.id, parentTreeItem.site); } } catch (error) { throw new Error(`Error in creating access policy for connection in reference - '${referenceKey}'. ${error}`); } settingsToExclude.push(`${referenceKey}-connectionKey`); } await writeFormattedJson(connectionsFilePathDeploy, connectionsData); await writeFormattedJson(parametersFilePathDeploy, parametersJson); return deployProjectPath; } return undefined; } async function cleanAndRemoveDeployFolder(deployProjectPath: string): Promise<void> { await fse.emptyDir(deployProjectPath); fse.rmdirSync(deployProjectPath); } async function checkAADDetailsExistsInAppSettings(node: SlotTreeItem, identityWizardContext: IIdentityWizardContext): Promise<boolean> { const client = await node.site.createClient(identityWizardContext); const appSettings: StringDictionary | undefined = (await client.listApplicationSettings())?.properties; if (appSettings) { const clientId = appSettings[workflowAppAADClientId]; const objectId = appSettings[workflowAppAADObjectId]; const tenantId = appSettings[workflowAppAADTenantId]; const clientSecret = appSettings[workflowAppAADClientSecret]; const aadDetailsExists = !!clientId && !!objectId && !!tenantId && !!clientSecret; identityWizardContext.clientId = clientId; identityWizardContext.clientSecret = clientSecret; identityWizardContext.objectId = objectId; identityWizardContext.tenantId = tenantId; identityWizardContext.useAdvancedIdentity = aadDetailsExists; return aadDetailsExists; } return false; }