Skip to content
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

Feat/post start hook #5706

Open
wants to merge 7 commits into
base: development
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
8 changes: 8 additions & 0 deletions examples/post-start-secret-delivery/README.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions examples/post-start-secret-delivery/app.js
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions examples/post-start-secret-delivery/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"fooKey": "defaultFooValue",
"barKey": "defaultBarValue"
}
23 changes: 23 additions & 0 deletions examples/post-start-secret-delivery/post-start-hook.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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);
7 changes: 7 additions & 0 deletions examples/post-start-secret-delivery/process.yaml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions examples/post-start-secret-delivery/secrets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"app-1": {
"barKey": "secretBarValueForApp1"
},
"app-2": {
"barKey": "secretBarValueForApp2"
}
}
4 changes: 4 additions & 0 deletions lib/API/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 54 additions & 7 deletions lib/God.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/post_start_hook/echo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
console.log('app running');

process.stdin.on('data', function(chunk) {
process.stdout.write(chunk);
});
5 changes: 5 additions & 0 deletions test/fixtures/post_start_hook/post_start_hook_errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

module.exports = function hook(info, cb) {
cb(new Error('error-from-post-start-hook-' + info.pid));
}
16 changes: 16 additions & 0 deletions test/fixtures/post_start_hook/post_start_hook_normal.js
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 5 additions & 0 deletions test/fixtures/post_start_hook/post_start_hook_throws.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

module.exports = function hook(info, cb) {
throw new Error('thrown-from-post-start-hook-' + info.pid);
}
126 changes: 126 additions & 0 deletions test/programmatic/post_start_hook.mocha.js
Original file line number Diff line number Diff line change
@@ -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')
})