From d6e5d5fd33ecf969dc1589e30d39a7e4f4624e3c Mon Sep 17 00:00:00 2001 From: Chandni Patel Date: Mon, 20 May 2024 13:29:22 -0500 Subject: [PATCH] adding support for flex consumption plan --- .../run-e2e-tests-python310-flexcon.yaml | 78 +++++++++++++++++++ README.md | 7 ++ action.yml | 13 ++++ .../Kudu/azure-app-kudu-service.ts | 71 ++++++++++++++++- .../Utilities/KuduServiceUtility.ts | 22 ++++++ src/constants/configuration.ts | 2 + src/constants/function_sku.ts | 6 +- src/constants/publish_method.ts | 5 +- src/handlers/contentPreparer.ts | 26 +++++-- src/handlers/contentPublisher.ts | 7 +- src/handlers/parameterValidator.ts | 6 ++ src/interfaces/IActionParameters.ts | 2 + src/managers/builder.ts | 4 +- src/publishers/index.ts | 3 +- src/publishers/oneDeployFlex.ts | 28 +++++++ tests/handlers/contentPreparer.spec.ts | 3 +- 16 files changed, 269 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/run-e2e-tests-python310-flexcon.yaml create mode 100644 src/publishers/oneDeployFlex.ts diff --git a/.github/workflows/run-e2e-tests-python310-flexcon.yaml b/.github/workflows/run-e2e-tests-python310-flexcon.yaml new file mode 100644 index 000000000..d2ab78d46 --- /dev/null +++ b/.github/workflows/run-e2e-tests-python310-flexcon.yaml @@ -0,0 +1,78 @@ +name: RUN_E2E_TESTS_PYTHON310_FLEXCON +on: + push: + branches: + - master + - dev + - releases/* + paths-ignore: + - '**.md' + pull_request: + paths-ignore: + - '**.md' + +env: + AZURE_FUNCTIONAPP_NAME: gae-fa-python310-flexcon + AZURE_FUNCTIONAPP_PACKAGE_PATH: './tests/e2e/python310' + PYTHON_VERSION: '3.10' + +jobs: + run: + name: Run E2E Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set Node.js 20.x for GitHub Action + uses: actions/setup-node@v1 + with: + node-version: 20.x + + - name: Setup Python ${{ env.PYTHON_VERSION }} Environment + uses: actions/setup-python@v1 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run Npm Install for GitHub Action + run: npm install + + - name: Build GitHub Action + run: npm run build + + - name: E2E Resolve Project Dependencies Using Pip + shell: bash + run: | + pushd '${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}' + python -m pip install --upgrade pip + pip install -r requirements.txt --target=".python_packages/lib/site-packages" + echo "$GITHUB_SHA" > sha.txt + popd + + - name: 'Login via Azure CLI' + uses: azure/login@v1 + with: + creds: ${{ secrets.RBAC_GAE_FA_PYTHON310_FLEXCON }} + + - name: E2E Run Azure Functions Action + uses: ./ + id: fa + with: + app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }} + package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} + remote-build: true + + - name: E2E Check HttpTrigger Result + shell: pwsh + run: | + $i = 0 + while ($i -lt 10) { + sleep 10 + $RESPONSE = $(curl "${{ steps.fa.outputs.app-url }}/api/HttpTrigger") + $RESULT = ($RESPONSE -eq "$env:GITHUB_SHA") + if ($RESULT) { + exit 0 + } + $i = $i + 1 + } + exit 1 diff --git a/README.md b/README.md index 9a3f42f3c..71311d9c2 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,13 @@ If `WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID` is defined then user-assigned - **respect-funcignore**: Allow GitHub Action to parse your .funcignore file and exclude files and folders defined in it. By default, this value is set to `false`. If your GitHub repo contains .funcignore file and want to exclude certain paths (e.g. text editor configs .vscode/, Python virtual environment .venv/), we recommend setting this to `true`. - **scm-do-build-during-deployment**: Allow Kudu site (e.g. https://your-site-name.scm.azurewebsites.net/) to perform pre-deployment operation. By default, this is set to `false`. If you don't want to resolve the dependencies in the GitHub Workflow, and instead, you want to control the deployments in Kudu / KuduLite, you may want to change this setting to `true`. For more information on SCM_DO_BUILD_DURING_DEPLOYMENT setting, please visit our [Kudu doc](https://github.com/projectkudu/kudu/wiki/Configurable-settings#enabledisable-build-actions). - **enable-oryx-build**: Allow Kudu site to resolve your project dependencies with [Oryx](https://github.com/Microsoft/Oryx). By default, this is set to `false`. If you want to use Oryx to resolve your dependencies (e.g. [remote build](https://docs.microsoft.com/en-us/azure/azure-functions/functions-deployment-technologies#remote-build)) instead of GitHub Workflow, please consider setting **scm-do-build-during-deployment** and **enable-oryx-build** to `true`. +- **sku**: For function app on Flex Consumption plan, set this to `flexconsumption`. You can skip this parameter for function app on other plans. +If using RBAC credentials, then by default, GitHub Action will resolve the value for this paramter. But if using **publish-profile**, +then you must set this for function app on Flex Consumption plan. +- **remote-build**: For function app on Flex Consumption plan, enable build action from Kudu when the package is deployed onto the function app by setting this to `true`. +Parameter **remote-build** is only supported for function app on Flex Consumption plan. For other plans, you can use **scm-do-build-during-deployment** and **enable-oryx-build**. +For function app on Flex Consumption plan, do not set **scm-do-build-during-deployment** and **enable-oryx-build**. +By default, this is set to `false`. ## Dependencies on other GitHub Actions * [Checkout](https://github.com/actions/checkout) Checkout your Git repository content into GitHub Actions agent. diff --git a/action.yml b/action.yml index 54a63ff02..ec416d77e 100644 --- a/action.yml +++ b/action.yml @@ -46,6 +46,19 @@ inputs: redundant build action from Kudu endpoint. (default: 'false')." required: false default: 'false' + sku: + description: "For function app on Flex Consumption plan, please set this to 'flexconsumption'. You can skip this parameter for function app on other plans. + If using RBAC credentials, then by default, GitHub Action will resolve the value for this paramter. But if using publish-profile, + then you must set this for function app on Flex Consumption plan." + required: false + remote-build: + description: "For function app on Flex Consumption plan, enable build action from Kudu when the package is deployed onto the function app by setting this to 'true'. + Parameter 'remote-build' is only supported for function app on Flex Consumption plan. For other plans, please set 'scm-do-build-during-deployment' and 'enable-oryx-build'. + For function app on Flex Consumption plan, please do not set 'scm-do-build-during-deployment' and 'enable-oryx-build'. + By default, GitHub Action respects the packages resolved in GitHub workflow, disabling the + redundant build action from Kudu endpoint. (default: 'false')." + required: false + default: 'false' outputs: app-url: description: 'URL to work with your function app' diff --git a/src/appservice-rest/Kudu/azure-app-kudu-service.ts b/src/appservice-rest/Kudu/azure-app-kudu-service.ts index 280976777..a98df3a2f 100644 --- a/src/appservice-rest/Kudu/azure-app-kudu-service.ts +++ b/src/appservice-rest/Kudu/azure-app-kudu-service.ts @@ -6,10 +6,14 @@ import { KuduServiceClient } from './KuduServiceClient'; import { exists } from '@actions/io/lib/io-util'; import core = require('@actions/core'); +import { CANCELLED } from 'dns'; export const KUDU_DEPLOYMENT_CONSTANTS = { + CANCELLED: -1, + FAILED: 3, SUCCESS: 4, - FAILED: 3 + CONFLICT: 5, + PARTIALSUCCESS: 6 } export class Kudu { @@ -236,6 +240,42 @@ export class Kudu { } } + public async oneDeployFlex(webPackage: string, queryParameters?: Array): Promise { + let httpRequest: WebRequest = { + method: 'POST', + uri: this._client.getRequestUri(`/api/publish`, queryParameters), + body: fs.createReadStream(webPackage) + }; + + try { + let response = await this._client.beginRequest(httpRequest, null, 'application/zip'); + core.debug(`One Deploy response: ${JSON.stringify(response)}`); + if(response.statusCode == 200) { + core.debug('Deployment passed'); + return null; + } + else if(response.statusCode == 202) { + let deploymentId: string = response.body; + if(!!deploymentId) { + core.debug(`Polling for deployment ID: ${deploymentId}`); + return await this._getDeploymentDetailsFromDeploymentID(deploymentId); + } + else { + core.debug('one deploy returned 202 without deployment ID.'); + return null; + } + } + else { + throw response; + } + } + catch(error) { + const deploymentError = new Error("Failed to deploy web package to Function App.\n" + this._getFormattedError(error)); + (deploymentError as any).statusCode = error.statusCode; + throw deploymentError; + } + } + public async validateZipDeploy(webPackage: string): Promise { try { core.info("Validating deployment package for functions app before Zip Deploy (RBAC)"); @@ -486,6 +526,35 @@ export class Kudu { } } + private async _getDeploymentDetailsFromDeploymentID(deploymentId: string):Promise { + let httpRequest: WebRequest = { + method: 'GET', + uri: this._client.getRequestUri(`/api/deployments/${deploymentId}`), + headers: {} + }; + + while(true) { + let response = await this._client.beginRequest(httpRequest); + if(response.statusCode == 200 || response.statusCode == 202) { + var result = response.body; + core.debug(`POLL URL RESULT: ${JSON.stringify(response)}`); + if(result.status == KUDU_DEPLOYMENT_CONSTANTS.SUCCESS || result.status == KUDU_DEPLOYMENT_CONSTANTS.PARTIALSUCCESS + || result.status == KUDU_DEPLOYMENT_CONSTANTS.CANCELLED || result.status == KUDU_DEPLOYMENT_CONSTANTS.CONFLICT + || result.status == KUDU_DEPLOYMENT_CONSTANTS.FAILED) { + return result; + } + else { + core.debug(`Deployment status: ${result.status} '${result.status_text}'. retry after 5 seconds`); + await this._sleep(5); + continue; + } + } + else { + throw response; + } + } + } + private _getFormattedError(error: any) { if(error && error.statusCode) { return `${error.statusMessage} (CODE: ${error.statusCode})`; diff --git a/src/appservice-rest/Utilities/KuduServiceUtility.ts b/src/appservice-rest/Utilities/KuduServiceUtility.ts index 81fff23b9..9e780b448 100644 --- a/src/appservice-rest/Utilities/KuduServiceUtility.ts +++ b/src/appservice-rest/Utilities/KuduServiceUtility.ts @@ -58,6 +58,28 @@ export class KuduServiceUtility { } } + public async deployUsingOneDeployFlex(packagePath: string, remoteBuild: string, customMessage?: any): Promise { + try { + console.log('Package deployment using One Deploy initiated.'); + + let queryParameters: Array = [ + 'remoteBuild=' + remoteBuild, + 'deployer=' + GITHUB_ZIP_DEPLOY + ]; + var deploymentMessage = this._getUpdateHistoryRequest(null, null, customMessage).message; + queryParameters.push('message=' + encodeURIComponent(deploymentMessage)); + let deploymentDetails = await this._webAppKuduService.oneDeployFlex(packagePath, queryParameters); + await this._processDeploymentResponse(deploymentDetails); + + console.log('Successfully deployed web package to Function App.'); + return deploymentDetails.id; + } + catch(error) { + core.error('Failed to deploy web package to Function App.'); + throw error; + } + } + public async deployUsingWarDeploy(packagePath: string, customMessage?: any, targetFolderName?: any): Promise { try { console.log('Package deployment using WAR Deploy initiated.'); diff --git a/src/constants/configuration.ts b/src/constants/configuration.ts index ca9a732b3..bb7e6c8dd 100644 --- a/src/constants/configuration.ts +++ b/src/constants/configuration.ts @@ -7,6 +7,8 @@ export class ConfigurationConstant { public static readonly ParamInRespectFuncignore: string = 'respect-funcignore'; public static readonly ParamInEnableOryxBuild: string = 'enable-oryx-build'; public static readonly ParamInScmDoBuildDuringDeployment: string = 'scm-do-build-during-deployment'; + public static readonly ParamInRemoteBuild: string = 'remote-build'; + public static readonly ParamInSku: string = 'sku'; public static readonly ParamOutResultName: string = 'app-url'; public static readonly ParamOutPackageUrl: string = 'package-url'; diff --git a/src/constants/function_sku.ts b/src/constants/function_sku.ts index 45061f361..e301069f7 100644 --- a/src/constants/function_sku.ts +++ b/src/constants/function_sku.ts @@ -3,7 +3,8 @@ import { UnexpectedConversion } from "../exceptions"; export enum FunctionSkuConstant { Consumption = 1, Dedicated, - ElasticPremium + ElasticPremium, + FlexConsumption } export class FunctionSkuUtil { @@ -15,6 +16,9 @@ export class FunctionSkuUtil { if (skuLowercasedString.startsWith('elasticpremium')) { return FunctionSkuConstant.ElasticPremium; } + if (skuLowercasedString.startsWith('flexconsumption')) { + return FunctionSkuConstant.FlexConsumption; + } return FunctionSkuConstant.Dedicated; } } \ No newline at end of file diff --git a/src/constants/publish_method.ts b/src/constants/publish_method.ts index a15415569..ce153af68 100644 --- a/src/constants/publish_method.ts +++ b/src/constants/publish_method.ts @@ -3,5 +3,8 @@ export enum PublishMethodConstant { ZipDeploy = 1, // Setting WEBSITE_RUN_FROM_PACKAGE app setting - WebsiteRunFromPackageDeploy + WebsiteRunFromPackageDeploy, + + // OneDeploy for function apps on Flex consumption plan + OneDeployFlex } \ No newline at end of file diff --git a/src/handlers/contentPreparer.ts b/src/handlers/contentPreparer.ts index 73a5bd1b7..6b2197211 100644 --- a/src/handlers/contentPreparer.ts +++ b/src/handlers/contentPreparer.ts @@ -10,7 +10,7 @@ import { IActionContext } from "../interfaces/IActionContext"; import { IActionParameters } from "../interfaces/IActionParameters"; import { ValidationError, FileIOError } from "../exceptions"; import { PublishMethodConstant } from "../constants/publish_method"; -import { FunctionSkuConstant } from "../constants/function_sku"; +import { FunctionSkuConstant, FunctionSkuUtil } from '../constants/function_sku'; import { RuntimeStackConstant } from "../constants/runtime_stack"; import { Logger, FuncIgnore } from '../utils'; import { AuthenticationType } from '../constants/authentication_type'; @@ -25,7 +25,7 @@ export class ContentPreparer implements IOrchestratable { this.validatePackageType(state, context.package); this._packageType = context.package.getPackageType(); this._publishContentPath = await this.generatePublishContent(state, params, this._packageType); - this._publishMethod = this.derivePublishMethod(state, this._packageType, context.os, context.sku, context.authenticationType); + this._publishMethod = this.derivePublishMethod(state, this._packageType, context.os, context.sku, context.authenticationType, params); // Warm up instances await this.warmUpInstance(params, context); @@ -173,7 +173,8 @@ export class ContentPreparer implements IOrchestratable { packageType: PackageType, osType: RuntimeStackConstant, sku: FunctionSkuConstant, - authenticationType: AuthenticationType + authenticationType: AuthenticationType, + params: IActionParameters ): PublishMethodConstant { // Package Type Check early if (packageType !== PackageType.zip && packageType !== PackageType.folder) { @@ -184,12 +185,25 @@ export class ContentPreparer implements IOrchestratable { ); } - // Uses api/zipdeploy endpoint if scm credential is provided if (authenticationType == AuthenticationType.Scm) { - Logger.Info('Will use Kudu https:///api/zipdeploy to deploy since publish-profile is detected.'); - return PublishMethodConstant.ZipDeploy; + // Flex Consumption plan uses api/publish endpoint + if (!!params.sku && FunctionSkuUtil.FromString(params.sku) === FunctionSkuConstant.FlexConsumption) { + Logger.Info('Will use Kudu https:///api/publish to deploy since Flex consumption plan is detected.'); + return PublishMethodConstant.OneDeployFlex; + } + // Uses api/zipdeploy endpoint if scm credential is provided + else{ + Logger.Info('Will use Kudu https:///api/zipdeploy to deploy since publish-profile is detected.'); + return PublishMethodConstant.ZipDeploy; + } } + // Flex Consumption plan uses api/publish endpoint + if (osType === RuntimeStackConstant.Linux && sku === FunctionSkuConstant.FlexConsumption) { + Logger.Info('Will use Kudu https:///api/publish to deploy since Flex consumption plan is detected.'); + return PublishMethodConstant.OneDeployFlex; + } + // Linux Consumption sets WEBSITE_RUN_FROM_PACKAGE app settings when scm credential is not available if (osType === RuntimeStackConstant.Linux && sku === FunctionSkuConstant.Consumption) { Logger.Info('Will use WEBSITE_RUN_FROM_PACKAGE to deploy since RBAC is detected and your function app is ' + diff --git a/src/handlers/contentPublisher.ts b/src/handlers/contentPublisher.ts index 9310da644..f62a88440 100644 --- a/src/handlers/contentPublisher.ts +++ b/src/handlers/contentPublisher.ts @@ -4,7 +4,7 @@ import { IActionParameters } from "../interfaces/IActionParameters"; import { IActionContext } from "../interfaces/IActionContext"; import { PublishMethodConstant } from "../constants/publish_method"; import { ValidationError } from "../exceptions"; -import { ZipDeploy, WebsiteRunFromPackageDeploy } from "../publishers"; +import { ZipDeploy, WebsiteRunFromPackageDeploy, OneDeployFlex } from "../publishers"; export class ContentPublisher implements IOrchestratable { @@ -16,8 +16,11 @@ export class ContentPublisher implements IOrchestratable { case PublishMethodConstant.WebsiteRunFromPackageDeploy: await WebsiteRunFromPackageDeploy.execute(state, context); break; + case PublishMethodConstant.OneDeployFlex: + await OneDeployFlex.execute(state, context, params.remoteBuild); + break; default: - throw new ValidationError(state, "publisher", "can only performs ZipDeploy or WebsiteRunFromPackageDeploy"); + throw new ValidationError(state, "publisher", "can only performs ZipDeploy or WebsiteRunFromPackageDeploy or OneDeploy (for Flex Consumption plan only)"); } return StateConstant.ValidatePublishedContent; } diff --git a/src/handlers/parameterValidator.ts b/src/handlers/parameterValidator.ts index 61bf08e2b..d503c7349 100644 --- a/src/handlers/parameterValidator.ts +++ b/src/handlers/parameterValidator.ts @@ -25,8 +25,10 @@ export class ParameterValidator implements IOrchestratable { private _respectPomXml: string; private _respectFuncignore: string; private _scmDoBuildDuringDeployment: string; + private _remoteBuild: string; private _enableOryxBuild: string; private _scmCredentials: IScmCredentials + private _sku: string; constructor() { this.parseScmCredentials = this.parseScmCredentials.bind(this); @@ -42,7 +44,9 @@ export class ParameterValidator implements IOrchestratable { this._respectPomXml = core.getInput(ConfigurationConstant.ParamInRespectPomXml); this._respectFuncignore = core.getInput(ConfigurationConstant.ParamInRespectFuncignore); this._scmDoBuildDuringDeployment = core.getInput(ConfigurationConstant.ParamInScmDoBuildDuringDeployment); + this._remoteBuild = core.getInput(ConfigurationConstant.ParamInRemoteBuild); this._enableOryxBuild = core.getInput(ConfigurationConstant.ParamInEnableOryxBuild); + this._sku = core.getInput(ConfigurationConstant.ParamInSku); // Validate field if (this._slot !== undefined && this._slot.trim() === "") { @@ -62,7 +66,9 @@ export class ParameterValidator implements IOrchestratable { params.respectPomXml = Parser.IsTrueLike(this._respectPomXml); params.respectFuncignore = Parser.IsTrueLike(this._respectFuncignore); params.scmDoBuildDuringDeployment = ScmBuildUtil.FromString(this._scmDoBuildDuringDeployment); + params.remoteBuild = Parser.IsTrueLike(this._remoteBuild); params.enableOryxBuild = EnableOryxBuildUtil.FromString(this._enableOryxBuild); + params.sku = this._sku.toLowerCase(); return params; } diff --git a/src/interfaces/IActionParameters.ts b/src/interfaces/IActionParameters.ts index 9178c6349..ebae18779 100644 --- a/src/interfaces/IActionParameters.ts +++ b/src/interfaces/IActionParameters.ts @@ -9,5 +9,7 @@ export interface IActionParameters { respectPomXml: boolean; respectFuncignore: boolean; scmDoBuildDuringDeployment: ScmBuildConstant; + remoteBuild: boolean; enableOryxBuild: EnableOryxBuildConstant; + sku: string; } \ No newline at end of file diff --git a/src/managers/builder.ts b/src/managers/builder.ts index 04885f91c..5deacf7e2 100644 --- a/src/managers/builder.ts +++ b/src/managers/builder.ts @@ -23,7 +23,9 @@ export class Builder { respectPomXml: false, respectFuncignore: false, scmDoBuildDuringDeployment: ScmBuildConstant.Disabled, - enableOryxBuild: EnableOryxBuildConstant.Disabled + remoteBuild: false, + enableOryxBuild: EnableOryxBuildConstant.Disabled, + sku: undefined } } diff --git a/src/publishers/index.ts b/src/publishers/index.ts index da4d668d0..5f0afa2d9 100644 --- a/src/publishers/index.ts +++ b/src/publishers/index.ts @@ -1,2 +1,3 @@ export { ZipDeploy } from './zipDeploy'; -export { WebsiteRunFromPackageDeploy } from './websiteRunFromPackageDeploy'; \ No newline at end of file +export { WebsiteRunFromPackageDeploy } from './websiteRunFromPackageDeploy'; +export { OneDeployFlex } from './oneDeployFlex'; \ No newline at end of file diff --git a/src/publishers/oneDeployFlex.ts b/src/publishers/oneDeployFlex.ts new file mode 100644 index 000000000..117843d0d --- /dev/null +++ b/src/publishers/oneDeployFlex.ts @@ -0,0 +1,28 @@ +import { StateConstant } from "../constants/state"; +import { IActionContext } from "../interfaces/IActionContext"; +import { AzureResourceError } from "../exceptions"; +import { ScmBuildConstant, ScmBuildUtil } from "../constants/scm_build"; + +export class OneDeployFlex { + public static async execute( + state: StateConstant, + context: IActionContext, + remoteBuild: boolean + ): Promise { + const filePath: string = context.publishContentPath; + let deploymentId: string; + let isDeploymentSucceeded: boolean = false; + + try { + deploymentId = await context.kuduServiceUtil.deployUsingOneDeployFlex(filePath, remoteBuild.toString(), { + 'slotName': context.appService ? context.appService.getSlot() : 'production' + }); + + isDeploymentSucceeded = true; + } catch (expt) { + throw new AzureResourceError(state, "oneDeploy", `Failed to use ${filePath} as OneDeploy content`, expt); + } + + return deploymentId; + } +} diff --git a/tests/handlers/contentPreparer.spec.ts b/tests/handlers/contentPreparer.spec.ts index 71d0b505c..29f500245 100644 --- a/tests/handlers/contentPreparer.spec.ts +++ b/tests/handlers/contentPreparer.spec.ts @@ -185,6 +185,7 @@ describe('Check ContentPreparer', function () { it('should use zipdeploy for all other skus', function () { const preparer = new ContentPreparer(); + const params = Builder.GetDefaultActionParameters(); [PackageType.folder, PackageType.zip].forEach(p => { [RuntimeStackConstant.Linux, RuntimeStackConstant.Windows].forEach(r => { @@ -193,7 +194,7 @@ describe('Check ContentPreparer', function () { // @ts-ignore const result = preparer.derivePublishMethod( StateConstant.PreparePublishContent, - p, r, f, a + p, r, f, a, params ); if (r !== RuntimeStackConstant.Linux && f !== FunctionSkuConstant.Consumption) {