diff --git a/Dockerfile b/Dockerfile index 750d287..d1d345e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,5 +17,11 @@ RUN ln -s /usr/src/app/node_modules/screwdriver-store/config /config # Expose the web service port EXPOSE 80 +# Add Tini +ENV TINI_VERSION v0.19.0 +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini +RUN chmod +x /tini +ENTRYPOINT ["/tini", "--"] + # Run the service -CMD [ "npm", "start" ] +CMD [ "node", "./bin/server" ] diff --git a/plugins/shutdown.js b/plugins/shutdown.js new file mode 100644 index 0000000..acbf4d0 --- /dev/null +++ b/plugins/shutdown.js @@ -0,0 +1,102 @@ +'use strict'; + +const joi = require('joi'); +const logger = require('screwdriver-logger'); + +const tasks = {}; +const taskSchema = joi.object({ + taskname: joi.string().required(), + task: joi.func().required(), + timeout: joi.number().integer() +}); + +/** + * Function to return promise timeout or resolution + * whichever happens first + * @param {function} fn + * @param {string} timeout + */ +function promiseTimeout(fn, timeout) { + return Promise.race([ + Promise.resolve(fn), + new Promise(resolve => { + setTimeout(() => { + resolve(`Promise timed out after ${timeout} ms`); + }, timeout); + }) + ]); +} + +/** + * Hapi plugin to handle server graceful shutdown + * @method register + * @param {Hapi.Server} server + */ +const shutdownPlugin = { + name: 'shutdown', + async register(server) { + const terminationGracePeriod = parseInt(process.env.TERMINATION_GRACE_PERIOD, 10) || 30; + + const taskHandler = async () => { + try { + await Promise.all( + Object.keys(tasks).map(async key => { + logger.info(`shutdown-> executing task ${key}`); + const item = tasks[key]; + + await item.task(); + }) + ); + + return Promise.resolve(); + } catch (err) { + logger.error('shutdown-> Error in taskHandler %s', err); + throw err; + } + }; + + const gracefulStop = async () => { + try { + logger.info('shutdown-> gracefully shutting down server'); + await server.stop({ + timeout: 5000 + }); + process.exit(0); + } catch (err) { + logger.error('shutdown-> error in graceful shutdown %s', err); + process.exit(1); + } + }; + + const onSigterm = async () => { + try { + logger.info('shutdown-> got SIGTERM; running triggers before shutdown'); + const res = await promiseTimeout(taskHandler(), terminationGracePeriod * 1000); + + if (res) { + logger.error(res); + } + await gracefulStop(); + } catch (err) { + logger.error('shutdown-> Error in plugin %s', err); + process.exit(1); + } + }; + + // catch sigterm signal + process.on('SIGTERM', onSigterm); + + server.expose('handler', task => { + const res = taskSchema.validate(task); + + if (res.error) { + return res.error; + } + tasks[task.taskname] = task; + + return ''; + }); + } +}; + +module.exports = shutdownPlugin; diff --git a/test/plugins/shutdown.test.js b/test/plugins/shutdown.test.js new file mode 100644 index 0000000..2e44717 --- /dev/null +++ b/test/plugins/shutdown.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const chai = require('chai'); +const { assert } = chai; +const hapi = require('@hapi/hapi'); +const sinon = require('sinon'); + +sinon.assert.expose(assert, { prefix: '' }); + +describe('test shutdown plugin', () => { + let plugin; + let server; + + beforeEach(async () => { + /* eslint-disable global-require */ + plugin = require('../../plugins/shutdown'); + /* eslint-enable global-require */ + + server = new hapi.Server({ + port: 1234 + }); + + await server.register({ plugin }); + }); + + afterEach(() => { + server = null; + }); + + it('registers the plugin', () => { + assert.isOk(server.registrations.shutdown); + }); +}); + +describe('test graceful shutdown', () => { + before(() => { + sinon.stub(process, 'exit'); + }); + + after(() => { + process.exit.restore(); + }); + + it('should catch the SIGTERM signal', () => { + /* eslint-disable global-require */ + const plugin = require('../../plugins/shutdown'); + /* eslint-enable global-require */ + const options = { + terminationGracePeriod: 30 + }; + let stopCalled = false; + const server = new hapi.Server({ + port: 1234 + }); + + server.log = () => {}; + server.root = { + stop: () => { + stopCalled = true; + } + }; + server.expose = sinon.stub(); + + plugin.register(server, options, () => {}); + + process.exit(1); + process.exit.callsFake(() => { + assert.isTrue(stopCalled); + }); + assert(process.exit.isSinonProxy); + sinon.assert.called(process.exit); + sinon.assert.calledWith(process.exit, 1); + }); +});