Ghost/core/server/ghost-server.js
Hannah Wolfe bbbd011074 Added job-manager & wired up shutdown and testmode
- Bottom line - we need to manage shutting down gracefully when doing long-running tasks
- To achieve that, we're going to use job queues

In this commit:
- added new @tryghost/job-manager dependency
- added a minimal job service, that handles in passing things like logging and (maybe later) config
- job service is wired up to server shutdown, so that the queue finishes before the server exits
- also added a new job endpoint to testmode so that it's easy to test job behaviour without needing to do real work
2020-08-11 21:31:34 +01:00

309 lines
10 KiB
JavaScript

// # Ghost Server
// Handles the creation of an HTTP Server for Ghost
const debug = require('ghost-ignition').debug('server');
const Promise = require('bluebird');
const fs = require('fs-extra');
const path = require('path');
const _ = require('lodash');
const config = require('../shared/config');
const urlUtils = require('./../shared/url-utils');
const errors = require('@tryghost/errors');
const {events, i18n} = require('./lib/common');
const logging = require('../shared/logging');
const moment = require('moment');
const bootstrapSocket = require('@tryghost/bootstrap-socket');
const stoppable = require('stoppable');
/**
* ## GhostServer
*/
class GhostServer {
/**
* @constructor
* @param {Object} rootApp - parent express instance
*/
constructor(rootApp) {
this.rootApp = rootApp;
this.httpServer = null;
// Expose config module for use externally.
this.config = config;
// Tasks that should be run before the server exits
this.cleanupTasks = [];
}
/**
* ## Public API methods
*
* ### Start
* Starts the ghost server listening on the configured port.
* Alternatively you can pass in your own express instance and let Ghost
* start listening for you.
* @param {Object} externalApp - Optional express app instance.
* @return {Promise} Resolves once Ghost has started
*/
start(externalApp) {
debug('Starting...');
const self = this;
const rootApp = externalApp ? externalApp : self.rootApp;
let socketConfig;
const socketValues = {
path: path.join(config.get('paths').contentPath, config.get('env') + '.socket'),
permissions: '660'
};
return new Promise(function (resolve, reject) {
if (Object.prototype.hasOwnProperty.call(config.get('server'), 'socket')) {
socketConfig = config.get('server').socket;
if (_.isString(socketConfig)) {
socketValues.path = socketConfig;
} else if (_.isObject(socketConfig)) {
socketValues.path = socketConfig.path || socketValues.path;
socketValues.permissions = socketConfig.permissions || socketValues.permissions;
}
// Make sure the socket is gone before trying to create another
try {
fs.unlinkSync(socketValues.path);
} catch (e) {
// We can ignore this.
}
self.httpServer = rootApp.listen(socketValues.path);
fs.chmod(socketValues.path, socketValues.permissions);
config.set('server:socket', socketValues);
} else {
self.httpServer = rootApp.listen(
config.get('server').port,
config.get('server').host
);
}
self.httpServer.on('error', function (error) {
let ghostError;
if (error.errno === 'EADDRINUSE') {
ghostError = new errors.GhostError({
message: i18n.t('errors.httpServer.addressInUse.error'),
context: i18n.t('errors.httpServer.addressInUse.context', {port: config.get('server').port}),
help: i18n.t('errors.httpServer.addressInUse.help')
});
} else {
ghostError = new errors.GhostError({
message: i18n.t('errors.httpServer.otherError.error', {errorNumber: error.errno}),
context: i18n.t('errors.httpServer.otherError.context'),
help: i18n.t('errors.httpServer.otherError.help')
});
}
reject(ghostError);
});
self.httpServer.on('listening', function () {
debug('...Started');
self._logStartMessages();
return GhostServer.announceServerReadiness()
.finally(() => {
resolve(self);
});
});
stoppable(self.httpServer, config.get('server:shutdownTimeout'));
// ensure that Ghost exits correctly on Ctrl+C and SIGTERM
process
.removeAllListeners('SIGINT').on('SIGINT', self.shutdown.bind(self))
.removeAllListeners('SIGTERM').on('SIGTERM', self.shutdown.bind(self));
if (config.get('server:testmode')) {
// Debug code
setInterval(() => self.httpServer.getConnections(
(err, connections) => logging.warn(`${connections} connections currently open`)
), 5000);
}
});
}
/**
* ### Shutdown
* Stops the server, handles cleanup and exits the process = a full shutdown
* Called on SIGINT or SIGTERM
*/
async shutdown() {
try {
logging.warn(i18n.t('notices.httpServer.ghostIsShuttingDown'));
await this.stop();
process.exit(0);
} catch (error) {
logging.error(error);
process.exit(-1);
}
}
/**
* ### Stop
* Stops the server & handles cleanup, but does not exit the process
* Used in tests for quick start/stop actions
* Called by shutdown to handle server stop and cleanup before exiting
* @returns {Promise} Resolves once Ghost has stopped
*/
async stop() {
// If we never fully started, there's nothing to stop
if (this.httpServer === null) {
return;
}
try {
// We stop the server first so that no new long running requests or processes can be started
await this._stopServer();
// Do all of the cleanup tasks
await this._cleanup();
} finally {
// Wrap up
events.emit('server.stop');
this.httpServer = null;
this._logStopMessages();
}
}
/**
* ### Hammertime
* To be called after `stop`
*/
async hammertime() {
logging.info(i18n.t('notices.httpServer.cantTouchThis'));
}
registerCleanupTask(task) {
this.cleanupTasks.push(task);
}
/**
* ### Stop Server
* Does the work of stopping the server using stoppable
* This handles closing connections:
* - New connections are rejected
* - Idle connections are closed immediately
* - Active connections are allowed to complete in-flight requests before being closed
*
* If server.shutdownTimeout is reached, requests are terminated in-flight
*/
async _stopServer() {
return new Promise((resolve, reject) => {
this.httpServer.stop((err, status) => (err ? reject(err) : resolve(status)));
});
}
async _cleanup() {
// Wait for all cleanup tasks to finish
await Promise
.all(this.cleanupTasks.map(task => task()));
}
_onShutdownComplete() {
// Wrap up
events.emit('server.stop');
this.httpServer = null;
this._logStopMessages();
}
/**
* ### Log Start Messages
*/
_logStartMessages() {
logging.info(i18n.t('notices.httpServer.ghostIsRunningIn', {env: config.get('env')}));
if (config.get('env') === 'production') {
logging.info(i18n.t('notices.httpServer.yourBlogIsAvailableOn', {url: urlUtils.urlFor('home', true)}));
} else {
logging.info(i18n.t('notices.httpServer.listeningOn', {
host: config.get('server').socket || config.get('server').host,
port: config.get('server').port
}));
logging.info(i18n.t('notices.httpServer.urlConfiguredAs', {url: urlUtils.urlFor('home', true)}));
}
logging.info(i18n.t('notices.httpServer.ctrlCToShutDown'));
}
/**
* ### Log Stop Messages
* Private / internal API
*/
_logStopMessages() {
logging.warn(i18n.t('notices.httpServer.ghostHasShutdown'));
// Extra clear message for production mode
if (config.get('env') === 'production') {
logging.warn(i18n.t('notices.httpServer.yourBlogIsNowOffline'));
}
// Always output uptime
logging.warn(
i18n.t('notices.httpServer.ghostWasRunningFor'),
moment.duration(process.uptime(), 'seconds').humanize()
);
}
}
module.exports = GhostServer;
/**
* We call announce server readiness when the server is ready
* When the server is started, but not ready, it is only able to serve 503s
*
* If the server isn't able to reach readiness, announceServerReadiness is called with an error
* A status message, any error, and debug info are all passed to managing processes via IPC and the bootstrap socket
*/
let announceServerReadinessCalled = false;
const debugInfo = {
versions: process.versions,
platform: process.platform,
arch: process.arch,
release: process.release
};
module.exports.announceServerReadiness = function (error = null) {
// If we already announced readiness, we should not do it again
if (announceServerReadinessCalled) {
return Promise.resolve();
}
// Mark this function as called
announceServerReadinessCalled = true;
// Build our message
// - if there's no error then the server is ready
let message = {
started: true,
debug: debugInfo
};
// - if there's an error then the server is not ready, include the errors
if (error) {
message.started = false;
message.error = error;
} else {
events.emit('server.start');
}
// CASE: IPC communication to the CLI for local process manager
if (process.send) {
process.send(message);
}
// CASE: use bootstrap socket to communicate with CLI for systemd
let socketAddress = config.get('bootstrap-socket');
if (socketAddress) {
return bootstrapSocket.connectAndSend(socketAddress, logging, message);
}
return Promise.resolve();
};