learning_ai_common_plat/packages/devops/src/server.ts
root 51fc8d09b0
Some checks are pending
CI — Common Platform / Build, Test & Typecheck (push) Waiting to run
CI — Common Platform / Publish @bytelyst/* to Gitea npm registry (push) Blocked by required conditions
feat(devops): admin-only collector helpers, public version, deps + tests
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>
2026-05-10 05:52:21 +00:00

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`;
}