Bumps @bytelyst/devops to 0.1.2. Adds: - getBuildInfo() — public-safe build info (commit + branch + image), no env vars or runtime introspection. Suitable for unauthenticated /api/devops/version endpoints used by ops/CI. - getRuntimeInfo() / getConfigInfo() — exposed individually for callers who want to compose their own payload. - readServiceVersion(import.meta.url) — walks up to package.json so consumers don't need to hardcode the version string. - dependencyCheck(name, fn, timeoutMs) — timed health check wrapper with consistent ok/latency/detail shape. Enforces a hard timeout. - httpDependencyCheck(name, url, timeoutMs) — convenience for HTTP probes. Other improvements: - SECRET_PATTERN extracted as a module constant. - 17 unit tests covering build/runtime/config collectors, secret-leak guards, version walker fallbacks, dep timeouts, full collect payload. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
/**
|
|
* 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<DependencyStatus>>;
|
|
extra?: Record<string, unknown>;
|
|
}
|
|
|
|
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<DevopsInfo> {
|
|
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<unknown>,
|
|
timeoutMs = 5000
|
|
): Promise<DependencyStatus> {
|
|
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<DependencyStatus> {
|
|
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<DependencyStatus>>
|
|
): Promise<DependencyStatus[]> {
|
|
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`;
|
|
}
|