Repository: serverless/serverless-azure-functions
Pull request #306 (2019-09-04 18:27:33)

feat: More detailed error message upon ARM deployment failure
Logs a more detailed error message when an ARM template deployment fails:

![image](https://user-images.githubusercontent.com/10962815/64280926-f0e7fe80-cf06-11e9-856b-895380038168.png)



Patch:
diff --git a/package-lock.json b/package-lock.json
index 846d7d1..1c07b97 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "serverless-azure-functions",
-  "version": "1.0.0-32",
+  "version": "1.0.0-34",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -24,6 +24,16 @@
         "tslib": "^1.9.3"
       }
     },
+    "@azure/arm-keyvault": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@azure/arm-keyvault/-/arm-keyvault-1.2.1.tgz",
+      "integrity": "sha512-R2ao/GkTPXgb+60He4XB1XI1Mc7QAnC12sq1xuKbE4aH2D8kTsbTwXH7KIu4yuzLvX+jSov+FVwkq1v9+JeHMQ==",
+      "requires": {
+        "@azure/ms-rest-azure-js": "^1.3.2",
+        "@azure/ms-rest-js": "^1.8.1",
+        "tslib": "^1.9.3"
+      }
+    },
     "@azure/arm-resources": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-1.0.1.tgz",
@@ -1718,9 +1728,9 @@
       }
     },
     "acorn": {
-      "version": "5.7.3",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz",
-      "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw=="
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.0.0.tgz",
+      "integrity": "sha512-PaF/MduxijYYt7unVGRuds1vBC9bFxbNf+VWqhOClfdgy7RlVkQqt610ig1/yxTgsDIfW1cWDel5EBbOy3jdtQ=="
     },
     "acorn-dynamic-import": {
       "version": "2.0.2",
@@ -7476,6 +7486,12 @@
         "xml-name-validator": "^3.0.0"
       },
       "dependencies": {
+        "acorn": {
+          "version": "5.7.3",
+          "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz",
+          "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==",
+          "dev": true
+        },
         "escodegen": {
           "version": "1.11.1",
           "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.11.1.tgz",
@@ -11513,6 +11529,12 @@
         "yargs": "^8.0.2"
       },
       "dependencies": {
+        "acorn": {
+          "version": "5.7.3",
+          "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz",
+          "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==",
+          "optional": true
+        },
         "async": {
           "version": "2.6.2",
           "resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz",
diff --git a/package.json b/package.json
index 6214c37..5e71fb5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "serverless-azure-functions",
-  "version": "1.0.0-32",
+  "version": "1.0.0-34",
   "description": "Provider plugin for the Serverless Framework v1.x which adds support for Azure Functions.",
   "license": "MIT",
   "main": "./lib/index.js",
@@ -42,10 +42,12 @@
   "dependencies": {
     "@azure/arm-apimanagement": "^5.1.0",
     "@azure/arm-appservice": "^5.7.0",
+    "@azure/arm-keyvault": "^1.2.1",
     "@azure/arm-resources": "^1.0.1",
     "@azure/arm-storage": "^9.0.1",
     "@azure/ms-rest-nodeauth": "^1.0.1",
     "@azure/storage-blob": "^10.3.0",
+    "acorn": "^7.0.0",
     "axios": "^0.18.0",
     "azure-functions-core-tools": "^2.7.1575",
     "deep-equal": "^1.0.1",
diff --git a/src/armTemplates/resources/functionApp.ts b/src/armTemplates/resources/functionApp.ts
index 3b281ac..fb022dd 100644
--- a/src/armTemplates/resources/functionApp.ts
+++ b/src/armTemplates/resources/functionApp.ts
@@ -58,6 +58,9 @@ export class FunctionAppResource implements ArmResourceTemplateGenerator {
           "apiVersion": "2016-03-01",
           "name": "[parameters('functionAppName')]",
           "location": "[parameters('location')]",
+          "identity": {
+            "type": "SystemAssigned"
+          },
           "dependsOn": [
             "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]",
             "[concat('microsoft.insights/components/', parameters('appInsightsName'))]"
diff --git a/src/index.ts b/src/index.ts
index 9e8ceb8..f628de4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -16,6 +16,7 @@ import { AzureApimFunctionPlugin } from "./plugins/apim/azureApimFunctionPlugin"
 import { AzureFuncPlugin } from "./plugins/func/azureFuncPlugin";
 import { AzureOfflinePlugin } from "./plugins/offline/azureOfflinePlugin"
 import { AzureRollbackPlugin } from "./plugins/rollback/azureRollbackPlugin"
+import { AzureKeyVaultPlugin } from "./plugins/identity/azureKeyVaultPlugin"
 
 
 export default class AzureIndex {
@@ -34,6 +35,7 @@ export default class AzureIndex {
     this.serverless.pluginManager.addPlugin(AzureFuncPlugin);
     this.serverless.pluginManager.addPlugin(AzureOfflinePlugin);
     this.serverless.pluginManager.addPlugin(AzureRollbackPlugin);
+    this.serverless.pluginManager.addPlugin(AzureKeyVaultPlugin);
   }
 }
 
diff --git a/src/models/azureProvider.ts b/src/models/azureProvider.ts
index 08483f8..33c0e64 100644
--- a/src/models/azureProvider.ts
+++ b/src/models/azureProvider.ts
@@ -18,3 +18,9 @@ export interface FunctionMetadata {
   handler: string;
   events: FunctionEvent[];
 }
+
+export interface DeploymentExtendedError {
+  code: string;
+  message: string;
+  details?: DeploymentExtendedError[];
+}
diff --git a/src/plugins/identity/azureKeyVaultPlugin.ts b/src/plugins/identity/azureKeyVaultPlugin.ts
new file mode 100644
index 0000000..999d3d3
--- /dev/null
+++ b/src/plugins/identity/azureKeyVaultPlugin.ts
@@ -0,0 +1,27 @@
+import Serverless from "serverless";
+import { AzureBasePlugin } from "../azureBasePlugin";
+import { AzureKeyVaultService } from "../../services/azureKeyVaultService";
+
+export class AzureKeyVaultPlugin extends AzureBasePlugin {
+  public constructor(serverless: Serverless, options: Serverless.Options) {
+    super(serverless, options);
+    this.hooks = {
+      "after:deploy:deploy": this.link.bind(this)
+    };
+  }
+
+  private async link() {
+    const keyVaultConfig = this.serverless.service.provider["keyVault"];
+    if (!keyVaultConfig) {
+      return Promise.resolve();
+    }
+    this.serverless.cli.log("Starting KeyVault service setup");
+
+    const keyVaultService = new AzureKeyVaultService(this.serverless, this.options);
+    const result = await keyVaultService.setPolicy(keyVaultConfig);
+
+    this.serverless.cli.log("Finished KeyVault service setup");
+
+    return result;
+  }
+}
diff --git a/src/services/armService.ts b/src/services/armService.ts
index d2dfd75..09d2b93 100644
--- a/src/services/armService.ts
+++ b/src/services/armService.ts
@@ -10,6 +10,7 @@ import { Guard } from "../shared/guard";
 import { BaseService } from "./baseService";
 import { ResourceService } from "./resourceService"
 import deepEqual from "deep-equal";
+import { DeploymentExtendedError } from "../models/azureProvider";
 
 export class ArmService extends BaseService {
   private resourceClient: ResourceManagementClient;
@@ -130,14 +131,39 @@ export class ArmService extends BaseService {
     this.log(`---> Resource Group: ${this.resourceGroup}`)
     this.log(`---> Deployment Name: ${this.deploymentName}`)
 
-    const result = await this.resourceClient.deployments.createOrUpdate(
-      this.resourceGroup,
-      this.deploymentName,
-      armDeployment
-    );
-    this.log("-> ARM deployment complete");
+    try {
+      const result = await this.resourceClient.deployments.createOrUpdate(
+        this.resourceGroup,
+        this.deploymentName,
+        armDeployment
+      );
+      this.log("-> ARM deployment complete");
+      return result;
+    } catch (err) {
+      const lastDeployment = await resourceService.getLastDeployment();
+      const errorDetails: DeploymentExtendedError = lastDeployment.properties["error"];
+      if (errorDetails) {
+        throw new Error(this.deploymentErrorToString(errorDetails));
+      }
+    }
+  }
 
-    return result;
+  private deploymentErrorToString(deploymentError: DeploymentExtendedError) {
+    if (!deploymentError.code || !deploymentError.message) {
+      return JSON.stringify(deploymentError);
+    }
+    let errorString = `${deploymentError.code} - ${deploymentError.message}`;
+    if (deploymentError.details) {
+
+      errorString += `
+      ------------------------
+      DEPLOYMENT ERROR DETAILS
+      ------------------------\n`
+      deploymentError.details.forEach((childError) => {
+        errorString += `\n${this.deploymentErrorToString(childError)}`
+      })
+    }
+    return errorString;
   }
 
   /**
diff --git a/src/services/azureKeyVaultService.ts b/src/services/azureKeyVaultService.ts
new file mode 100644
index 0000000..a8056f5
--- /dev/null
+++ b/src/services/azureKeyVaultService.ts
@@ -0,0 +1,61 @@
+import Serverless from "serverless";
+import { BaseService } from "./baseService";
+import { FunctionAppService } from "./functionAppService";
+import { KeyVaultManagementClient } from "@azure/arm-keyvault";
+import { KeyPermissions, SecretPermissions, Vault } from "@azure/arm-keyvault/esm/models/index";
+
+/**
+ * Defines the Azure Key Vault configuration
+ */
+export interface AzureKeyVaultConfig {
+  /** The name of the azure key vault */
+  name: string;
+  /** The name of the azure resource group with the key vault */
+  resourceGroup: string;
+}
+
+/**
+ * Services for the Key Vault Plugin
+ */
+export class AzureKeyVaultService extends BaseService {
+  private funcApp: FunctionAppService;
+
+  /**
+   * Initialize key vault service and get function app
+   * @param serverless Serverless object
+   * @param options Serverless CLI options
+   */
+  public constructor(serverless: Serverless, options: Serverless.Options) {
+    super(serverless, options);
+    this.funcApp = new FunctionAppService(serverless, options);
+  }
+
+  /**
+   * Sets the KeyVault policy for the function app to allow secrets permissions.
+   * @param keyVaultConfig Azure Key Vault settings
+   */
+  public async setPolicy(keyVaultConfig: AzureKeyVaultConfig) {
+    const subscriptionID = this.subscriptionId;
+
+    const func = await this.funcApp.get();
+    const identity = func.identity;
+    let vault: Vault;
+    const keyVaultClient = new KeyVaultManagementClient(this.credentials, subscriptionID);
+    try {
+      vault = await keyVaultClient.vaults.get(keyVaultConfig.resourceGroup, keyVaultConfig.name)
+    } catch (error) {
+      throw new Error("Error: Specified vault not found")
+    }
+
+    const newEntry = {
+      tenantId: identity.tenantId,
+      objectId: identity.principalId,
+      permissions: {
+        secrets: ["get" as SecretPermissions],
+      }
+    }
+    vault.properties.accessPolicies.push(newEntry);
+
+    return keyVaultClient.vaults.createOrUpdate(keyVaultConfig.resourceGroup, keyVaultConfig.name, {location: vault.location, properties: vault.properties})
+  }
+}
diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts
index 06af8b2..0045018 100644
--- a/src/services/functionAppService.ts
+++ b/src/services/functionAppService.ts
@@ -195,7 +195,7 @@ export class FunctionAppService extends BaseService {
    *    resource-group, storage account, app service plan, and app service at the minimum
    */
   public async deploy() {
-    this.log(`Creating function app: ${this.serviceName}`);
+    this.log(`Creating function app: ${FunctionAppResource.getResourceName(this.config)}`);
 
     const armService = new ArmService(this.serverless, this.options);
     const { armTemplate, type } = this.config.provider;


Test patch:
diff --git a/src/plugins/identity/azureKeyVaultPlugin.test.ts b/src/plugins/identity/azureKeyVaultPlugin.test.ts
new file mode 100644
index 0000000..71782c6
--- /dev/null
+++ b/src/plugins/identity/azureKeyVaultPlugin.test.ts
@@ -0,0 +1,53 @@
+import Serverless from "serverless";
+import { MockFactory } from "../../test/mockFactory";
+import { invokeHook } from "../../test/utils";
+import { AzureKeyVaultPlugin } from "./azureKeyVaultPlugin";
+
+jest.mock("../../services/azureKeyVaultService.ts");
+import { AzureKeyVaultService } from "../../services/azureKeyVaultService";
+
+describe("Azure Key Vault Plugin", () => {
+  it("is defined", () => {
+    expect(AzureKeyVaultPlugin).toBeDefined();
+  });
+
+  it("can be instantiated", () => {
+    const serverless = MockFactory.createTestServerless();
+    const options: Serverless.Options = MockFactory.createTestServerlessOptions();
+    const plugin = new AzureKeyVaultPlugin(serverless, options);
+
+    expect(plugin).not.toBeNull();
+  });
+
+  it("calls set policy when key vault specified", async () => {
+    const setPolicy = jest.fn();
+
+    AzureKeyVaultService.prototype.setPolicy = setPolicy;
+
+    const sls = MockFactory.createTestServerless();
+    sls.service.provider["keyVault"] = { name: "testVault", resourceGroup: "testGroup"}
+    const options = MockFactory.createTestServerlessOptions();
+    const plugin = new AzureKeyVaultPlugin(sls, options);
+
+    await invokeHook(plugin, "after:deploy:deploy");
+
+    expect(sls.cli.log).toBeCalledWith("Starting KeyVault service setup")
+    expect(setPolicy).toBeCalled();
+    expect(sls.cli.log).lastCalledWith("Finished KeyVault service setup")
+  });
+
+  it("does not call deploy API or deploy functions when \"keyVault\" not included in config", async () => {
+    const setPolicy = jest.fn();
+
+    AzureKeyVaultService.prototype.setPolicy = setPolicy;
+
+    const sls = MockFactory.createTestServerless();
+    const options = MockFactory.createTestServerlessOptions();
+    const plugin = new AzureKeyVaultPlugin(sls, options);
+
+    await invokeHook(plugin, "after:deploy:deploy");
+
+    expect(sls.cli.log).not.toBeCalled()
+    expect(setPolicy).not.toBeCalled();
+  });
+});
diff --git a/src/services/armService.test.ts b/src/services/armService.test.ts
index d70cb46..a052906 100644
--- a/src/services/armService.test.ts
+++ b/src/services/armService.test.ts
@@ -8,6 +8,7 @@ import jsonpath from "jsonpath";
 import { Deployments } from "@azure/arm-resources";
 import { Deployment, DeploymentExtended } from "@azure/arm-resources/esm/models";
 import { ResourceService } from "./resourceService";
+import { DeploymentExtendedError } from "../models/azureProvider";
 
 describe("Arm Service", () => {
   let sls: Serverless
@@ -311,5 +312,47 @@ describe("Arm Service", () => {
       expect(call[1]).toMatch(expectedDeploymentNameRegex);
       expect(call[2]).toEqual(expectedDeployment);
     });
+
+    it("Throws more detailed error message upon failed ARM deployment", async () => {
+      Deployments.prototype.createOrUpdate = jest.fn(() => Promise.reject(null));
+      const lastDeploymentError: DeploymentExtendedError = {
+        code: "DeploymentFailed",
+        message: "At least one resource deployment operation failed. Please list deployment operations for details. Please see https://aka.ms/arm-debug for usage details.",
+        details: [
+          {
+            code: "ServiceAlreadyExists",
+            message: "Api service already exists: abc-123-apim"
+          },
+          {
+            code: "StorageAccountAlreadyTaken",
+            message: "The storage account named ABC123 is already taken."
+          }
+        ]
+      }
+      ResourceService.prototype.getLastDeployment = jest.fn(() => Promise.resolve({
+        properties: {
+          error: lastDeploymentError
+        }
+      })) as any;
+      const deployment: ArmDeployment = {
+        parameters: MockFactory.createTestParameters(false),
+        template: MockFactory.createTestArmTemplate()
+      };
+      deployment.parameters.param1 = "3"
+      const { code, message, details } = lastDeploymentError;
+      let errorPattern = [
+        code,
+        message,
+        details[0].code,
+        details[0].message,
+        details[1].code,
+        details[1].message
+      ].join(".*")
+      await expect(service.deployTemplate(deployment))
+        .rejects
+        .toThrowError(
+          new RegExp(`.*${errorPattern}.*`,"s")
+        );
+    });
   });
 });
diff --git a/src/services/azureKeyVaultService.test.ts b/src/services/azureKeyVaultService.test.ts
new file mode 100644
index 0000000..a928c01
--- /dev/null
+++ b/src/services/azureKeyVaultService.test.ts
@@ -0,0 +1,98 @@
+import Serverless from "serverless";
+import { MockFactory } from "../test/mockFactory";
+import {
+  AzureKeyVaultService,
+  AzureKeyVaultConfig
+} from "./azureKeyVaultService";
+
+import { Vaults } from "@azure/arm-keyvault";
+import { FunctionAppService } from "./functionAppService";
+
+describe("Azure Key Vault Service", () => {
+  const options: Serverless.Options = MockFactory.createTestServerlessOptions();
+  const knownVaults = {
+    testVault: {
+      location: "WestUS",
+      properties: {
+        accessPolicies: []
+      }
+    }
+  };
+
+  let serverless: Serverless = MockFactory.createTestServerless();
+
+  beforeEach(() => {
+    Vaults.prototype.createOrUpdate = jest.fn();
+    Vaults.prototype.get = jest.fn(async (resourceGroup, vaultName) => {
+      if (!knownVaults.hasOwnProperty(vaultName)) {
+        throw new Error("No matching vaults");
+      }
+      return knownVaults["testVault"];
+    }) as any;
+
+    FunctionAppService.prototype.get = jest.fn(() => {
+      console.log("testing");
+      return {
+        identity: {
+          tenantId: "tid",
+          principalId: "oid"
+        }
+      } as any;
+    });
+  });
+
+  it("is defined", () => {
+    expect(AzureKeyVaultService).toBeDefined();
+  });
+
+  it("can be instantiated", () => {
+    const service = new AzureKeyVaultService(serverless, options);
+    expect(service).not.toBeNull();
+  });
+
+  it("Throws an error if keyvault doesn't exist", async () => {
+    const service = new AzureKeyVaultService(serverless, options);
+    const keyVault: AzureKeyVaultConfig = MockFactory.createTestKeyVaultConfig(
+      "fake-vault"
+    );
+
+    await expect(service.setPolicy(keyVault)).rejects.toThrowError(
+      "Error: Specified vault not found"
+    );
+    await expect(Vaults.prototype.createOrUpdate).not.toBeCalled();
+  });
+
+  it("Sets correct policy if correct keyvault name is specified", async () => {
+    const keyVault: AzureKeyVaultConfig = MockFactory.createTestKeyVaultConfig(
+      "testVault"
+    );
+    const slsConfig: any = {
+      ...MockFactory.createTestService(
+        MockFactory.createTestSlsFunctionConfig()
+      ),
+      service: "test-sls",
+      provider: {
+        name: "azure",
+        resourceGroup: "test-sls-rg",
+        region: "West US",
+        keyVault
+      }
+    };
+
+    serverless = MockFactory.createTestServerless({
+      service: slsConfig
+    });
+
+    const service = new AzureKeyVaultService(serverless, options);
+    await service.setPolicy(keyVault);
+
+    await expect(Vaults.prototype.createOrUpdate).toBeCalledWith(
+      keyVault.resourceGroup,
+      keyVault.name,
+      {
+        location: knownVaults["testVault"].location,
+        properties: knownVaults["testVault"].properties
+      }
+    );
+  });
+});
diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts
index b91e4f8..c02d327 100644
--- a/src/test/mockFactory.ts
+++ b/src/test/mockFactory.ts
@@ -323,6 +323,13 @@ export class MockFactory {
     };
   }
 
+  public static createTestKeyVaultConfig(name: string = "testVault") {
+    return {
+      name: name,
+      resourceGroup: "testGroup",
+    };
+  }
+
   public static createTestFunctionMetadata(name: string) {
     return {
       "handler": `${name}.handler`,