nginx-testing
Version:
Support for integration/acceptance testing of nginx configuration.
275 lines • 12.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.__testing = exports.startNginx = exports.configPatch = void 0;
const OS = require("node:os");
const path = require("node:path");
const process = require("node:process");
const node_stream_1 = require("node:stream");
const TailFile = require("@logdna/tail-file");
const execa = require("execa");
const getPort = require("get-port");
const nginx_binaries_1 = require("nginx-binaries");
const stream_buffers_1 = require("stream-buffers");
const utils_1 = require("./internal/utils");
const FS = require("./internal/fs");
const useCleanup_1 = require("./internal/useCleanup");
const tempDir_1 = require("./internal/tempDir");
const waitForHttpPortOpen_1 = require("./internal/waitForHttpPortOpen");
const logger_1 = require("./logger");
const nginxConf_1 = require("./nginxConf");
const nginxVersionInfo_1 = require("./nginxVersionInfo");
/**
* The default patch to be applied on the nginx configs to make it compatible with
* the runner.
*/
exports.configPatch = [
{ path: '/daemon', op: 'set', value: 'off' },
{ path: '/pid', op: 'set', value: 'nginx.pid' },
// This is necessary on Windows, otherwise execa fails to kill nginx.
{ path: '/master_process', op: 'default', value: 'off' },
{ path: '/error_log', op: 'default', value: 'stderr info' },
{ path: '/http/access_log', op: 'default', value: 'access.log' },
{ path: '/http/client_body_temp_path', op: 'default', value: 'client_body_temp' },
{ path: '/http/proxy_temp_path', op: 'default', value: 'proxy_temp', ifModule: 'http_proxy' },
{ path: '/http/fastcgi_temp_path', op: 'default', value: 'fastcgi_temp', ifModule: 'http_fastcgi' },
{ path: '/http/uwsgi_temp_path', op: 'default', value: 'uwsgi_temp', ifModule: 'http_uwsgi' },
{ path: '/http/scgi_temp_path', op: 'default', value: 'scgi_temp', ifModule: 'http_scgi' },
];
/**
* Starts nginx server with the given configuration.
*
* @example
* import { startNginx, NginxServer } from 'nginx-testing'
* import fetch from 'node-fetch'
*
* let nginx: NginxServer
*
* before(async () => {
* nginx = await startNginx({ version: '1.24.x', configPath: './nginx.conf' })
* })
* after(nginx.stop)
*
* test('GET / results in HTTP 200', async () => {
* const resp = await fetch(`http://localhost:${nginx.port}/`)
* assert(resp.status === 200)
* })
*/
async function startNginx(opts) {
var _a;
if (!opts.config && !opts.configPath) {
throw TypeError('Either config or configPath must be provided');
}
const { accessLog = 'buffer', bindAddress = '127.0.0.1', errorLog = 'buffer', ports = [], preferredPorts = [], startTimeoutMsec = 1000, } = opts;
const [onCleanup, cleanup] = (0, useCleanup_1.useCleanup)({ registerExitHook: true });
try {
let workDir;
if (opts.workDir) {
workDir = opts.workDir;
await FS.mkdir(workDir, { recursive: true });
}
else {
workDir = (0, tempDir_1.createTempDir)('nginx-testing');
// Async rm does not remove the dir on Windows (and I have no idea why).
onCleanup(() => FS.rmRfSync(workDir));
}
const binPath = opts.version
? await nginx_binaries_1.NginxBinary.download({ version: opts.version })
: (opts.binPath || 'nginx');
const versionInfo = await (0, nginxVersionInfo_1.nginxVersionInfo)(binPath);
// Prepare config
let config = (_a = opts.config) !== null && _a !== void 0 ? _a : await FS.readFile(opts.configPath, 'utf8');
let portsCount = countNeededPorts(config);
if (portsCount === 0 && ports.length === 0 && preferredPorts.length === 0) {
throw Error('No __PORT__ placeholder found in nginx config and options ports and preferredPorts are empty');
}
portsCount || (portsCount = 1);
if (ports.length < portsCount) {
ports.push(...await getFreePorts(bindAddress, portsCount - ports.length, preferredPorts));
}
const configPath = opts.configPath
? tempConfigPath(opts.configPath)
: path.join(workDir, 'nginx.conf');
const configParams = { ...versionInfo, bindAddress, configPath, ports, workDir };
config = adjustConfig(config, configParams);
await writeConfigFile(configPath, config);
onCleanup(() => FS.rmRfSync(configPath));
// Start nginx
logger_1.log.info(`Starting nginx ${versionInfo.version} on port(s): ${ports.join(', ')}`);
const startOpts = { binPath, configPath, bindAddress, ports, workDir, errorLog, startTimeoutMsec };
let [ngxProcess, errorLogBuffer] = await startAndCheckNginxProcess(startOpts, onCleanup);
// Set-up access log
const accessLogStream = accessLog instanceof node_stream_1.EventEmitter
? accessLog
: new stream_buffers_1.WritableStreamBuffer();
const accessLogTail = accessLog !== 'ignore'
? await tailLogFile(path.join(workDir, 'access.log'), accessLogStream, onCleanup)
: null;
// Commons for control functions
const updateConfigIfDefined = async (opts) => {
var _a;
if (opts.config || opts.configPath) {
const newConfig = (_a = opts.config) !== null && _a !== void 0 ? _a : await FS.readFile(opts.configPath, 'utf8');
config = adjustConfig(newConfig, configParams);
await writeConfigFile(configPath, config);
}
};
let isMasterProcess;
// Return
return {
get config() { return config; },
get pid() { return ngxProcess.pid; },
ports,
port: ports[0],
workDir,
readAccessLog: async () => {
if (!accessLogTail || !(accessLogStream instanceof stream_buffers_1.WritableStreamBuffer)) {
throw Error("This function is available only when the option 'accessLog' is 'buffer'");
}
if ('_pollFileForChanges' in accessLogTail) {
await accessLogTail._pollFileForChanges();
}
return accessLogStream.getContentsAsString() || '';
},
// This function doesn't need to be async now, but may be in the future.
readErrorLog: async () => {
if (!errorLogBuffer) {
throw Error("This function is available only when the option 'errorLog' is 'buffer'");
}
return errorLogBuffer.getContentsAsString() || '';
},
reload: async (opts = {}) => {
if (OS.platform() === 'win32') {
throw Error('Not supported on Windows');
}
if (!(isMasterProcess !== null && isMasterProcess !== void 0 ? isMasterProcess : (isMasterProcess = isMasterProcessEnabled(config)))) {
throw Error('Nginx cannot be reloaded when master_process is off');
}
logger_1.log.info(`Reloading nginx`);
await updateConfigIfDefined(opts);
logger_1.log.debug('Sending SIGHUP to nginx process');
process.kill(ngxProcess.pid, 'SIGHUP');
},
restart: async (opts = {}) => {
logger_1.log.info(`Restarting nginx`);
ngxProcess.cancel();
await updateConfigIfDefined(opts);
logger_1.log.debug('Starting new nginx process');
[ngxProcess, errorLogBuffer] = await startAndCheckNginxProcess(startOpts, onCleanup);
},
stop: cleanup,
};
}
catch (err) {
await cleanup();
throw err;
}
}
exports.startNginx = startNginx;
const portPlaceholderRx = /\b__PORT(?:_(\d))?__\b/g;
function countNeededPorts(config) {
const portIndexes = Array.from(config.matchAll(portPlaceholderRx), ([_, n]) => Number(n || 0));
return Math.max(...portIndexes, -1) + 1;
}
async function getFreePorts(address, count, preferred = []) {
const ports = [];
for (let i = count; i > 0; i--) {
ports.push(await getPort({ host: address, port: preferred }));
}
return ports;
}
function adjustConfig(config, { bindAddress, configPath, modules, ports, workDir }) {
const placeholders = {
ADDRESS: bindAddress,
// nginx requires forward slashes even on Windows.
CONFDIR: unixPath(path.dirname(configPath)),
CWD: unixPath(process.cwd()),
WORKDIR: unixPath(workDir),
};
config = config
.replace(portPlaceholderRx, (match, idx) => { var _a, _b; return (_b = (_a = ports[Number(idx) || 0]) === null || _a === void 0 ? void 0 : _a.toString()) !== null && _b !== void 0 ? _b : match; })
.replace(/\b__([A-Z_]+)__\b/g, (match, name) => { var _a; return (_a = placeholders[name]) !== null && _a !== void 0 ? _a : match; });
const patch = exports.configPatch.filter(({ ifModule }) => {
return !ifModule || modules[ifModule] !== 'without' && modules[ifModule] !== 'with-dynamic';
});
if (patch.length > 0) {
config = (0, nginxConf_1.parseConf)(config).applyPatch(patch).toString();
}
return config;
}
const unixPath = (filepath) => filepath.replace(/\\/g, '/');
function tempConfigPath(filepath) {
return path.join(path.dirname(path.resolve(filepath)), `.${path.basename(filepath)}~`);
}
async function writeConfigFile(configPath, config) {
logger_1.log.debug(`Writing config to ${configPath}:\n-----BEGIN CONFIG-----\n${config}\n-----END CONFIG-----`);
await FS.writeFile(configPath, config, 'utf8');
}
async function startAndCheckNginxProcess(opts, onCleanup) {
const { errorLog } = opts;
// Start process
const ngxProcess = execa(opts.binPath, ['-c', opts.configPath, '-p', opts.workDir], {
stdin: 'ignore',
stdout: 'ignore',
stderr: errorLog === 'buffer' ? 'pipe' : errorLog,
});
onCleanup(() => {
if (!ngxProcess.killed) {
logger_1.log.debug(`Stopping nginx (${ngxProcess.pid})`);
ngxProcess.cancel();
}
});
logger_1.log.debug(`Nginx started with PID ${ngxProcess.pid}`);
// Set-up error log
let errorLogBuffer;
if (errorLog === 'buffer') {
errorLogBuffer = new stream_buffers_1.WritableStreamBuffer();
ngxProcess.stderr.pipe(errorLogBuffer);
}
const dumpErrorLog = () => {
const msg = errorLogBuffer === null || errorLogBuffer === void 0 ? void 0 : errorLogBuffer.getContentsAsString();
msg && logger_1.log.error(msg);
};
// Check if running
// Wait up to 50 ms for an error and continue if no error appeared.
// If nginx cannot be executed, e.g. invalid path, we want to fail fast,
// dump error log and throw a relevant error.
try {
await waitForProcessError(ngxProcess, 50);
}
catch (err) {
dumpErrorLog();
throw err;
}
const checkRequestOpts = { hostname: opts.bindAddress, port: opts.ports[0], path: '/health' };
if (!await (0, waitForHttpPortOpen_1.waitForHttpPortOpen)(checkRequestOpts, opts.startTimeoutMsec)) {
dumpErrorLog();
throw Error(`Failed to start nginx, no response on port ${opts.ports[0]}`);
}
return [ngxProcess, errorLogBuffer];
}
async function tailLogFile(filepath, output, onCleanup) {
const tail = new TailFile(filepath, { pollFileIntervalMs: 10 });
tail.pipe(output);
logger_1.log.debug(`Begins polling of ${filepath}`);
await tail.start();
// TailFile startPos from EOF doesn't work reliably, so better to remove
// the file to avoid reading old logs on next run.
onCleanup(() => FS.rmSync(filepath));
onCleanup(async () => await tail.quit());
return tail;
}
const waitForProcessError = (process, timeout) => new Promise((resolve, reject) => {
process.once('error', reject);
setTimeout(() => {
process.removeListener('error', reject);
resolve();
}, timeout);
});
function isMasterProcessEnabled(config) {
return (0, utils_1.arrify)((0, nginxConf_1.parseConf)(config).get('/master_process')).pop() !== 'off';
}
/** @internal */
exports.__testing = {
adjustConfig,
};
//# sourceMappingURL=nginxRunner.js.map