Repository: serverless/serverless-azure-functions
Pull request #205 (2019-07-17 21:00:25)

feat: Deploy pre-existing package with `-p` CLI option
- [x] Allow `-p` or `--package` param to specify package to deploy
- [x] Log in before deployment rather than packaging
- [x] Don't run deployment if local package does not exist

Resolves [AB#500]

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

Coverage increased (+0.1%) to 84.305% when pulling **1357750a00471af51f69f8d706cdfbbe52e78722 on tabarlow/deploy-package** into **7ceb4848ca381ee3cbb5dbbe67e88f876a0b14eb on dev**.


Patch:
diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts
index 753f8b2..70f4822 100644
--- a/src/plugins/deploy/azureDeployPlugin.ts
+++ b/src/plugins/deploy/azureDeployPlugin.ts
@@ -1,9 +1,10 @@
+import fs from "fs";
 import Serverless from "serverless";
 import { FunctionAppService } from "../../services/functionAppService";
+import { AzureLoginOptions } from "../../services/loginService";
 import { ResourceService } from "../../services/resourceService";
 import { Utils } from "../../shared/utils";
 import { AzureBasePlugin } from "../azureBasePlugin";
-import { AzureLoginOptions } from "../../services/loginService";
 
 export class AzureDeployPlugin extends AzureBasePlugin<AzureLoginOptions> {
   public hooks: { [eventName: string]: Promise<any> };
@@ -28,13 +29,17 @@ export class AzureDeployPlugin extends AzureBasePlugin<AzureLoginOptions> {
           }
         },
         options: {
-          "resourceGroup": {
+          resourceGroup: {
             usage: "Resource group for the service",
             shortcut: "g",
           },
           subscriptionId: {
             usage: "Sets the Azure subscription ID",
             shortcut: "i",
+          },
+          package: {
+            usage: "Package to deploy",
+            shortcut: "p",
           }
         }
       }
@@ -67,12 +72,14 @@ export class AzureDeployPlugin extends AzureBasePlugin<AzureLoginOptions> {
 
   private async deploy() {
     const resourceService = new ResourceService(this.serverless, this.options);
-
-    await resourceService.deployResourceGroup();
-
     const functionAppService = new FunctionAppService(this.serverless, this.options);
+    const zipFile = functionAppService.getFunctionZipFile();
+    if (!fs.existsSync(zipFile)) {
+      this.log(`Function app zip file '${zipFile}' does not exist`);
+      return Promise.resolve();
+    }
+    await resourceService.deployResourceGroup();
     const functionApp = await functionAppService.deploy();
-
     await functionAppService.uploadFunctions(functionApp);
   }
 }
diff --git a/src/plugins/login/azureLoginPlugin.ts b/src/plugins/login/azureLoginPlugin.ts
index fa0b246..77311ae 100644
--- a/src/plugins/login/azureLoginPlugin.ts
+++ b/src/plugins/login/azureLoginPlugin.ts
@@ -13,7 +13,7 @@ export class AzureLoginPlugin extends AzureBasePlugin<AzureLoginOptions> {
     this.provider = (this.serverless.getProvider("azure") as any) as AzureProvider;
 
     this.hooks = {
-      "before:package:initialize": this.login.bind(this),
+      "before:deploy:deploy": this.login.bind(this),
       "before:deploy:list:list": this.login.bind(this),
       "before:invoke:invoke": this.login.bind(this),
       "before:rollback:rollback": this.login.bind(this),
diff --git a/src/plugins/package/azurePackagePlugin.ts b/src/plugins/package/azurePackagePlugin.ts
index abfb7b9..9fe86c3 100644
--- a/src/plugins/package/azurePackagePlugin.ts
+++ b/src/plugins/package/azurePackagePlugin.ts
@@ -22,6 +22,10 @@ export class AzurePackagePlugin extends AzureBasePlugin {
   }
 
   private async setupProviderConfiguration(): Promise<void> {
+    if (this.getOption("package")) {
+      this.log("No need to create bindings. Using pre-existing package");
+      return Promise.resolve();
+    }
     await this.packageService.createBindings();
     this.bindingsCreated = true;
 
@@ -29,6 +33,10 @@ export class AzurePackagePlugin extends AzureBasePlugin {
   }
 
   private async webpack(): Promise<void> {
+    if (this.getOption("package")) {
+      this.log("No need to perform webpack. Using pre-existing package");
+      return Promise.resolve();
+    }
     if (!this.bindingsCreated) {
       await this.setupProviderConfiguration();
     }
@@ -40,7 +48,10 @@ export class AzurePackagePlugin extends AzureBasePlugin {
    * Cleans up generated folders & files after packaging is complete
    */
   private async finalize(): Promise<void> {
+    if (this.getOption("package")) {
+      this.log("No need to clean up generated folders & files. Using pre-existing package");
+      return Promise.resolve();
+    }
     await this.packageService.cleanUp();
   }
 }
-
diff --git a/src/services/baseService.ts b/src/services/baseService.ts
index f7f1e79..58d0d50 100644
--- a/src/services/baseService.ts
+++ b/src/services/baseService.ts
@@ -121,7 +121,7 @@ export abstract class BaseService {
   protected getAccessToken(): string {
     return (this.credentials.tokenCache as any)._entries[0].accessToken;
   }
-  
+
   /**
    * Sends an API request using axios HTTP library
    * @param method The HTTP method
@@ -189,6 +189,10 @@ export abstract class BaseService {
     return "config" in this.options ? this.options["config"] : "serverless.yml";
   }
 
+  protected getOption(key: string, defaultValue?: any) {
+    return Utils.get(this.options, key, defaultValue);
+  }
+
   private setDefaultValues(): void {
     // TODO: Right now the serverless core will always default to AWS default region if the
     // region has not been set in the serverless.yml or CLI options
@@ -231,4 +235,4 @@ export abstract class BaseService {
     }
     return timestamp;
   }
-}
\ No newline at end of file
+}
diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts
index 7f8f820..b0eb5cd 100644
--- a/src/services/functionAppService.ts
+++ b/src/services/functionAppService.ts
@@ -185,6 +185,17 @@ export class FunctionAppService extends BaseService {
     });
   }
 
+  /**
+   * Gets local path of packaged function app
+   */
+  public getFunctionZipFile(): string {
+    let functionZipFile = this.getOption("package") || this.serverless.service["artifact"];
+    if (!functionZipFile) {
+      functionZipFile = path.join(this.serverless.config.servicePath, ".serverless", `${this.serverless.service.getServiceName()}.zip`);
+    }
+    return functionZipFile;
+  }
+
   /**
    * Uploads artifact file to blob storage container
    */
@@ -198,17 +209,6 @@ export class FunctionAppService extends BaseService {
     );
   }
 
-  /**
-   * Gets local path of packaged function app
-   */
-  private getFunctionZipFile(): string {
-    let functionZipFile = this.serverless.service["artifact"];
-    if (!functionZipFile) {
-      functionZipFile = path.join(this.serverless.config.servicePath, ".serverless", `${this.serverless.service.getServiceName()}.zip`);
-    }
-    return functionZipFile;
-  }
-
   /**
    * Get rollback-configured artifact name. Contains `-t{timestamp}`
    * if rollback is configured
diff --git a/src/services/packageService.ts b/src/services/packageService.ts
index 89127a4..86a8d2d 100644
--- a/src/services/packageService.ts
+++ b/src/services/packageService.ts
@@ -92,4 +92,4 @@ export class PackageService {
 
     return Promise.resolve();
   }
-}
\ No newline at end of file
+}


Test patch:
diff --git a/src/plugins/deploy/azureDeployPlugin.test.ts b/src/plugins/deploy/azureDeployPlugin.test.ts
index f54ed11..52b98fb 100644
--- a/src/plugins/deploy/azureDeployPlugin.test.ts
+++ b/src/plugins/deploy/azureDeployPlugin.test.ts
@@ -1,23 +1,31 @@
+import { Site } from "@azure/arm-appservice/esm/models";
+import Serverless from "serverless";
+import { ServerlessAzureOptions } from "../../models/serverless";
 import { MockFactory } from "../../test/mockFactory";
 import { invokeHook } from "../../test/utils";
 import { AzureDeployPlugin } from "./azureDeployPlugin";
+import mockFs  from "mock-fs"
 
 jest.mock("../../services/functionAppService");
 import { FunctionAppService } from "../../services/functionAppService";
 
 jest.mock("../../services/resourceService");
 import { ResourceService } from "../../services/resourceService";
-import { Site } from "@azure/arm-appservice/esm/models";
-import { ServerlessAzureOptions } from "../../models/serverless";
-import Serverless from "serverless";
 
 describe("Deploy plugin", () => {
   let sls: Serverless;
   let options: ServerlessAzureOptions;
   let plugin: AzureDeployPlugin;
 
+  beforeAll(() => {
+    mockFs({
+      "serviceName.zip": "contents",
+    }, { createCwd: true, createTmp: true });
+  });
+
   beforeEach(() => {
-    jest.resetAllMocks();
+    FunctionAppService.prototype.getFunctionZipFile = jest.fn(() => "serviceName.zip");
+
     sls = MockFactory.createTestServerless();
     options = MockFactory.createTestServerlessOptions();
 
@@ -26,9 +34,13 @@ describe("Deploy plugin", () => {
 
   afterEach(() => {
     jest.resetAllMocks();
+  });
+
+  afterAll(() => {
+    mockFs.restore();
   })
 
-  it("calls deploy hook", async () => {
+  it("calls deploy", async () => {
     const deployResourceGroup = jest.fn();
     const functionAppStub: Site = MockFactory.createTestSite();
     const deploy = jest.fn(() => Promise.resolve(functionAppStub));
@@ -45,6 +57,27 @@ describe("Deploy plugin", () => {
     expect(uploadFunctions).toBeCalledWith(functionAppStub);
   });
 
+  it("does not call deploy if zip does not exist", async () => {
+    const deployResourceGroup = jest.fn();
+    const functionAppStub: Site = MockFactory.createTestSite();
+    const deploy = jest.fn(() => Promise.resolve(functionAppStub));
+    const uploadFunctions = jest.fn();
+
+    const zipFile = "fake.zip";
+
+    FunctionAppService.prototype.getFunctionZipFile = (() => zipFile);
+    ResourceService.prototype.deployResourceGroup = deployResourceGroup;
+    FunctionAppService.prototype.deploy = deploy;
+    FunctionAppService.prototype.uploadFunctions = uploadFunctions;
+
+    await invokeHook(plugin, "deploy:deploy");
+
+    expect(deployResourceGroup).not.toBeCalled();
+    expect(deploy).not.toBeCalled();
+    expect(uploadFunctions).not.toBeCalled();
+    expect(sls.cli.log).lastCalledWith(`Function app zip file '${zipFile}' does not exist`);
+  });
+
   it("lists deployments with timestamps", async () => {
     const deployments = MockFactory.createTestDeployments(5, true);
     ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve(deployments));
diff --git a/src/plugins/login/azureLoginPlugin.test.ts b/src/plugins/login/azureLoginPlugin.test.ts
index 846dfba..1838318 100644
--- a/src/plugins/login/azureLoginPlugin.test.ts
+++ b/src/plugins/login/azureLoginPlugin.test.ts
@@ -32,7 +32,7 @@ describe("Login Plugin", () => {
 
   async function invokeLoginHook(hasCreds = false, serverless?: Serverless, options?: Serverless.Options) {
     const plugin = createPlugin(hasCreds, serverless, options);
-    await invokeHook(plugin, "before:package:initialize");
+    await invokeHook(plugin, "before:deploy:deploy");
   }
 
   beforeEach(() => {
diff --git a/src/plugins/package/azurePackagePlugin.test.ts b/src/plugins/package/azurePackagePlugin.test.ts
index f40c307..d3d3867 100644
--- a/src/plugins/package/azurePackagePlugin.test.ts
+++ b/src/plugins/package/azurePackagePlugin.test.ts
@@ -39,5 +39,33 @@ describe("Azure Package Plugin", () => {
   it("cleans up package after package:finalize", async () => {
     await invokeHook(plugin, "after:package:finalize");
     expect(PackageService.prototype.cleanUp).toBeCalled();
+  });
+
+  describe("Package specified in options", () => {
+
+    beforeEach(() => {
+      plugin = new AzurePackagePlugin(sls, MockFactory.createTestServerlessOptions({
+        package: "fake.zip",
+      }));
+    });
+
+    it("does not call create bindings if package specified in options", async () => {
+      await invokeHook(plugin, "before:package:setupProviderConfiguration");
+      expect(PackageService.prototype.createBindings).not.toBeCalled();
+      expect(sls.cli.log).lastCalledWith("No need to create bindings. Using pre-existing package");
+    });
+
+    it("does not call webpack if package specified in options", async () => {
+      await invokeHook(plugin, "before:webpack:package:packageModules");
+      expect(PackageService.prototype.createBindings).not.toBeCalled();
+      expect(PackageService.prototype.prepareWebpack).not.toBeCalled();
+      expect(sls.cli.log).lastCalledWith("No need to perform webpack. Using pre-existing package");
+    });
+
+    it("does not call finalize if package specified in options", async () => {
+      await invokeHook(plugin, "after:package:finalize");
+      expect(PackageService.prototype.cleanUp).not.toBeCalled();
+      expect(sls.cli.log).lastCalledWith("No need to clean up generated folders & files. Using pre-existing package");
+    });
   })
 });
diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts
index e81cef8..9de1cf7 100644
--- a/src/services/functionAppService.test.ts
+++ b/src/services/functionAppService.test.ts
@@ -1,6 +1,7 @@
 import axios from "axios";
 import MockAdapter from "axios-mock-adapter";
 import mockFs from "mock-fs";
+import path from "path";
 import Serverless from "serverless";
 import { MockFactory } from "../test/mockFactory";
 import { FunctionAppService } from "./functionAppService";
@@ -55,6 +56,9 @@ describe("Function App Service", () => {
 
     mockFs({
       "app.zip": "contents",
+      ".serverless": {
+        "serviceName.zip": "contents",
+      }
     }, { createCwd: true, createTmp: true });
   });
 
@@ -222,6 +226,37 @@ describe("Function App Service", () => {
     expect(uploadCall[2]).toMatch(/.*-t([0-9]+)/)
   });
 
+  it("uploads functions to function app and blob storage with default naming convention", async () => {
+    const scmDomain = app.enabledHostNames.find((hostname) => hostname.endsWith("scm.azurewebsites.net"));
+    const expectedUploadUrl = `https://${scmDomain}/api/zipdeploy/`;
+
+    const sls = MockFactory.createTestServerless();
+    delete sls.service["artifact"]
+    const service = createService(sls);
+    await service.uploadFunctions(app);
+
+    const defaultArtifact = path.join(".serverless", `${sls.service.getServiceName()}.zip`);
+
+    expect((FunctionAppService.prototype as any).sendFile).toBeCalledWith({
+      method: "POST",
+      uri: expectedUploadUrl,
+      json: true,
+      headers: {
+        Authorization: `Bearer ${variables["azureCredentials"].tokenCache._entries[0].accessToken}`,
+        Accept: "*/*",
+        ContentType: "application/octet-stream",
+      }
+    }, defaultArtifact);
+    const expectedArtifactName = service.getDeploymentName().replace("rg-deployment", "artifact");
+    expect((AzureBlobStorageService.prototype as any).uploadFile).toBeCalledWith(
+      defaultArtifact,
+      configConstants.deploymentConfig.container,
+      `${expectedArtifactName}.zip`,
+    )
+    const uploadCall = ((AzureBlobStorageService.prototype as any).uploadFile).mock.calls[0];
+    expect(uploadCall[2]).toMatch(/.*-t([0-9]+)/)
+  });
+
   it("uploads functions with custom SCM domain (aka App service environments)", async () => {
     const customApp = {
       ...MockFactory.createTestSite("CustomAppWithinASE"),
@@ -248,10 +283,19 @@ describe("Function App Service", () => {
     }, slsService["artifact"])
   });
 
-  it("throws an error with no zip file", async () => {
+  it("uses default name when no artifact provided", async () => {
     const sls = MockFactory.createTestServerless();
     delete sls.service["artifact"];
     const service = createService(sls);
-    await expect(service.uploadFunctions(app)).rejects.not.toBeNull()
+    expect(service.getFunctionZipFile()).toEqual(path.join(".serverless", `${sls.service.getServiceName()}.zip`))
+  });
+
+  it("uses package param from options if provided", async () => {
+    const sls = MockFactory.createTestServerless();
+    const options = MockFactory.createTestServerlessOptions({
+      package: "fake.zip",
+    });
+    const service = createService(sls, options);
+    expect(service.getFunctionZipFile()).toEqual("fake.zip")
   });
 });
diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts
index 485b689..e453e20 100644
--- a/src/test/mockFactory.ts
+++ b/src/test/mockFactory.ts
@@ -65,7 +65,7 @@ export class MockFactory {
     sls.service = MockFactory.createTestService(sls.service["functions"]);
   }
 
-  public static createTestServerlessOptions(): Serverless.Options {
+  public static createTestServerlessOptions(options?: any): Serverless.Options {
     return {
       extraServicePath: null,
       function: null,
@@ -73,6 +73,7 @@ export class MockFactory {
       region: null,
       stage: null,
       watch: null,
+      ...options
     };
   }