From 51fc8d09b032b1db36b96aabf1f7458bcd1ae49c Mon Sep 17 00:00:00 2001 From: root Date: Sun, 10 May 2026 05:52:21 +0000 Subject: [PATCH] feat(devops): admin-only collector helpers, public version, deps + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- packages/devops/package.json | 2 +- packages/devops/src/__tests__/server.test.ts | 212 +++++++++++++++++++ packages/devops/src/server.ts | 126 ++++++++--- 3 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 packages/devops/src/__tests__/server.test.ts diff --git a/packages/devops/package.json b/packages/devops/package.json index a856bea0..afc9837a 100644 --- a/packages/devops/package.json +++ b/packages/devops/package.json @@ -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": { diff --git a/packages/devops/src/__tests__/server.test.ts b/packages/devops/src/__tests__/server.test.ts new file mode 100644 index 00000000..44db5acf --- /dev/null +++ b/packages/devops/src/__tests__/server.test.ts @@ -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; + + 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'); + }); + }); +}); diff --git a/packages/devops/src/server.ts b/packages/devops/src/server.ts index 230434a3..4f0799bc 100644 --- a/packages/devops/src/server.ts +++ b/packages/devops/src/server.ts @@ -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>; - /** Optional: extra service-specific fields. */ extra?: Record; } @@ -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 { - 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, + 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 {