Skip to content

Add in missing serverless commands #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ on:
types: [published]

jobs:

publish:
name: Publish to NPM
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ npm-debug.log
package-lock.json
yarn.lock
node_modules
.serverless
.serverless
.vscode
81 changes: 62 additions & 19 deletions components/framework/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand All @@ -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]) => {
Expand All @@ -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() {
Expand All @@ -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());
Expand Down Expand Up @@ -118,9 +153,7 @@ class ServerlessFramework {

async package() {
this.context.startProgress('packaging');

await this.exec('serverless', ['package']);

this.context.successProgress('packaged');
}

Expand Down Expand Up @@ -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);
Expand All @@ -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' },
});

Expand All @@ -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());
Expand All @@ -271,17 +311,20 @@ class ServerlessFramework {
}
});
}

if (child.stderr) {
child.stderr.on('data', (data) => {
this.context.logVerbose(data.toString().trim());
stderr += data;
allOutput += data;
});
}

child.on('error', (err) => {
process.removeListener('exit', processExitCallback);
reject(err);
});

child.on('close', (code) => {
process.removeListener('exit', processExitCallback);
if (code !== 0) {
Expand Down
2 changes: 1 addition & 1 deletion scripts/pkg/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
81 changes: 50 additions & 31 deletions src/ComponentsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading