UNPKG

nginx-testing

Version:

Support for integration/acceptance testing of nginx configuration.

275 lines 12.4 kB
"use strict"; 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