603 lines
17 KiB
JavaScript
603 lines
17 KiB
JavaScript
// Node Web App (NWA) Library Module
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import yargs from 'yargs';
|
|
import { hideBin } from 'yargs/helpers';
|
|
import { fileURLToPath } from 'url';
|
|
import crypto from 'crypto';
|
|
import { execFileSync } from 'child_process';
|
|
import { get } from 'http';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// console.log("DEBUG: __dirname: " + __dirname);
|
|
|
|
// Process start time (set once per Node.js process)
|
|
const PROCESS_START_TS = Date.now();
|
|
|
|
const config = load_config();
|
|
|
|
function load_config() {
|
|
let config = {};
|
|
|
|
const argv = yargs(hideBin(process.argv))
|
|
.usage('Usage: npm start -- <options>')
|
|
.option('configfile', {
|
|
alias: 'c',
|
|
description: 'Path to config file',
|
|
type: 'string'
|
|
})
|
|
.help()
|
|
.alias('help', 'h')
|
|
.argv;
|
|
|
|
// console.log("DEBUG: dirname: " + __dirname);
|
|
let sdl_home = path.join(__dirname, '..', '..', '..', '..', '..'); // SDL_HOME Default
|
|
let sdl_mgr_cfg_file = path.join(sdl_home, 'conf', 'sdl-mgr-config.json');
|
|
|
|
// console.log("DEBUG: SDL_HOME: " + sdl_home);
|
|
// console.log("DEBUG: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
|
|
try {
|
|
fs.accessSync(sdl_mgr_cfg_file, fs.constants.R_OK);
|
|
} catch (err) {
|
|
console.error(err);
|
|
process.exit(1);
|
|
}
|
|
|
|
// // Load from ENV
|
|
// if (process.env.SDL_HOME) {
|
|
// sdl_home = process.env.SDL_HOME;
|
|
// sdl_mgr_cfg_file = path.join(sdl_home, 'conf', 'sdl-mgr-config.json');
|
|
// // console.log("DEBUG3: SDL_HOME: " + sdl_home);
|
|
// // console.log("DEBUG3: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
|
|
// try {
|
|
// fs.accessSync(sdl_mgr_cfg_file, fs.constants.R_OK);
|
|
// } catch (err) {
|
|
// console.error(err);
|
|
// process.exit(1);
|
|
// }
|
|
// }
|
|
|
|
// // Load from ARGV
|
|
// if (argv.configfile) {
|
|
// sdl_home = path.dirname(argv.configfile);
|
|
// sdl_mgr_cfg_file = path.join(sdl_home, 'conf', 'sdl-mgr-config.json');
|
|
// // console.log("DEBUG4: SDL_HOME: " + sdl_home);
|
|
// // console.log("DEBUG4: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
|
|
// try {
|
|
// fs.accessSync(sdl_mgr_cfg_file, fs.constants.R_OK);
|
|
// } catch (err) {
|
|
// console.error(err);
|
|
// process.exit(1);
|
|
// }
|
|
// }
|
|
|
|
// console.log("DEBUG: SDL_HOME: " + sdl_home);
|
|
// console.log("DEBUG: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
|
|
|
|
|
|
|
|
// Load Config
|
|
try {
|
|
config = JSON.parse(fs.readFileSync(sdl_mgr_cfg_file, 'utf8'));
|
|
config.dirs.base = config.dirs.base + '/..'
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
|
|
// console.log("DEBUG: sdl_home: " + sdl_home);
|
|
|
|
// Expand Data Directory
|
|
let search_replace = {}
|
|
search_replace['DIRNAME'] = __dirname
|
|
// search_replace['SDLHOME'] = sdl_home
|
|
config.dirs['SDLHOME'] = sdl_home
|
|
for (let d in config.dirs) {
|
|
search_replace[d.toUpperCase()] = d
|
|
}
|
|
|
|
for (let d in config.dirs) {
|
|
for (let s in search_replace) {
|
|
switch (s) {
|
|
case 'DIRNAME':
|
|
config.dirs[d] = path.join(config.dirs[d].replace(s, __dirname))
|
|
break
|
|
default:
|
|
config.dirs[d] = path.join(config.dirs[d].replace(s, config.dirs[search_replace[s]]))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Replace DIRNAMES for metadata objects
|
|
for (let m in config.metadata) {
|
|
for (let s in search_replace) {
|
|
switch (s) {
|
|
case 'DIRNAME':
|
|
config.metadata[m] = path.join(config.metadata[m].replace(s, __dirname))
|
|
break
|
|
default:
|
|
config.metadata[m] = path.join(config.metadata[m].replace(s, config.dirs[search_replace[s]]))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure all directories exist
|
|
for (let d in config.dirs) {
|
|
if (!fs.existsSync(config.dirs[d])) {
|
|
fs.mkdirSync(config.dirs[d], { recursive: true });
|
|
}
|
|
}
|
|
// for (let k in config.dependencies[d]) {
|
|
// // if k matches case insensitive "dependencies" the remove elemend k.
|
|
// if (k.match(/[Dd]ependencies/)) {
|
|
// delete config.dependencies[d][k]
|
|
// }
|
|
|
|
// if (k.match(/scripts/)) {
|
|
// delete config.dependencies[d][k]
|
|
// }
|
|
// }
|
|
|
|
// Load Package Info
|
|
config.package = JSON.parse(fs.readFileSync(path.join(config.dirs.app, 'package.json'), 'utf8'));
|
|
config.dependencies = {}
|
|
|
|
for (let d in config.package.dependencies) {
|
|
config.dependencies[d] = JSON.parse(fs.readFileSync(path.join(config.dirs.app, 'node_modules', d, 'package.json'), 'utf8'));
|
|
}
|
|
|
|
// -------------------------------
|
|
// Host Metadata (static)
|
|
// -------------------------------
|
|
config.host = {
|
|
hostname: os.hostname(),
|
|
ips: [],
|
|
|
|
os: {
|
|
id: null,
|
|
name: null,
|
|
version: null,
|
|
pretty_name: null
|
|
, },
|
|
|
|
cpu: {
|
|
arch: os.arch(), // x64, arm64, etc.
|
|
model: null,
|
|
cores_logical: 0
|
|
},
|
|
|
|
memory: {
|
|
total_gb: Math.round(os.totalmem() / (1024 ** 3))
|
|
},
|
|
|
|
gpu: {
|
|
},
|
|
|
|
network: {}
|
|
|
|
};
|
|
|
|
config.identity = {
|
|
sdl_id: null,
|
|
created_at: null,
|
|
hostname: null
|
|
}
|
|
|
|
// console.log('config: host:', JSON.stringify(config.host, null, 2));
|
|
|
|
config.host.gpu = detectGPUs();
|
|
|
|
// Get Host IP Addresses
|
|
const networkInterfaces = os.networkInterfaces();
|
|
// console.log(JSON.stringify(networkInterfaces, null, 2));
|
|
config.host.ips = [];
|
|
for (const interfaceName in networkInterfaces) {
|
|
config.host.network[interfaceName] = networkInterfaces[interfaceName];
|
|
|
|
const addresses = networkInterfaces[interfaceName];
|
|
for (const address of addresses) {
|
|
if (address.family === 'IPv4' && !address.internal) {
|
|
config.host.ips.push(address.address);
|
|
}
|
|
}
|
|
}
|
|
|
|
config.host.ips.push('127.0.0.1');
|
|
|
|
// -------------------------------
|
|
// CPU Info
|
|
// -------------------------------
|
|
const cpus = os.cpus();
|
|
// console.log('cpus: ', JSON.stringify(cpus, null, 2));
|
|
if (cpus.length > 0) {
|
|
config.host.cpu.model = cpus[0].model;
|
|
config.host.cpu.cores_logical = cpus.length;
|
|
}
|
|
|
|
// -------------------------------
|
|
// OS Info from /etc/os-release (Linux)
|
|
// -------------------------------
|
|
const osReleasePath = '/etc/os-release';
|
|
|
|
if (fs.existsSync(osReleasePath)) {
|
|
try {
|
|
const content = fs.readFileSync(osReleasePath, 'utf8');
|
|
const lines = content.split('\n');
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^([A-Z_]+)=(.*)$/);
|
|
if (!match) continue;
|
|
|
|
const key = match[1];
|
|
const value = match[2].replace(/^"/, '').replace(/"$/, '');
|
|
|
|
switch (key) {
|
|
case 'ID':
|
|
config.host.os.id = value;
|
|
break;
|
|
case 'NAME':
|
|
config.host.os.name = value;
|
|
break;
|
|
case 'VERSION_ID':
|
|
config.host.os.version = value;
|
|
break;
|
|
case 'PRETTY_NAME':
|
|
config.host.os.pretty_name = value;
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Non-fatal: OS info optional
|
|
}
|
|
}
|
|
|
|
// -------------------------------
|
|
// SDL Identity
|
|
// -------------------------------
|
|
|
|
try {
|
|
const idinfo = load_or_create_sdl_id(config.dirs.sdlhome);
|
|
config.identity.sdl_id = idinfo.sdl_id;
|
|
config.identity.created_at = idinfo.created_at;
|
|
config.identity.hostname = config.host.hostname;
|
|
} catch (err) {
|
|
console.error('Idnetity Error:', err);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Node.js Version
|
|
// config.nodejs = {
|
|
// version: process.version,
|
|
// }
|
|
config.nodejs = {
|
|
version: process.version, // v22.16.0
|
|
v8: process.versions.v8, // V8 engine version
|
|
abi: process.versions.modules, // Node ABI
|
|
arch: process.arch, // x64, arm64
|
|
platform: process.platform, // linux, darwin, win32
|
|
exec_path: process.execPath, // /usr/bin/node
|
|
openssl: process.versions.openssl || null,
|
|
uv: process.versions.uv || null,
|
|
icu: process.versions.icu || null
|
|
};
|
|
|
|
// console.log('config: ', JSON.stringify(config.dirs, null, 2));
|
|
// console.log('modconf: ', config.dirs.modconf);
|
|
|
|
|
|
// Load Module Config
|
|
for (let m in config.modules) {
|
|
try {
|
|
config.modules[m] = JSON.parse(fs.readFileSync(path.join(config.dirs.modconf, `${m}.json`), 'utf8'));
|
|
config.modules[m].dir = path.join(config.dirs.modules, m);
|
|
config.modules[m].package = JSON.parse(fs.readFileSync(path.join(config.modules[m].dir, 'package.json'), 'utf8'));
|
|
config.modules[m].main = path.join(config.modules[m].dir, config.modules[m].package.main);
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
|
|
// for each item, expand dirs with search and replace
|
|
for (let i in config.modules[m]) {
|
|
// console.log("DEBUG: " + i + " " + String(config.modules[m][i]))
|
|
// If item is a path
|
|
if (String(config.modules[m][i]).match(/\//)) {
|
|
// console.log("DEBUG: PATH: " + i + " " + String(config.modules[m][i]))
|
|
for (let s in search_replace) {
|
|
switch (s) {
|
|
case 'DIRNAME':
|
|
// config.modules[m][i] = String(path.join(config.modules[m][i])).replace(s, __dirname)
|
|
config.modules[m][i] = path.join(config.modules[m][i]).replace(s, __dirname)
|
|
break
|
|
default:
|
|
// config.modules[m][i] = String(path.join(config.modules[m][i])).replace(s, config.dirs[search_replace[s]])
|
|
config.modules[m][i] = path.join(config.modules[m][i]).replace(s, config.dirs[search_replace[s]])
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
// console.log("DEBUG: NOT PATH: " + i + " " + String(config.modules[m][i]))
|
|
}
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
|
|
// Logger Function
|
|
function log(msg, cnsl=false) {
|
|
const now = new Date();
|
|
const ts_datetime = now.toISOString(); // "YYYY-MM-DDTHH:MM:SS.sssZ"
|
|
const ts_date = ts_datetime.split('T')[0]; // "YYYY-MM-DD"
|
|
|
|
const logFile = path.join(config.dirs.logs, `${config.package.name}-${ts_date}.log`);
|
|
|
|
try {
|
|
fs.appendFileSync(logFile, `${ts_datetime} : ${msg}\n`, 'utf8');
|
|
if (cnsl) { console.log(msg); }
|
|
} catch (err) {
|
|
console.error(`Logger error: Could not write to ${logFile}`, err);
|
|
}
|
|
}
|
|
|
|
function ipToInt(ip) {
|
|
return ip.split('.').reduce((acc, oct) => (acc << 8) + Number(oct), 0) >>> 0;
|
|
}
|
|
|
|
function intToIp(int) {
|
|
return [
|
|
(int >>> 24) & 255,
|
|
(int >>> 16) & 255,
|
|
(int >>> 8) & 255,
|
|
int & 255
|
|
].join('.');
|
|
}
|
|
|
|
function computeBroadcast(address, netmask) {
|
|
try {
|
|
const ipInt = ipToInt(address);
|
|
const maskInt = ipToInt(netmask);
|
|
const networkInt = ipInt & maskInt;
|
|
const broadcastInt = networkInt | (~maskInt >>> 0);
|
|
return intToIp(broadcastInt);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function load_or_create_sdl_id(sdl_home) {
|
|
const id_dir = path.join(sdl_home, 'conf');
|
|
const id_file = path.join(id_dir, 'sdl-id.json');
|
|
|
|
try {
|
|
if (fs.existsSync(id_file)) {
|
|
const data = JSON.parse(fs.readFileSync(id_file, 'utf8'));
|
|
if (data.sdl_id) {
|
|
return data;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to read sdl-id.json:', err);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Create new identity
|
|
const sdl_id = {
|
|
sdl_id: crypto.randomUUID(),
|
|
created_at: new Date().toISOString(),
|
|
hostname: os.hostname()
|
|
};
|
|
|
|
try {
|
|
fs.mkdirSync(id_dir, { recursive: true });
|
|
fs.writeFileSync(id_file, JSON.stringify(sdl_id, null, 2), { mode: 0o600 });
|
|
} catch (err) {
|
|
console.error('Failed to write sdl-id.json:', err);
|
|
process.exit(1);
|
|
}
|
|
|
|
return sdl_id;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Detect GPUs using /sys/class/drm (authoritative) + lspci (labels)
|
|
* Non-root, Linux-only.
|
|
*/
|
|
export function detectGPUs() {
|
|
const drmPath = '/sys/class/drm';
|
|
const gpus = [];
|
|
|
|
if (!fs.existsSync(drmPath)) {
|
|
return [];
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
const read = p => {
|
|
try { return fs.readFileSync(p, 'utf8').trim(); }
|
|
catch { return null; }
|
|
};
|
|
|
|
const readlink = p => {
|
|
try { return fs.realpathSync(p); }
|
|
catch { return null; }
|
|
};
|
|
|
|
const exec = (cmd, args) => {
|
|
try {
|
|
return execFileSync(cmd, args, { encoding: 'utf8' }).trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const vendorMap = {
|
|
'0x10de': 'nvidia',
|
|
'0x8086': 'intel',
|
|
'0x1002': 'amd'
|
|
};
|
|
|
|
// --- Collect render nodes indexed by PCI bus ---
|
|
const renderNodes = {};
|
|
for (const entry of fs.readdirSync(drmPath)) {
|
|
if (!entry.startsWith('renderD')) continue;
|
|
|
|
const devPath = readlink(path.join(drmPath, entry, 'device'));
|
|
if (!devPath) continue;
|
|
|
|
const pciBusId = path.basename(devPath);
|
|
renderNodes[pciBusId] = entry;
|
|
}
|
|
|
|
// --- Enumerate card devices ---
|
|
for (const entry of fs.readdirSync(drmPath)) {
|
|
if (!/^card[0-9]+$/.test(entry)) continue;
|
|
|
|
const cardPath = path.join(drmPath, entry);
|
|
const devicePath = readlink(path.join(cardPath, 'device'));
|
|
if (!devicePath) continue;
|
|
|
|
const pciBusId = path.basename(devicePath);
|
|
|
|
const vendorHex = read(path.join(cardPath, 'device', 'vendor'));
|
|
const vendor = vendorMap[vendorHex] || 'unknown';
|
|
|
|
const driver = (() => {
|
|
const d = readlink(path.join(cardPath, 'device', 'driver'));
|
|
return d ? path.basename(d) : null;
|
|
})();
|
|
|
|
// Human-readable model (best-effort)
|
|
const lspciOut = exec('lspci', ['-s', pciBusId]);
|
|
// const model = lspciOut
|
|
// ? lspciOut.replace(/^.*?:\s*/, '')
|
|
// : null;
|
|
const model = (() => {
|
|
if (!lspciOut) return null;
|
|
|
|
// Remove leading PCI address (e.g. "04:00.0 ")
|
|
let s = lspciOut.replace(/^[0-9a-fA-F:.]+\s+/, '');
|
|
|
|
// Remove device class prefix
|
|
s = s.replace(/^(VGA compatible controller|3D controller):\s*/i, '');
|
|
|
|
// Remove revision suffix
|
|
s = s.replace(/\s*\(rev.*\)$/i, '');
|
|
|
|
return s.trim();
|
|
})();
|
|
|
|
const renderNode = renderNodes[pciBusId] || null;
|
|
const computeCapable = Boolean(renderNode);
|
|
|
|
const gpu = {
|
|
id: entry,
|
|
pci_bus_id: pciBusId,
|
|
vendor,
|
|
driver,
|
|
model,
|
|
compute_capable: computeCapable,
|
|
render_node: renderNode,
|
|
memory: {
|
|
type: vendor === 'nvidia' ? 'dedicated' : 'shared',
|
|
total_mb: null
|
|
}
|
|
};
|
|
|
|
// --- NVIDIA enrichment (non-root, correct fields) ---
|
|
if (vendor === 'nvidia') {
|
|
const nvidiaSmi = findNvidiaSmi();
|
|
|
|
if (nvidiaSmi) {
|
|
const smi = exec(nvidiaSmi, [
|
|
'--query-gpu=pci.bus_id,memory.total',
|
|
'--format=csv,noheader,nounits'
|
|
]);
|
|
|
|
if (smi) {
|
|
const target = pciShortId(pciBusId);
|
|
|
|
for (const line of smi.split('\n')) {
|
|
const cols = line.split(',').map(s => s.trim());
|
|
if (cols.length < 2) continue;
|
|
|
|
const [bus, memMb] = cols;
|
|
|
|
if (pciShortId(bus) === target) {
|
|
gpu.memory.total_mb = Number(memMb);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
gpus.push(gpu);
|
|
}
|
|
|
|
return gpus;
|
|
}
|
|
|
|
// Normalize PCI bus IDs so "0000:04:00.0" === "00000000:04:00.0"
|
|
function normalizePciBusId(id) {
|
|
return id
|
|
.toLowerCase()
|
|
.replace(/^0+/, '') // strip leading domain zeros
|
|
.replace(/^:/, ''); // safety
|
|
}
|
|
|
|
function findNvidiaSmi() {
|
|
const candidates = [
|
|
'/usr/bin/nvidia-smi',
|
|
'/bin/nvidia-smi',
|
|
'/usr/local/bin/nvidia-smi'
|
|
];
|
|
|
|
for (const p of candidates) {
|
|
try {
|
|
fs.accessSync(p, fs.constants.X_OK);
|
|
return p;
|
|
} catch {}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function pciShortId(id) {
|
|
if (!id) return null;
|
|
|
|
// Extract the stable "bus:device.function" portion
|
|
const m = id.match(/([0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9])/);
|
|
return m ? m[1].toLowerCase() : null;
|
|
}
|
|
|
|
function getProcessStartTs() {
|
|
return PROCESS_START_TS;
|
|
}
|
|
|
|
function getUptimeMs() {
|
|
return Date.now() - PROCESS_START_TS;
|
|
}
|
|
|
|
function getUptimeSec() {
|
|
return Math.floor((Date.now() - PROCESS_START_TS) / 1000);
|
|
}
|
|
|
|
function getUptimeDHMS() {
|
|
const uptimeSec = getUptimeSec();
|
|
const d = Math.floor(uptimeSec / 86400);
|
|
const h = Math.floor((uptimeSec % 86400) / 3600);
|
|
const m = Math.floor((uptimeSec % 3600) / 60);
|
|
const s = Math.floor(uptimeSec % 60);
|
|
return `${d}.${h}.${m}.${s}`;
|
|
}
|
|
|
|
|
|
export { load_config, log, ipToInt, intToIp, computeBroadcast, getProcessStartTs, getUptimeMs, getUptimeSec, getUptimeDHMS };
|