/** * Server-side devops info collector. * * Usage in a Node backend: * ```ts * import { collectDevopsInfo, readServiceVersion, dependencyCheck } from '@bytelyst/devops/server'; * * app.get('/api/devops/info', requireAdmin, async (_req, res) => { * const info = await collectDevopsInfo({ * productId: 'invttrdg', * serviceName: 'trading-backend', * serviceVersion: readServiceVersion(import.meta.url), * dependencyChecks: [ * () => dependencyCheck('cosmos', () => cosmosPing()), * ], * }); * res.json(info); * }); * * app.get('/api/devops/version', (_req, res) => res.json(getBuildInfo())); * ``` */ import os from 'node:os'; import process from 'node:process'; import { readFileSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { BuildInfo, ConfigInfo, DependencyStatus, DevopsInfo, RuntimeInfo } from './types.js'; export type { BuildInfo, ConfigInfo, DependencyStatus, DevopsInfo, RuntimeInfo }; /** Options for {@link collectDevopsInfo}. */ export interface CollectOptions { productId: string; serviceName: string; serviceVersion: string; safeEnvPrefixes?: string[]; dependencyChecks?: Array<() => Promise>; extra?: Record; } const DEFAULT_SAFE_PREFIXES = [ 'NODE_ENV', 'NODE_VERSION', 'PRODUCT_ID', 'PLATFORM_', 'VITE_', 'BYTELYST_', 'COSMOS_DATABASE', 'PORT', ]; const SECRET_PATTERN = /SECRET|KEY|TOKEN|PASSWORD|PRIVATE/i; /** * Collect runtime + build info. Safe to call from any request handler. * * Never returns secret values; only env var *keys* are listed. */ export async function collectDevopsInfo(opts: CollectOptions): Promise { const build = getBuildInfo(); const runtime = getRuntimeInfo(); const config = getConfigInfo(opts); const dependencies = opts.dependencyChecks ? await runDependencyChecks(opts.dependencyChecks) : undefined; return { build, runtime, config, dependencies, extra: opts.extra }; } /** * Build-only info — safe to expose without auth (commit SHA + version). */ export function getBuildInfo(): BuildInfo { const full = process.env.BYTELYST_COMMIT_SHA_FULL || process.env.BYTELYST_COMMIT_SHA || null; const short = process.env.BYTELYST_COMMIT_SHA || (full ? full.substring(0, 7) : null); return { commitSha: short, commitShaFull: full, branch: process.env.BYTELYST_BRANCH || null, builtAt: process.env.BYTELYST_BUILT_AT || null, commitAuthor: process.env.BYTELYST_COMMIT_AUTHOR || null, commitMessage: process.env.BYTELYST_COMMIT_MESSAGE || null, dockerImage: process.env.BYTELYST_DOCKER_IMAGE || null, }; } /** Runtime info (process + container). */ export function getRuntimeInfo(): RuntimeInfo { const uptimeSeconds = Math.floor(process.uptime()); const mem = process.memoryUsage(); return { uptimeSeconds, uptimeHuman: humanizeUptime(uptimeSeconds), nodeVersion: process.version, platform: process.platform, arch: process.arch, pid: process.pid, hostname: os.hostname(), memoryMb: Math.round(mem.rss / 1024 / 1024), heapMb: Math.round(mem.heapUsed / 1024 / 1024), startedAt: new Date(Date.now() - uptimeSeconds * 1000).toISOString(), }; } /** Configuration-level info (safe, non-secret env keys only). */ export function getConfigInfo(opts: CollectOptions): ConfigInfo { const prefixes = opts.safeEnvPrefixes ?? DEFAULT_SAFE_PREFIXES; const envKeys = Object.keys(process.env) .filter(k => prefixes.some(p => (p.endsWith('_') ? k.startsWith(p) : k === p))) .filter(k => !SECRET_PATTERN.test(k)) .sort(); return { productId: opts.productId, serviceName: opts.serviceName, serviceVersion: opts.serviceVersion, nodeEnv: process.env.NODE_ENV || 'development', envKeys, }; } /** * Walk up from the given path/import.meta.url to find package.json and return its `version`. * Falls back to `npm_package_version` env var, then to "0.0.0". */ export function readServiceVersion(fromPathOrUrl?: string): string { let dir: string; if (!fromPathOrUrl) { dir = process.cwd(); } else if (fromPathOrUrl.startsWith('file://')) { dir = dirname(fileURLToPath(fromPathOrUrl)); } else { dir = dirname(fromPathOrUrl); } for (let i = 0; i < 8; i++) { const pkg = join(dir, 'package.json'); if (existsSync(pkg)) { try { const data = JSON.parse(readFileSync(pkg, 'utf8')) as { version?: string }; if (data.version) return data.version; } catch { /* fall through */ } } const parent = dirname(dir); if (parent === dir) break; dir = parent; } return process.env.npm_package_version || '0.0.0'; } /** * Wrap an async health check with timing + timeout + error-to-status conversion. */ export async function dependencyCheck( name: string, check: () => Promise, timeoutMs = 5000 ): Promise { const start = Date.now(); try { await Promise.race([ check(), new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs) ), ]); return { name, ok: true, latencyMs: Date.now() - start }; } catch (e) { return { name, ok: false, latencyMs: Date.now() - start, detail: e instanceof Error ? e.message : String(e), }; } } /** Convenience HTTP ping — returns ok if status < 500. */ export async function httpDependencyCheck( name: string, url: string, timeoutMs = 5000 ): Promise { return dependencyCheck( name, async () => { const res = await fetch(url); if (res.status >= 500) { throw new Error(`HTTP ${res.status}`); } }, timeoutMs ); } async function runDependencyChecks( checks: Array<() => Promise> ): Promise { const results = await Promise.allSettled(checks.map(c => c())); return results.map((r, i) => r.status === 'fulfilled' ? r.value : { name: `check-${i}`, ok: false, detail: String((r as PromiseRejectedResult).reason) } ); } function humanizeUptime(seconds: number): string { if (seconds < 60) return `${seconds}s`; const mins = Math.floor(seconds / 60); if (mins < 60) return `${mins}m ${seconds % 60}s`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ${mins % 60}m`; const days = Math.floor(hrs / 24); return `${days}d ${hrs % 24}h ${mins % 60}m`; }