Skip to content

Commit 3fe2bea

Browse files
authored
Merge pull request #244 from indubala0103-hue/feat/204-centralized-config-management
Centralized Configuration Management with Hot-Reload and Validation
2 parents c81c0e0 + 4c846b7 commit 3fe2bea

9 files changed

Lines changed: 974 additions & 1 deletion

File tree

index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ async function bootstrap() {
9191
console.warn('[config-drift] Drift modules not loaded');
9292
}
9393

94+
// 1b-2. Config management API (issue #204): GET/PUT /config, reload, rollback.
95+
const configRoutesModule = loadTsModule('config/routes');
96+
if (configModule && configRoutesModule) {
97+
try {
98+
const { registerConfigRoutes } = configRoutesModule;
99+
registerConfigRoutes(app);
100+
console.log('[config] Management API registered (/config, /config/reload, /config/rollback/:version)');
101+
} catch (err) {
102+
console.warn('[config] Failed to register management API:', (err && err.message) ? err.message : String(err));
103+
}
104+
}
105+
94106
// 1c. Start DB index-health monitor + expose read-only endpoint (issue #197).
95107
// Advisory only — the analyzer runs READ ONLY and never executes DDL.
96108
const indexHealthModule = loadTsModule('database/index_health/index');

src/config/etcd_watch.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Etcd config watcher.
3+
*
4+
* Polls the etcd v3 HTTP gateway for changes under a key prefix
5+
* (e.g. /config/{service_name}/) and invokes a callback whenever any
6+
* key/value in that prefix changes. Uses the KV range + a monotonic
7+
* mod_revision cursor so only *new* changes are delivered.
8+
*
9+
* The watcher is deliberately implemented against the HTTP /v3/kv/range
10+
* endpoint (matching ConfigLoader.loadRemoteEtcd) so no native etcd client
11+
* dependency is required and the same endpoint list / failover behavior applies.
12+
*/
13+
14+
import { createLogger } from '../diagnostics/logger';
15+
16+
const log = createLogger('config_etcd_watch');
17+
18+
export interface EtcdWatchOptions {
19+
endpoints: string[];
20+
keyPrefix: string;
21+
/** Poll interval in ms (default 10_000). */
22+
pollIntervalMs?: number;
23+
/** Optional basic auth. */
24+
username?: string;
25+
password?: string;
26+
/** Called with the full decoded prefix state on every detected change. */
27+
onChange: (state: Record<string, any>) => void;
28+
/** Called when all endpoints fail; watcher keeps retrying. */
29+
onError?: (err: Error) => void;
30+
}
31+
32+
export interface EtcdKv {
33+
key: string;
34+
value: string;
35+
modRevision: number;
36+
}
37+
38+
function getRangeEnd(prefix: string): string {
39+
if (prefix.length === 0) return '\xff';
40+
const lastChar = prefix.charCodeAt(prefix.length - 1);
41+
return prefix.slice(0, -1) + String.fromCharCode(lastChar + 1);
42+
}
43+
44+
export class EtcdConfigWatcher {
45+
private options: EtcdWatchOptions;
46+
private timer: NodeJS.Timeout | null = null;
47+
private lastModRevision = 0;
48+
private running = false;
49+
private inFlight = false;
50+
51+
constructor(options: EtcdWatchOptions) {
52+
this.options = options;
53+
}
54+
55+
start(): void {
56+
if (this.running) return;
57+
this.running = true;
58+
const interval = this.options.pollIntervalMs ?? 10_000;
59+
this.timer = setInterval(() => {
60+
void this.poll();
61+
}, interval);
62+
this.timer.unref?.();
63+
// Prime the revision cursor without emitting an initial "change".
64+
void this.poll(true);
65+
}
66+
67+
stop(): void {
68+
this.running = false;
69+
if (this.timer) {
70+
clearInterval(this.timer);
71+
this.timer = null;
72+
}
73+
}
74+
75+
isRunning(): boolean {
76+
return this.running;
77+
}
78+
79+
/**
80+
* Fetch all KVs under the prefix. Returns decoded entries.
81+
*/
82+
private async fetchKvs(): Promise<EtcdKv[]> {
83+
const prefix = this.options.keyPrefix.endsWith('/')
84+
? this.options.keyPrefix
85+
: `${this.options.keyPrefix}/`;
86+
const rangeEnd = getRangeEnd(prefix);
87+
88+
const body = {
89+
key: Buffer.from(prefix).toString('base64'),
90+
range_end: Buffer.from(rangeEnd).toString('base64'),
91+
};
92+
93+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
94+
if (this.options.username && this.options.password) {
95+
const token = Buffer.from(`${this.options.username}:${this.options.password}`).toString(
96+
'base64',
97+
);
98+
headers['Authorization'] = `Basic ${token}`;
99+
}
100+
101+
let lastError: Error | null = null;
102+
for (const endpoint of this.options.endpoints) {
103+
try {
104+
const url = `${endpoint.replace(/\/$/, '')}/v3/kv/range`;
105+
const response = await fetch(url, {
106+
method: 'POST',
107+
headers,
108+
body: JSON.stringify(body),
109+
signal: AbortSignal.timeout(5000),
110+
});
111+
if (!response.ok) {
112+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
113+
}
114+
const data = (await response.json()) as any;
115+
const kvs: EtcdKv[] = [];
116+
if (data.kvs && Array.isArray(data.kvs)) {
117+
for (const kv of data.kvs) {
118+
kvs.push({
119+
key: Buffer.from(kv.key, 'base64').toString('utf8'),
120+
value: kv.value ? Buffer.from(kv.value, 'base64').toString('utf8') : '',
121+
modRevision: Number(kv.mod_revision ?? 0),
122+
});
123+
}
124+
}
125+
return kvs;
126+
} catch (err: any) {
127+
lastError = err;
128+
log.warn('etcd watch poll failed for endpoint', {
129+
'server.address': endpoint,
130+
'error.message': err.message,
131+
});
132+
}
133+
}
134+
throw lastError || new Error('All etcd endpoints failed');
135+
}
136+
137+
/**
138+
* Decode raw KVs under the prefix into a nested config object.
139+
* Keys are slash-separated paths; a KV at the exact prefix is parsed as JSON.
140+
*/
141+
decodeState(kvs: EtcdKv[]): Record<string, any> {
142+
const prefix = this.options.keyPrefix.endsWith('/')
143+
? this.options.keyPrefix
144+
: `${this.options.keyPrefix}/`;
145+
146+
const result: Record<string, any> = {};
147+
for (const kv of kvs) {
148+
let relativeKey = kv.key;
149+
if (relativeKey.startsWith(prefix)) relativeKey = relativeKey.substring(prefix.length);
150+
if (relativeKey.startsWith('/')) relativeKey = relativeKey.substring(1);
151+
152+
let parsedVal: any = kv.value;
153+
try {
154+
parsedVal = JSON.parse(kv.value);
155+
} catch {
156+
// keep raw string
157+
}
158+
159+
if (!relativeKey) {
160+
// Value stored directly at the prefix: merge the object if possible.
161+
if (parsedVal && typeof parsedVal === 'object' && !Array.isArray(parsedVal)) {
162+
Object.assign(result, parsedVal);
163+
}
164+
continue;
165+
}
166+
167+
const parts = relativeKey.split('/').filter(Boolean);
168+
let current = result;
169+
for (let i = 0; i < parts.length - 1; i++) {
170+
if (typeof current[parts[i]] !== 'object' || current[parts[i]] === null) {
171+
current[parts[i]] = {};
172+
}
173+
current = current[parts[i]];
174+
}
175+
current[parts[parts.length - 1]] = parsedVal;
176+
}
177+
return result;
178+
}
179+
180+
private async poll(initial = false): Promise<void> {
181+
if (this.inFlight || !this.running) return;
182+
this.inFlight = true;
183+
try {
184+
const kvs = await this.fetchKvs();
185+
const maxRevision = kvs.reduce((max, kv) => Math.max(max, kv.modRevision), 0);
186+
187+
if (initial) {
188+
// Just prime the cursor.
189+
this.lastModRevision = maxRevision;
190+
return;
191+
}
192+
193+
if (maxRevision > this.lastModRevision) {
194+
this.lastModRevision = maxRevision;
195+
const state = this.decodeState(kvs);
196+
log.info('etcd config change detected', { 'etcd.prefix': this.options.keyPrefix });
197+
this.options.onChange(state);
198+
}
199+
} catch (err: any) {
200+
this.options.onError?.(err);
201+
} finally {
202+
this.inFlight = false;
203+
}
204+
}
205+
}

src/config/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export { ConfigValidationError, ValidationResult, ConfigValidator } from './vali
1212
export { ConfigEvent, ConfigEventPayload, ConfigEventBus, configEventBus } from './eventbus';
1313
export { ConfigSource, ConfigLoader } from './loader';
1414
export { ConfigManager, ConfigChangeCallback, getConfigManager } from './manager';
15+
export { ConfigMetrics } from './metrics';
16+
export { ConfigVersion, ConfigVersionHistory } from './versions';
17+
export { MASKED_VALUE, isSecretKey, maskSecrets, safeConfigForLog } from './secrets';
18+
export { registerConfigRoutes } from './routes';
19+
export { EtcdConfigWatcher } from './etcd_watch';
1520
export { mergeConfigs, normalizeEnvKey, flattenToEnv } from './validator';
1621
export {
1722
deepClone,

0 commit comments

Comments
 (0)