From 676960a46e4913c4d0072ea9e998dabb1d555fa7 Mon Sep 17 00:00:00 2001 From: Tanner Barlow Date: Wed, 7 Aug 2019 07:49:22 -0700 Subject: [PATCH 1/2] feat: Deploy function app code from blob storage URL Add tests --- docs/DEPLOY.md | 23 +++++----- src/models/serverless.ts | 1 + src/services/baseService.ts | 7 +-- src/services/functionAppService.test.ts | 58 ++++++++++++++++++++++++- src/services/functionAppService.ts | 36 ++++++++------- src/services/rollbackService.test.ts | 4 +- 6 files changed, 98 insertions(+), 31 deletions(-) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 51cd26a8..e6d8d969 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -69,7 +69,19 @@ then user try to deploy ```yml service: my-app provider: - ... + deployment: + # Rollback enabled, deploying to blob storage + # Default is true + # If false, deploys directly to function app + rollback: true + # Container in blob storage containing deployed packages + # Default is DEPLOYMENT_ARTIFACTS + container: MY_CONTAINER_NAME + # Sets the WEBSITE_RUN_FROM_PACKAGE setting of function app + # to the SAS url of the artifact sitting in blob storage + # Recommended when using linux, not recommended when using windows + # Default is false + runFromBlobUrl: true plugins: - serverless-azure-functions @@ -77,15 +89,6 @@ plugins: package: ... -deploy: - # Rollback enabled, deploying to blob storage - # Default is true - # If false, deploys directly to function app - rollback: true - # Container in blob storage containing deployed packages - # Default is DEPLOYMENT_ARTIFACTS - container: MY_CONTAINER_NAME - functions: ... ``` diff --git a/src/models/serverless.ts b/src/models/serverless.ts index 240b6ca0..1505415b 100644 --- a/src/models/serverless.ts +++ b/src/models/serverless.ts @@ -39,6 +39,7 @@ export interface ServerlessAzureProvider { environment?: { [key: string]: any; }; + deployment?: DeploymentConfig; deploymentName?: string; resourceGroup?: string; apim?: ApiManagementConfig; diff --git a/src/services/baseService.ts b/src/services/baseService.ts index cbf5acbe..4d1181c1 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -23,6 +23,7 @@ export abstract class BaseService { protected subscriptionId: string; protected resourceGroup: string; protected deploymentName: string; + protected artifactName: string; protected deploymentConfig: DeploymentConfig; protected storageAccountName: string; protected config: ServerlessAzureConfig; @@ -43,6 +44,7 @@ export abstract class BaseService { this.resourceGroup = this.getResourceGroupName(); this.deploymentConfig = this.getDeploymentConfig(); this.deploymentName = this.getDeploymentName(); + this.artifactName = this.getArtifactName(this.deploymentName); this.storageAccountName = StorageAccountResource.getResourceName(this.config); if (!this.credentials && authenticate) { @@ -84,10 +86,9 @@ export abstract class BaseService { * Defaults can be found in the `config.ts` file */ public getDeploymentConfig(): DeploymentConfig { - const providedConfig = this.serverless["deploy"] as DeploymentConfig; return { ...configConstants.deploymentConfig, - ...providedConfig, + ...this.config.provider.deployment, } } @@ -123,7 +124,7 @@ export abstract class BaseService { const { deployment, artifact } = configConstants.naming.suffix; return `${deploymentName .replace(`rg-${deployment}`, artifact) - .replace(deployment, artifact)}` + .replace(deployment, artifact)}.zip` } /** diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index 9de1cf7a..2d4db144 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -17,7 +17,6 @@ jest.mock("./azureBlobStorageService"); import { AzureBlobStorageService } from "./azureBlobStorageService" import configConstants from "../config"; - describe("Function App Service", () => { const app = MockFactory.createTestSite(); const slsService = MockFactory.createTestService(); @@ -185,6 +184,63 @@ describe("Function App Service", () => { expect(ArmService.prototype.deployTemplate).toBeCalledWith(expectedDeployment); }); + it("deploys ARM template with SAS URL if running from blob URL", async() => { + const sasUrl = "sasUrl"; + AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(() => Promise.resolve(sasUrl)); + + const newSlsService = MockFactory.createTestService(); + newSlsService.provider["armTemplate"] = null; + newSlsService.provider["deployment"] = { + runFromBlobUrl: true, + } + + const service = createService(MockFactory.createTestServerless({ + service: newSlsService, + })); + + const site = await service.deploy(); + + // Deploy should upload to blob FIRST and then set the SAS URL + // as the WEBSITE_RUN_FROM_PACKAGE setting in the template + const uploadFileCalls = (AzureBlobStorageService.prototype.uploadFile as any).mock.calls; + expect(uploadFileCalls).toHaveLength(1); + const call = uploadFileCalls[0]; + expect(call[0]).toEqual("app.zip"); + expect(call[1]).toEqual("deployment-artifacts"); + expect(call[2]).toMatch(/myDeploymentName-t([0-9])+.zip/); + + expect(site).toEqual(expectedSite); + expect(ArmService.prototype.createDeploymentFromConfig).not.toBeCalled(); + expect(ArmService.prototype.createDeploymentFromType).toBeCalledWith(ArmTemplateType.Consumption); + // Should set parameter of arm template to include SAS URL + expect(ArmService.prototype.deployTemplate).toBeCalledWith({ + ...expectedDeployment, + parameters: { + ...expectedDeployment.parameters, + functionAppRunFromPackage: sasUrl, + } + }); + }); + + it("does not generate SAS URL if not configured", async() => { + AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(); + + const newSlsService = MockFactory.createTestService(); + newSlsService.provider["armTemplate"] = null; + newSlsService.provider["deployment"] = { + runFromBlobUrl: false, + } + + const service = createService(MockFactory.createTestServerless({ + service: newSlsService, + })); + + await service.deploy(); + + expect(AzureBlobStorageService.prototype.generateBlobSasTokenUrl).not.toBeCalled(); + expect(ArmService.prototype.deployTemplate).toBeCalledWith(expectedDeployment); + }); + it("deploys ARM template from well-known configuration", async () => { slsService.provider["armTemplate"] = null; slsService.provider["type"] = "premium"; diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index a94f575b..f8d80dbc 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -7,10 +7,10 @@ import { FunctionAppResource } from "../armTemplates/resources/functionApp"; import { ArmDeployment } from "../models/armTemplates"; import { FunctionAppHttpTriggerConfig } from "../models/functionApp"; import { Guard } from "../shared/guard"; +import { Utils } from "../shared/utils"; import { ArmService } from "./armService"; import { AzureBlobStorageService } from "./azureBlobStorageService"; import { BaseService } from "./baseService"; -import { Utils } from "../shared/utils"; export class FunctionAppService extends BaseService { private static readonly retryCount: number = 10; @@ -149,7 +149,13 @@ export class FunctionAppService extends BaseService { const functionZipFile = this.getFunctionZipFile(); const uploadFunctionApp = this.uploadZippedArfifactToFunctionApp(functionApp, functionZipFile); - const uploadBlobStorage = this.uploadZippedArtifactToBlobStorage(functionZipFile); + // If `runFromBlobUrl` is configured, the artifact will have already been uploaded + const uploadBlobStorage = (this.deploymentConfig.runFromBlobUrl) + ? + Promise.resolve() + : + this.uploadZippedArtifactToBlobStorage(functionZipFile) + await Promise.all([uploadFunctionApp, uploadBlobStorage]); @@ -178,10 +184,18 @@ export class FunctionAppService extends BaseService { this.log(`Creating function app: ${this.serviceName}`); const armService = new ArmService(this.serverless, this.options); - let deployment: ArmDeployment = this.config.provider.armTemplate - ? await armService.createDeploymentFromConfig(this.config.provider.armTemplate) - : await armService.createDeploymentFromType(this.config.provider.type || "consumption"); - + const { armTemplate, type } = this.config.provider; + let deployment: ArmDeployment = armTemplate + ? await armService.createDeploymentFromConfig(armTemplate) + : await armService.createDeploymentFromType(type || "consumption"); + + if (this.deploymentConfig.runFromBlobUrl) { + await this.uploadZippedArtifactToBlobStorage(this.getFunctionZipFile()); + deployment.parameters.functionAppRunFromPackage = await this.blobService.generateBlobSasTokenUrl( + this.deploymentConfig.container, + this.artifactName + ) + } await armService.deployTemplate(deployment); // Return function app @@ -235,18 +249,10 @@ export class FunctionAppService extends BaseService { await this.blobService.uploadFile( functionZipFile, this.deploymentConfig.container, - this.getArtifactName(this.deploymentName), + this.artifactName, ); } - /** - * Get rollback-configured artifact name. Contains `-t{timestamp}` - * if rollback is configured - */ - public getArtifactName(deploymentName: string): string { - return `${deploymentName.replace("rg-deployment", "artifact")}.zip`; - } - public getFunctionHttpTriggerConfig(functionApp: Site, functionConfig: FunctionEnvelope): FunctionAppHttpTriggerConfig { const httpTrigger = functionConfig.config.bindings.find((binding) => { return binding.type === "httpTrigger"; diff --git a/src/services/rollbackService.test.ts b/src/services/rollbackService.test.ts index 7d183ae3..e5e0ce1c 100644 --- a/src/services/rollbackService.test.ts +++ b/src/services/rollbackService.test.ts @@ -30,7 +30,7 @@ describe("Rollback Service", () => { const sasURL = "sasURL"; const containerName = "deployment-artifacts"; const artifactName = MockFactory.createTestDeployment().name.replace( - configConstants.naming.suffix.deployment, configConstants.naming.suffix.artifact); + configConstants.naming.suffix.deployment, configConstants.naming.suffix.artifact) + ".zip"; const artifactPath = `.serverless${path.sep}${artifactName}` const armDeployment: ArmDeployment = { template, parameters }; const deploymentString = "deployments"; @@ -115,7 +115,7 @@ describe("Rollback Service", () => { const deploymentConfig: DeploymentConfig = { runFromBlobUrl: true } - sls["deploy"] = deploymentConfig; + sls.service.provider["deployment"] = deploymentConfig; const service = createService(sls); await service.rollback(); expect(AzureBlobStorageService.prototype.initialize).toBeCalled(); From fd762b4902a260452996858564b7ba5dc89a835c Mon Sep 17 00:00:00 2001 From: Tanner Barlow Date: Fri, 9 Aug 2019 15:41:41 -0700 Subject: [PATCH 2/2] feat: Update function app setting to run from external package --- src/config.ts | 1 + src/plugins/login/azureLoginPlugin.test.ts | 3 +- src/services/baseService.ts | 4 + src/services/functionAppService.test.ts | 146 +++++++++++++-------- src/services/functionAppService.ts | 39 +++--- 5 files changed, 119 insertions(+), 74 deletions(-) diff --git a/src/config.ts b/src/config.ts index ec67058e..8e03c0f5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ export const configConstants = { funcCoreTools: "func", funcCoreToolsArgs: ["host", "start"], funcConsoleColor: "blue", + runFromPackageSetting: "WEBSITE_RUN_FROM_PACKAGE", jsonContentType: "application/json", logInvocationsApiPath: "/azurejobs/api/functions/definitions/", logOutputApiPath: "/azurejobs/api/log/output/", diff --git a/src/plugins/login/azureLoginPlugin.test.ts b/src/plugins/login/azureLoginPlugin.test.ts index 9e715952..9e7bb686 100644 --- a/src/plugins/login/azureLoginPlugin.test.ts +++ b/src/plugins/login/azureLoginPlugin.test.ts @@ -52,6 +52,7 @@ describe("Login Plugin", () => { }); it("calls login if azure credentials are not set", async () => { + unsetServicePrincipalEnvVariables(); await invokeLoginHook(); expect(AzureLoginService.interactiveLogin).toBeCalled(); expect(AzureLoginService.servicePrincipalLogin).not.toBeCalled(); @@ -115,7 +116,7 @@ describe("Login Plugin", () => { expect(sls.variables["subscriptionId"]).toEqual("azureSubId"); expect(sls.cli.log).toBeCalledWith("Using subscription ID: azureSubId"); }); - + it("Uses the subscription ID specified in serverless yaml", async () => { const sls = MockFactory.createTestServerless(); const opt = MockFactory.createTestServerlessOptions(); diff --git a/src/services/baseService.ts b/src/services/baseService.ts index 4d1181c1..37ccbe0e 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -190,6 +190,10 @@ export abstract class BaseService { (this.serverless.cli.log as any)(message, entity, options); } + protected prettyPrint(object: any) { + this.log(JSON.stringify(object, null, 2)); + } + /** * Get function objects */ diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index 2d4db144..58ad329b 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -36,6 +36,11 @@ describe("Function App Service", () => { const syncTriggersUrl = `${baseUrl}${app.id}/syncfunctiontriggers?api-version=2016-08-01`; const listFunctionsUrl = `${baseUrl}${app.id}/functions?api-version=2016-08-01`; + const appSettings = { + setting1: "value1", + setting2: "value2", + } + beforeAll(() => { const axiosMock = new MockAdapter(axios); @@ -65,6 +70,8 @@ describe("Function App Service", () => { WebSiteManagementClient.prototype.webApps = { get: jest.fn(() => app), deleteFunction: jest.fn(), + listApplicationSettings: jest.fn(() => Promise.resolve({ properties: { ...appSettings } })), + updateApplicationSettings: jest.fn(), } as any; (FunctionAppService.prototype as any).sendFile = jest.fn(); }); @@ -155,11 +162,22 @@ describe("Function App Service", () => { beforeEach(() => { FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(expectedSite)); + (FunctionAppService.prototype as any).sendFile = jest.fn(); ArmService.prototype.createDeploymentFromConfig = jest.fn(() => Promise.resolve(expectedDeployment)); ArmService.prototype.createDeploymentFromType = jest.fn(() => Promise.resolve(expectedDeployment)); ArmService.prototype.deployTemplate = jest.fn(() => Promise.resolve(null)); + WebSiteManagementClient.prototype.webApps = { + get: jest.fn(() => app), + deleteFunction: jest.fn(), + listApplicationSettings: jest.fn(() => Promise.resolve({ properties: { ...appSettings } })), + updateApplicationSettings: jest.fn(), + } as any; }); + afterEach(() => { + jest.restoreAllMocks(); + }) + it("deploys ARM templates with custom configuration", async () => { slsService.provider["armTemplate"] = {}; @@ -184,63 +202,6 @@ describe("Function App Service", () => { expect(ArmService.prototype.deployTemplate).toBeCalledWith(expectedDeployment); }); - it("deploys ARM template with SAS URL if running from blob URL", async() => { - const sasUrl = "sasUrl"; - AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(() => Promise.resolve(sasUrl)); - - const newSlsService = MockFactory.createTestService(); - newSlsService.provider["armTemplate"] = null; - newSlsService.provider["deployment"] = { - runFromBlobUrl: true, - } - - const service = createService(MockFactory.createTestServerless({ - service: newSlsService, - })); - - const site = await service.deploy(); - - // Deploy should upload to blob FIRST and then set the SAS URL - // as the WEBSITE_RUN_FROM_PACKAGE setting in the template - const uploadFileCalls = (AzureBlobStorageService.prototype.uploadFile as any).mock.calls; - expect(uploadFileCalls).toHaveLength(1); - const call = uploadFileCalls[0]; - expect(call[0]).toEqual("app.zip"); - expect(call[1]).toEqual("deployment-artifacts"); - expect(call[2]).toMatch(/myDeploymentName-t([0-9])+.zip/); - - expect(site).toEqual(expectedSite); - expect(ArmService.prototype.createDeploymentFromConfig).not.toBeCalled(); - expect(ArmService.prototype.createDeploymentFromType).toBeCalledWith(ArmTemplateType.Consumption); - // Should set parameter of arm template to include SAS URL - expect(ArmService.prototype.deployTemplate).toBeCalledWith({ - ...expectedDeployment, - parameters: { - ...expectedDeployment.parameters, - functionAppRunFromPackage: sasUrl, - } - }); - }); - - it("does not generate SAS URL if not configured", async() => { - AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(); - - const newSlsService = MockFactory.createTestService(); - newSlsService.provider["armTemplate"] = null; - newSlsService.provider["deployment"] = { - runFromBlobUrl: false, - } - - const service = createService(MockFactory.createTestServerless({ - service: newSlsService, - })); - - await service.deploy(); - - expect(AzureBlobStorageService.prototype.generateBlobSasTokenUrl).not.toBeCalled(); - expect(ArmService.prototype.deployTemplate).toBeCalledWith(expectedDeployment); - }); - it("deploys ARM template from well-known configuration", async () => { slsService.provider["armTemplate"] = null; slsService.provider["type"] = "premium"; @@ -354,4 +315,75 @@ describe("Function App Service", () => { const service = createService(sls, options); expect(service.getFunctionZipFile()).toEqual("fake.zip") }); + + it("adds a new function app setting", async () => { + const service = createService(); + const settingName = "TEST_SETTING"; + const settingValue = "TEST_VALUE" + await service.updateFunctionAppSetting(app, settingName, settingValue); + expect(WebSiteManagementClient.prototype.webApps.updateApplicationSettings).toBeCalledWith( + "myResourceGroup", + "Test", + { + ...appSettings, + TEST_SETTING: settingValue + } + ) + }); + + it("updates an existing function app setting", async () => { + const service = createService(); + const settingName = "setting1"; + const settingValue = "TEST_VALUE" + await service.updateFunctionAppSetting(app, settingName, settingValue); + expect(WebSiteManagementClient.prototype.webApps.updateApplicationSettings).toBeCalledWith( + "myResourceGroup", + "Test", + { + setting1: settingValue, + setting2: appSettings.setting2 + } + ); + }); + + describe("Updating Function App Settings", () => { + + const sasUrl = "sasUrl" + + beforeEach(() => { + FunctionAppService.prototype.updateFunctionAppSetting = jest.fn(); + AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(() => Promise.resolve(sasUrl)); + }); + + afterEach(() => { + (FunctionAppService.prototype.updateFunctionAppSetting as any).mockRestore(); + }); + + it("updates WEBSITE_RUN_FROM_PACKAGE with SAS URL if configured to run from blob", async () => { + const newSlsService = MockFactory.createTestService(); + newSlsService.provider["deployment"] = { + runFromBlobUrl: true, + } + + const service = createService(MockFactory.createTestServerless({ + service: newSlsService, + })); + await service.uploadFunctions(app); + expect(AzureBlobStorageService.prototype.generateBlobSasTokenUrl).toBeCalled(); + expect(FunctionAppService.prototype.updateFunctionAppSetting).toBeCalledWith( + app, + "WEBSITE_RUN_FROM_PACKAGE", + sasUrl + ); + }); + + it("does not generate SAS URL or update WEBSITE_RUN_FROM_PACKAGE if not configured to run from blob", async() => { + const service = createService(); + await service.uploadFunctions(app); + expect(AzureBlobStorageService.prototype.generateBlobSasTokenUrl).not.toBeCalled(); + expect(FunctionAppService.prototype.updateFunctionAppSetting).not.toBeCalled(); + }); + }); + + }); diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index f8d80dbc..d7dfa290 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -11,9 +11,10 @@ import { Utils } from "../shared/utils"; import { ArmService } from "./armService"; import { AzureBlobStorageService } from "./azureBlobStorageService"; import { BaseService } from "./baseService"; +import configConstants from "../config"; export class FunctionAppService extends BaseService { - private static readonly retryCount: number = 10; + private static readonly retryCount: number = 30; private static readonly retryInterval: number = 5000; private webClient: WebSiteManagementClient; private blobService: AzureBlobStorageService; @@ -99,7 +100,7 @@ export class FunctionAppService extends BaseService { if (listFunctionsResponse.status !== 200 || listFunctionsResponse.data.value.length === 0) { this.log("-> Function App not ready. Retrying..."); - throw new Error(listFunctionsResponse.data); + throw new Error(JSON.stringify(listFunctionsResponse.data, null, 2)); } return listFunctionsResponse; @@ -130,7 +131,7 @@ export class FunctionAppService extends BaseService { if (getFunctionResponse.status !== 200) { this.log("-> Function app not ready. Retrying...") - throw new Error(response.data); + throw new Error(JSON.stringify(response.data, null, 2)); } return getFunctionResponse; @@ -149,15 +150,22 @@ export class FunctionAppService extends BaseService { const functionZipFile = this.getFunctionZipFile(); const uploadFunctionApp = this.uploadZippedArfifactToFunctionApp(functionApp, functionZipFile); - // If `runFromBlobUrl` is configured, the artifact will have already been uploaded - const uploadBlobStorage = (this.deploymentConfig.runFromBlobUrl) - ? - Promise.resolve() - : - this.uploadZippedArtifactToBlobStorage(functionZipFile) + const uploadBlobStorage = this.uploadZippedArtifactToBlobStorage(functionZipFile); await Promise.all([uploadFunctionApp, uploadBlobStorage]); + if (this.deploymentConfig.runFromBlobUrl) { + this.log("Updating function app setting to run from external package..."); + const sasUrl = await this.blobService.generateBlobSasTokenUrl( + this.deploymentConfig.container, + this.artifactName + ) + await this.updateFunctionAppSetting( + functionApp, + configConstants.runFromPackageSetting, + sasUrl + ) + } this.log("Deployed serverless functions:") const serverlessFunctions = this.serverless.service.getAllFunctions(); @@ -189,13 +197,6 @@ export class FunctionAppService extends BaseService { ? await armService.createDeploymentFromConfig(armTemplate) : await armService.createDeploymentFromType(type || "consumption"); - if (this.deploymentConfig.runFromBlobUrl) { - await this.uploadZippedArtifactToBlobStorage(this.getFunctionZipFile()); - deployment.parameters.functionAppRunFromPackage = await this.blobService.generateBlobSasTokenUrl( - this.deploymentConfig.container, - this.artifactName - ) - } await armService.deployTemplate(deployment); // Return function app @@ -240,6 +241,12 @@ export class FunctionAppService extends BaseService { return functionZipFile; } + public async updateFunctionAppSetting(functionApp: Site, setting: string, value: string) { + const { properties } = await this.webClient.webApps.listApplicationSettings(this.resourceGroup, functionApp.name); + properties[setting] = value; + await this.webClient.webApps.updateApplicationSettings(this.resourceGroup, functionApp.name, properties); + } + /** * Uploads artifact file to blob storage container */