Repository: serverless/serverless-azure-functions
Pull request #231 (2019-08-07 16:40:22)

feat: Deploy function app code from blob storage URL
- [x] Allow configuration to specify whether or not to run from external package
- [x] Set function app setting `WEBSITE_RUN_FROM_PACKAGE` with SAS URL of package from blob storage
- [x] Get deployment configuration from `config.provider`
- [x] Upload to blob storage before deployment if `runFromBlobUrl` is specified


coveralls: 
[![Coverage Status](https://coveralls.io/builds/25041512/badge)](https://coveralls.io/builds/25041512)

Coverage increased (+0.006%) to 84.335% when pulling **676960a46e4913c4d0072ea9e998dabb1d555fa7 on tabarlow/deploy-from-blob** into **c3f08053bc0493434ec384493ac146fa78733a32 on dev**.


Patch:
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index 853f0ea..f5ef0e9 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 ec67058..8e03c0f 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 240b6ca..1505415 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/loginHooks.ts b/src/plugins/login/loginHooks.ts
index 4840de9..97db4a3 100644
--- a/src/plugins/login/loginHooks.ts
+++ b/src/plugins/login/loginHooks.ts
@@ -2,7 +2,6 @@
  * Hooks that require authentication before execution
  */
 export const loginHooks = [
-  "package:initialize",
   "deploy:list:list",
   "deploy:deploy",
   "invoke:invoke",
diff --git a/src/services/baseService.ts b/src/services/baseService.ts
index d516e69..dc21b70 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.ts b/src/services/functionAppService.ts
index 61357fa..bd57a50 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";


Test patch:
diff --git a/src/plugins/login/azureLoginPlugin.test.ts b/src/plugins/login/azureLoginPlugin.test.ts
index 51e43b3..6deb1aa 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/functionAppService.test.ts b/src/services/functionAppService.test.ts
index 715819a..bf60773 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/rollbackService.test.ts b/src/services/rollbackService.test.ts
index 7d183ae..e5e0ce1 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();