diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 853f0ea6..f5ef0e91 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/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/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/plugins/login/azureLoginPlugin.test.ts b/src/plugins/login/azureLoginPlugin.test.ts index 51e43b3f..6deb1aa2 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 d516e694..dc21b702 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` } /** @@ -190,6 +191,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 715819a7..bf607730 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(); @@ -37,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); @@ -66,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(); }); @@ -156,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"] = {}; @@ -298,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 61357fa7..bd57a503 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -7,13 +7,14 @@ 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"; +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; @@ -150,8 +151,21 @@ export class FunctionAppService extends BaseService { const functionZipFile = this.getFunctionZipFile(); const uploadFunctionApp = this.uploadZippedArfifactToFunctionApp(functionApp, 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(); @@ -178,9 +192,10 @@ 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"); await armService.deployTemplate(deployment); @@ -226,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 */ @@ -235,18 +256,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();