diff --git a/compile/servicebindings/README.md b/compile/servicebindings/README.md new file mode 100644 index 0000000..98a4912 --- /dev/null +++ b/compile/servicebindings/README.md @@ -0,0 +1,66 @@ +# Compile Packages + +This plugins compiles the packages in `serverless.yaml` to corresponding [OpenWhisk Packages](https://github.com/openwhisk/openwhisk/blob/master/docs/packages.md) +definitions. + +## How it works + +`Compile Packages` hooks into the [`package:compileEvents`](/lib/plugins/deploy) lifecycle. + +It loops over all packages which are defined in `serverless.yaml`. + +### Implicit Packages + +Actions can be assigned to packages by setting the function `name` with a package reference. + +```yaml +functions: + foo: + handler: handler.foo + name: "myPackage/foo" + bar: + handler: handler.bar + name: "myPackage/bar" +``` + +In this example, two new actions (`foo` & `bar`) will be created using the `myPackage` package. + +Packages which do not exist will be automatically created during deployments. When using the `remove` command, any packages referenced in the `serverless.yml` will be deleted. + +### Explicit Packages + +Packages can also be defined explicitly to set shared configuration parameters. Default package parameters are merged into event parameters for each invocation. + +```yaml +functions: + foo: + handler: handler.foo + name: "myPackage/foo" + +resources: + packages: + myPackage: + parameters: + hello: world +``` + +### Binding Packages + +OpenWhisk also supports "binding" external packages into your workspace. Bound packages can have default parameters set for shared actions. + +For example, binding the `/whisk.system/cloudant` package into a new package allows you to set default values for the `username`, `password` and `dbname` properties. Actions from this package can then be invoked with having to pass these parameters in. + +Define packages explicitly with a `binding` parameter to use this behaviour. + +```yaml +resources: + packages: + mySamples: + binding: /whisk.system/cloudant + parameters: + username: bernie + password: sanders + dbname: vermont +``` + +For more details on package binding, please see the documentation [here](https://github.com/apache/incubator-openwhisk/blob/master/docs/packages.md#creating-and-using-package-bindings). \ No newline at end of file diff --git a/compile/servicebindings/index.js b/compile/servicebindings/index.js new file mode 100644 index 0000000..4bb59bd --- /dev/null +++ b/compile/servicebindings/index.js @@ -0,0 +1,66 @@ +'use strict'; + +const BbPromise = require('bluebird'); + +class OpenWhiskCompileServiceBindings { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options; + this.provider = this.serverless.getProvider('openwhisk'); + + this.hooks = { + 'package:compileEvents': this.compileServiceBindings.bind(this) + }; + } + + calculateFunctionName(name, props) { + return props.name || `${this.serverless.service.service}_${name}`; + } + + parseServiceBindings(action, properties) { + const name = { action: this.calculateFunctionName(action, properties) } + const bindings = properties.bind || [] + const servicebindings = bindings.filter(b => b.service) + .map(b => Object.assign(b.service, name)) + + const serviceNames = new Set() + + for (let sb of servicebindings) { + if (!sb.hasOwnProperty('name')) { + throw new Error(`service binding missing name parameter: ${JSON.stringify(sb)}`) + } + + if (serviceNames.has(sb.name)) { + throw new Error(`multiple bindings for same service not supported: ${sb.name}`) + } + + serviceNames.add(sb.name) + } + + return servicebindings + } + + compileFnServiceBindings() { + return this.serverless.service.getAllFunctions() + .map(name => this.parseServiceBindings(name, this.serverless.service.getFunction(name))) + } + + compilePkgServiceBindings() { + const manifestResources = this.serverless.service.resources || {} + const packages = manifestResources.packages || {} + + return Object.keys(packages).map(name => this.parseServiceBindings(name, packages[name])) + } + + compileServiceBindings() { + this.serverless.cli.log('Compiling Service Bindings...'); + + const fnServiceBindings = this.compileFnServiceBindings() + const pkgServiceBindings = this.compilePkgServiceBindings() + + this.serverless.service.bindings = [].concat(...pkgServiceBindings, ...fnServiceBindings) + return BbPromise.resolve(); + } +} + +module.exports = OpenWhiskCompileServiceBindings; diff --git a/compile/servicebindings/tests/index.js b/compile/servicebindings/tests/index.js new file mode 100644 index 0000000..9e6996e --- /dev/null +++ b/compile/servicebindings/tests/index.js @@ -0,0 +1,167 @@ +'use strict'; + +const expect = require('chai').expect; +const chaiAsPromised = require('chai-as-promised'); + +require('chai').use(chaiAsPromised); + +const sinon = require('sinon'); +const OpenWhiskCompileServiceBindings = require('../index'); + +describe('OpenWhiskCompileServiceBindings', () => { + let serverless; + let sandbox; + let openwhiskCompileServiceBindings; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + serverless = {classes: {Error}, service: {provider: {}, resources: {}, getAllFunctions: () => []}, getProvider: sandbox.spy()}; + const options = { + stage: 'dev', + region: 'us-east-1', + }; + openwhiskCompileServiceBindings = new OpenWhiskCompileServiceBindings(serverless, options); + serverless.service.service = 'serviceName'; + serverless.service.provider = { + namespace: 'testing', + apihost: '', + auth: '', + }; + + serverless.cli = { consoleLog: () => {}, log: () => {} }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#parseServiceBindings()', () => { + it('should return empty array when missing service bindings', () => { + const action = 'fnName' + expect(openwhiskCompileServiceBindings.parseServiceBindings(action, {})).to.deep.equal([]) + expect(openwhiskCompileServiceBindings.parseServiceBindings(action, {bind: []})).to.deep.equal([]) + expect(openwhiskCompileServiceBindings.parseServiceBindings(action, {bind: [{}]})).to.deep.equal([]) + expect(openwhiskCompileServiceBindings.parseServiceBindings(action, {bind: [{blah: {}}]})).to.deep.equal([]) + }) + + it('should return array with single service binding property', () => { + const action = 'fnName' + const service = { name: 'my-service', instance: 'my-instance', key: 'mykey' } + const response = { action: `serviceName_fnName`, name: 'my-service', instance: 'my-instance', key: 'mykey' } + const result = openwhiskCompileServiceBindings.parseServiceBindings(action, {bind: [{ service }]}) + expect(result).to.deep.equal([response]) + }) + + it('should return array with multiple service binding properties', () => { + const action = 'fnName' + const service_a = { action: `serviceName_fnName`, name: 'my-service-a', instance: 'my-instance-a', key: 'mykey' } + const service_b = { action: `serviceName_fnName`, name: 'my-service-b', instance: 'my-instance-b', key: 'mykey' } + const service_c = { action: `serviceName_fnName`, name: 'my-service-c', instance: 'my-instance-c', key: 'mykey' } + const services = [{ service: service_a }, { service: service_b }, { service: service_c } ] + const result = openwhiskCompileServiceBindings.parseServiceBindings(action, {bind: services}) + expect(result).to.deep.equal([service_a, service_b, service_c]) + }) + + it('should throw an error if service binding is missing name', () => { + const service = { instance: 'my-instance-a', key: 'mykey' } + const action = 'fnName' + const services = [{ service }] + expect(() => openwhiskCompileServiceBindings.parseServiceBindings(action, {bind: services})) + .to.throw(Error, /service binding missing name parameter/); + }); + + it('should throw an error if multiple bindings for same service name', () => { + const action = 'fnName' + const service = { name: 'my-service', instance: 'my-instance-a', key: 'mykey' } + const services = [{ service }, { service }] + expect(() => openwhiskCompileServiceBindings.parseServiceBindings(action, {bind: services})) + .to.throw(Error, /multiple bindings for same service not supported/); + }); + }) + + describe('#compileServiceBindings()', () => { + it('should return service bindings for simple functions', () => { + const fns = { + a: { bind: [{ service: { name: 'service-name-a' } }] }, + b: { bind: [{ service: { name: 'service-name-b', instance: 'instance-name' } }] }, + c: { bind: [{ service: { name: 'service-name-a' } }, { service: { name: 'service-name-b' } }] }, + d: { }, + } + + const service = openwhiskCompileServiceBindings.serverless.service + service.getAllFunctions = () => Object.keys(fns) + service.getFunction = name => fns[name] + + const services = [ + { action: 'serviceName_a', name: 'service-name-a' }, + { action: 'serviceName_b', name: 'service-name-b', instance: 'instance-name' }, + { action: 'serviceName_c', name: 'service-name-a' }, + { action: 'serviceName_c', name: 'service-name-b' } + ] + return openwhiskCompileServiceBindings.compileServiceBindings().then(result => { + expect(service.bindings).to.deep.equal(services) + }) + }) + + it('should return service bindings for functions with explicit name', () => { + const fns = { + a: { name: 'some_name', bind: [{ service: { name: 'service-name-a' } }] } + } + + const service = openwhiskCompileServiceBindings.serverless.service + service.getAllFunctions = () => Object.keys(fns) + service.getFunction = name => fns[name] + + const services = [ { action: 'some_name', name: 'service-name-a' } ] + return openwhiskCompileServiceBindings.compileServiceBindings().then(result => { + expect(service.bindings).to.deep.equal(services) + }) + }) + + + it('should return service bindings for packages', () => { + const service = openwhiskCompileServiceBindings.serverless.service + service.resources.packages = { + a: { bind: [{ service: { name: 'service-name-a' } }] }, + b: { bind: [{ service: { name: 'service-name-b', instance: 'instance-name' } }] }, + c: { bind: [{ service: { name: 'service-name-a' } }, { service: { name: 'service-name-b' } }] }, + d: { }, + } + + const services = [ + { action: 'serviceName_a', name: 'service-name-a' }, + { action: 'serviceName_b', name: 'service-name-b', instance: 'instance-name' }, + { action: 'serviceName_c', name: 'service-name-a' }, + { action: 'serviceName_c', name: 'service-name-b' } + ] + + return openwhiskCompileServiceBindings.compileServiceBindings().then(() => { + expect(service.bindings).to.deep.equal(services); + }); + }); + + it('should return service bindings for functions & packages', () => { + const service = openwhiskCompileServiceBindings.serverless.service; + service.resources.packages = { + a: { bind: [{ service: { name: 'service-name-a' } }] } + }; + + const fns = { + b: { bind: [{ service: { name: 'service-name-b', instance: 'instance-name' } }] }, + } + + service.getAllFunctions = () => Object.keys(fns) + service.getFunction = name => fns[name] + + const services = [ + { action: 'serviceName_a', name: 'service-name-a' }, + { action: 'serviceName_b', name: 'service-name-b', instance: 'instance-name' } + ] + + return openwhiskCompileServiceBindings.compileServiceBindings().then(() => { + expect(service.bindings).to.deep.equal(services); + }); + + }) + }) +}); diff --git a/deploy/index.js b/deploy/index.js index c538580..f5e759f 100644 --- a/deploy/index.js +++ b/deploy/index.js @@ -8,6 +8,7 @@ const deployRules = require('./lib/deployRules'); const deployTriggers = require('./lib/deployTriggers'); const deployFeeds = require('./lib/deployFeeds'); const deployApiGw = require('./lib/deployApiGw'); +const deployServiceBindings = require('./lib/deployServiceBindings'); class OpenWhiskDeploy { constructor(serverless, options) { @@ -23,7 +24,8 @@ class OpenWhiskDeploy { deployApiGw, deployRules, deployTriggers, - deployFeeds + deployFeeds, + deployServiceBindings ); this.hooks = { @@ -37,6 +39,7 @@ class OpenWhiskDeploy { .then(this.deployTriggers) .then(this.deployFeeds) .then(this.deployRules) + .then(this.configureServiceBindings) .then(() => this.serverless.cli.log('Deployment successful!')), }; } diff --git a/deploy/lib/deployServiceBindings.js b/deploy/lib/deployServiceBindings.js new file mode 100644 index 0000000..ff2f0e8 --- /dev/null +++ b/deploy/lib/deployServiceBindings.js @@ -0,0 +1,81 @@ +'use strict'; + +const BbPromise = require('bluebird'); +const { spawn } = require('child_process'); + +module.exports = { + configureServiceBinding(binding) { + if (this.options.verbose) { + this.serverless.cli.log(`Configuring Service Binding: ${binding}`); + } + + return new Promise((resolve, reject) => { + const args = ['wsk', 'service', 'bind', binding.name, binding.action] + + if (binding.instance) { + args.push("--instance", binding.instance) + } + + if (binding.key) { + args.push("--keyname", binding.key) + } + + const bx = spawn('bx', args); + + const stdout = [] + const stderr = [] + + bx.stdout.on('data', data => { + stdout.push(data.toString()) + }); + + bx.stderr.on('data', (data) => { + stderr.push(data.toString()) + }); + + bx.on('error', (err) => { + if (err.code === 'ENOENT') { + const err = new this.serverless.classes.Error( + 'Unable to execute `bx wsk service bind` command. Is IBM Cloud CLI installed?' + ) + return reject(err) + } + reject(err.message) + }); + + bx.on('close', (code) => { + if (code === 2) { + const err = new this.serverless.classes.Error( + 'Unable to execute `bx wsk service bind` command. Is IBM Cloud Functions CLI plugin installed?' + ) + return reject(err) + } + if (code > 0) { + const errmsg = (stderr[0] || '').split('\n')[0] + const err = new this.serverless.classes.Error(`Failed to configure service binding (${JSON.stringify(binding)})\n ${errmsg}`); + return reject(err) + } + if (this.options.verbose) { + this.serverless.cli.log(`Configured Service Binding: ${binding}`); + } + resolve() + }); + }); + }, + + configureServiceBindings() { + const bindings = this.getServiceBindings(); + + if (bindings.length) { + this.serverless.cli.log('Configuring Service Bindings...'); + } + + return BbPromise.all( + bindings.map(sb => this.configureServiceBinding(sb)) + ); + }, + + getServiceBindings() { + return this.serverless.service.bindings || []; + } +}; diff --git a/deploy/tests/all.js b/deploy/tests/all.js index bc276b8..160c0e1 100644 --- a/deploy/tests/all.js +++ b/deploy/tests/all.js @@ -8,4 +8,5 @@ require('./deployPackages'); require('./deployTriggers'); require('./deployFeeds'); require('./deployApiGw'); +require('./deployServiceBindings'); require('./index'); diff --git a/deploy/tests/deployServiceBindings.js b/deploy/tests/deployServiceBindings.js new file mode 100644 index 0000000..92f4949 --- /dev/null +++ b/deploy/tests/deployServiceBindings.js @@ -0,0 +1,70 @@ +'use strict'; + +const expect = require('chai').expect; +const OpenWhiskDeploy = require('../index'); +const sinon = require('sinon'); +const chaiAsPromised = require('chai-as-promised'); + +require('chai').use(chaiAsPromised); + +describe('deployServiceBindings', () => { + let serverless; + let openwhiskDeploy; + let sandbox; + + const mockPackageObject = { + packages: { + myPackage: { + name: 'myPackage', + namespace: 'myNamespace' + }, + }, + }; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + const CLI = function () { this.log = function () {};}; + serverless = {classes: {Error, CLI}, service: {provider: {}, resources: {}, getAllFunctions: () => []}, getProvider: sandbox.spy()}; + const options = { + stage: 'dev', + region: 'us-east-1', + }; + openwhiskDeploy = new OpenWhiskDeploy(serverless, options); + openwhiskDeploy.serverless.cli = { consoleLog: () => {}, log: () => {} }; + openwhiskDeploy.serverless.service.provider = { + namespace: 'testing', + apihost: 'openwhisk.org', + auth: 'user:pass', + }; + openwhiskDeploy.provider = { client: () => {} } + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#configureServiceBindings()', () => { + it('should call binding command for each binding and return when all finish', () => { + const bindings = [{name: 'a'}, {name: 'a'}, {name: 'a'}] + openwhiskDeploy.serverless.service.bindings = bindings + sandbox.stub(openwhiskDeploy, 'configureServiceBinding', () => { + return Promise.resolve(); + }); + return openwhiskDeploy.configureServiceBindings().then(results => { + expect(results.length).to.equal(bindings.length) + }) + }); + + it('should reject when function handler fails to deploy with error message', () => { + const bindings = [{name: 'a'}, {name: 'a'}, {name: 'a'}] + openwhiskDeploy.serverless.service.bindings = bindings + const err = { message: 'some reason' }; + sandbox.stub(openwhiskDeploy, 'configureServiceBinding', () => { + return Promise.reject(err); + }); + + return expect(openwhiskDeploy.configureServiceBindings()) + .to.eventually.be.rejectedWith(new RegExp(`${err.message}`)); + }); + }); +}); diff --git a/index.js b/index.js index 85aa08f..880a6f4 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ const CompileHttpEvents = require('./compile/apigw/index.js'); const CompileSchedule = require('./compile/schedule/index.js'); const CompileMessageHub = require('./compile/message_hub/index.js'); const CompileCloudant = require('./compile/cloudant/index.js'); +const CompileServiceBindings = require('./compile/servicebindings/index.js'); const Deploy = require('./deploy/index.js'); const Invoke = require('./invoke/index.js'); const InvokeLocal = require('./invokeLocal/index.js'); @@ -38,6 +39,7 @@ class Index { this.serverless.pluginManager.addPlugin(CompileSchedule); this.serverless.pluginManager.addPlugin(CompileMessageHub); this.serverless.pluginManager.addPlugin(CompileCloudant); + this.serverless.pluginManager.addPlugin(CompileServiceBindings); this.serverless.pluginManager.addPlugin(Remove); this.serverless.pluginManager.addPlugin(Invoke); this.serverless.pluginManager.addPlugin(InvokeLocal); diff --git a/tests/all.js b/tests/all.js index 043b295..b727047 100644 --- a/tests/all.js +++ b/tests/all.js @@ -1,4 +1,5 @@ // OpenWhisk Plugins Tests +require('../compile/servicebindings/tests'); require('../compile/packages/tests'); require('../compile/triggers/tests'); require('../compile/rules/tests');