Skip to content

Commit

Permalink
refactor(Plugins): Seclude plugin install standalone command (#9942)
Browse files Browse the repository at this point in the history
  • Loading branch information
issea1015 committed Sep 27, 2021
1 parent bfbed72 commit 713ac1e
Show file tree
Hide file tree
Showing 6 changed files with 259 additions and 15 deletions.
127 changes: 127 additions & 0 deletions commands/plugin-install.js
@@ -0,0 +1,127 @@
'use strict';

const spawn = require('child-process-ext/spawn');
const fsp = require('fs').promises;
const fse = require('fs-extra');
const path = require('path');
const _ = require('lodash');
const isPlainObject = require('type/plain-object/is');
const yaml = require('js-yaml');
const cloudformationSchema = require('@serverless/utils/cloudformation-schema');
const log = require('@serverless/utils/log');
const ServerlessError = require('../lib/serverless-error');
const yamlAstParser = require('../lib/utils/yamlAstParser');
const npmCommandDeferred = require('../lib/utils/npm-command-deferred');
const {
getPluginInfo,
getServerlessFilePath,
validate,
} = require('../lib/commands/plugin-management');

module.exports = async ({ configuration, serviceDir, configurationFilename, options }) => {
validate({ serviceDir });

const pluginInfo = getPluginInfo(options.name);
const pluginName = pluginInfo.name;
const pluginVersion = pluginInfo.version || 'latest';
const configurationFilePath = getServerlessFilePath({ serviceDir, configurationFilename });

const context = { configuration, serviceDir, configurationFilePath, pluginName, pluginVersion };
await installPlugin(context);
await addPluginToServerlessFile(context);

const message = ['Successfully installed', ` "${pluginName}@${pluginVersion}"`].join('');
log(message);
};

const installPlugin = async ({ serviceDir, pluginName, pluginVersion }) => {
const pluginFullName = `${pluginName}@${pluginVersion}`;
const message = [
`Installing plugin "${pluginFullName}"`,
' (this might take a few seconds...)',
].join('');
log(message);
await npmInstall(pluginFullName, { serviceDir });
};

const addPluginToServerlessFile = async ({ configurationFilePath, pluginName }) => {
const fileExtension = path.extname(configurationFilePath);
if (fileExtension === '.js' || fileExtension === '.ts') {
requestManualUpdate(configurationFilePath);
return;
}

const checkIsArrayPluginsObject = (pluginsObject) =>
pluginsObject == null || Array.isArray(pluginsObject);
// pluginsObject type determined based on the value loaded during the serverless init.
if (_.last(configurationFilePath.split('.')) === 'json') {
const serverlessFileObj = await fse.readJson(configurationFilePath);
const newServerlessFileObj = serverlessFileObj;
const isArrayPluginsObject = checkIsArrayPluginsObject(newServerlessFileObj.plugins);
// null modules property is not supported
let plugins = isArrayPluginsObject
? newServerlessFileObj.plugins || []
: newServerlessFileObj.plugins.modules;

if (plugins == null) {
throw new ServerlessError(
'plugins modules property must be present',
'PLUGINS_MODULES_MISSING'
);
}

plugins.push(pluginName);
plugins = _.sortedUniq(plugins);

if (isArrayPluginsObject) {
newServerlessFileObj.plugins = plugins;
} else {
newServerlessFileObj.plugins.modules = plugins;
}

await fse.writeJson(configurationFilePath, newServerlessFileObj);
return;
}

const serverlessFileObj = yaml.load(await fsp.readFile(configurationFilePath, 'utf8'), {
filename: configurationFilePath,
schema: cloudformationSchema,
});
if (serverlessFileObj.plugins != null) {
// Plugins section can be behind veriables, opt-out in such case
if (isPlainObject(serverlessFileObj.plugins)) {
if (
serverlessFileObj.plugins.modules != null &&
!Array.isArray(serverlessFileObj.plugins.modules)
) {
requestManualUpdate(configurationFilePath);
return;
}
} else if (!Array.isArray(serverlessFileObj.plugins)) {
requestManualUpdate(configurationFilePath);
return;
}
}
await yamlAstParser.addNewArrayItem(
configurationFilePath,
checkIsArrayPluginsObject(serverlessFileObj.plugins) ? 'plugins' : 'plugins.modules',
pluginName
);
};

const npmInstall = async (name, { serviceDir }) => {
const npmCommand = await npmCommandDeferred;
await spawn(npmCommand, ['install', '--save-dev', name], {
cwd: serviceDir,
stdio: 'ignore',
// To parse quotes used in module versions. E.g. 'serverless@"^1.60.0 || 2"'
// https://stackoverflow.com/a/48015470
shell: true,
});
};

const requestManualUpdate = (configurationFilePath) =>
log(`
Can't automatically add plugin into "${path.basename(configurationFilePath)}" file.
Please make it manually.
`);
37 changes: 37 additions & 0 deletions lib/commands/plugin-management.js
@@ -0,0 +1,37 @@
'use strict';

const path = require('path');
const ServerlessError = require('../../lib/serverless-error');

module.exports = {
validate({ serviceDir }) {
if (!serviceDir) {
throw new ServerlessError(
'This command can only be run inside a service directory',
'MISSING_SERVICE_DIRECTORY'
);
}
},

getServerlessFilePath({ serviceDir, configurationFilename }) {
if (configurationFilename) {
return path.resolve(serviceDir, configurationFilename);
}
throw new ServerlessError(
'Could not find any serverless service definition file.',
'MISSING_SERVICE_CONFIGURATION_FILE'
);
},

getPluginInfo(name_) {
let name;
let version;
if (name_.startsWith('@')) {
[, name, version] = name_.split('@', 3);
name = `@${name}`;
} else {
[name, version] = name_.split('@', 2);
}
return { name, version };
},
};
2 changes: 2 additions & 0 deletions lib/plugins/plugin/install.js
@@ -1,3 +1,5 @@
// TODO: Remove in v3

'use strict';

const BbPromise = require('bluebird');
Expand Down
40 changes: 28 additions & 12 deletions scripts/serverless.js
Expand Up @@ -41,6 +41,8 @@ let hasTelemetryBeenReported = false;
// to propery handle e.g. `SIGINT` interrupt
const keepAliveTimer = setTimeout(() => {}, 60 * 60 * 1000);

const standaloneCommands = ['plugin install'];

process.once('uncaughtException', (error) => {
clearTimeout(keepAliveTimer);
progress.clear();
Expand Down Expand Up @@ -505,24 +507,38 @@ const processSpanPromise = (async () => {

const configurationFilename = configuration && configurationPath.slice(serviceDir.length + 1);

if (isInteractiveSetup) {
require('../lib/cli/ensure-supported-command')(configuration);
const isStandaloneCommand = standaloneCommands.includes(command);

if (!process.stdin.isTTY && !process.env.SLS_INTERACTIVE_SETUP_ENABLE) {
throw new ServerlessError(
'Attempted to run an interactive setup in non TTY environment.\n' +
"If that's intentended enforce with SLS_INTERACTIVE_SETUP_ENABLE=1 environment variable",
'INTERACTIVE_SETUP_IN_NON_TTY'
);
}
const { configuration: configurationFromInteractive } =
await require('../lib/cli/interactive-setup')({
if (isInteractiveSetup || isStandaloneCommand) {
if (isInteractiveSetup) {
require('../lib/cli/ensure-supported-command')(configuration);

if (!process.stdin.isTTY && !process.env.SLS_INTERACTIVE_SETUP_ENABLE) {
throw new ServerlessError(
'Attempted to run an interactive setup in non TTY environment.\n' +
"If that's intentended enforce with SLS_INTERACTIVE_SETUP_ENABLE=1 environment variable",
'INTERACTIVE_SETUP_IN_NON_TTY'
);
}
const result = await require('../lib/cli/interactive-setup')({
configuration,
serviceDir,
configurationFilename,
options,
commandUsage,
});
if (result.configuration) {
configuration = result.configuration;
}
} else {
require('../lib/cli/ensure-supported-command')(configuration);
await require(`../commands/${commands.join('-')}`)({
configuration,
serviceDir,
configurationFilename,
options,
});
}

progress.clear();

Expand All @@ -537,7 +553,7 @@ const processSpanPromise = (async () => {
options,
commandSchema,
serviceDir,
configuration: configurationFromInteractive,
configuration,
commandUsage,
variableSources: variableSourcesInConfig,
}),
Expand Down
6 changes: 3 additions & 3 deletions test/README.md
Expand Up @@ -61,7 +61,7 @@ Check existing set of AWS integration tests at [test/integration](./integration)
Pass test file to Mocha directly as follows

```
AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=xxx npx mocha tests/integration/{chosen}.test.js
AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=xxx npx mocha test/integration/{chosen}.test.js
```

### Tests that depend on shared infrastructure stack
Expand Down Expand Up @@ -90,13 +90,13 @@ To run all integration tests run:
To run only a specific integration test run:

```
tests/templates/integration-test-template TEMPLATE_NAME BUILD_COMMAND
test/templates/integration-test-template TEMPLATE_NAME BUILD_COMMAND
```

so for example:

```
tests/templates/integration-test-template aws-java-maven mvn package
test/templates/integration-test-template aws-java-maven mvn package
```

If you add a new template make sure to add it to the `test-all-templates` file and configure the `docker-compose.yml` file for your template.
62 changes: 62 additions & 0 deletions test/unit/commands/plugin-install.test.js
@@ -0,0 +1,62 @@
'use strict';

const chai = require('chai');
const sinon = require('sinon');
const yaml = require('js-yaml');
const fse = require('fs-extra');
const proxyquire = require('proxyquire');
const fixturesEngine = require('../../fixtures/programmatic');
const resolveConfigurationPath = require('../../../lib/cli/resolve-configuration-path');
const { expect } = require('chai');

chai.use(require('chai-as-promised'));

const npmCommand = 'npm';

describe('test/unit/commands/plugin-install.test.js', async () => {
let spawnFake;
let serviceDir;
let configurationFilePath;

const pluginName = 'serverless-plugin-1';

before(async () => {
spawnFake = sinon.fake();
const installPlugin = proxyquire('../../../commands/plugin-install', {
'child-process-ext/spawn': spawnFake,
});

const fixture = await fixturesEngine.setup('function');

const configuration = fixture.serviceConfig;
serviceDir = fixture.servicePath;
configurationFilePath = await resolveConfigurationPath({
cwd: serviceDir,
});
const configurationFilename = configurationFilePath.slice(serviceDir.length + 1);
const options = {
name: pluginName,
};

await installPlugin({
configuration,
serviceDir,
configurationFilename,
options,
});
});

it('should install plugin', () => {
const firstCall = spawnFake.firstCall;
const command = [firstCall.args[0], ...firstCall.args[1]].join(' ');
const expectedCommand = `${npmCommand} install --save-dev ${pluginName}`;
expect(command).to.have.string(expectedCommand);
});

it('should add plugin to serverless file', async () => {
const serverlessFileObj = yaml.load(await fse.readFile(configurationFilePath, 'utf8'), {
filename: configurationFilePath,
});
expect(serverlessFileObj.plugins).to.include(pluginName);
});
});

0 comments on commit 713ac1e

Please sign in to comment.