Repository: serverless/serverless-azure-functions
Pull request #208 (2019-07-19 17:23:16)

feat: List deployments for rollback when no timestamp given
Rollback requires a timestamp. Rather than forcing users to run `sls deploy list` to generate the deployments they can choose from, `sls rollback` with no args also generates the same list.

Resolves [AB#493]

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

Coverage decreased (-0.4%) to 83.941% when pulling **89ac6abd111999ed5200512b5fb8eb629c480bf4 on tabarlow/rollback-deploys** into **2b97f75ca9dd5a6b29e823c4aa46e34bf3195d52 on dev**.

tbarlow12: Rollback requires a timestamp. Rather than forcing users to run `sls deploy list` to generate the deployments they can choose from, `sls rollback` with no args also generates the same list.

Resolves [AB#493]

Patch:
diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts
index 70f4822..8e38560 100644
--- a/src/plugins/deploy/azureDeployPlugin.ts
+++ b/src/plugins/deploy/azureDeployPlugin.ts
@@ -3,7 +3,6 @@ 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";
 
 export class AzureDeployPlugin extends AzureBasePlugin<AzureLoginOptions> {
@@ -49,25 +48,7 @@ export class AzureDeployPlugin extends AzureBasePlugin<AzureLoginOptions> {
   private async list() {
     this.log("Listing deployments");
     const resourceService = new ResourceService(this.serverless, this.options);
-    const deployments = await resourceService.getDeployments();
-    if (!deployments || deployments.length === 0) {
-      this.log(`No deployments found for resource group '${resourceService.getResourceGroupName()}'`);
-      return;
-    }
-    let stringDeployments = "\n\nDeployments";
-
-    for (const dep of deployments) {
-      stringDeployments += "\n-----------\n"
-      stringDeployments += `Name: ${dep.name}\n`
-      const timestampFromName = Utils.getTimestampFromName(dep.name);
-      stringDeployments += `Timestamp: ${(timestampFromName) ? timestampFromName : "None"}\n`;
-
-      const dateTime = timestampFromName ? new Date(+timestampFromName).toISOString() : "None";
-      stringDeployments += `Datetime: ${dateTime}\n`
-    }
-
-    stringDeployments += "-----------\n"
-    this.log(stringDeployments);
+    this.log(await resourceService.listDeployments());
   }
 
   private async deploy() {
diff --git a/src/services/resourceService.ts b/src/services/resourceService.ts
index 4b7dca0..2d75181 100644
--- a/src/services/resourceService.ts
+++ b/src/services/resourceService.ts
@@ -20,6 +20,31 @@ export class ResourceService extends BaseService {
     return await this.resourceClient.deployments.listByResourceGroup(this.resourceGroup);
   }
 
+  /**
+   * Returns stringified list of deployments with timestamps
+   */
+  public async listDeployments(): Promise<string> {
+    const deployments = await this.getDeployments()
+    if (!deployments || deployments.length === 0) {
+      this.log(`No deployments found for resource group '${this.getResourceGroupName()}'`);
+      return;
+    }
+    let stringDeployments = "\n\nDeployments";
+
+    for (const dep of deployments) {
+      stringDeployments += "\n-----------\n"
+      stringDeployments += `Name: ${dep.name}\n`
+      const timestampFromName = Utils.getTimestampFromName(dep.name);
+      stringDeployments += `Timestamp: ${(timestampFromName) ? timestampFromName : "None"}\n`;
+
+      const dateTime = timestampFromName ? new Date(+timestampFromName).toISOString() : "None";
+      stringDeployments += `Datetime: ${dateTime}\n`
+    }
+
+    stringDeployments += "-----------\n"
+    return stringDeployments
+  }
+
   /**
    * Get ARM template for previous deployment
    * @param deploymentName Name of deployment
@@ -45,4 +70,4 @@ export class ResourceService extends BaseService {
     this.log(`Deleting resource group: ${this.resourceGroup}`);
     return await this.resourceClient.resourceGroups.deleteMethod(this.resourceGroup);
   }
-}
\ No newline at end of file
+}
diff --git a/src/services/rollbackService.ts b/src/services/rollbackService.ts
index 0adb0bd..a857693 100644
--- a/src/services/rollbackService.ts
+++ b/src/services/rollbackService.ts
@@ -102,12 +102,14 @@ export class RollbackService extends BaseService {
 
   /**
    * Get deployment specified by timestamp in Serverless options
+   * Lists deployments if no timestamp is provided
    */
   private async getDeployment(): Promise<DeploymentExtended> {
     let timestamp = Utils.get(this.options, "timestamp");
     if (!timestamp) {
-      this.log("Need to specify a timestamp for rollback. Run `sls deploy list` to see timestamps of deployments");
+      this.log("Need to specify a timestamp for rollback.");
       this.log("Example usage:\n\nsls rollback -t 1562014362");
+      this.log(await this.resourceService.listDeployments());
       return null;
     }
     const deployments = await this.getArmDeploymentsByTimestamp();


Test patch:
diff --git a/src/plugins/deploy/azureDeployPlugin.test.ts b/src/plugins/deploy/azureDeployPlugin.test.ts
index 52b98fb..7b1f3e0 100644
--- a/src/plugins/deploy/azureDeployPlugin.test.ts
+++ b/src/plugins/deploy/azureDeployPlugin.test.ts
@@ -57,75 +57,11 @@ 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));
-
-    await invokeHook(plugin, "deploy:list:list");
-    expect(ResourceService.prototype.getDeployments).toBeCalled();
-
-    let expectedLogStatement = "\n\nDeployments";
-    const originalTimestamp = +MockFactory.createTestTimestamp();
-    let i = 0
-    for (const dep of deployments) {
-      const timestamp = originalTimestamp + i
-      expectedLogStatement += "\n-----------\n"
-      expectedLogStatement += `Name: ${dep.name}\n`
-      expectedLogStatement += `Timestamp: ${timestamp}\n`;
-      expectedLogStatement += `Datetime: ${new Date(timestamp).toISOString()}\n`
-      i++
-    }
-    expectedLogStatement += "-----------\n"
-    expect(sls.cli.log).lastCalledWith(expectedLogStatement);
-  });
-
-  it("lists deployments without timestamps", async () => {
-    const deployments = MockFactory.createTestDeployments();
-    ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve(deployments));
-
+  it("lists deployments", async () => {
+    const deploymentString = "deployments";
+    ResourceService.prototype.listDeployments = jest.fn(() => Promise.resolve(deploymentString));
     await invokeHook(plugin, "deploy:list:list");
-    expect(ResourceService.prototype.getDeployments).toBeCalled();
-
-    let expectedLogStatement = "\n\nDeployments";
-    for (const dep of deployments) {
-      expectedLogStatement += "\n-----------\n"
-      expectedLogStatement += `Name: ${dep.name}\n`
-      expectedLogStatement += "Timestamp: None\n";
-      expectedLogStatement += "Datetime: None\n"
-    }
-    expectedLogStatement += "-----------\n"
-    expect(sls.cli.log).lastCalledWith(expectedLogStatement);
-  });
-
-  it("logs empty deployment list", async () => {
-    const resourceGroup = "rg1";
-    ResourceService.prototype.getDeployments = jest.fn(() => Promise.resolve([])) as any;
-    ResourceService.prototype.getResourceGroupName = jest.fn(() => resourceGroup);
-
-    await invokeHook(plugin, "deploy:list:list");
-    expect(ResourceService.prototype.getDeployments).toBeCalled();
-
-    expect(sls.cli.log).lastCalledWith(`No deployments found for resource group '${resourceGroup}'`);
+    expect(ResourceService.prototype.listDeployments).toBeCalled();
+    expect(sls.cli.log).lastCalledWith(deploymentString);
   });
 });
diff --git a/src/services/resourceService.test.ts b/src/services/resourceService.test.ts
index bd22314..c78df52 100644
--- a/src/services/resourceService.test.ts
+++ b/src/services/resourceService.test.ts
@@ -1,16 +1,17 @@
+import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models";
+import { Utils } from "../shared/utils";
 import { MockFactory } from "../test/mockFactory";
 import { ResourceService } from "./resourceService";
 
-
 jest.mock("@azure/arm-resources")
 import { ResourceManagementClient } from "@azure/arm-resources";
-import { Utils } from "../shared/utils";
 
 describe("Resource Service", () => {
-  const deployments = MockFactory.createTestDeployments();
+  let deployments: DeploymentsListByResourceGroupResponse;
   const template = "myTemplate";
 
-  beforeAll(() => {
+  beforeEach(() => {
+    deployments = MockFactory.createTestDeployments(5, true);
     ResourceManagementClient.prototype.resourceGroups = {
       createOrUpdate: jest.fn(),
       deleteMethod: jest.fn(),
@@ -80,7 +81,7 @@ describe("Resource Service", () => {
       .toBeCalledWith(resourceGroup);
   });
 
-  it("lists deployments", async () => {
+  it("gets deployments", async () => {
     const sls = MockFactory.createTestServerless();
     const resourceGroup = "myResourceGroup";
     sls.service.provider["resourceGroup"] = resourceGroup
@@ -91,6 +92,53 @@ describe("Resource Service", () => {
     expect(deps).toEqual(deployments);
   });
 
+  it("lists deployments as string with timestamps", async () => {
+    const sls = MockFactory.createTestServerless();
+    const resourceGroup = "myResourceGroup";
+    sls.service.provider["resourceGroup"] = resourceGroup
+    sls.variables["azureCredentials"] = "fake credentials"
+    const options = MockFactory.createTestServerlessOptions();
+    const service = new ResourceService(sls, options);
+    const deploymentString = await service.listDeployments();
+    let expectedDeploymentString = "\n\nDeployments";
+    const originalTimestamp = +MockFactory.createTestTimestamp();
+    let i = 0
+    for (const dep of deployments) {
+      const timestamp = originalTimestamp + i
+      expectedDeploymentString += "\n-----------\n"
+      expectedDeploymentString += `Name: ${dep.name}\n`
+      expectedDeploymentString += `Timestamp: ${timestamp}\n`;
+      expectedDeploymentString += `Datetime: ${new Date(timestamp).toISOString()}\n`
+      i++
+    }
+    expectedDeploymentString += "-----------\n"
+    expect(deploymentString).toEqual(expectedDeploymentString);
+  });
+
+  it("lists deployments as string without timestamps", async () => {
+    deployments = MockFactory.createTestDeployments();
+    ResourceManagementClient.prototype.deployments = {
+      listByResourceGroup: jest.fn(() => Promise.resolve(deployments)),
+    } as any;
+
+    const sls = MockFactory.createTestServerless();
+    const resourceGroup = "myResourceGroup";
+    sls.service.provider["resourceGroup"] = resourceGroup
+    sls.variables["azureCredentials"] = "fake credentials"
+    const options = MockFactory.createTestServerlessOptions();
+    const service = new ResourceService(sls, options);
+    const deploymentString = await service.listDeployments();
+    let expectedDeploymentString = "\n\nDeployments";
+    for (const dep of deployments) {
+      expectedDeploymentString += "\n-----------\n"
+      expectedDeploymentString += `Name: ${dep.name}\n`
+      expectedDeploymentString += "Timestamp: None\n";
+      expectedDeploymentString += "Datetime: None\n"
+    }
+    expectedDeploymentString += "-----------\n"
+    expect(deploymentString).toEqual(expectedDeploymentString);
+  });
+
   it("gets deployment template",async () => {
     const sls = MockFactory.createTestServerless();
     const resourceGroup = "myResourceGroup";
@@ -107,4 +155,4 @@ describe("Resource Service", () => {
       );
     expect(result).toEqual(template);
   });
-});
\ No newline at end of file
+});
diff --git a/src/services/rollbackService.test.ts b/src/services/rollbackService.test.ts
index 2b0e51d..547bbe2 100644
--- a/src/services/rollbackService.test.ts
+++ b/src/services/rollbackService.test.ts
@@ -31,6 +31,7 @@ describe("Rollback Service", () => {
   const artifactName = MockFactory.createTestDeployment().name.replace("deployment", "artifact") + ".zip";
   const artifactPath = `.serverless${path.sep}${artifactName}`
   const armDeployment: ArmDeployment = { template, parameters };
+  const deploymentString = "deployments";
 
   function createOptions(timestamp?: string): Serverless.Options {
     return {
@@ -58,6 +59,7 @@ describe("Rollback Service", () => {
     ResourceService.prototype.getDeploymentTemplate = jest.fn(
       () => Promise.resolve({ template })
     ) as any;
+    ResourceService.prototype.listDeployments = jest.fn(() => Promise.resolve(deploymentString))
     AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(() => sasURL) as any;
     FunctionAppService.prototype.get = jest.fn(() => appStub) as any;
   });
@@ -72,8 +74,7 @@ describe("Rollback Service", () => {
     const options = {} as any;
     const service = createService(sls, options);
     await service.rollback();
-    const calls = (sls.cli.log as any).mock.calls;
-    expect(calls[0][0]).toEqual("Need to specify a timestamp for rollback. Run `sls deploy list` to see timestamps of deployments");
+    expect(sls.cli.log).lastCalledWith(deploymentString);
   });
 
   it("should return early with invalid timestamp", async () => {