cn-shell
Version:
Cloud Native Shell
525 lines (524 loc) • 15.1 kB
JavaScript
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;
}
}