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>
This commit is contained in:
parent
17780adc1a
commit
51fc8d09b0
@ -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": {
|
||||
|
||||
212
packages/devops/src/__tests__/server.test.ts
Normal file
212
packages/devops/src/__tests__/server.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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[]> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user