UNPKG

serverless

Version:

Serverless Framework - Build web, mobile and IoT applications with serverless architectures using AWS Lambda, Azure Functions, Google CloudFunctions & more

670 lines (601 loc) • 23.1 kB
'use strict'; const path = require('path'); const config = require('@serverless/utils/config'); const cjsResolve = require('ncjsm/resolve/sync'); const BbPromise = require('bluebird'); const _ = require('lodash'); const crypto = require('crypto'); const isModuleNotFoundError = require('ncjsm/is-module-not-found-error'); const writeFile = require('../utils/fs/writeFile'); const getCacheFilePath = require('../utils/getCacheFilePath'); const serverlessConfigFileUtils = require('../utils/getServerlessConfigFile'); const getCommandSuggestion = require('../utils/getCommandSuggestion'); const requireServicePlugin = (servicePath, pluginPath, localPluginsPath) => { if (localPluginsPath && !pluginPath.startsWith('./')) { // TODO (BREAKING): Consider removing support for localPluginsPath with next major const absoluteLocalPluginPath = path.resolve(localPluginsPath, pluginPath); try { return require(absoluteLocalPluginPath); } catch (error) { if (!isModuleNotFoundError(error, absoluteLocalPluginPath)) throw error; } } try { return require(cjsResolve(servicePath, pluginPath).realPath); } catch (error) { if (!isModuleNotFoundError(error, pluginPath) || pluginPath.startsWith('.')) { throw error; } } // Search in "node_modules" in which framework is placed return require(cjsResolve(__dirname, pluginPath).realPath); }; /** * @private * Error type to terminate the currently running hook chain successfully without * executing the rest of the current command's lifecycle chain. */ class TerminateHookChain extends Error { constructor(commands) { const commandChain = commands.join(':'); const message = `Terminating ${commandChain}`; super(message); this.message = message; this.name = 'TerminateHookChain'; } } class PluginManager { constructor(serverless) { this.serverless = serverless; this.serverlessConfigFile = null; this.cliOptions = {}; this.cliCommands = []; this.pluginIndependentCommands = new Set(['help', 'plugin']); this.plugins = []; this.externalPlugins = new Set(); this.commands = {}; this.aliases = {}; this.hooks = {}; this.deprecatedEvents = {}; } loadConfigFile() { return serverlessConfigFileUtils.getServerlessConfigFile(this.serverless).then( (serverlessConfigFile) => { this.serverlessConfigFile = serverlessConfigFile; return; }, (error) => { if (this.serverless.cli.isHelpRequest(this.serverless.processedInput)) { this.serverless.config.servicePath = null; return null; } throw error; } ); } setCliOptions(options) { this.cliOptions = options; } setCliCommands(commands) { this.cliCommands = commands; } addPlugin(Plugin, options = { isExternal: false }) { const pluginInstance = new Plugin(this.serverless, this.cliOptions); // isExternal differentiates plugin as OOTB or not if (options.isExternal) { this.externalPlugins.add(pluginInstance); } let pluginProvider = null; // check if plugin is provider agnostic if (pluginInstance.provider) { if (typeof pluginInstance.provider === 'string') { pluginProvider = pluginInstance.provider; } else if (_.isObject(pluginInstance.provider)) { pluginProvider = pluginInstance.provider.constructor.getProviderName(); } } // ignore plugins that specify a different provider than the current one if (pluginProvider && pluginProvider !== this.serverless.service.provider.name) { return; } // don't load plugins twice if (this.plugins.some((plugin) => plugin instanceof Plugin)) { this.serverless.cli.log(`WARNING: duplicate plugin ${Plugin.name} was not loaded\n`); return; } this.loadCommands(pluginInstance, options); this.loadHooks(pluginInstance); this.loadVariableResolvers(pluginInstance); this.plugins.push(pluginInstance); } loadAllPlugins(servicePlugins) { const EnterprisePlugin = this.resolveEnterprisePlugin(); require('../plugins') .filter(Boolean) .forEach((Plugin) => this.addPlugin(Plugin)); this.resolveServicePlugins(servicePlugins) .filter(Boolean) .forEach((Plugin) => this.addPlugin(Plugin, { isExternal: true })); if (EnterprisePlugin) this.addPlugin(EnterprisePlugin); return this.asyncPluginInit(); } resolveServicePlugins(servicePlugs) { const pluginsObject = this.parsePluginsObject(servicePlugs); const servicePath = this.serverless.config.servicePath; return pluginsObject.modules .filter((name) => name !== '@serverless/enterprise-plugin') .map((name) => { let Plugin; try { Plugin = requireServicePlugin(servicePath, name, pluginsObject.localPath); } catch (error) { if (!isModuleNotFoundError(error, name)) throw error; // Plugin not installed if (this.cliOptions.help || this.pluginIndependentCommands.has(this.cliCommands[0])) { // User may intend to install plugins just listed in serverless config // Therefore skip on MODULE_NOT_FOUND case return null; } throw new this.serverless.classes.Error( [ `Serverless plugin "${name}" not found.`, ' Make sure it\'s installed and listed in the "plugins" section', ' of your serverless config file.', ].join('') ); } if (!Plugin) { throw new this.serverless.classes.Error( `Serverless plugin "${name}", didn't export Plugin constructor.` ); } return Plugin; }); } resolveEnterprisePlugin() { if (config.getGlobalConfig().enterpriseDisabled) return null; this.pluginIndependentCommands.add('login').add('logout').add('dashboard'); return require('@serverless/enterprise-plugin'); } parsePluginsObject(servicePlugs) { let localPath = this.serverless && this.serverless.config && this.serverless.config.servicePath && path.join(this.serverless.config.servicePath, '.serverless_plugins'); let modules = []; if (Array.isArray(servicePlugs)) { modules = servicePlugs; } else if (servicePlugs) { localPath = servicePlugs.localPath && typeof servicePlugs.localPath === 'string' ? servicePlugs.localPath : localPath; if (Array.isArray(servicePlugs.modules)) { modules = servicePlugs.modules; } } return { modules, localPath }; } createCommandAlias(alias, command) { // Deny self overrides if (command.startsWith(alias)) { throw new this.serverless.classes.Error(`Command "${alias}" cannot be overriden by an alias`); } const splitAlias = alias.split(':'); const aliasTarget = splitAlias.reduce((__, aliasPath) => { const currentAlias = __; if (!currentAlias[aliasPath]) { currentAlias[aliasPath] = {}; } return currentAlias[aliasPath]; }, this.aliases); // Check if the alias is already defined if (aliasTarget.command) { throw new this.serverless.classes.Error( `Alias "${alias}" is already defined for command ${aliasTarget.command}` ); } // Check if the alias would overwrite an exiting command if ( splitAlias.reduce((__, aliasPath) => { if (!__ || !__.commands || !__.commands[aliasPath]) { return null; } return __.commands[aliasPath]; }, this) ) { throw new this.serverless.classes.Error(`Command "${alias}" cannot be overriden by an alias`); } aliasTarget.command = command; } loadCommand(pluginName, details, key, isEntryPoint) { const commandIsEntryPoint = details.type === 'entrypoint' || isEntryPoint; if (process.env.SLS_DEBUG && !commandIsEntryPoint) { this.serverless.cli.log(`Load command ${key}`); } // Check if there is already an alias for the same path as the command const aliasCommand = this.getAliasCommandTarget(key.split(':')); if (aliasCommand) { throw new this.serverless.classes.Error(`Command "${key}" cannot override an existing alias`); } // Load the command const commands = _.mapValues(details.commands, (subDetails, subKey) => this.loadCommand(pluginName, subDetails, `${key}:${subKey}`, commandIsEntryPoint) ); // Handle command aliases (details.aliases || []).forEach((alias) => { if (process.env.SLS_DEBUG) { this.serverless.cli.log(` -> @${alias}`); } this.createCommandAlias(alias, key); }); return Object.assign({}, details, { key, pluginName, commands }); } loadCommands(pluginInstance, options = { isExternal: false }) { const pluginName = pluginInstance.constructor.name; if (pluginInstance.commands) { Object.entries(pluginInstance.commands).forEach(([key, details]) => { const command = this.loadCommand(pluginName, details, key); // Grab and extract deprecated events command.lifecycleEvents = (command.lifecycleEvents || []).map((event) => { if (event.startsWith('deprecated#')) { // Extract event and optional redirect const transformedEvent = /^deprecated#(.*?)(?:->(.*?))?$/.exec(event); this.deprecatedEvents[`${command.key}:${transformedEvent[1]}`] = transformedEvent[2] || null; return transformedEvent[1]; } return event; }); this.commands[key] = _.merge({}, this.commands[key], command, { isExternal: options.isExternal, }); }); } } loadHooks(pluginInstance) { const pluginName = pluginInstance.constructor.name; if (pluginInstance.hooks) { Object.entries(pluginInstance.hooks).forEach(([event, hook]) => { let target = event; const baseEvent = event.replace(/^(?:after:|before:)/, ''); if (this.deprecatedEvents[baseEvent]) { const redirectedEvent = this.deprecatedEvents[baseEvent]; if (process.env.SLS_DEBUG) { this.serverless.cli.log(`WARNING: Plugin ${pluginName} uses deprecated hook ${event}, use ${redirectedEvent} hook instead`); } if (redirectedEvent) { target = event.replace(baseEvent, redirectedEvent); } } this.hooks[target] = this.hooks[target] || []; this.hooks[target].push({ pluginName, hook, }); }); } } loadVariableResolvers(pluginInstance) { const pluginName = pluginInstance.constructor.name; for (const [variablePrefix, resolverOrOptions] of Object.entries( pluginInstance.variableResolvers || {} )) { let options = resolverOrOptions; if (!options) { this.serverless.cli.log( `Warning! Ignoring falsy variableResolver for ${variablePrefix} in ${pluginName}.` ); continue; } if (typeof resolverOrOptions === 'function') { options = { resolver: resolverOrOptions, }; } if (!_.isObject(options)) { throw new Error( `Custom variable resolver {${variablePrefix}: ${JSON.stringify( options )}} defined by ${pluginName} isn't an object` ); } else if (!variablePrefix.match(/[0-9a-zA-Z_-]+/)) { throw new Error( `Custom variable resolver prefix ${variablePrefix} defined by ${pluginName} may only contain alphanumeric characters, hyphens or underscores.` ); } if (typeof options.resolver !== 'function') { throw new Error( `Custom variable resolver for ${variablePrefix} defined by ${pluginName} specifies a resolver that isn't a function: ${options.resolver}` ); } if (options.isDisabledAtPrepopulation && typeof options.serviceName !== 'string') { throw new Error( `Custom variable resolver for ${variablePrefix} defined by ${pluginName} specifies isDisabledAtPrepopulation but doesn't provide a string for a name` ); } this.serverless.variables.variableResolvers.push({ regex: new RegExp(`^${variablePrefix}:`), resolver: options.resolver.bind(this.serverless.variables), isDisabledAtPrepopulation: options.isDisabledAtPrepopulation || false, serviceName: options.serviceName || null, }); } } getCommands() { const result = {}; // Iterate through the commands and stop at entrypoints to include only public // command throughout the hierarchy. const stack = [{ commands: this.commands, target: result }]; while (stack.length) { const currentCommands = stack.pop(); const commands = currentCommands.commands; const target = currentCommands.target; if (commands) { Object.entries(commands).forEach(([name, command]) => { if (command.type !== 'entrypoint') { target[name] = _.omit(command, 'commands'); if ( Object.values(command.commands).some( (childCommand) => childCommand.type !== 'entrypoint' ) ) { target[name].commands = {}; stack.push({ commands: command.commands, target: target[name].commands }); } } }); } } // Iterate through the existing aliases and add them as commands _.remove(stack); stack.push({ aliases: this.aliases, target: result }); while (stack.length) { const currentAlias = stack.pop(); const aliases = currentAlias.aliases; const target = currentAlias.target; if (aliases) { Object.entries(aliases).forEach(([name, alias]) => { if (name === 'command') { return; } if (alias.command) { const commandPath = alias.command.split(':').join('.commands.'); target[name] = _.get(this.commands, commandPath); } else { target[name] = target[name] || {}; target[name].commands = target[name].commands || {}; } stack.push({ aliases: alias, target: target[name].commands }); }); } } return result; } getAliasCommandTarget(aliasArray) { // Check if the command references an alias const aliasCommand = aliasArray.reduce((__, commandPath) => { if (!__ || !__[commandPath]) { return null; } return __[commandPath]; }, this.aliases); return _.get(aliasCommand, 'command'); } /** * Retrieve the command specified by a command list. The method can be configured * to include entrypoint commands (which are invisible to the CLI and can only * be used by plugins). * @param commandsArray {Array<String>} Commands * @param allowEntryPoints {undefined|boolean} Allow entrypoint commands to be returned * @returns {Object} Command */ getCommand(commandsArray, allowEntryPoints) { // Check if the command references an alias const aliasCommandTarget = this.getAliasCommandTarget(commandsArray); const commandOrAlias = aliasCommandTarget ? aliasCommandTarget.split(':') : commandsArray; return commandOrAlias.reduce( (current, name, index) => { const commandExists = name in current.commands; const isNotContainer = commandExists && current.commands[name].type !== 'container'; const isNotEntrypoint = commandExists && current.commands[name].type !== 'entrypoint'; const remainingIterationsLeft = index < commandOrAlias.length - 1; if ( commandExists && (isNotContainer || remainingIterationsLeft) && (isNotEntrypoint || allowEntryPoints) ) { return current.commands[name]; } // if user is using a top level command properly, but sub commands are not if (this.serverless.cli.loadedCommands[commandOrAlias[0]]) { const errorMessage = [`"${name}" is not a valid sub command. Run "serverless `]; for (let i = 0; commandOrAlias[i] !== name; i++) { errorMessage.push(`${commandOrAlias[i]}`); if (commandOrAlias[i + 1] !== name) { errorMessage.push(' '); } } errorMessage.push('" to see a more helpful error message for this command.'); throw new this.serverless.classes.Error(errorMessage.join('')); } // top level command isn't valid. give a suggestion const commandName = commandOrAlias.slice(0, index + 1).join(' '); const suggestedCommand = getCommandSuggestion( commandName, this.serverless.cli.loadedCommands ); const errorMessage = [ `Serverless command "${commandName}" not found. Did you mean "${suggestedCommand}"?`, ' Run "serverless help" for a list of all available commands.', ].join(''); throw new this.serverless.classes.Error(errorMessage); }, { commands: this.commands } ); } getEvents(command) { return _.flatMap(command.lifecycleEvents, (event) => [ `before:${command.key}:${event}`, `${command.key}:${event}`, `after:${command.key}:${event}`, ]); } getPlugins() { return this.plugins; } getHooks(events) { return _.flatMap([].concat(events), (event) => this.hooks[event] || []); } invoke(commandsArray, allowEntryPoints) { const command = this.getCommand(commandsArray, allowEntryPoints); this.convertShortcutsIntoOptions(command); this.validateServerlessConfigDependency(command); this.assignDefaultOptions(command); this.validateOptions(command); const events = this.getEvents(command); const hooks = this.getHooks(events); if (process.env.SLS_DEBUG) { this.serverless.cli.log(`Invoke ${commandsArray.join(':')}`); if (hooks.length === 0) { const warningMessage = 'Warning: The command you entered did not catch on any hooks'; this.serverless.cli.log(warningMessage); } } return BbPromise.reduce(hooks, (__, hook) => hook.hook(), null).catch( TerminateHookChain, () => { if (process.env.SLS_DEBUG) { this.serverless.cli.log(`Terminate ${commandsArray.join(':')}`); } return BbPromise.resolve(); } ); } /** * Invokes the given command and starts the command's lifecycle. * This method can be called by plugins directly to spawn a separate sub lifecycle. */ spawn(commandsArray, options) { let commands = commandsArray; if (typeof commandsArray === 'string') { commands = commandsArray.split(':'); } return this.invoke(commands, true).then(() => { if (_.get(options, 'terminateLifecycleAfterExecution', false)) { return BbPromise.reject(new TerminateHookChain(commands)); } return BbPromise.resolve(); }); } /** * Called by the CLI to start a public command. */ run(commandsArray) { // first initialize hooks return this.getHooks(['initialize']) .reduce((chain, { hook }) => chain.then(hook), BbPromise.resolve()) .then(() => this.invoke(commandsArray)); } /** * Check if the command is valid. Internally this function will only find * CLI accessible commands (command.type !== 'entrypoint') */ validateCommand(commandsArray) { this.getCommand(commandsArray); } /** * If the command has no use when operated in a working directory with no serverless * configuration file, throw an error */ validateServerlessConfigDependency(command) { if ('configDependent' in command && command.configDependent) { if (!this.serverlessConfigFile) { const msg = [ 'This command can only be run in a Serverless service directory. ', "Make sure to reference a valid config file in the current working directory if you're using a custom config file", ].join(''); throw new this.serverless.classes.Error(msg); } } } validateOptions(command) { if (command.options) { Object.entries(command.options).forEach(([key, value]) => { if (value.required && (this.cliOptions[key] === true || !this.cliOptions[key])) { let requiredThings = `the --${key} option`; if (value.shortcut) { requiredThings += ` / -${value.shortcut} shortcut`; } let errorMessage = `This command requires ${requiredThings}.`; if (value.usage) { errorMessage = `${errorMessage} Usage: ${value.usage}`; } throw new this.serverless.classes.Error(errorMessage); } if ( _.isPlainObject(value.customValidation) && value.customValidation.regularExpression instanceof RegExp && typeof value.customValidation.errorMessage === 'string' && !value.customValidation.regularExpression.test(this.cliOptions[key]) ) { throw new this.serverless.classes.Error(value.customValidation.errorMessage); } }); } } updateAutocompleteCacheFile() { const commands = _.clone(this.getCommands()); const cacheFile = { commands: {}, validationHash: '', }; Object.entries(commands).forEach(([commandName, commandObj]) => { const command = commandObj; if (!command.options) { command.options = {}; } if (!command.commands) { command.commands = {}; } cacheFile.commands[commandName] = Object.keys(command.options) .map((option) => `--${option}`) .concat(Object.keys(command.commands)); }); const serverlessConfigFileHash = crypto .createHash('sha256') .update(JSON.stringify(this.serverlessConfigFile)) .digest('hex'); cacheFile.validationHash = serverlessConfigFileHash; const cacheFilePath = getCacheFilePath(this.serverless.config.servicePath); return writeFile(cacheFilePath, cacheFile); } convertShortcutsIntoOptions(command) { if (command.options) { Object.entries(command.options).forEach(([optionKey, optionObject]) => { if (optionObject.shortcut && Object.keys(this.cliOptions).includes(optionObject.shortcut)) { Object.keys(this.cliOptions).forEach((option) => { if (option === optionObject.shortcut) { this.cliOptions[optionKey] = this.cliOptions[option]; } }); } }); } } assignDefaultOptions(command) { if (command.options) { Object.entries(command.options).forEach(([key, value]) => { if (value.default != null && (!this.cliOptions[key] || this.cliOptions[key] === true)) { this.cliOptions[key] = value.default; } }); } } asyncPluginInit() { return BbPromise.all(this.plugins.map((plugin) => plugin.asyncInit && plugin.asyncInit())); } } module.exports = PluginManager;