diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45242a2..88483b9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,6 @@ on: types: [published] jobs: - publish: name: Publish to NPM runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7193114..84cb834 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ npm-debug.log package-lock.json yarn.lock node_modules -.serverless \ No newline at end of file +.serverless +.vscode \ No newline at end of file diff --git a/components/framework/index.js b/components/framework/index.js index 5c79b6d..b82e115 100644 --- a/components/framework/index.js +++ b/components/framework/index.js @@ -15,6 +15,32 @@ const MINIMAL_FRAMEWORK_VERSION = '3.7.7'; const doesSatisfyRequiredFrameworkVersion = (version) => semver.gte(version, MINIMAL_FRAMEWORK_VERSION); +/** + * Commands with their appropriate verbs when executed. + */ +const commandTextMap = new Map([ + ['deploy:function', ['deploying function', 'deployed']], + ['deploy:list', ['listing deployments', 'listed']], + ['rollback:function', ['rolling back', 'rolled back']], + ['invoke', ['invoking', 'invoked']], + ['invoke:local', ['invoking', 'invoked']], +]); + +/** + * Rather than do boilerplate, just create a command with a given name and + * forward that directly on to serverless. + */ +const commandsMemo = (cmdNames, inst) => + cmdNames.reduce( + (iter, cmdName) => ({ + ...iter, + [cmdName]: { + handler: (options) => inst.command(cmdName, options), + }, + }), + {} + ); + class ServerlessFramework { /** * @param {string} id @@ -26,6 +52,10 @@ class ServerlessFramework { this.inputs = inputs; this.context = context; + this.commands = { + ...commandsMemo(Array.from(commandTextMap.keys()), this), + }; + if (path.relative(process.cwd(), inputs.path) === '') { throw new ServerlessError( `Service "${id}" cannot have a "path" that points to the root directory of the Serverless Framework Compose project`, @@ -34,20 +64,20 @@ class ServerlessFramework { } } - // TODO: - // Component-specific commands - // In the long run, they should be generated based on configured command schema - // and options schema for each command - // commands = { - // print: { - // handler: async () => await this.command(['print']), - // }, - // package: { - // handler: async () => await this.command(['package']), - // }, - // }; - // For now the workaround is to just pray that the command is correct and rely on validation from the Framework - async command(command, options) { + /** + * Runs the command with specified parameters using the serverless CLI command. + * @param {string} command The command name + * @param {object} options The command line options + * @returns Promise The result of the execution of the CLI command. + */ + async command(command, options = {}) { + const [startText, endText] = commandTextMap.get(command) || [null, null]; + + // If it includes functionName, use that as it looks nicer and is clearer.. + const appendText = options.function ? ` (${options.function}) ` : ''; + + if (startText) this.context.startProgress(`${startText}${appendText}`); + const cliparams = Object.entries(options) .filter(([key]) => key !== 'stage') .flatMap(([key, value]) => { @@ -62,8 +92,12 @@ class ServerlessFramework { } return `--${key}=${value}`; }); + const args = [...command.split(':'), ...cliparams]; - return await this.exec('serverless', args, true); + const result = await this.exec('serverless', args, true); + + if (endText) this.context.successProgress(`${endText}${appendText}`); + return result; } async deploy() { @@ -87,6 +121,7 @@ class ServerlessFramework { const hasOutputs = this.context.outputs && Object.keys(this.context.outputs).length > 0; const hasChanges = !deployOutput.includes('No changes to deploy. Deployment skipped.'); + // Skip retrieving outputs via `sls info` if we already have outputs (faster) if (hasChanges || !hasOutputs) { await this.context.updateOutputs(await this.retrieveOutputs()); @@ -118,9 +153,7 @@ class ServerlessFramework { async package() { this.context.startProgress('packaging'); - await this.exec('serverless', ['package']); - this.context.successProgress('packaged'); } @@ -217,12 +250,18 @@ class ServerlessFramework { } /** - * @return {Promise<{ stdout: string, stderr: string }>} + * Executes the serverless CLI command with argumants. + * @param {string} command The command (e.g. deploy) + * @param {array} args The command line arguments + * @param {boolean} streamStdout Should the stdout be streamed? + * @param {function} stdoutCallback Function to call when stdout is received + * @returns */ async exec(command, args, streamStdout = false, stdoutCallback = undefined) { await this.ensureFrameworkVersion(); // Add stage args.push('--stage', this.context.stage); + // Add config file name if necessary if (this.inputs && this.inputs.config) { args.push('--config', this.inputs.config); @@ -248,7 +287,7 @@ class ServerlessFramework { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: this.inputs.path, - stdio: streamStdout ? 'inherit' : undefined, + stdio: streamStdout ? 'pipe' : null, env: { ...process.env, SLS_DISABLE_AUTO_UPDATE: '1', SLS_COMPOSE: '1' }, }); @@ -261,6 +300,7 @@ class ServerlessFramework { let stdout = ''; let stderr = ''; let allOutput = ''; + if (child.stdout) { child.stdout.on('data', (data) => { this.context.logVerbose(data.toString().trim()); @@ -271,6 +311,7 @@ class ServerlessFramework { } }); } + if (child.stderr) { child.stderr.on('data', (data) => { this.context.logVerbose(data.toString().trim()); @@ -278,10 +319,12 @@ class ServerlessFramework { allOutput += data; }); } + child.on('error', (err) => { process.removeListener('exit', processExitCallback); reject(err); }); + child.on('close', (code) => { process.removeListener('exit', processExitCallback); if (code !== 0) { diff --git a/scripts/pkg/build.js b/scripts/pkg/build.js index 768dfbf..4e13896 100644 --- a/scripts/pkg/build.js +++ b/scripts/pkg/build.js @@ -29,7 +29,7 @@ const spawnOptions = { cwd: componentsPath, stdio: 'inherit' }; 'node16-linux-x64,node16-mac-x64', '--out-path', 'dist', - 'bin/bin', + 'bin/serverless-compose', ], spawnOptions ); diff --git a/src/ComponentsService.js b/src/ComponentsService.js index 7a59cae..7e4a7ef 100644 --- a/src/ComponentsService.js +++ b/src/ComponentsService.js @@ -9,7 +9,6 @@ const ServerlessError = require('./serverless-error'); const utils = require('./utils'); const { loadComponent } = require('./load'); const colors = require('./cli/colors'); -const ServerlessFramework = require('../components/framework'); const INTERNAL_COMPONENTS = { 'serverless-framework': resolve(__dirname, '../components/framework'), @@ -381,6 +380,50 @@ class ComponentsService { await this[method](options); } + /** + * Gets the relevant handler function required. Either the handler is on the class, it is in the "commands" property + * or it is in the commands property with a handler. + */ + getHandlerCommand(command, component, componentName) { + const isInternalCommand = [ + 'deploy', + 'deploy:function', + 'remove', + 'logs', + 'info', + 'package', + ].includes(command); + + const hasComponentCommands = component && component.commands; + + // No optional chaining is a real slap in the face here + const usableCommands = [ + component[command], + hasComponentCommands && component.commands[command] && component.commands[command].handler, + hasComponentCommands && component.commands[command], + ]; + + // If there are no usable functions, it's game over. + if (!usableCommands.some((c) => typeof c === 'function')) { + throw new ServerlessError( + `No method "${command}" on service "${componentName}"`, + 'COMPONENT_COMMAND_NOT_FOUND' + ); + } + + const [internalCmd, extCmd, extCmdHandler] = usableCommands; + const internalBound = internalCmd ? internalCmd.bind(component) : null; + return isInternalCommand + ? internalBound || extCmd || extCmdHandler + : extCmd || extCmdHandler || internalBound; + } + + /** + * Invokes a command for a given component. + * @param {string} componentName The name of the component + * @param {string} command The name of the command (internal or plugin) + * @param {object} options The command line options passed to the function. + */ async invokeComponentCommand(componentName, command, options) { // We can have commands that do not have to call commands directly on the component, // but are global commands that can accept the componentName parameter @@ -392,44 +435,20 @@ class ComponentsService { } else { await this.instantiateComponents(); + // No optional chaining is fun. const component = this.allComponents && this.allComponents[componentName] && this.allComponents[componentName].instance; + if (component === undefined) { throw new ServerlessError(`Unknown service "${componentName}"`, 'COMPONENT_NOT_FOUND'); } - this.context.logVerbose(`Invoking "${command}" on service "${componentName}"`); - - const isDefaultCommand = ['deploy', 'remove', 'logs', 'info', 'package'].includes(command); - if (isDefaultCommand) { - // Default command defined for all components (deploy, logs, dev, etc.) - if (!component || !component[command]) { - throw new ServerlessError( - `No method "${command}" on service "${componentName}"`, - 'COMPONENT_COMMAND_NOT_FOUND' - ); - } - handler = (opts) => component[command](opts); - } else if ( - (!component || !component.commands || !component.commands[command]) && - component instanceof ServerlessFramework - ) { - // Workaround to invoke all custom Framework commands - // TODO: Support options and validation - handler = (opts) => component.command(command, opts); - } else { - // Custom command: the handler is defined in the component's `commands` property - if (!component || !component.commands || !component.commands[command]) { - throw new ServerlessError( - `No command "${command}" on service ${componentName}`, - 'COMPONENT_COMMAND_NOT_FOUND' - ); - } - const commandHandler = component.commands[command].handler; - handler = (opts) => commandHandler.call(component, opts); - } + this.context.logVerbose( + `Invoking "${command.replaceAll(':', '')}" on service "${componentName}"` + ); + handler = this.getHandlerCommand(command, component, componentName); } try { diff --git a/src/index.js b/src/index.js index 808a69b..4de57b4 100644 --- a/src/index.js +++ b/src/index.js @@ -59,7 +59,7 @@ const runComponents = async () => { } else if (method.includes(':')) { let methods; [componentName, ...methods] = method.split(':'); - method = methods.join(':'); + method = methods.join(':').replace(':', ''); } delete options._; // remove the method name if any diff --git a/test/unit/components/framework/index.test.js b/test/unit/components/framework/index.test.js index f1de072..5d44bd6 100644 --- a/test/unit/components/framework/index.test.js +++ b/test/unit/components/framework/index.test.js @@ -8,7 +8,7 @@ const ComponentContext = require('../../../../src/ComponentContext'); const { validateComponentInputs } = require('../../../../src/configuration/validate'); const { configSchema } = require('../../../../components/framework/configuration'); const ServerlessFramework = require('../../../../components/framework'); - +const { describe, it } = require('mocha'); // Configure chai chai.use(require('chai-as-promised')); chai.use(require('sinon-chai')); @@ -65,6 +65,210 @@ describe('test/unit/components/framework/index.test.js', () => { expect(context.outputs).to.deep.equal({ Key: 'Output' }); }); + it('correctly handles deploy function', async () => { + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + stdout: { + on: (arg, cb) => { + const data = 'region: us-east-1\n\nStack Outputs:\n Key: Output'; + if (arg === 'data') cb(data); + }, + }, + kill: () => {}, + }); + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + await component.command('deploy:function', { + function: 'testFunction', + stage: 'test', + }); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.firstCall.firstArg).to.equal('serverless'); + expect(spawnStub.firstCall.args[1]).to.deep.equal([ + 'deploy', + 'function', + '--function=testFunction', + '--stage', + 'dev', + ]); + expect(spawnStub.firstCall.lastArg.cwd).to.equal('path'); + expect(context.state).to.deep.equal({ detectedFrameworkVersion: '9.9.9' }); + expect(context.outputs).to.deep.equal({ Key: 'Output' }); + }); + + it('correctly handles deploy list', async () => { + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + stdout: { + on: (arg, cb) => { + const data = 'region: us-east-1\n\nStack Outputs:\n Key: Output'; + if (arg === 'data') cb(data); + }, + }, + kill: () => {}, + }); + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + await component.command('deploy:list', { + 'region': 'eu-west-1', + 'aws-profile': 'zibbidy', + 'stage': 'test', + }); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.firstCall.firstArg).to.equal('serverless'); + expect(spawnStub.firstCall.args[1]).to.deep.equal([ + 'deploy', + 'list', + '--region=eu-west-1', + '--aws-profile=zibbidy', + '--stage', + 'dev', + ]); + expect(spawnStub.firstCall.lastArg.cwd).to.equal('path'); + expect(context.state).to.deep.equal({ detectedFrameworkVersion: '9.9.9' }); + expect(context.outputs).to.deep.equal({ Key: 'Output' }); + }); + + it('correctly handles rollback function', async () => { + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + stdout: { + on: (arg, cb) => { + const data = 'region: us-east-1\n\nStack Outputs:\n Key: Output'; + if (arg === 'data') cb(data); + }, + }, + kill: () => {}, + }); + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + await component.command('rollback:function', { + function: 'testFunction', + stage: 'test', + }); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.firstCall.firstArg).to.equal('serverless'); + expect(spawnStub.firstCall.args[1]).to.deep.equal([ + 'rollback', + 'function', + '--function=testFunction', + '--stage', + 'dev', + ]); + expect(spawnStub.firstCall.lastArg.cwd).to.equal('path'); + expect(context.state).to.deep.equal({ detectedFrameworkVersion: '9.9.9' }); + expect(context.outputs).to.deep.equal({ Key: 'Output' }); + }); + + it('correctly handles invoke', async () => { + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + stdout: { + on: (arg, cb) => { + const data = 'region: us-east-1\n\nStack Outputs:\n Key: Output'; + if (arg === 'data') cb(data); + }, + }, + kill: () => {}, + }); + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + await component.command('invoke', { + 'function': 'testFunction', + 'stage': 'test', + 'region': 'eu-west-1', + 'aws-profile': 'test', + }); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.firstCall.firstArg).to.equal('serverless'); + expect(spawnStub.firstCall.args[1]).to.deep.equal([ + 'invoke', + '--function=testFunction', + '--region=eu-west-1', + '--aws-profile=test', + '--stage', + 'dev', + ]); + expect(spawnStub.firstCall.lastArg.cwd).to.equal('path'); + expect(context.state).to.deep.equal({ detectedFrameworkVersion: '9.9.9' }); + expect(context.outputs).to.deep.equal({ Key: 'Output' }); + }); + + it('correctly handles invoke', async () => { + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + stdout: { + on: (arg, cb) => { + const data = 'region: us-east-1\n\nStack Outputs:\n Key: Output'; + if (arg === 'data') cb(data); + }, + }, + kill: () => {}, + }); + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + await component.command('invoke:local', { + 'function': 'testFunction', + 'stage': 'test', + 'region': 'eu-west-1', + 'aws-profile': 'test', + }); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.firstCall.firstArg).to.equal('serverless'); + expect(spawnStub.firstCall.args[1]).to.deep.equal([ + 'invoke', + 'local', + '--function=testFunction', + '--region=eu-west-1', + '--aws-profile=test', + '--stage', + 'dev', + ]); + expect(spawnStub.firstCall.lastArg.cwd).to.equal('path'); + expect(context.state).to.deep.equal({ detectedFrameworkVersion: '9.9.9' }); + expect(context.outputs).to.deep.equal({ Key: 'Output' }); + }); + it('correctly handles package', async () => { const spawnStub = sinon.stub().returns({ on: (arg, cb) => {