UNPKG

cn-shell

Version:
525 lines (524 loc) 15.1 kB
import { Logger, LogLevel } from "./logger.js"; import { LoggerConsole } from "./logger-console.js"; import { ShellExt } from "./shell-ext.js"; import { ConfigMan, ConfigTypes } from "./config-man.js"; import shelljs from "shelljs"; import { Pool } from "undici"; import * as undici from "undici"; import * as http from "node:http"; import * as os from "node:os"; import * as readline from "node:readline"; export { Logger, LogLevel, ShellExt, ConfigMan, ConfigTypes, undici }; const CFG_LOG_LEVEL = "LOG_LEVEL"; const CFG_LOG_TIMESTAMP = "LOG_TIMESTAMP"; const CFG_LOG_TIMESTAMP_FORMAT = "LOG_TIMESTAMP_FORMAT"; const CFG_HTTP_KEEP_ALIVE_TIMEOUT = "HTTP_KEEP_ALIVE_TIMEOUT"; const CFG_HTTP_HEADER_TIMEOUT = "HTTP_HEADER_TIMEOUT"; const CFG_HTTP_HEALTHCHECK_PORT = "HTTP_HEALTHCHECK_PORT"; const CFG_HTTP_HEALTHCHECK_INTERFACE = "HTTP_HEALTHCHECK_INTERFACE"; const CFG_HTTP_HEALTHCHECK_PATH = "HTTP_HEALTHCHECK_PATH"; const CFG_HTTP_HEALTHCHECK_GOOD_RES = "HTTP_HEALTHCHECK_GOOD_RES"; const CFG_HTTP_HEALTHCHECK_BAD_RES = "HTTP_HEALTHCHECK_BAD_RES"; const DEFAULT_SHELL_CONFIG = { appVersion: "N/A", log: { level: "INFO", timestamp: false, timestampFormat: "ISO", }, http: { keepAliveTimeout: 65000, headerTimeout: 66000, healthcheckPort: 8080, healthcheckInterface: "", healthcheckPath: "/healthcheck", healthcheckGoodRes: 200, healthcheckBadRes: 503, }, }; const DEFAULT_CONFIG_OPTIONS = { envVarPrefix: "CNA_", }; const DEFAULT_QUESTION_OPTIONS = { muteAnswer: false, muteChar: "*", }; const DEFAULT_HTTP_REQ_POOL_OPTIONS = {}; const DEFAULT_HTTP_REQ_OPTIONS = { method: "GET", }; const NODE_ENV = process.env.NODE_ENV === undefined ? "development" : process.env.NODE_ENV; const LOGGER_APP_NAME = "App"; export class Shell { _name; _appVersion; _configMan; _logger; _httpKeepAliveTimeout; _httpHeaderTimeout; _healthcheckPort; _healthcheckInterface; _healthCheckPath; _healthCheckGoodResCode; _healthCheckBadResCode; _healthcheckServer; _exts; _httpReqPools; cat = shelljs.cat; cd = shelljs.cd; chmod = shelljs.chmod; cp = shelljs.cp; dirs = shelljs.dirs; echo = shelljs.echo; env = shelljs.env; testError = shelljs.error; exec = shelljs.exec; find = shelljs.find; grep = shelljs.grep; head = shelljs.head; ln = shelljs.ln; ls = shelljs.ls; mkdir = shelljs.mkdir; mv = shelljs.mv; popd = shelljs.popd; pushd = shelljs.pushd; pwd = shelljs.pwd; rm = shelljs.rm; sed = shelljs.sed; set = shelljs.set; sort = shelljs.sort; tail = shelljs.tail; tempdir = shelljs.tempdir; touch = shelljs.touch; uniq = shelljs.uniq; constructor(passedConfig) { let config = { name: passedConfig.name, appVersion: passedConfig.appVersion === undefined ? DEFAULT_SHELL_CONFIG.appVersion : passedConfig.appVersion, http: { ...DEFAULT_SHELL_CONFIG.http, ...passedConfig.http, }, log: { ...DEFAULT_SHELL_CONFIG.log, ...passedConfig.log, }, }; this._name = config.name; this._appVersion = config.appVersion; this._configMan = new ConfigMan(); this._exts = []; this._httpReqPools = {}; this.ls = shelljs.ls; if (config.log?.logger !== undefined) { this._logger = config.log.logger; } else { let logTimestamps = this.getConfigBool(CFG_LOG_TIMESTAMP, { defaultVal: config.log.timestamp, }); let logTimestampFormat = this.getConfigStr(CFG_LOG_TIMESTAMP_FORMAT, { defaultVal: config.log.timestampFormat, }); this._logger = new LoggerConsole( config.name, logTimestamps, logTimestampFormat, ); } this._logger.start(); let logLevel = this.getConfigStr(CFG_LOG_LEVEL, { defaultVal: config.log.level, }); switch (logLevel.toUpperCase()) { case "SILENT": this._logger.level = LogLevel.LOG_COMPLETE_SILENCE; break; case "QUIET": this._logger.level = LogLevel.LOG_QUIET; break; case "INFO": this._logger.level = LogLevel.LOG_INFO; break; case "STARTUP": this._logger.level = LogLevel.LOG_START_UP; break; case "DEBUG": this._logger.level = LogLevel.LOG_DEBUG; break; case "TRACE": this._logger.level = LogLevel.LOG_TRACE; break; default: this._logger.level = LogLevel.LOG_INFO; this._logger.warn( `LogLevel ${logLevel} is unknown. Setting level to INFO.`, ); break; } this._healthcheckInterface = this.getConfigStr( CFG_HTTP_HEALTHCHECK_INTERFACE, { defaultVal: config.http.healthcheckInterface }, ); this._healthcheckPort = this.getConfigNum(CFG_HTTP_HEALTHCHECK_PORT, { defaultVal: config.http.healthcheckPort, }); this._httpKeepAliveTimeout = this.getConfigNum( CFG_HTTP_KEEP_ALIVE_TIMEOUT, { defaultVal: config.http.keepAliveTimeout }, ); this._httpHeaderTimeout = this.getConfigNum(CFG_HTTP_HEADER_TIMEOUT, { defaultVal: config.http.headerTimeout, }); this._healthCheckPath = this.getConfigStr(CFG_HTTP_HEALTHCHECK_PATH, { defaultVal: config.http.healthcheckPath, }); this._healthCheckGoodResCode = this.getConfigNum( CFG_HTTP_HEALTHCHECK_GOOD_RES, { defaultVal: config.http.healthcheckGoodRes }, ); this._healthCheckBadResCode = this.getConfigNum( CFG_HTTP_HEALTHCHECK_BAD_RES, { defaultVal: config.http.healthcheckBadRes }, ); this.startup("Shell created!"); } async start() { this.startup("Started!"); return true; } async stop() { this.startup("Stopped!"); return; } async healthCheck() { this.debug("Health check called"); return true; } get name() { return this._name; } get appVersion() { return this._appVersion; } get logger() { return this._logger; } set level(level) { this._logger.level = level; } setupHealthcheck() { if (this._healthcheckInterface.length === 0) { this.startup( "No HTTP interface specified for healthcheck endpoint - healthcheck disabled!", ); return; } this.startup("Initialising healthcheck HTTP endpoint ..."); this.startup(`Finding IP for interface (${this._healthcheckInterface})`); let ifaces = os.networkInterfaces(); this.startup("Interfaces on host: %j", ifaces); if (ifaces[this._healthcheckInterface] === undefined) { throw new Error( `${this._healthcheckInterface} is not an interface on this server`, ); } let ip = ""; let found = ifaces[this._healthcheckInterface]?.find( (i) => i.family === "IPv4", ); if (found !== undefined) { ip = found.address; this.startup( `Found IP (${ip}) for interface ${this._healthcheckInterface}`, ); this.startup( `Will listen on interface ${this._healthcheckInterface} (IP: ${ip})`, ); } if (ip.length === 0) { throw new Error( `${this._healthcheckInterface} is not an interface on this server`, ); } this.startup( `Attempting to listen on (http://${ip}:${this._healthcheckPort})`, ); this._healthcheckServer = http .createServer((req, res) => this.healthcheckCallback(req, res)) .listen(this._healthcheckPort, ip); this._healthcheckServer.keepAliveTimeout = this._httpKeepAliveTimeout; this._healthcheckServer.headersTimeout = this._httpHeaderTimeout; this.startup("Now listening. Healthcheck endpoint enabled!"); } async startupError(code, testing) { this.error("Heuston, we have a problem. Shutting down now ..."); if (testing) { await this.exit(code, false); return; } await this.exit(code); } async init(testing = false) { this.startup("Initialising ..."); this.startup(`App Version (${this._appVersion})`); this.startup(`NODE_ENV (${NODE_ENV})`); for (let ext of this._exts) { this.startup(`Attempting to start extension ${ext.name} ...`); await ext.start().catch(async (e) => { this.error(e); await this.startupError(-1, testing); }); } this.startup("Attempting to start the application ..."); await this.start().catch(async (e) => { this.error(e); await this.startupError(-1, testing); }); await this.setupHealthcheck(); this.startup("Setting up event handler for SIGINT and SIGTERM"); process.on("SIGINT", async () => await this.exit(0)); process.on("SIGTERM", async () => await this.exit(0)); this.startup("Ready to Rock and Roll baby!"); } async exit(code, hard = true) { this.startup("Exiting ..."); if (this._healthcheckServer !== undefined) { this.startup("Closing healthcheck endpoint port now ..."); this._healthcheckServer.close(); this.startup("Port closed"); } this.startup("Attempting to stop the application ..."); await this.stop().catch((e) => { this.error(e); }); for (let ext of this._exts.reverse()) { this.startup(`Attempting to stop extension ${ext.name} ...`); await ext.stop().catch((e) => { this.error(e); }); } for (let origin in this._httpReqPools) { this._httpReqPools[origin].destroy(); } this.startup("So long and thanks for all the fish!"); this._logger.stop(); if (hard) { process.exit(code); } } getConfigStr(config, passedOptions = {}, appOrExtName = LOGGER_APP_NAME) { let options = { ...DEFAULT_CONFIG_OPTIONS, ...passedOptions, }; return this._configMan.get( config, ConfigTypes.String, options, appOrExtName, this._logger, ); } getConfigBool(config, passedOptions = {}, appOrExtName = LOGGER_APP_NAME) { let options = { ...DEFAULT_CONFIG_OPTIONS, ...passedOptions, }; return this._configMan.get( config, ConfigTypes.Boolean, options, appOrExtName, this._logger, ); } getConfigNum(config, passedOptions = {}, appOrExtName = LOGGER_APP_NAME) { let options = { ...DEFAULT_CONFIG_OPTIONS, ...passedOptions, }; return this._configMan.get( config, ConfigTypes.Number, options, appOrExtName, this._logger, ); } async healthcheckCallback(req, res) { if ( req.method?.toLowerCase() !== "get" || req.url !== this._healthCheckPath ) { res.statusCode = 404; res.end(); return; } let healthy = await this.healthCheck().catch((e) => { this.error(e); }); if (healthy) { res.statusCode = this._healthCheckGoodResCode; } else { res.statusCode = this._healthCheckBadResCode; } res.end(); } fatal(...args) { this._logger.fatal(LOGGER_APP_NAME, ...args); } error(...args) { this._logger.error(LOGGER_APP_NAME, ...args); } warn(...args) { this._logger.warn(LOGGER_APP_NAME, ...args); } info(...args) { this._logger.info(LOGGER_APP_NAME, ...args); } startup(...args) { this._logger.startup(LOGGER_APP_NAME, ...args); } debug(...args) { this._logger.debug(LOGGER_APP_NAME, ...args); } trace(...args) { this._logger.trace(LOGGER_APP_NAME, ...args); } force(...args) { this._logger.force(LOGGER_APP_NAME, ...args); } addExt(ext) { this.startup(`Adding extension ${ext.name}`); this._exts.push(ext); } async sleep(durationInSeconds) { let ms = Math.round(durationInSeconds * 1000); return new Promise((resolve) => { setTimeout(resolve, ms); }); } async question(ask, passedOptions) { let input = process.stdin; let output = process.stdout; let options = { ...DEFAULT_QUESTION_OPTIONS, ...passedOptions, }; return new Promise((resolve) => { let rl = readline.createInterface({ input, output, }); if (options.muteAnswer) { input.on("keypress", () => { var len = rl.line.length; if (options.muteChar.length === 0) { readline.moveCursor(output, -1, 0); readline.clearLine(output, 1); } else { readline.moveCursor(output, -len, 0); readline.clearLine(output, 1); for (var i = 0; i < len; i++) { output.write(options.muteChar[0]); } } }); } rl.question(ask, (answer) => { resolve(answer); rl.close(); }); }); } createHttpReqPool(origin, passedOptions) { let options = { ...DEFAULT_HTTP_REQ_POOL_OPTIONS, ...passedOptions, }; this.trace( "Creating new HTTP pool for (%s) with options (%j)", origin, passedOptions, ); if (this._httpReqPools[origin] !== undefined) { throw new Error(`A HTTP pool already exists for ${origin}`); } this._httpReqPools[origin] = new Pool(origin, options); } async httpReq(origin, path, passedOptions) { let options = { ...DEFAULT_HTTP_REQ_OPTIONS, ...passedOptions, }; this.trace("httpReq for origin (%s) path (%s)", origin, path); let pool = this._httpReqPools[origin]; if (pool === undefined) { this.createHttpReqPool(origin); pool = this._httpReqPools[origin]; } let headers = options.headers === undefined ? {} : options.headers; if (options.bearerToken !== undefined) { headers.Authorization = `Bearer ${options.bearerToken}`; } if (options.auth !== undefined) { let token = Buffer.from( `${options.auth.username}:${options.auth.password}`, ).toString("base64"); headers.Authorization = `Basic ${token}`; } let body; if (options.body !== undefined && options.method !== "GET") { if ( options.headers?.["content-type"] === undefined || typeof options.body === "object" ) { headers["content-type"] = "application/json; charset=utf-8"; body = JSON.stringify(options.body); } else { body = options.body; } } let results = await pool.request({ origin, path, method: options.method, headers, query: options.searchParams, body, }); let resData; let contentExists = false; if ( results.headers["content-length"] !== undefined && results.headers["content-length"] !== "0" ) { contentExists = true; } if ( contentExists && results.headers["content-type"]?.startsWith("application/json") === true ) { resData = await results.body.json(); } else { resData = await results.body.text(); if ( resData.length && results.headers["content-type"]?.startsWith("application/json") === true ) { resData = JSON.parse(resData); } } let res = { statusCode: results.statusCode, headers: results.headers, trailers: results.trailers, body: resData, }; return res; } }