diff --git a/examples/post-start-secret-delivery/README.md b/examples/post-start-secret-delivery/README.md new file mode 100644 index 0000000000..e249e197d2 --- /dev/null +++ b/examples/post-start-secret-delivery/README.md @@ -0,0 +1,8 @@ +# Secret injection through post_start_hook +This example shows a method to retrieve run-time secrets from a vault and deliver them to newly-started app instances using a post_start_hook. +In this set-up PM2, with the help of the hook, acts as a 'trusted intermediate'; it is the only entity that has +full access to the secret store, and it is responsible for delivering the appropriate secrets to the appropriate app instances. + +One key point of this solution is that the secret data is not passed through environment variables, which could be exposed. +Instead, the secret data is delivered to the app using its stdin file descriptor. +Note that this will not work in cluster mode, as in that case apps run with stdin detached. diff --git a/examples/post-start-secret-delivery/app.js b/examples/post-start-secret-delivery/app.js new file mode 100644 index 0000000000..9b107e0cf0 --- /dev/null +++ b/examples/post-start-secret-delivery/app.js @@ -0,0 +1,25 @@ +const readline = require('node:readline/promises'); +const { stdin, stdout } = require('node:process'); +const rl = readline.createInterface({ input: stdin, output: stdout }); + +const defaultConfig = require('./config.json'); + +async function main() { + // Read overrides from stdin + const overridesStr = await rl.question('overrides? '); + let overrides; + try { + overrides = JSON.parse(overridesStr); + } catch (e) { + console.error(`Error parsing >${overridesStr}<:`, e); + process.exit(1); + } + + // Merge overrides into default config to form final config + const config = Object.assign({}, defaultConfig, overrides); + console.log(`App running with config: ${JSON.stringify(config, null, 2)}`); + // Keep it alive + setInterval(() => {}, 1000); +} + +main().catch(console.error); diff --git a/examples/post-start-secret-delivery/config.json b/examples/post-start-secret-delivery/config.json new file mode 100644 index 0000000000..270ecb220b --- /dev/null +++ b/examples/post-start-secret-delivery/config.json @@ -0,0 +1,4 @@ +{ + "fooKey": "defaultFooValue", + "barKey": "defaultBarValue" +} diff --git a/examples/post-start-secret-delivery/post-start-hook.js b/examples/post-start-secret-delivery/post-start-hook.js new file mode 100644 index 0000000000..eab6384710 --- /dev/null +++ b/examples/post-start-secret-delivery/post-start-hook.js @@ -0,0 +1,23 @@ +'use strict'; +const fs = require('fs').promises; + +/** + * This is a post-start hook that will be called after an app started. + * @param {object} info + * @param {number} info.pid The apps PID + * @param {Stream} info.stdin The apps STDIN stream + * @param {Stream} info.stdout The apps STDOUT stream + * @param {Stream} info.stderr The apps STDERR stream + * @param {object} pm2_env The apps environment variables + * @returns {Promise} + */ +async function hook(info) { + const appName = info.pm2_env.name; + // In a real scenario secrets would be retrieved from some secret store + const allSecrets = JSON.parse(await fs.readFile('secrets.json', 'utf8')); + const appOverrides = allSecrets[appName] || {}; + // Write the overrides json to the apps STDIN stream + info.stdin.write(JSON.stringify(appOverrides) + '\n'); +} + +module.exports = require('util').callbackify(hook); diff --git a/examples/post-start-secret-delivery/process.yaml b/examples/post-start-secret-delivery/process.yaml new file mode 100644 index 0000000000..8c030eb27b --- /dev/null +++ b/examples/post-start-secret-delivery/process.yaml @@ -0,0 +1,7 @@ +- name: app-1 + script: app.js + post_start_hook: post-start-hook.js + +- name: app-2 + script: app.js + post_start_hook: post-start-hook.js diff --git a/examples/post-start-secret-delivery/secrets.json b/examples/post-start-secret-delivery/secrets.json new file mode 100644 index 0000000000..61f3f79d69 --- /dev/null +++ b/examples/post-start-secret-delivery/secrets.json @@ -0,0 +1,8 @@ +{ + "app-1": { + "barKey": "secretBarValueForApp1" + }, + "app-2": { + "barKey": "secretBarValueForApp2" + } +} diff --git a/lib/API/schema.json b/lib/API/schema.json index fc5f868cd5..e602b7d19b 100644 --- a/lib/API/schema.json +++ b/lib/API/schema.json @@ -258,6 +258,10 @@ "docDefault": false, "docDescription": "Start a script even if it is already running (only the script path is considered)" }, + "post_start_hook": { + "type": "string", + "docDescription": "Script to run after app start" + }, "append_env_to_name": { "type": "boolean", "docDefault": false, diff --git a/lib/God.js b/lib/God.js index 620e5b5d49..91880ea375 100644 --- a/lib/God.js +++ b/lib/God.js @@ -25,6 +25,7 @@ var Utility = require('./Utility'); var cst = require('../constants.js'); var timesLimit = require('async/timesLimit'); var Configuration = require('./Configuration.js'); +var which = require('./tools/which'); /** * Override cluster module configuration @@ -212,7 +213,53 @@ God.executeApp = function executeApp(env, cb) { God.registerCron(env_copy) /** Callback when application is launched */ - var readyCb = function ready(proc) { + var appRunningCb = function(clu) { + var post_start_hook = env_copy['post_start_hook']; + if (post_start_hook) { + // Full path script resolution + var hook_path = path.resolve(clu.pm2_env.cwd, post_start_hook); + + // If script does not exist after resolution + if (!fs.existsSync(hook_path)) { + var ckd; + // Try resolve command available in $PATH + if ((ckd = which(post_start_hook))) { + if (typeof(ckd) !== 'string') + ckd = ckd.toString(); + hook_path = ckd; + } + else + // Throw critical error + return new Error(`post_start_hook not found: ${post_start_hook}`); + } + try { + var hookFn = require(hook_path); + if (typeof hookFn !== 'function') { + throw new Error('post_start_hook module.exports must be a function'); + } + hookFn({ + pid: clu.process.pid, + stdin: clu.stdin, + stdout: clu.stdout, + stderr: clu.stderr, + pm2_env: clu.pm2_env, + }, function (hook_err) { + if (hook_err) { + console.error('post_start_hook returned error:', hook_err); + } + return hooksDoneCb(clu); + }); + } catch (require_hook_err) { + console.error('executing post_start_hook failed:', require_hook_err.message); + return hooksDoneCb(clu); + } + } else { + return hooksDoneCb(clu); + } + }; + + /** Callback when post-start hook is done */ + var hooksDoneCb = function ready(proc) { // If vizion enabled run versioning retrieval system if (proc.pm2_env.vizion !== false && proc.pm2_env.vizion !== "false") God.finalizeProcedure(proc); @@ -265,12 +312,12 @@ God.executeApp = function executeApp(env, cb) { return clu.once('online', function () { if (!clu.pm2_env.wait_ready) - return readyCb(clu); + return appRunningCb(clu); // Timeout if the ready message has not been sent before listen_timeout var ready_timeout = setTimeout(function() { God.bus.removeListener('process:msg', listener) - return readyCb(clu) + return appRunningCb(clu) }, clu.pm2_env.listen_timeout || cst.GRACEFUL_LISTEN_TIMEOUT); var listener = function (packet) { @@ -279,7 +326,7 @@ God.executeApp = function executeApp(env, cb) { packet.process.pm_id === clu.pm2_env.pm_id) { clearTimeout(ready_timeout); God.bus.removeListener('process:msg', listener) - return readyCb(clu) + return appRunningCb(clu) } } @@ -321,12 +368,12 @@ God.executeApp = function executeApp(env, cb) { }); if (!clu.pm2_env.wait_ready) - return readyCb(clu); + return appRunningCb(clu); // Timeout if the ready message has not been sent before listen_timeout var ready_timeout = setTimeout(function() { God.bus.removeListener('process:msg', listener) - return readyCb(clu) + return appRunningCb(clu) }, clu.pm2_env.listen_timeout || cst.GRACEFUL_LISTEN_TIMEOUT); var listener = function (packet) { @@ -335,7 +382,7 @@ God.executeApp = function executeApp(env, cb) { packet.process.pm_id === clu.pm2_env.pm_id) { clearTimeout(ready_timeout); God.bus.removeListener('process:msg', listener) - return readyCb(clu) + return appRunningCb(clu) } } God.bus.on('process:msg', listener); diff --git a/test/fixtures/post_start_hook/echo.js b/test/fixtures/post_start_hook/echo.js new file mode 100644 index 0000000000..6a295dcacd --- /dev/null +++ b/test/fixtures/post_start_hook/echo.js @@ -0,0 +1,5 @@ +console.log('app running'); + +process.stdin.on('data', function(chunk) { + process.stdout.write(chunk); +}); diff --git a/test/fixtures/post_start_hook/post_start_hook_errors.js b/test/fixtures/post_start_hook/post_start_hook_errors.js new file mode 100644 index 0000000000..abe9917568 --- /dev/null +++ b/test/fixtures/post_start_hook/post_start_hook_errors.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function hook(info, cb) { + cb(new Error('error-from-post-start-hook-' + info.pid)); +} diff --git a/test/fixtures/post_start_hook/post_start_hook_normal.js b/test/fixtures/post_start_hook/post_start_hook_normal.js new file mode 100644 index 0000000000..655a910ccc --- /dev/null +++ b/test/fixtures/post_start_hook/post_start_hook_normal.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = function hook(info, cb) { + console.log('hello-from-post-start-hook-' + info.pid); + info.pm2_env.post_start_hook_info = { + pid: info.pid, + stdin: info.stdin, + stdout: info.stdout, + stderr: info.stderr, + have_env: info.pm2_env.post_start_hook_test, + }; + if (info.stdin) { + info.stdin.write('post-start-hook-hello-to-' + info.pid + '\n'); + } + cb(null); +} diff --git a/test/fixtures/post_start_hook/post_start_hook_throws.js b/test/fixtures/post_start_hook/post_start_hook_throws.js new file mode 100644 index 0000000000..d17c9e0683 --- /dev/null +++ b/test/fixtures/post_start_hook/post_start_hook_throws.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function hook(info, cb) { + throw new Error('thrown-from-post-start-hook-' + info.pid); +} diff --git a/test/programmatic/post_start_hook.mocha.js b/test/programmatic/post_start_hook.mocha.js new file mode 100644 index 0000000000..29481bf020 --- /dev/null +++ b/test/programmatic/post_start_hook.mocha.js @@ -0,0 +1,126 @@ +process.chdir(__dirname) + +var PM2 = require('../..') +var should = require('should') +const fs = require("fs"); + +describe('When a post_start_hook is configured', function() { + before(function(done) { + PM2.delete('all', function() { done() }) + }) + + after(function(done) { + PM2.kill(done) + }) + + afterEach(function(done) { + PM2.delete('all', done) + }) + + function defineTestsForMode(mode) { + describe('when running app in ' + mode + ' mode', function() { + it('should start app and run the post_start_hook script', function(done) { + PM2.start({ + script: './../fixtures/post_start_hook/echo.js', + post_start_hook: './../fixtures/post_start_hook/post_start_hook_normal.js', + exec_mode: mode, + env: { + post_start_hook_test: 'true' + } + }, (err) => { + should(err).be.null() + PM2.list(function(err, list) { + try { + should(err).be.null() + should(list.length).eql(1) + should.exists(list[0].pm2_env.post_start_hook_info) + should(list[0].pm2_env.post_start_hook_info.pid).eql(list[0].pid) + should(list[0].pm2_env.post_start_hook_info.have_env).eql('true') + var log_file = list[0].pm2_env.PM2_HOME + '/pm2.log'; + fs.readFileSync(log_file).toString().should.containEql('hello-from-post-start-hook-' + list[0].pid) + if (mode === 'fork') { + should.exist(list[0].pm2_env.post_start_hook_info.stdin) + should.exist(list[0].pm2_env.post_start_hook_info.stdout) + should.exist(list[0].pm2_env.post_start_hook_info.stderr) + var out_file = list[0].pm2_env.pm_out_log_path; + setTimeout(function() { + fs.readFileSync(out_file).toString().should.containEql('post-start-hook-hello-to-' + list[0].pid) + done() + }, 100) + } else { + done(); + } + } catch(e) { + done(e) + } + }) + }) + }) + + it('should log error in pm2 log but keep app running when post_start_hook script throws', function(done) { + PM2.start({ + script: './../fixtures/post_start_hook/echo.js', + post_start_hook: './../fixtures/post_start_hook/post_start_hook_throws.js', + exec_mode: mode, + }, (err) => { + should(err).be.null() + PM2.list(function(err, list) { + try { + should(err).be.null() + should(list.length).eql(1) + var log_file = list[0].pm2_env.PM2_HOME + '/pm2.log'; + fs.readFileSync(log_file).toString().should.containEql('thrown-from-post-start-hook-' + list[0].pid) + done() + } catch(e) { + done(e) + } + }) + }) + }) + + it('should log error in pm2 log but keep app running when post_start_hook script returns error', function(done) { + PM2.start({ + script: './../fixtures/post_start_hook/echo.js', + post_start_hook: './../fixtures/post_start_hook/post_start_hook_errors.js', + exec_mode: mode, + }, (err) => { + should(err).be.null() + PM2.list(function(err, list) { + try { + should(err).be.null() + should(list.length).eql(1) + var log_file = list[0].pm2_env.PM2_HOME + '/pm2.log'; + fs.readFileSync(log_file).toString().should.containEql('error-from-post-start-hook-' + list[0].pid) + done() + } catch(e) { + done(e) + } + }) + }) + }) + + it('should log error in pm2 log but keep app running when post_start_hook script is not found', function(done) { + PM2.start({ + script: './../fixtures/post_start_hook/echo.js', + post_start_hook: './../fixtures/post_start_hook/post_start_hook_nonexistent.js', + exec_mode: mode, + }, (err) => { + should(err).be.null() + PM2.list(function(err, list) { + try { + should(err).be.null() + should(list.length).eql(1) + var log_file = list[0].pm2_env.PM2_HOME + '/pm2.log'; + fs.readFileSync(log_file).toString().should.match(/PM2 error: executing post_start_hook failed: Cannot find module .*post_start_hook_nonexistent\.js/) + done() + } catch(e) { + done(e) + } + }) + }) + }) + }) + } + defineTestsForMode('fork') + defineTestsForMode('cluster') +})