feat(devops): admin-only collector helpers, public version, deps + tests
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

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>
This commit is contained in:
root 2026-05-10 05:52:21 +00:00
parent 17780adc1a
commit 51fc8d09b0
3 changed files with 313 additions and 27 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/devops",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"description": "Runtime devops metadata (build info, uptime, health) — server collector + React UI",
"exports": {

View File

@ -0,0 +1,212 @@
/**
* Tests for @bytelyst/devops/server.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
collectDevopsInfo,
dependencyCheck,
getBuildInfo,
getConfigInfo,
getRuntimeInfo,
readServiceVersion,
} from '../server.js';
describe('@bytelyst/devops/server', () => {
let envBackup: Record<string, string | undefined>;
beforeEach(() => {
envBackup = { ...process.env };
});
afterEach(() => {
for (const k of Object.keys(process.env)) {
if (!(k in envBackup)) delete process.env[k];
}
Object.assign(process.env, envBackup);
});
describe('getBuildInfo', () => {
it('reads BYTELYST_* env vars', () => {
process.env.BYTELYST_COMMIT_SHA = 'abcdef0';
process.env.BYTELYST_COMMIT_SHA_FULL = 'abcdef0123456789';
process.env.BYTELYST_BRANCH = 'main';
process.env.BYTELYST_BUILT_AT = '2026-01-01T00:00:00Z';
const info = getBuildInfo();
expect(info.commitSha).toBe('abcdef0');
expect(info.commitShaFull).toBe('abcdef0123456789');
expect(info.branch).toBe('main');
expect(info.builtAt).toBe('2026-01-01T00:00:00Z');
});
it('derives short SHA from full SHA when only full is set', () => {
delete process.env.BYTELYST_COMMIT_SHA;
process.env.BYTELYST_COMMIT_SHA_FULL = '1234567890abcdef';
const info = getBuildInfo();
expect(info.commitSha).toBe('1234567');
});
it('returns null fields when env vars are missing', () => {
delete process.env.BYTELYST_COMMIT_SHA;
delete process.env.BYTELYST_COMMIT_SHA_FULL;
delete process.env.BYTELYST_BRANCH;
delete process.env.BYTELYST_BUILT_AT;
const info = getBuildInfo();
expect(info.commitSha).toBeNull();
expect(info.branch).toBeNull();
expect(info.builtAt).toBeNull();
});
});
describe('getRuntimeInfo', () => {
it('returns process metadata', () => {
const info = getRuntimeInfo();
expect(info.nodeVersion).toMatch(/^v\d/);
expect(info.platform).toBeTruthy();
expect(info.pid).toBeGreaterThan(0);
expect(info.memoryMb).toBeGreaterThan(0);
expect(info.uptimeSeconds).toBeGreaterThanOrEqual(0);
expect(info.uptimeHuman).toMatch(/^\d/);
expect(new Date(info.startedAt).toString()).not.toBe('Invalid Date');
});
it('humanizes uptime correctly', () => {
const info = getRuntimeInfo();
// Some format containing s/m/h/d
expect(info.uptimeHuman).toMatch(/^\d+(s|m|h|d)/);
});
});
describe('getConfigInfo', () => {
it('lists safe env keys, excluding secrets', () => {
process.env.PRODUCT_ID = 'test';
process.env.PLATFORM_API_URL = 'http://x';
process.env.PLATFORM_JWT_SECRET = 'shh'; // should be excluded by SECRET pattern
process.env.SOMETHING_ELSE = 'not-listed'; // not in prefix list
const info = getConfigInfo({
productId: 'test',
serviceName: 'svc',
serviceVersion: '1.0.0',
});
expect(info.envKeys).toContain('PRODUCT_ID');
expect(info.envKeys).toContain('PLATFORM_API_URL');
expect(info.envKeys).not.toContain('PLATFORM_JWT_SECRET');
expect(info.envKeys).not.toContain('SOMETHING_ELSE');
});
it('uses NODE_ENV or defaults to development', () => {
process.env.NODE_ENV = 'production';
let info = getConfigInfo({ productId: 'x', serviceName: 'y', serviceVersion: '0.0.0' });
expect(info.nodeEnv).toBe('production');
delete process.env.NODE_ENV;
info = getConfigInfo({ productId: 'x', serviceName: 'y', serviceVersion: '0.0.0' });
expect(info.nodeEnv).toBe('development');
});
it('respects custom safeEnvPrefixes', () => {
process.env.MY_CUSTOM_VAR = 'x';
const info = getConfigInfo({
productId: 'x',
serviceName: 'y',
serviceVersion: '0.0.0',
safeEnvPrefixes: ['MY_CUSTOM_'],
});
expect(info.envKeys).toEqual(['MY_CUSTOM_VAR']);
});
});
describe('readServiceVersion', () => {
it('returns a non-empty version string', () => {
// Walks up from current file location and finds a package.json.
const v = readServiceVersion(import.meta.url);
expect(v).toMatch(/^\d/);
});
it('falls back to "0.0.0" when nothing is found', () => {
delete process.env.npm_package_version;
// Walk up from /tmp (no package.json above it inside the test fs)
const v = readServiceVersion('/no-such-dir/file.js');
expect(v).toBe('0.0.0');
});
});
describe('dependencyCheck', () => {
it('marks ok=true with latency when check resolves', async () => {
const status = await dependencyCheck('test', async () => {
await new Promise(r => setTimeout(r, 5));
});
expect(status.ok).toBe(true);
expect(status.name).toBe('test');
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
expect(status.detail).toBeUndefined();
});
it('marks ok=false with detail when check rejects', async () => {
const status = await dependencyCheck('test', async () => {
throw new Error('boom');
});
expect(status.ok).toBe(false);
expect(status.detail).toBe('boom');
});
it('times out long-running checks', async () => {
const status = await dependencyCheck('slow', () => new Promise(r => setTimeout(r, 1000)), 50);
expect(status.ok).toBe(false);
expect(status.detail).toMatch(/timed out/);
});
});
describe('collectDevopsInfo', () => {
it('returns full payload with all sections', async () => {
const info = await collectDevopsInfo({
productId: 'p',
serviceName: 's',
serviceVersion: '0.0.0',
});
expect(info.build).toBeDefined();
expect(info.runtime).toBeDefined();
expect(info.config).toBeDefined();
expect(info.config.productId).toBe('p');
});
it('populates dependencies when checks are provided', async () => {
const info = await collectDevopsInfo({
productId: 'p',
serviceName: 's',
serviceVersion: '0.0.0',
dependencyChecks: [
() => dependencyCheck('a', async () => undefined),
() =>
dependencyCheck('b', async () => {
throw new Error('fail');
}),
],
});
expect(info.dependencies).toHaveLength(2);
expect(info.dependencies?.[0]?.name).toBe('a');
expect(info.dependencies?.[0]?.ok).toBe(true);
expect(info.dependencies?.[1]?.ok).toBe(false);
});
it('passes through extra fields', async () => {
const info = await collectDevopsInfo({
productId: 'p',
serviceName: 's',
serviceVersion: '0.0.0',
extra: { customField: 'value', count: 42 },
});
expect(info.extra).toEqual({ customField: 'value', count: 42 });
});
it('does not leak secret env values in config.envKeys', async () => {
process.env.PLATFORM_FAKE_SECRET = 'super-secret';
const info = await collectDevopsInfo({
productId: 'p',
serviceName: 's',
serviceVersion: '0.0.0',
});
// Key is in PLATFORM_ prefix BUT matches SECRET pattern → excluded.
expect(info.config.envKeys).not.toContain('PLATFORM_FAKE_SECRET');
});
});
});

View File

@ -3,48 +3,40 @@
*
* Usage in a Node backend:
* ```ts
* import { collectDevopsInfo } from '@bytelyst/devops/server';
* import { collectDevopsInfo, readServiceVersion, dependencyCheck } from '@bytelyst/devops/server';
*
* app.get('/api/devops/info', async (req, res) => {
* app.get('/api/devops/info', requireAdmin, async (_req, res) => {
* const info = await collectDevopsInfo({
* productId: 'invttrdg',
* serviceName: 'trading-backend',
* serviceVersion: pkg.version,
* serviceVersion: readServiceVersion(import.meta.url),
* dependencyChecks: [
* () => dependencyCheck('cosmos', () => cosmosPing()),
* ],
* });
* res.json(info);
* });
*
* app.get('/api/devops/version', (_req, res) => res.json(getBuildInfo()));
* ```
*
* Build-time values (commit SHA, branch, build time, image) are read from env vars:
* - BYTELYST_COMMIT_SHA / BYTELYST_COMMIT_SHA_FULL
* - BYTELYST_BRANCH
* - BYTELYST_BUILT_AT (ISO 8601)
* - BYTELYST_COMMIT_AUTHOR
* - BYTELYST_COMMIT_MESSAGE
* - BYTELYST_DOCKER_IMAGE
*
* Dockerfiles should bake these via ARG/ENV or a build script.
*/
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 {
/** Required: product identifier (e.g. "invttrdg"). */
productId: string;
/** Required: service name (e.g. "trading-backend"). */
serviceName: string;
/** Required: service version from package.json. */
serviceVersion: string;
/** Optional: prefixes of env var keys that are safe to list (defaults to NODE_ENV, PRODUCT_ID, PLATFORM_, VITE_, BYTELYST_). */
safeEnvPrefixes?: string[];
/** Optional: async health checks for external dependencies. */
dependencyChecks?: Array<() => Promise<DependencyStatus>>;
/** Optional: extra service-specific fields. */
extra?: Record<string, unknown>;
}
@ -59,22 +51,27 @@ const DEFAULT_SAFE_PREFIXES = [
'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 = collectBuildInfo();
const runtime = collectRuntimeInfo();
const config = collectConfigInfo(opts);
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 };
}
function collectBuildInfo(): BuildInfo {
/**
* 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 {
@ -88,7 +85,8 @@ function collectBuildInfo(): BuildInfo {
};
}
function collectRuntimeInfo(): RuntimeInfo {
/** Runtime info (process + container). */
export function getRuntimeInfo(): RuntimeInfo {
const uptimeSeconds = Math.floor(process.uptime());
const mem = process.memoryUsage();
return {
@ -105,11 +103,12 @@ function collectRuntimeInfo(): RuntimeInfo {
};
}
function collectConfigInfo(opts: CollectOptions): ConfigInfo {
/** 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|KEY|TOKEN|PASSWORD|PRIVATE/i.test(k))
.filter(k => !SECRET_PATTERN.test(k))
.sort();
return {
productId: opts.productId,
@ -120,6 +119,81 @@ function collectConfigInfo(opts: CollectOptions): ConfigInfo {
};
}
/**
* 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[]> {