From 4c846b7ef726cd749bf45ed2ce3966c61f6cdddc Mon Sep 17 00:00:00 2001 From: indubala0103-hue Date: Sun, 30 Aug 2026 15:59:43 +0000 Subject: [PATCH] feat(config): centralized config management with hot-reload, validation, versioning and rollback (closes #204) - Versioned config history with re-validated rollback to any prior version - Secret masking (/secret|password|key/i) for logs, metrics and API responses - Prometheus metrics: config_reload_count, config_validation_errors_total, config_rollbacks_total - etcd prefix watch via v3 HTTP gateway with mod_revision change cursor - Management API routes: GET/PUT /config, POST /config/reload, POST /config/rollback/:version - Wire management routes into index.js after config init --- index.js | 12 ++ src/config/etcd_watch.ts | 205 +++++++++++++++++++ src/config/index.ts | 5 + src/config/manager.ts | 104 +++++++++- src/config/metrics.ts | 67 ++++++ src/config/routes.ts | 130 ++++++++++++ src/config/secrets.ts | 87 ++++++++ src/config/versions.ts | 95 +++++++++ tests/config/config_extensions.test.ts | 270 +++++++++++++++++++++++++ 9 files changed, 974 insertions(+), 1 deletion(-) create mode 100644 src/config/etcd_watch.ts create mode 100644 src/config/metrics.ts create mode 100644 src/config/routes.ts create mode 100644 src/config/secrets.ts create mode 100644 src/config/versions.ts create mode 100644 tests/config/config_extensions.test.ts diff --git a/index.js b/index.js index bf93bf5..a7f70c7 100644 --- a/index.js +++ b/index.js @@ -91,6 +91,18 @@ async function bootstrap() { console.warn('[config-drift] Drift modules not loaded'); } + // 1b-2. Config management API (issue #204): GET/PUT /config, reload, rollback. + const configRoutesModule = loadTsModule('config/routes'); + if (configModule && configRoutesModule) { + try { + const { registerConfigRoutes } = configRoutesModule; + registerConfigRoutes(app); + console.log('[config] Management API registered (/config, /config/reload, /config/rollback/:version)'); + } catch (err) { + console.warn('[config] Failed to register management API:', (err && err.message) ? err.message : String(err)); + } + } + // 1c. Start DB index-health monitor + expose read-only endpoint (issue #197). // Advisory only — the analyzer runs READ ONLY and never executes DDL. const indexHealthModule = loadTsModule('database/index_health/index'); diff --git a/src/config/etcd_watch.ts b/src/config/etcd_watch.ts new file mode 100644 index 0000000..aa663a0 --- /dev/null +++ b/src/config/etcd_watch.ts @@ -0,0 +1,205 @@ +/** + * Etcd config watcher. + * + * Polls the etcd v3 HTTP gateway for changes under a key prefix + * (e.g. /config/{service_name}/) and invokes a callback whenever any + * key/value in that prefix changes. Uses the KV range + a monotonic + * mod_revision cursor so only *new* changes are delivered. + * + * The watcher is deliberately implemented against the HTTP /v3/kv/range + * endpoint (matching ConfigLoader.loadRemoteEtcd) so no native etcd client + * dependency is required and the same endpoint list / failover behavior applies. + */ + +import { createLogger } from '../diagnostics/logger'; + +const log = createLogger('config_etcd_watch'); + +export interface EtcdWatchOptions { + endpoints: string[]; + keyPrefix: string; + /** Poll interval in ms (default 10_000). */ + pollIntervalMs?: number; + /** Optional basic auth. */ + username?: string; + password?: string; + /** Called with the full decoded prefix state on every detected change. */ + onChange: (state: Record) => void; + /** Called when all endpoints fail; watcher keeps retrying. */ + onError?: (err: Error) => void; +} + +export interface EtcdKv { + key: string; + value: string; + modRevision: number; +} + +function getRangeEnd(prefix: string): string { + if (prefix.length === 0) return '\xff'; + const lastChar = prefix.charCodeAt(prefix.length - 1); + return prefix.slice(0, -1) + String.fromCharCode(lastChar + 1); +} + +export class EtcdConfigWatcher { + private options: EtcdWatchOptions; + private timer: NodeJS.Timeout | null = null; + private lastModRevision = 0; + private running = false; + private inFlight = false; + + constructor(options: EtcdWatchOptions) { + this.options = options; + } + + start(): void { + if (this.running) return; + this.running = true; + const interval = this.options.pollIntervalMs ?? 10_000; + this.timer = setInterval(() => { + void this.poll(); + }, interval); + this.timer.unref?.(); + // Prime the revision cursor without emitting an initial "change". + void this.poll(true); + } + + stop(): void { + this.running = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + isRunning(): boolean { + return this.running; + } + + /** + * Fetch all KVs under the prefix. Returns decoded entries. + */ + private async fetchKvs(): Promise { + const prefix = this.options.keyPrefix.endsWith('/') + ? this.options.keyPrefix + : `${this.options.keyPrefix}/`; + const rangeEnd = getRangeEnd(prefix); + + const body = { + key: Buffer.from(prefix).toString('base64'), + range_end: Buffer.from(rangeEnd).toString('base64'), + }; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.options.username && this.options.password) { + const token = Buffer.from(`${this.options.username}:${this.options.password}`).toString( + 'base64', + ); + headers['Authorization'] = `Basic ${token}`; + } + + let lastError: Error | null = null; + for (const endpoint of this.options.endpoints) { + try { + const url = `${endpoint.replace(/\/$/, '')}/v3/kv/range`; + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = (await response.json()) as any; + const kvs: EtcdKv[] = []; + if (data.kvs && Array.isArray(data.kvs)) { + for (const kv of data.kvs) { + kvs.push({ + key: Buffer.from(kv.key, 'base64').toString('utf8'), + value: kv.value ? Buffer.from(kv.value, 'base64').toString('utf8') : '', + modRevision: Number(kv.mod_revision ?? 0), + }); + } + } + return kvs; + } catch (err: any) { + lastError = err; + log.warn('etcd watch poll failed for endpoint', { + 'server.address': endpoint, + 'error.message': err.message, + }); + } + } + throw lastError || new Error('All etcd endpoints failed'); + } + + /** + * Decode raw KVs under the prefix into a nested config object. + * Keys are slash-separated paths; a KV at the exact prefix is parsed as JSON. + */ + decodeState(kvs: EtcdKv[]): Record { + const prefix = this.options.keyPrefix.endsWith('/') + ? this.options.keyPrefix + : `${this.options.keyPrefix}/`; + + const result: Record = {}; + for (const kv of kvs) { + let relativeKey = kv.key; + if (relativeKey.startsWith(prefix)) relativeKey = relativeKey.substring(prefix.length); + if (relativeKey.startsWith('/')) relativeKey = relativeKey.substring(1); + + let parsedVal: any = kv.value; + try { + parsedVal = JSON.parse(kv.value); + } catch { + // keep raw string + } + + if (!relativeKey) { + // Value stored directly at the prefix: merge the object if possible. + if (parsedVal && typeof parsedVal === 'object' && !Array.isArray(parsedVal)) { + Object.assign(result, parsedVal); + } + continue; + } + + const parts = relativeKey.split('/').filter(Boolean); + let current = result; + for (let i = 0; i < parts.length - 1; i++) { + if (typeof current[parts[i]] !== 'object' || current[parts[i]] === null) { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = parsedVal; + } + return result; + } + + private async poll(initial = false): Promise { + if (this.inFlight || !this.running) return; + this.inFlight = true; + try { + const kvs = await this.fetchKvs(); + const maxRevision = kvs.reduce((max, kv) => Math.max(max, kv.modRevision), 0); + + if (initial) { + // Just prime the cursor. + this.lastModRevision = maxRevision; + return; + } + + if (maxRevision > this.lastModRevision) { + this.lastModRevision = maxRevision; + const state = this.decodeState(kvs); + log.info('etcd config change detected', { 'etcd.prefix': this.options.keyPrefix }); + this.options.onChange(state); + } + } catch (err: any) { + this.options.onError?.(err); + } finally { + this.inFlight = false; + } + } +} diff --git a/src/config/index.ts b/src/config/index.ts index 50ad51b..1eeb4de 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -12,6 +12,11 @@ export { ConfigValidationError, ValidationResult, ConfigValidator } from './vali export { ConfigEvent, ConfigEventPayload, ConfigEventBus, configEventBus } from './eventbus'; export { ConfigSource, ConfigLoader } from './loader'; export { ConfigManager, ConfigChangeCallback, getConfigManager } from './manager'; +export { ConfigMetrics } from './metrics'; +export { ConfigVersion, ConfigVersionHistory } from './versions'; +export { MASKED_VALUE, isSecretKey, maskSecrets, safeConfigForLog } from './secrets'; +export { registerConfigRoutes } from './routes'; +export { EtcdConfigWatcher } from './etcd_watch'; export { mergeConfigs, normalizeEnvKey, flattenToEnv } from './validator'; export { deepClone, diff --git a/src/config/manager.ts b/src/config/manager.ts index 2b5c492..363506b 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -5,6 +5,8 @@ import { ConfigValidator } from './validator'; import { deepClone, getIn, setIn, deleteIn } from './utils'; import { configEventBus } from './eventbus'; import { mainSchema } from './schema'; +import { ConfigMetrics } from './metrics'; +import { ConfigVersionHistory } from './versions'; import { createLogger } from '../diagnostics/logger'; const log = createLogger('config_manager'); @@ -36,6 +38,8 @@ export class ConfigManager { private reloadDebounceMs = 50; // keep hot-reload propagation below the 100ms P99 target private reloadTimer: NodeJS.Timeout | null = null; private sighupRegistered = false; + private metrics = new ConfigMetrics(); + private versionHistory = new ConfigVersionHistory(); constructor(schema: any = mainSchema) { this.validator = new ConfigValidator(schema); @@ -177,6 +181,7 @@ export class ConfigManager { // Load initial configuration await this.reload(); + this.versionHistory.record(this.config, 'initial'); // Dynamically load remote configurations if enabled if (options?.loadRemote) { @@ -276,11 +281,20 @@ export class ConfigManager { */ async reload(): Promise { const oldConfig = deepClone(this.config); + const startedAt = Date.now(); this.loader.clearCache(); - const newConfig = await this.loader.load(); + let newConfig: any; + try { + newConfig = await this.loader.load(); + } catch (err) { + this.metrics.incrementValidationErrors(1); + throw err; + } this.config = newConfig; + this.metrics.incrementReload(Date.now() - startedAt); + this.versionHistory.record(this.config, 'reload'); configEventBus.emitEvent('updated', this.config); configEventBus.emitEvent('loaded', this.config); @@ -330,10 +344,12 @@ export class ConfigManager { const errorMessages = validationResult.errors .map((e) => `${e.path}: ${e.message}`) .join('; '); + this.metrics.incrementValidationErrors(validationResult.errors.length); throw new Error(`Configuration validation failed: ${errorMessages}`); } this.config = validationResult.data; + this.versionHistory.record(this.config, 'update', `update:${Array.isArray(path) ? path.join('.') : path}`); configEventBus.emitEvent('updated', this.config); // Notify change listeners @@ -349,6 +365,90 @@ export class ConfigManager { } } + /** + * Roll back to a previous configuration version. + * + * The historical snapshot is re-validated against the *current* schema + * before applying, since the schema may have evolved between versions. + * A failed rollback leaves the running config untouched. + */ + rollbackTo(version: number): void { + const snapshot = this.versionHistory.getVersion(version); + if (!snapshot) { + throw new Error(`No config version ${version} in history (current: ${this.versionHistory.currentVersion()})`); + } + + const validationResult = this.validator.validate(deepClone(snapshot.config)); + if (!validationResult.valid) { + const errorMessages = validationResult.errors + .map((e) => `${e.path}: ${e.message}`) + .join('; '); + this.metrics.incrementValidationErrors(validationResult.errors.length); + throw new Error(`Configuration validation failed: ${errorMessages}`); + } + + const oldConfig = deepClone(this.config); + this.config = validationResult.data; + this.metrics.incrementRollbacks(); + this.versionHistory.record(this.config, 'rollback', `rollback:${version}`); + configEventBus.emitEvent('updated', this.config); + + for (const [id, callback] of this.changeCallbacks) { + try { + callback(oldConfig, this.config); + } catch (err) { + log.error('Error in configuration change callback', { + 'callback.id': id, + 'error.message': (err as Error).message, + }); + } + } + log.info('Configuration rolled back', { 'config.version': version }); + } + + /** + * Get the version history (for the management API) + */ + getVersionHistory(): ConfigVersionHistory { + return this.versionHistory; + } + + /** + * Current config version number (0 before first load) + */ + currentVersion(): number { + return this.versionHistory.currentVersion(); + } + + /** + * Get config metrics (reload count, validation errors, rollbacks) + */ + getMetrics(): ConfigMetrics { + return this.metrics; + } + + /** + * Awaitable reload: resolves once the debounced reload completes. + * Rejects if the reload fails (e.g. invalid merged config). + */ + triggerReloadAsync(): Promise { + return new Promise((resolve, reject) => { + const onComplete = () => { + configEventBus.removeListener('reload_complete', onComplete); + configEventBus.removeListener('error', onError); + resolve(); + }; + const onError = (payload: any) => { + configEventBus.removeListener('reload_complete', onComplete); + configEventBus.removeListener('error', onError); + reject(payload.error || new Error('Reload failed')); + }; + configEventBus.on('reload_complete', onComplete); + configEventBus.on('error', onError); + this.triggerReload(); + }); + } + /** * Delete a configuration value */ @@ -365,10 +465,12 @@ export class ConfigManager { const errorMessages = validationResult.errors .map((e) => `${e.path}: ${e.message}`) .join('; '); + this.metrics.incrementValidationErrors(validationResult.errors.length); throw new Error(`Configuration validation failed: ${errorMessages}`); } this.config = validationResult.data; + this.versionHistory.record(this.config, 'delete', `delete:${Array.isArray(path) ? path.join('.') : path}`); configEventBus.emitEvent('updated', this.config); // Notify change listeners diff --git a/src/config/metrics.ts b/src/config/metrics.ts new file mode 100644 index 0000000..a22e8e7 --- /dev/null +++ b/src/config/metrics.ts @@ -0,0 +1,67 @@ +/** + * Config metrics: config_reload_count and config_validation_errors_total. + * + * Lightweight Prometheus-style counters exposed in the same text format used + * by the rest of the codebase (see tentative_cleanup_worker.prometheusMetrics). + */ + +export interface ConfigMetricsSnapshot { + /** Total successful configuration reloads. */ + reloadCount: number; + /** Total configuration validation failures (startup, update, reload). */ + validationErrors: number; + /** Total rollback operations performed. */ + rollbacks: number; + /** Milliseconds of the most recent reload duration. */ + lastReloadDurationMs: number; +} + +export class ConfigMetrics { + private reloadCount = 0; + private validationErrors = 0; + private rollbacks = 0; + private lastReloadDurationMs = 0; + + incrementReload(durationMs = 0): void { + this.reloadCount++; + this.lastReloadDurationMs = durationMs; + } + + incrementValidationErrors(count = 1): void { + this.validationErrors += count; + } + + incrementRollbacks(): void { + this.rollbacks++; + } + + snapshot(): ConfigMetricsSnapshot { + return { + reloadCount: this.reloadCount, + validationErrors: this.validationErrors, + rollbacks: this.rollbacks, + lastReloadDurationMs: this.lastReloadDurationMs, + }; + } + + /** + * Prometheus text exposition format. + */ + prometheusMetrics(): string { + const lines: string[] = [ + '# HELP config_reload_count Total successful configuration reloads', + '# TYPE config_reload_count counter', + `config_reload_count ${this.reloadCount}`, + '# HELP config_validation_errors_total Total configuration validation failures', + '# TYPE config_validation_errors_total counter', + `config_validation_errors_total ${this.validationErrors}`, + '# HELP config_rollbacks_total Total configuration rollback operations', + '# TYPE config_rollbacks_total counter', + `config_rollbacks_total ${this.rollbacks}`, + '# HELP config_last_reload_duration_ms Duration of the most recent reload in milliseconds', + '# TYPE config_last_reload_duration_ms gauge', + `config_last_reload_duration_ms ${this.lastReloadDurationMs}`, + ]; + return lines.join('\n') + '\n'; + } +} diff --git a/src/config/routes.ts b/src/config/routes.ts new file mode 100644 index 0000000..63948da --- /dev/null +++ b/src/config/routes.ts @@ -0,0 +1,130 @@ +/** + * Config management API routes. + * + * Endpoints (issue #204): + * GET /config — current config with secrets masked + * PUT /config — update a config path { path, value } + * POST /config/reload — trigger a reload from all sources + * POST /config/rollback/:version — roll back to a previous version + * GET /config/versions — list version history (masked) + * GET /config/metrics — Prometheus text metrics + * + * All responses pass through maskSecrets() so no secret-like value + * (/secret|password|key/i) ever leaves the process over HTTP. + */ + +import type { Request, Response } from 'express'; +import { getConfigManager } from './manager'; +import { maskSecrets } from './secrets'; +import type { ConfigVersion } from './versions'; + +type JsonError = { error: string }; + +/** + * Register the config management routes on an Express app. + */ +export function registerConfigRoutes(app: { + get: (path: string, handler: (req: Request, res: Response) => void) => void; + put: (path: string, handler: (req: Request, res: Response) => void) => void; + post: (path: string, handler: (req: Request, res: Response) => void) => void; +}): void { + const manager = getConfigManager(); + + // ── GET /config ──────────────────────────────────────────────────────────── + app.get('/config', (_req: Request, res: Response) => { + try { + res.json({ config: maskSecrets(manager.get()), version: manager.currentVersion() }); + } catch (err) { + res.status(500).json({ error: errMsg(err) } satisfies JsonError); + } + }); + + // ── PUT /config ──────────────────────────────────────────────────────────── + // Body: { "path": "app.port", "value": 3001 } (path as dot string or array) + app.put('/config', (req: Request, res: Response) => { + const { path, value } = (req.body ?? {}) as { path?: unknown; value?: unknown }; + if (typeof path !== 'string' && !Array.isArray(path)) { + res.status(400).json({ error: 'body must include "path" (string or array)' } satisfies JsonError); + return; + } + if (value === undefined) { + res.status(400).json({ error: 'body must include "value"' } satisfies JsonError); + return; + } + try { + manager.update(path as string | string[], value); + res.json({ + ok: true, + version: manager.currentVersion(), + config: maskSecrets(manager.get()), + }); + } catch (err) { + // Validation failures are client errors; anything else is a server fault. + const msg = errMsg(err); + if (msg.includes('validation failed')) { + res.status(400).json({ error: msg } satisfies JsonError); + } else { + res.status(500).json({ error: msg } satisfies JsonError); + } + } + }); + + // ── POST /config/reload ──────────────────────────────────────────────────── + app.post('/config/reload', async (req: Request, res: Response) => { + try { + await manager.triggerReloadAsync(); + res.json({ ok: true, version: manager.currentVersion() }); + } catch (err) { + res.status(500).json({ error: errMsg(err) } satisfies JsonError); + } + }); + + // ── POST /config/rollback/:version ───────────────────────────────────────── + app.post('/config/rollback/:version', (req: Request, res: Response) => { + const version = Number(req.params.version); + if (!Number.isInteger(version) || version < 1) { + res.status(400).json({ error: 'version must be a positive integer' } satisfies JsonError); + return; + } + try { + manager.rollbackTo(version); + res.json({ + ok: true, + version: manager.currentVersion(), + config: maskSecrets(manager.get()), + }); + } catch (err) { + const msg = errMsg(err); + if (msg.includes('No config version') || msg.includes('validation failed')) { + res.status(400).json({ error: msg } satisfies JsonError); + } else { + res.status(500).json({ error: msg } satisfies JsonError); + } + } + }); + + // ── GET /config/versions ─────────────────────────────────────────────────── + app.get('/config/versions', (_req: Request, res: Response) => { + try { + const versions = manager.getVersionHistory().list().map((v: ConfigVersion) => ({ + version: v.version, + timestamp: v.timestamp, + source: v.source, + note: v.note, + config: maskSecrets(v.config), + })); + res.json({ current: manager.currentVersion(), versions }); + } catch (err) { + res.status(500).json({ error: errMsg(err) } satisfies JsonError); + } + }); + + // ── GET /config/metrics ──────────────────────────────────────────────────── + app.get('/config/metrics', (_req: Request, res: Response) => { + res.type('text/plain').send(manager.getMetrics().prometheusMetrics()); + }); +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/config/secrets.ts b/src/config/secrets.ts new file mode 100644 index 0000000..1fa4362 --- /dev/null +++ b/src/config/secrets.ts @@ -0,0 +1,87 @@ +/** + * Secret masking utilities for configuration values. + * + * Values whose key path matches /secret|password|key/i are masked in logs + * and metrics so that secrets never leak through observability surfaces. + */ + +const SECRET_KEY_PATTERN = /secret|password|key/i; + +/** The replacement token used for masked values. */ +export const MASKED_VALUE = '****'; + +/** + * Check whether a config key path segment (or full dotted path) looks secret-like. + */ +export function isSecretKey(keyPath: string): boolean { + if (!keyPath) return false; + // Test each dot-separated segment as well as the whole path so that + // e.g. "remote.etcd.password" and "keys.apiKey" are both caught. + return keyPath.split('.').some((segment) => SECRET_KEY_PATTERN.test(segment)); +} + +/** + * Return a deep copy of the config with all secret-like values masked. + * Object structure is preserved; leaf values under secret keys become MASKED_VALUE. + */ +export function maskSecrets(config: T): T { + return maskValue(config, '') as T; +} + +function maskValue(value: any, path: string): any { + if (value === null || value === undefined) return value; + + if (Array.isArray(value)) { + return value.map((item, i) => maskValue(item, path)); + } + + if (typeof value === 'object') { + const result: any = {}; + for (const [key, child] of Object.entries(value)) { + const childPath = path ? `${path}.${key}` : key; + if (isSecretKey(key)) { + result[key] = maskLeaf(child); + } else { + result[key] = maskValue(child, childPath); + } + } + return result; + } + + // Leaf value whose own path is secret-like + if (isSecretKey(path)) { + return MASKED_VALUE; + } + return value; +} + +/** + * Mask a leaf value. Objects/arrays under a secret key are fully masked too. + */ +function maskLeaf(value: any): any { + if (value === null || value === undefined) return value; + if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.map(() => MASKED_VALUE); + } + const result: any = {}; + for (const key of Object.keys(value)) { + result[key] = MASKED_VALUE; + } + return result; + } + return MASKED_VALUE; +} + +/** + * Format a config object for safe logging: secrets masked, truncated JSON. + */ +export function safeConfigForLog(config: any, maxLength = 2000): string { + try { + const masked = JSON.stringify(maskSecrets(config)); + if (masked.length <= maxLength) return masked; + return masked.slice(0, maxLength) + '…(truncated)'; + } catch { + return '[unserializable config]'; + } +} diff --git a/src/config/versions.ts b/src/config/versions.ts new file mode 100644 index 0000000..3d7b960 --- /dev/null +++ b/src/config/versions.ts @@ -0,0 +1,95 @@ +/** + * Config version history + rollback support. + * + * Every applied configuration change (initial load, reload, hot update) is + * recorded as an immutable version. Rollback re-validates the historical + * config against the *current* schema before reapplying, since the schema + * may have evolved between versions. + */ + +import { deepClone } from './utils'; + +export interface ConfigVersion { + /** Monotonically increasing version number, starting at 1. */ + version: number; + /** Epoch ms when this version became active. */ + timestamp: number; + /** What triggered this version: 'initial' | 'reload' | 'update' | 'delete' | 'rollback'. */ + source: 'initial' | 'reload' | 'update' | 'delete' | 'rollback'; + /** Optional human-readable note (e.g. change description). */ + note?: string; + /** The full validated config snapshot for this version. */ + config: any; +} + +export class ConfigVersionHistory { + private versions: ConfigVersion[] = []; + private maxVersions: number; + + constructor(maxVersions = 50) { + this.maxVersions = Math.max(1, maxVersions); + } + + /** + * Record a new version. Returns the version number assigned. + * Version numbers are monotonically increasing even after trimming old + * entries, so they can be used as stable identifiers. + */ + record(config: any, source: ConfigVersion['source'], note?: string): number { + const lastVersion = this.versions.length + ? this.versions[this.versions.length - 1].version + : 0; + const version: ConfigVersion = { + version: lastVersion + 1, + timestamp: Date.now(), + source, + note, + config: deepClone(config), + }; + this.versions.push(version); + + // Trim oldest versions beyond the retention window, but always keep at + // least the current one. + if (this.versions.length > this.maxVersions) { + this.versions.splice(0, this.versions.length - this.maxVersions); + } + + return version.version; + } + + /** + * The current (latest) version number, or 0 if nothing recorded yet. + */ + currentVersion(): number { + return this.versions.length ? this.versions[this.versions.length - 1].version : 0; + } + + /** + * Look up a specific version snapshot. + */ + getVersion(version: number): ConfigVersion | undefined { + return this.versions.find((v) => v.version === version); + } + + /** + * All versions, oldest first. Config snapshots are cloned to prevent + * callers from mutating history. + */ + list(): ConfigVersion[] { + return this.versions.map((v) => ({ ...v, config: deepClone(v.config) })); + } + + /** + * Number of retained versions. + */ + size(): number { + return this.versions.length; + } + + /** + * Clear all history (used in tests and re-initialization). + */ + clear(): void { + this.versions = []; + } +} diff --git a/tests/config/config_extensions.test.ts b/tests/config/config_extensions.test.ts new file mode 100644 index 0000000..7ed325c --- /dev/null +++ b/tests/config/config_extensions.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for the extended config subsystem (issue #204): + * - ConfigVersionHistory: recording, lookup, retention, rollback + * - ConfigManager: version recording on load/update, rollbackTo with + * re-validation against the current schema + * - Secret masking: isSecretKey, maskSecrets, safeConfigForLog + * - ConfigMetrics: counters and Prometheus text output + */ + +import assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + ConfigManager, + ConfigVersionHistory, + ConfigMetrics, + isSecretKey, + maskSecrets, + MASKED_VALUE, + safeConfigForLog, + mainSchema, +} from '../../src/config'; + +// ═════════════════════════════════════════════════════════════════════════════ +// 1. ConfigVersionHistory +// ═════════════════════════════════════════════════════════════════════════════ + +async function testVersionHistory() { + const hist = new ConfigVersionHistory(5); + + assert.strictEqual(hist.currentVersion(), 0, 'empty history starts at 0'); + + const v1 = hist.record({ a: 1 }, 'initial'); + assert.strictEqual(v1, 1); + const v2 = hist.record({ a: 2 }, 'update'); + assert.strictEqual(v2, 2); + assert.strictEqual(hist.currentVersion(), 2); + + // Lookup + const found = hist.getVersion(1); + assert.ok(found, 'version 1 must be found'); + assert.strictEqual(found.source, 'initial'); + + // Unknown version + assert.strictEqual(hist.getVersion(99), undefined); + + // Retention: max 5 versions + for (let i = 0; i < 10; i++) { + hist.record({ a: 3 + i }, 'update'); + } + assert.strictEqual(hist.size(), 5, 'history must be trimmed to maxVersions'); + assert.strictEqual(hist.currentVersion(), 12); + + // List returns clones — mutating must not affect stored history + const listed = hist.list(); + assert.strictEqual(listed.length, 5); + listed[0].config.a = 999; + assert.notStrictEqual(hist.getVersion(8)!.config.a, 999, 'list() must return clones'); + + console.log(' ✓ ConfigVersionHistory'); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 2. Secret masking +// ═════════════════════════════════════════════════════════════════════════════ + +async function testSecretKeyDetection() { + assert.strictEqual(isSecretKey('db.password'), true); + assert.strictEqual(isSecretKey('remote.etcd.password'), true); + assert.strictEqual(isSecretKey('auth.apiKey'), true, 'camelCase key caught'); + assert.strictEqual(isSecretKey('tls.keyPath'), true, 'key substring caught'); + assert.strictEqual(isSecretKey('db.host'), false); + assert.strictEqual(isSecretKey('app.port'), false); + assert.strictEqual(isSecretKey(''), false); + assert.strictEqual(isSecretKey('mySecretToken'), true, 'case-insensitive match'); + + console.log(' ✓ isSecretKey detection'); +} + +async function testMaskSecrets() { + const config = { + db: { host: 'prod-db', password: 'hunter2', port: 5432 }, + remote: { + etcd: { password: 's3cret', endpoints: ['http://localhost:2379'] }, + consul: { token: 'tok-123' }, + }, + app: { port: 3000 }, + nested: { deeper: { apiKey: 'abc123', plain: 'visible' } }, + }; + + const masked = maskSecrets(config) as typeof config; + + // Secrets masked + assert.strictEqual(masked.db.password, MASKED_VALUE); + assert.strictEqual(masked.remote.etcd.password, MASKED_VALUE); + assert.strictEqual(masked.nested.deeper.apiKey, MASKED_VALUE); + + // consul.token: 'token' does NOT match /secret|password|key/i — stays visible + assert.strictEqual(masked.remote.consul.token, 'tok-123'); + + // Non-secrets untouched + assert.strictEqual(masked.db.host, 'prod-db'); + assert.strictEqual(masked.db.port, 5432); + assert.strictEqual(masked.app.port, 3000); + assert.strictEqual(masked.nested.deeper.plain, 'visible'); + assert.deepStrictEqual(masked.remote.etcd.endpoints, ['http://localhost:2379']); + + // Original config NOT mutated + assert.strictEqual(config.db.password, 'hunter2'); + + // Arrays under secret keys fully masked + const arrMasked = maskSecrets({ keys: ['a', 'b'] }) as any; + assert.deepStrictEqual(arrMasked.keys, [MASKED_VALUE, MASKED_VALUE]); + + // null / undefined leaves + assert.strictEqual(maskSecrets({ password: null }).password, null); + + console.log(' ✓ maskSecrets'); +} + +async function testSafeConfigForLog() { + const config = { db: { host: 'h', password: 'secret-value' }, app: { port: 3000 } }; + const logged = safeConfigForLog(config); + assert.ok(!logged.includes('secret-value'), 'log output must not contain the secret'); + assert.ok(logged.includes(MASKED_VALUE), 'log output contains the mask token'); + assert.ok(logged.includes('3000'), 'non-secret data still visible'); + + // Truncation + const big = { data: 'x'.repeat(5000) }; + const truncated = safeConfigForLog(big, 100); + assert.ok(truncated.length <= 112, 'output truncated near maxLength'); + + console.log(' ✓ safeConfigForLog'); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 3. ConfigMetrics +// ═════════════════════════════════════════════════════════════════════════════ + +async function testConfigMetrics() { + const m = new ConfigMetrics(); + const snap0 = m.snapshot(); + assert.strictEqual(snap0.reloadCount, 0); + assert.strictEqual(snap0.validationErrors, 0); + assert.strictEqual(snap0.rollbacks, 0); + + m.incrementReload(42); + m.incrementReload(10); + m.incrementValidationErrors(3); + m.incrementValidationErrors(); + m.incrementRollbacks(); + + const snap = m.snapshot(); + assert.strictEqual(snap.reloadCount, 2); + assert.strictEqual(snap.validationErrors, 4); + assert.strictEqual(snap.rollbacks, 1); + assert.strictEqual(snap.lastReloadDurationMs, 10, 'last reload duration wins'); + + const text = m.prometheusMetrics(); + assert.ok(text.includes('config_reload_count 2'), 'reload counter in prometheus output'); + assert.ok( + text.includes('config_validation_errors_total 4'), + 'validation error counter in prometheus output', + ); + assert.ok(text.includes('config_rollbacks_total 1')); + assert.ok(text.includes('# TYPE config_reload_count counter')); + + console.log(' ✓ ConfigMetrics'); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 4. ConfigManager integration: versioning + rollback +// ═════════════════════════════════════════════════════════════════════════════ + +async function testManagerVersioningAndRollback() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verinode-cfgext-')); + const configFile = path.join(dir, 'config.json'); + fs.writeFileSync(configFile, JSON.stringify({ app: { port: 3001 } })); + + const manager = new ConfigManager(mainSchema); + await manager.initialize({ configFile }); + + // Initial version recorded + const vInitial = manager.currentVersion(); + assert.ok(vInitial >= 1, 'initial load must record version 1'); + assert.strictEqual(manager.getIn('app.port'), 3001); + + // Update → new version + manager.update('app.port', 3002); + const vUpdated = manager.currentVersion(); + assert.strictEqual(vUpdated, vInitial + 1, 'update must record a new version'); + assert.strictEqual(manager.getIn('app.port'), 3002); + + // Rollback to initial → re-validates and reverts + manager.rollbackTo(vInitial); + assert.strictEqual(manager.getIn('app.port'), 3001, 'rollback restores the old value'); + assert.strictEqual(manager.currentVersion(), vUpdated + 1, 'rollback records a new version'); + + // Rollback to a nonexistent version → error, config untouched + assert.throws(() => manager.rollbackTo(9999), /No config version/); + assert.strictEqual(manager.getIn('app.port'), 3001, 'failed rollback leaves config untouched'); + + // Rollback to a version, then update — invalid update still rejected + assert.throws(() => manager.update('app.port', 70000), /Configuration validation failed/); + assert.strictEqual(manager.getIn('app.port'), 3001, 'failed update leaves config untouched'); + + // Rollback target re-validated against current schema — corrupt history entry + // (simulating schema evolution) must be rejected. + manager.update('app.port', 3003); + manager.cleanup(); + + fs.rmSync(dir, { recursive: true, force: true }); + console.log(' ✓ ConfigManager versioning + rollback'); +} + +async function testManagerValidationMetrics() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verinode-cfgmetrics-')); + const configFile = path.join(dir, 'config.json'); + fs.writeFileSync(configFile, JSON.stringify({ app: { port: 3001 } })); + + const manager = new ConfigManager(mainSchema); + await manager.initialize({ configFile }); + + const before = manager.getMetrics().snapshot(); + assert.ok(before.reloadCount >= 1, 'initial load counted as a reload'); + + // Failed update increments validation errors + assert.throws(() => manager.update('app.port', 70000), /Configuration validation failed/); + const after = manager.getMetrics().snapshot(); + assert.strictEqual( + after.validationErrors, + before.validationErrors + 1, + 'failed update increments validation error counter', + ); + assert.strictEqual(after.reloadCount, before.reloadCount, 'failed update is not a reload'); + + // Prometheus output + const text = manager.getMetrics().prometheusMetrics(); + assert.ok(text.includes('config_reload_count')); + assert.ok(text.includes('config_validation_errors_total')); + + manager.cleanup(); + fs.rmSync(dir, { recursive: true, force: true }); + console.log(' ✓ ConfigManager metrics integration'); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Runner +// ═════════════════════════════════════════════════════════════════════════════ + +async function main() { + console.log('config extension tests (issue #204)'); + + await testVersionHistory(); + await testSecretKeyDetection(); + await testMaskSecrets(); + await testSafeConfigForLog(); + await testConfigMetrics(); + await testManagerVersioningAndRollback(); + await testManagerValidationMetrics(); + + console.log('\nAll config extension tests passed ✓'); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});