diff --git a/README.md b/README.md index 310a693b..1535a58a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,26 @@ This plugin enables Azure Functions support within the Serverless Framework. 2. CD into the generated app directory: `cd ` 3. Install the app's NPM dependencies, which includes this plugin: `npm install` +### Creating or removing Azure Functions + +To create a new Azure Function within your function app, run the following command from within your app's directory: + +```bash +sls func add -n {functionName} +``` + +This will create a new `{functionName}` directory at the root of your application with `index.js` and `function.json` inside the directory. It will also update `serverless.yml` to contain the new function. + +To remove an existing Azure Function from your function app, run the following command from within your app's directory: + +```bash +sls func remove -n {functionName} +``` + +This will remove the `{functionName}` directory and remove the function from `serverless.yml` + +*Note: Add & remove currently only support HTTP triggered functions. For other triggers, you will need to update `serverless.yml` manually + ### Deploy, test, and diagnose your Azure service 1. Deploy your new service to Azure! The first time you do this, you will be asked to authenticate with your Azure account, so the `serverless` CLI can manage Functions on your behalf. Simply follow the provided instructions, and the deployment will continue as soon as the authentication process is completed. diff --git a/package-lock.json b/package-lock.json index 08b1f0a9..679c8e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -997,7 +997,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -3985,7 +3984,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -6058,7 +6056,6 @@ "version": "3.13.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -6067,8 +6064,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" } } }, @@ -7992,7 +7988,6 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "dev": true, "requires": { "glob": "^7.1.3" } @@ -8627,8 +8622,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "sshpk": { "version": "1.16.1", diff --git a/package.json b/package.json index ed382127..b038e446 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,12 @@ "@azure/arm-resources": "^1.0.1", "@azure/ms-rest-nodeauth": "^1.0.1", "axios": "^0.18.0", + "js-yaml": "^3.13.1", "jsonpath": "^1.0.1", "lodash": "^4.16.6", "open": "^6.3.0", - "request": "^2.81.0" + "request": "^2.81.0", + "rimraf": "^2.6.3" }, "devDependencies": { "@types/jest": "^24.0.13", diff --git a/src/index.ts b/src/index.ts index 1dcbbbc5..05e9c210 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ import { AzureDeployPlugin } from "./plugins/deploy/azureDeployPlugin"; import { AzureLoginPlugin } from "./plugins/login/loginPlugin"; import { AzureApimServicePlugin } from "./plugins/apim/apimServicePlugin"; import { AzureApimFunctionPlugin } from "./plugins/apim/apimFunctionPlugin"; +import { AzureFuncPlugin } from "./plugins/func/azureFunc"; + export class AzureIndex { public constructor(private serverless: Serverless, private options) { @@ -29,6 +31,7 @@ export class AzureIndex { this.serverless.pluginManager.addPlugin(AzureDeployPlugin); this.serverless.pluginManager.addPlugin(AzureApimServicePlugin); this.serverless.pluginManager.addPlugin(AzureApimFunctionPlugin); + this.serverless.pluginManager.addPlugin(AzureFuncPlugin); } } diff --git a/src/plugins/func/azureFunc.test.ts b/src/plugins/func/azureFunc.test.ts new file mode 100644 index 00000000..2ff32525 --- /dev/null +++ b/src/plugins/func/azureFunc.test.ts @@ -0,0 +1,117 @@ +import fs from "fs"; +import mockFs from "mock-fs"; +import path from "path"; +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzureFuncPlugin } from "./azureFunc"; +import rimraf from "rimraf"; + +describe("Azure Func Plugin", () => { + + it("displays a help message", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureFuncPlugin(sls, options); + await invokeHook(plugin, "func:func"); + expect(sls.cli.log).toBeCalledWith("Use the func plugin to add or remove functions within Function App"); + }) + + describe("Add command", () => { + + beforeAll(() => { + mockFs({ + "myExistingFunction": { + "index.js": "contents", + "function.json": "contents", + }, + "serverless.yml": MockFactory.createTestServerlessYml(true), + }, {createCwd: true, createTmp: true}) + }); + + afterAll(() => { + mockFs.restore(); + }); + + it("returns with missing name", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureFuncPlugin(sls, options); + await invokeHook(plugin, "func:add:add"); + expect(sls.cli.log).toBeCalledWith("Need to provide a name of function to add") + }); + + it("returns with pre-existing function", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["name"] = "myExistingFunction"; + const plugin = new AzureFuncPlugin(sls, options); + await invokeHook(plugin, "func:add:add"); + expect(sls.cli.log).toBeCalledWith(`Function myExistingFunction already exists`); + }); + + it("creates function directory and updates serverless.yml", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const functionName = "myFunction"; + options["name"] = functionName; + const plugin = new AzureFuncPlugin(sls, options); + const mkdirSpy = jest.spyOn(fs, "mkdirSync"); + await invokeHook(plugin, "func:add:add"); + expect(mkdirSpy).toBeCalledWith(functionName); + const calls = (sls.utils.writeFileSync as any).mock.calls; + expect(calls[0][0]).toBe(path.join(functionName, "index.js")); + expect(calls[1][0]).toBe(path.join(functionName, "function.json")); + const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata(); + expectedFunctionsYml[functionName] = MockFactory.createTestFunctionMetadata(); + expect(calls[2][0]).toBe("serverless.yml"); + expect(calls[2][1]).toBe(MockFactory.createTestServerlessYml(true, expectedFunctionsYml)); + }); + }); + + describe("Remove command", () => { + + beforeAll(() => { + mockFs({ + "function1": { + "index.js": "contents", + "function.json": "contents", + }, + }, {createCwd: true, createTmp: true}); + }); + + afterAll(() => { + mockFs.restore(); + }); + + it("returns with missing name", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureFuncPlugin(sls, options); + await invokeHook(plugin, "func:remove:remove"); + expect(sls.cli.log).toBeCalledWith("Need to provide a name of function to remove") + }); + + it("returns with non-existing function", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["name"] = "myNonExistingFunction"; + const plugin = new AzureFuncPlugin(sls, options); + await invokeHook(plugin, "func:remove:remove"); + expect(sls.cli.log).toBeCalledWith(`Function myNonExistingFunction does not exist`); + }); + + it("deletes directory and updates serverless.yml", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureFuncPlugin(sls, options); + const functionName = "function1"; + options["name"] = functionName; + const rimrafSpy = jest.spyOn(rimraf, "sync"); + await invokeHook(plugin, "func:remove:remove"); + expect(rimrafSpy).toBeCalledWith(functionName); + const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata(); + delete expectedFunctionsYml[functionName]; + expect(sls.utils.writeFileSync).toBeCalledWith("serverless.yml", MockFactory.createTestServerlessYml(true, expectedFunctionsYml)) + }); + }); +}); \ No newline at end of file diff --git a/src/plugins/func/azureFunc.ts b/src/plugins/func/azureFunc.ts new file mode 100644 index 00000000..00d1a212 --- /dev/null +++ b/src/plugins/func/azureFunc.ts @@ -0,0 +1,113 @@ +import fs from "fs"; +import path from "path"; +import rimraf from "rimraf"; +import Serverless from "serverless"; +import { FuncPluginUtils } from "./funcUtils"; + +export class AzureFuncPlugin { + public hooks: { [eventName: string]: Promise }; + public commands: any; + + + public constructor(private serverless: Serverless, private options: Serverless.Options) { + this.hooks = { + "func:func": this.func.bind(this), + "func:add:add": this.add.bind(this), + "func:remove:remove": this.remove.bind(this) + }; + + this.commands = { + func: { + usage: "Add or remove functions", + lifecycleEvents: [ + "func", + ], + commands: { + add: { + usage: "Add azure function", + lifecycleEvents: [ + "add", + ], + options: { + name: { + usage: "Name of function to add", + shortcut: "n", + } + } + }, + remove: { + usage: "Remove azure function", + lifecycleEvents: [ + "remove", + ], + options: { + name: { + usage: "Name of function to remove", + shortcut: "n", + } + } + } + } + } + } + } + + private async func() { + this.serverless.cli.log("Use the func plugin to add or remove functions within Function App"); + } + + private async add() { + if (!("name" in this.options)) { + this.serverless.cli.log("Need to provide a name of function to add"); + return; + } + const funcToAdd = this.options["name"] + const exists = fs.existsSync(funcToAdd); + if (exists) { + this.serverless.cli.log(`Function ${funcToAdd} already exists`); + return; + } + this.createFunctionDir(funcToAdd); + this.addToServerlessYml(funcToAdd); + } + + private createFunctionDir(name: string) { + this.serverless.cli.log("Creating function dir"); + try { + fs.mkdirSync(name); + } catch (e) { + this.serverless.cli.log(`Error making directory ${e}`); + } + this.serverless.utils.writeFileSync(path.join(name, "index.js"), FuncPluginUtils.getFunctionHandler(name)); + this.serverless.utils.writeFileSync(path.join(name, "function.json"), FuncPluginUtils.getFunctionJsonString(name, this.options)) + } + + private addToServerlessYml(name: string) { + this.serverless.cli.log("Adding to serverless.yml"); + const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless); + functionYml[name] = FuncPluginUtils.getFunctionSlsObject(name, this.options); + FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml); + } + + private async remove() { + if (!("name" in this.options)) { + this.serverless.cli.log("Need to provide a name of function to remove"); + return; + } + const funcToRemove = this.options["name"]; + const exists = fs.existsSync(funcToRemove); + if (!exists) { + this.serverless.cli.log(`Function ${funcToRemove} does not exist`); + return; + } + this.serverless.cli.log(`Removing ${funcToRemove}`); + rimraf.sync(funcToRemove); + await this.removeFromServerlessYml(funcToRemove); + } + + private async removeFromServerlessYml(name: string) { + const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless); + delete functionYml[name]; + FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml) + } +} \ No newline at end of file diff --git a/src/plugins/func/bindingTemplates/http.json b/src/plugins/func/bindingTemplates/http.json new file mode 100644 index 00000000..9f2d4d85 --- /dev/null +++ b/src/plugins/func/bindingTemplates/http.json @@ -0,0 +1,19 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} \ No newline at end of file diff --git a/src/plugins/func/funcUtils.test.ts b/src/plugins/func/funcUtils.test.ts new file mode 100644 index 00000000..83480457 --- /dev/null +++ b/src/plugins/func/funcUtils.test.ts @@ -0,0 +1,32 @@ +import { MockFactory } from "../../test/mockFactory"; +import { FuncPluginUtils } from "./funcUtils"; + +describe("Func Utils", () => { + + it("gets functions yml", () => { + const sls = MockFactory.createTestServerless(); + const funcYaml = FuncPluginUtils.getFunctionsYml(sls); + expect(funcYaml).toEqual(MockFactory.createTestFunctionsMetadata()); + }); + + it("updates functions yml", () => { + const updatedFunctions = MockFactory.createTestFunctionsMetadata(3); + const originalSls = MockFactory.createTestServerlessYml(false, 2); + const sls = MockFactory.createTestServerless(); + FuncPluginUtils.updateFunctionsYml(sls, updatedFunctions, originalSls); + const calls = (sls.utils.writeFileSync as any).mock.calls[0] + expect(calls[0]).toBe("serverless.yml"); + const expected = MockFactory.createTestServerlessYml( + true, + MockFactory.createTestFunctionsMetadata(3) + ); + expect(calls[1]).toBe(expected); + }); + + it("adds new function name to function handler", () => { + const name = "This is my function name" + const handler = FuncPluginUtils.getFunctionHandler(name); + expect(handler) + .toContain(`body: "${name} " + (req.query.name || req.body.name)`); + }); +}); \ No newline at end of file diff --git a/src/plugins/func/funcUtils.ts b/src/plugins/func/funcUtils.ts new file mode 100644 index 00000000..7d025d59 --- /dev/null +++ b/src/plugins/func/funcUtils.ts @@ -0,0 +1,77 @@ +import yaml from "js-yaml"; +import Serverless from "serverless"; +import httpBinding from "./bindingTemplates/http.json" + +export class FuncPluginUtils { + + public static getServerlessYml(sls: Serverless) { + return sls.utils.readFileSync("serverless.yml"); + } + + public static getFunctionsYml(sls: Serverless, serverlessYml?: any) { + serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls); + return serverlessYml["functions"]; + } + + public static updateFunctionsYml(sls: Serverless, functionYml: any, serverlessYml?: any) { + serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls); + serverlessYml["functions"] = functionYml; + sls.utils.writeFileSync("serverless.yml", yaml.dump(serverlessYml)); + } + + public static getFunctionHandler(name: string) { + return `"use strict"; + +module.exports.handler = async function (context, req) { + context.log("JavaScript HTTP trigger function processed a request."); + + if (req.query.name || (req.body && req.body.name)) { + context.res = { + // status: 200, /* Defaults to 200 */ + body: "${name} " + (req.query.name || req.body.name) + }; + } + else { + context.res = { + status: 400, + body: "Please pass a name on the query string or in the request body" + }; + } +};` + } + + public static getFunctionJsonString(name: string, options: any) { + // TODO: This is where we would just generate function JSON from SLS object + // using getFunctionSlsObject(name, options). Currently defaulting to http in and out + return JSON.stringify(httpBinding, null, 2); + } + + public static getFunctionSlsObject(name: string, options: any) { + return FuncPluginUtils.defaultFunctionSlsObject(name); + } + + private static defaultFunctionSlsObject(name: string) { + return { + handler: "index.handler", + events: FuncPluginUtils.httpEvents() + } + } + + private static httpEvents() { + return [ + { + http: true, + "x-azure-settings": { + authLevel: "anonymous" + } + }, + { + http: true, + "x-azure-settings": { + direction: "out", + name: "res" + } + }, + ] + } +} \ No newline at end of file diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 96ab9f06..ee4e3cfd 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,3 +1,4 @@ +import Serverless from "serverless"; import { BindingUtils } from "./bindings"; import { constants } from "./constants"; @@ -110,4 +111,11 @@ export class Utils { return metaData; } + + public static interpolateFile(sls: Serverless, path: string, params: Map) { + const template = sls.utils.readFileSync(path); + const names = params.keys(); + const vals = params.values(); + return new Function(...names, `return \`${template}\`;`)(...vals); + } } diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index ae3e18cf..5e3ad1a1 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -1,4 +1,5 @@ import { AuthResponse, LinkedSubscription, TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; +import yaml from "js-yaml"; import Serverless from "serverless"; import Service from "serverless/classes/Service"; import Utils = require("serverless/classes/Utils"); @@ -44,6 +45,50 @@ export class MockFactory { } } + public static createTestServerlessYml(asYaml = false, functionMetadata?) { + const data = { + "provider": { + "name": "azure", + "location": "West US 2" + }, + "plugins": [ + "serverless-azure-functions" + ], + "functions": functionMetadata || MockFactory.createTestFunctionsMetadata(2, false), + } + return (asYaml) ? yaml.dump(data) : data; + } + + public static createTestFunctionsMetadata(functionCount = 2, wrap = false) { + const data = {}; + for (let i = 0; i < functionCount; i++) { + const functionName = `function${i+1}`; + data[functionName] = MockFactory.createTestFunctionMetadata() + } + return (wrap) ? {"functions": data } : data; + } + + public static createTestFunctionMetadata() { + return { + "handler": "index.handler", + "events": [ + { + "http": true, + "x-azure-settings": { + "authLevel": "anonymous" + } + }, + { + "http": true, + "x-azure-settings": { + "direction": "out", + "name": "res" + } + } + ] + } + } + public static createTestFunctionApp() { return { id: "App Id", @@ -101,7 +146,11 @@ export class MockFactory { getVersion: jest.fn(), logStat: jest.fn(), readFile: jest.fn(), - readFileSync: jest.fn(), + readFileSync: jest.fn((filename) => { + if (filename === "serverless.yml") { + return MockFactory.createTestServerlessYml(); + } + }), walkDirSync: jest.fn(), writeFile: jest.fn(), writeFileDir: jest.fn(),