change service discovery
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* meta-discovery v1 — UDP multicast service discovery.
|
||||
*
|
||||
* Replaces the two file-based mechanisms that required a shared /meta-core
|
||||
* volume: `locks/kv-leader.info` (locating meta-core at boot) and
|
||||
* `services/<name>-<host>.json` (the dashboard nav registry). Both answered the
|
||||
* same question — where is meta-core, and who else is on this network — so one
|
||||
* announce packet answers both, and discovery scope becomes the docker network
|
||||
* rather than the mounted volume.
|
||||
*
|
||||
* The normative spec is docs/project-architecture/service-discovery.md.
|
||||
*
|
||||
* ⚠ MIRRORED FILE. Byte-identical copies live in meta-sort-core, meta-fuse-core
|
||||
* and meta-dup-core. They cannot share a package: meta-fuse-core and
|
||||
* meta-dup-core do not depend on @metazla/meta-interface at all, and each
|
||||
* submodule is its own Docker build context, so a workspace dep resolves
|
||||
* outside the build. Edit one copy → edit all three, and
|
||||
* `scripts/check-mirrors.sh` will tell you if you forgot.
|
||||
*/
|
||||
|
||||
import dgram from 'dgram';
|
||||
import { hostname, networkInterfaces } from 'os';
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
export const DEFAULT_GROUP = '239.255.77.1';
|
||||
export const DEFAULT_PORT = 9399;
|
||||
export const DEFAULT_INTERVAL_MS = 10_000;
|
||||
/** A neighbour unseen for interval × this is dropped. Replaces the reaper. */
|
||||
export const LIVENESS_FACTOR = 3;
|
||||
|
||||
export const TYPE_DISCOVERY = 'discovery';
|
||||
export const TYPE_ANNOUNCE = 'announce';
|
||||
export const ROLE_CORE = 'core';
|
||||
export const ROLE_SERVICE = 'service';
|
||||
|
||||
/** Mirrors meta-core's GET /urls response minus redisUrl (retired). */
|
||||
export interface MeshUrls {
|
||||
hostname: string;
|
||||
baseUrl: string;
|
||||
apiUrl: string;
|
||||
webdavUrl: string;
|
||||
webdavUrlInternal: string;
|
||||
}
|
||||
|
||||
export interface MeshMessage {
|
||||
v: number;
|
||||
type: string;
|
||||
name?: string;
|
||||
instance?: string;
|
||||
role?: string;
|
||||
version?: string;
|
||||
status?: string;
|
||||
baseUrl?: string;
|
||||
/** Present only when role === 'core'. */
|
||||
urls?: MeshUrls;
|
||||
/** Reserved for a future HMAC; v1 ignores it. */
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface MeshNeighbor extends MeshMessage {
|
||||
/** Source address taken from the packet, never from the payload. */
|
||||
addr: string;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export interface MeshNodeConfig {
|
||||
name: string;
|
||||
instance?: string;
|
||||
role?: string;
|
||||
version?: string;
|
||||
group?: string;
|
||||
port?: number;
|
||||
intervalMs?: number;
|
||||
enabled?: boolean;
|
||||
/** Rebuilt on every announce so the payload never goes stale. */
|
||||
payload?: () => { baseUrl?: string; status?: string; urls?: MeshUrls };
|
||||
}
|
||||
|
||||
/** IPv4 addresses of every up, non-internal interface. */
|
||||
function localInterfaceAddresses(): string[] {
|
||||
const out: string[] = [];
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const ni of nets[name] ?? []) {
|
||||
// Node has flip-flopped between 'IPv4' and 4 for `family`.
|
||||
const isV4 = (ni as { family: string | number }).family === 'IPv4' ||
|
||||
(ni as { family: string | number }).family === 4;
|
||||
if (isV4 && !ni.internal && ni.address) out.push(ni.address);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** First non-internal IPv4 address, or the hostname. Matches the Go helper. */
|
||||
export function localIPv4(): string {
|
||||
return localInterfaceAddresses()[0] ?? hostname();
|
||||
}
|
||||
|
||||
/**
|
||||
* One participant in the mesh: announces itself, answers probes, and keeps a
|
||||
* TTL map of everyone else.
|
||||
*/
|
||||
export class MeshNode {
|
||||
private cfg: Required<Omit<MeshNodeConfig, 'payload'>> & Pick<MeshNodeConfig, 'payload'>;
|
||||
private socket: dgram.Socket | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private neighbors = new Map<string, MeshNeighbor>();
|
||||
private ifaceAddrs: string[] = [];
|
||||
private listeners: ((n: MeshNeighbor) => void)[] = [];
|
||||
private started = false;
|
||||
|
||||
constructor(config: MeshNodeConfig) {
|
||||
this.cfg = {
|
||||
name: config.name,
|
||||
instance: config.instance ?? hostname(),
|
||||
role: config.role ?? ROLE_SERVICE,
|
||||
version: config.version ?? '',
|
||||
group: config.group ?? DEFAULT_GROUP,
|
||||
port: config.port ?? DEFAULT_PORT,
|
||||
intervalMs: config.intervalMs ?? DEFAULT_INTERVAL_MS,
|
||||
enabled: config.enabled ?? true,
|
||||
payload: config.payload,
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started || !this.cfg.enabled) {
|
||||
if (!this.cfg.enabled) console.log('[meshdisco] Disabled by configuration');
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
||||
this.socket = socket;
|
||||
|
||||
socket.on('message', (buf, rinfo) => this.onMessage(buf, rinfo));
|
||||
socket.on('error', (err) => {
|
||||
// Never fatal: a host that blocks multicast must degrade, not
|
||||
// take the service down.
|
||||
console.error('[meshdisco] Socket error:', err.message);
|
||||
});
|
||||
|
||||
socket.bind(this.cfg.port, '0.0.0.0', () => {
|
||||
try {
|
||||
socket.setMulticastTTL(1); // link-local only
|
||||
} catch (e) {
|
||||
console.warn('[meshdisco] Could not set multicast TTL:', e);
|
||||
}
|
||||
|
||||
// Join on EVERY interface. A container on two docker networks
|
||||
// that joins only the default route silently sees one network.
|
||||
this.ifaceAddrs = localInterfaceAddresses();
|
||||
let joined = 0;
|
||||
for (const addr of this.ifaceAddrs) {
|
||||
try {
|
||||
socket.addMembership(this.cfg.group, addr);
|
||||
joined++;
|
||||
} catch (e) {
|
||||
console.warn(`[meshdisco] Join ${this.cfg.group} on ${addr} failed:`, e);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[meshdisco] Listening on ${this.cfg.group}:${this.cfg.port} as ` +
|
||||
`${this.cfg.name}/${this.cfg.instance} (joined ${joined} of ${this.ifaceAddrs.length} interfaces)`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.announce();
|
||||
this.probe();
|
||||
this.timer = setInterval(() => this.announce(), this.cfg.intervalMs);
|
||||
// Don't hold the event loop open on shutdown.
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
this.started = false;
|
||||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||||
// Final "stopping" announce so neighbours drop us immediately rather
|
||||
// than waiting out the staleness window.
|
||||
this.announce('stopping');
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.socket) return resolve();
|
||||
try { this.socket.close(() => resolve()); } catch { resolve(); }
|
||||
});
|
||||
this.socket = null;
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
/** Multicast a probe; every listener replies immediately. */
|
||||
probe(): void {
|
||||
this.send({ v: PROTOCOL_VERSION, type: TYPE_DISCOVERY });
|
||||
}
|
||||
|
||||
/** Everyone heard from inside the staleness window, sorted by name. */
|
||||
getNeighbors(): MeshNeighbor[] {
|
||||
const cutoff = Date.now() - this.cfg.intervalMs * LIVENESS_FACTOR;
|
||||
const out: MeshNeighbor[] = [];
|
||||
for (const [key, nb] of this.neighbors) {
|
||||
if (nb.lastSeen < cutoff) { this.neighbors.delete(key); continue; }
|
||||
out.push(nb);
|
||||
}
|
||||
return out.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
||||
}
|
||||
|
||||
/** One row per service name, most recently seen instance wins. */
|
||||
getNeighborsByName(): MeshNeighbor[] {
|
||||
const best = new Map<string, MeshNeighbor>();
|
||||
for (const nb of this.getNeighbors()) {
|
||||
const cur = best.get(nb.name ?? '');
|
||||
if (!cur || nb.lastSeen > cur.lastSeen) best.set(nb.name ?? '', nb);
|
||||
}
|
||||
return [...best.values()].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
||||
}
|
||||
|
||||
/** Cores only — what a locator filters on. */
|
||||
getCores(): MeshNeighbor[] {
|
||||
return this.getNeighbors().filter((n) => n.role === ROLE_CORE && n.urls);
|
||||
}
|
||||
|
||||
/** This node's own announce, so a UI can render itself without waiting. */
|
||||
self(): MeshNeighbor {
|
||||
return { ...this.buildAnnounce('running'), addr: '', lastSeen: Date.now() };
|
||||
}
|
||||
|
||||
/** Fires for every announce received (after the self-echo filter). */
|
||||
onAnnounce(cb: (n: MeshNeighbor) => void): void {
|
||||
this.listeners.push(cb);
|
||||
}
|
||||
|
||||
private onMessage(buf: Buffer, rinfo: dgram.RemoteInfo): void {
|
||||
let msg: MeshMessage;
|
||||
try {
|
||||
msg = JSON.parse(buf.toString('utf8')) as MeshMessage;
|
||||
} catch {
|
||||
return; // foreign traffic on the group
|
||||
}
|
||||
if (msg.v !== PROTOCOL_VERSION) return;
|
||||
// Drop our own multicast echo.
|
||||
if (msg.instance && msg.instance === this.cfg.instance && msg.name === this.cfg.name) return;
|
||||
|
||||
if (msg.type === TYPE_DISCOVERY) {
|
||||
this.replyTo(rinfo);
|
||||
return;
|
||||
}
|
||||
if (msg.type !== TYPE_ANNOUNCE || !msg.name) return;
|
||||
|
||||
const key = msg.instance ? `${msg.name}|${msg.instance}` : msg.name;
|
||||
if (msg.status === 'stopping') { this.neighbors.delete(key); return; }
|
||||
|
||||
const nb: MeshNeighbor = { ...msg, addr: rinfo.address, lastSeen: Date.now() };
|
||||
this.neighbors.set(key, nb);
|
||||
for (const cb of this.listeners) {
|
||||
try { cb(nb); } catch (e) { console.error('[meshdisco] Listener threw:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
private buildAnnounce(status: string): MeshMessage {
|
||||
const msg: MeshMessage = {
|
||||
v: PROTOCOL_VERSION,
|
||||
type: TYPE_ANNOUNCE,
|
||||
name: this.cfg.name,
|
||||
instance: this.cfg.instance,
|
||||
role: this.cfg.role,
|
||||
version: this.cfg.version,
|
||||
status,
|
||||
};
|
||||
if (this.cfg.payload) {
|
||||
const p = this.cfg.payload();
|
||||
if (p.baseUrl) msg.baseUrl = p.baseUrl;
|
||||
if (p.status && status === 'running') msg.status = p.status;
|
||||
// Only a core may carry a URLs block — a service announcing one
|
||||
// would let any container impersonate meta-core.
|
||||
if (this.cfg.role === ROLE_CORE && p.urls) msg.urls = p.urls;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
private announce(status = 'running'): void {
|
||||
this.send(this.buildAnnounce(status));
|
||||
}
|
||||
|
||||
private replyTo(rinfo: dgram.RemoteInfo): void {
|
||||
const body = Buffer.from(JSON.stringify(this.buildAnnounce('running')));
|
||||
try {
|
||||
this.socket?.send(body, rinfo.port, rinfo.address);
|
||||
} catch (e) {
|
||||
console.warn('[meshdisco] Reply failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes once per interface — the default route alone reaches one network. */
|
||||
private send(msg: MeshMessage): void {
|
||||
const socket = this.socket;
|
||||
if (!socket) return;
|
||||
const body = Buffer.from(JSON.stringify(msg));
|
||||
for (const addr of this.ifaceAddrs) {
|
||||
try {
|
||||
socket.setMulticastInterface(addr);
|
||||
socket.send(body, this.cfg.port, this.cfg.group);
|
||||
} catch (e) {
|
||||
console.warn(`[meshdisco] Send on ${addr} failed:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MetaCoreLocatorConfig {
|
||||
/** This service's name, for its own announce. */
|
||||
serviceName: string;
|
||||
/** Browser-facing URL for the nav menu. */
|
||||
baseUrl?: string;
|
||||
version?: string;
|
||||
/**
|
||||
* Explicit meta-core API URL. When set this ALWAYS wins and the wire is
|
||||
* never consulted for core selection — see the spec's "the pin always
|
||||
* wins". This is what stops a client box from latching onto a gateway
|
||||
* box's core when both are reachable on a shared network.
|
||||
*/
|
||||
metaCoreUrl?: string;
|
||||
group?: string;
|
||||
port?: number;
|
||||
intervalMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates meta-core over UDP, replacing the read of
|
||||
* /meta-core/locks/kv-leader.info.
|
||||
*/
|
||||
export class MetaCoreLocator {
|
||||
private node: MeshNode;
|
||||
private cfg: MetaCoreLocatorConfig;
|
||||
private current: MeshUrls | null = null;
|
||||
private changeCallbacks: (() => void)[] = [];
|
||||
|
||||
constructor(config: MetaCoreLocatorConfig) {
|
||||
this.cfg = config;
|
||||
this.node = new MeshNode({
|
||||
name: config.serviceName,
|
||||
role: ROLE_SERVICE,
|
||||
version: config.version,
|
||||
group: config.group,
|
||||
port: config.port,
|
||||
intervalMs: config.intervalMs,
|
||||
enabled: config.enabled,
|
||||
payload: () => ({ baseUrl: config.baseUrl ?? `http://${localIPv4()}` }),
|
||||
});
|
||||
|
||||
this.node.onAnnounce((nb) => {
|
||||
if (nb.role !== ROLE_CORE || !nb.urls) return;
|
||||
if (this.cfg.metaCoreUrl) return; // pinned: the wire cannot move us
|
||||
|
||||
const prev = this.current;
|
||||
if (!prev) {
|
||||
this.current = nb.urls;
|
||||
console.log(`[meshdisco] meta-core discovered at ${nb.urls.apiUrl} (${nb.addr})`);
|
||||
this.notifyChange();
|
||||
return;
|
||||
}
|
||||
if (prev.apiUrl === nb.urls.apiUrl) {
|
||||
this.current = nb.urls; // refresh the rest of the fields
|
||||
return;
|
||||
}
|
||||
// A second, different core is announcing. Do not flap between
|
||||
// them — keep the first and say so loudly, because on a PCS box
|
||||
// this means a client container can see the gateway's core.
|
||||
console.warn(
|
||||
`[meshdisco] Ignoring a second meta-core at ${nb.urls.apiUrl} (${nb.addr}); ` +
|
||||
`staying with ${prev.apiUrl}. Set META_CORE_URL to pin this explicitly.`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.cfg.metaCoreUrl) {
|
||||
console.log(`[meshdisco] meta-core pinned to ${this.cfg.metaCoreUrl}; discovery is advisory`);
|
||||
}
|
||||
await this.node.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.node.stop();
|
||||
this.changeCallbacks = [];
|
||||
}
|
||||
|
||||
/** The pin if set, else whatever discovery elected. */
|
||||
getApiUrl(): string | null {
|
||||
if (this.cfg.metaCoreUrl) return this.cfg.metaCoreUrl;
|
||||
return this.current?.apiUrl ?? null;
|
||||
}
|
||||
|
||||
/** Full URLs block. null when pinned and /urls has not been fetched yet. */
|
||||
getUrls(): MeshUrls | null {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until meta-core is located. Probes immediately and re-probes every
|
||||
* 500ms — the same loop shape as the old LeaderClient.waitForLeader, so the
|
||||
* 30s boot timeout semantics are preserved exactly.
|
||||
*/
|
||||
async waitForCore(timeoutMs = 30_000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let logged = false;
|
||||
while (Date.now() < deadline) {
|
||||
const url = this.getApiUrl();
|
||||
if (url) return url;
|
||||
if (!logged) { console.log('[meshdisco] Waiting for meta-core...'); logged = true; }
|
||||
this.node.probe();
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`[meshdisco] No meta-core found within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/** Fires when the elected core's apiUrl changes. Replaces the fs.watch. */
|
||||
onChange(cb: () => void): void {
|
||||
this.changeCallbacks.push(cb);
|
||||
}
|
||||
|
||||
getNeighbors(): MeshNeighbor[] {
|
||||
return this.node.getNeighborsByName();
|
||||
}
|
||||
|
||||
self(): MeshNeighbor {
|
||||
return this.node.self();
|
||||
}
|
||||
|
||||
probe(): void {
|
||||
this.node.probe();
|
||||
}
|
||||
|
||||
private notifyChange(): void {
|
||||
for (const cb of this.changeCallbacks) {
|
||||
try { cb(); } catch (e) { console.error('[meshdisco] Change callback threw:', e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ async function main(): Promise<void> {
|
||||
serviceName: 'meta-fuse',
|
||||
apiPort: config.apiPort,
|
||||
baseUrl: config.baseUrl,
|
||||
// Pin: when META_CORE_URL is set, UDP discovery never overrides it.
|
||||
// See docs/project-architecture/service-discovery.md.
|
||||
metaCoreUrl: process.env.META_CORE_URL,
|
||||
redisPrefix: config.redisPrefix,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { hostname } from 'os';
|
||||
import { Logger } from 'tslog';
|
||||
import { LeaderClient } from './LeaderClient.js';
|
||||
import { ServiceRegistration } from './ServiceRegistration.js';
|
||||
import { RedisClient } from './RedisClient.js';
|
||||
import type { LeaderLockInfo } from './IKVClient.js';
|
||||
|
||||
@@ -48,7 +47,6 @@ interface KVManagerConfig {
|
||||
export class KVManager {
|
||||
private config: Required<KVManagerConfig>;
|
||||
private leaderClient: LeaderClient | null = null;
|
||||
private serviceRegistration: ServiceRegistration | null = null;
|
||||
private redisClient: RedisClient | null = null;
|
||||
private isStarted = false;
|
||||
private isShuttingDown = false;
|
||||
@@ -80,14 +78,10 @@ export class KVManager {
|
||||
|
||||
logger.info(`Starting KVManager for ${this.config.serviceName}...`);
|
||||
|
||||
// Initialize and start service registration for heartbeat
|
||||
// No separate service registration step any more: the LeaderClient's
|
||||
// discovery node announces this service on the same UDP group it
|
||||
// listens on, so being discoverable and discovering are one thing.
|
||||
const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`;
|
||||
this.serviceRegistration = new ServiceRegistration({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
serviceName: this.config.serviceName,
|
||||
apiUrl
|
||||
});
|
||||
await this.serviceRegistration.start();
|
||||
|
||||
// If direct Redis URL provided, skip leader discovery
|
||||
if (this.config.redisUrl) {
|
||||
@@ -99,8 +93,9 @@ export class KVManager {
|
||||
|
||||
// Use leader client
|
||||
this.leaderClient = new LeaderClient({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
metaCoreUrl: this.config.metaCoreUrl || undefined
|
||||
metaCoreUrl: this.config.metaCoreUrl || undefined,
|
||||
serviceName: this.config.serviceName,
|
||||
baseUrl: apiUrl,
|
||||
});
|
||||
|
||||
// Set up leader change callback
|
||||
@@ -163,10 +158,6 @@ export class KVManager {
|
||||
}
|
||||
|
||||
// Stop service registration (if it was started)
|
||||
if (this.serviceRegistration) {
|
||||
await this.serviceRegistration.stop();
|
||||
this.serviceRegistration = null;
|
||||
}
|
||||
|
||||
// Disconnect Redis
|
||||
await this.disconnectRedis();
|
||||
@@ -351,8 +342,9 @@ export class KVManager {
|
||||
/**
|
||||
* Get service registration instance
|
||||
*/
|
||||
getServiceRegistration(): ServiceRegistration | null {
|
||||
return this.serviceRegistration;
|
||||
/** @deprecated File-based registration is gone; always null. */
|
||||
getServiceRegistration(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,29 +1,48 @@
|
||||
/**
|
||||
* LeaderClient - Client for reading leader info from meta-core
|
||||
* LeaderClient - locates meta-core and exposes its URLs.
|
||||
*
|
||||
* This replaces the old LeaderDiscovery.ts by delegating leader election
|
||||
* to meta-core (Go sidecar) and just reading the results.
|
||||
* Since meta-discovery v1 this is a thin adapter over MetaCoreLocator
|
||||
* (../discovery/meshdisco.js): meta-core is found by UDP announce instead of by
|
||||
* reading /meta-core/locks/kv-leader.info, which means this service no longer
|
||||
* needs the /meta-core volume mounted at all.
|
||||
*
|
||||
* Features:
|
||||
* - Reads kv-leader.info file for leader URLs
|
||||
* - Calls meta-core /urls API for current leader URLs
|
||||
* - Watches for leader changes via file system watcher
|
||||
* The public surface is deliberately unchanged so KVManager and its callers did
|
||||
* not have to move: getLeaderInfo / getUrls / getApiUrl / getWebdavUrl*,
|
||||
* waitForLeader (same 30s default), startWatching / onChange (the fs.watch on
|
||||
* the lock dir became "an announce arrived carrying a different apiUrl").
|
||||
*
|
||||
* Precedence is the pin: when metaCoreUrl (META_CORE_URL) is set it always
|
||||
* wins and the wire is never consulted for core selection. See
|
||||
* docs/project-architecture/service-discovery.md.
|
||||
*
|
||||
* The name "leader" is legacy: meta-core's flock election is vestigial, and
|
||||
* redisUrl has been empty since the api-mediated-access lockdown.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { watch, FSWatcher } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { Logger } from 'tslog';
|
||||
import { MetaCoreLocator, type MeshNeighbor } from '../discovery/meshdisco.js';
|
||||
import type { LeaderLockInfo } from './IKVClient.js';
|
||||
|
||||
const logger = new Logger({ name: 'LeaderClient' });
|
||||
|
||||
export interface LeaderClientConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
/**
|
||||
* @deprecated Unused since meta-discovery v1. Kept so existing callers
|
||||
* compile unchanged; nothing reads the volume any more.
|
||||
*/
|
||||
metaCorePath?: string;
|
||||
|
||||
/** meta-core API URL (e.g., http://meta-core:9000 or http://localhost:9000) */
|
||||
/** meta-core API URL. When set, discovery never overrides it. */
|
||||
metaCoreUrl?: string;
|
||||
|
||||
/** This service's name, used for its own announce. */
|
||||
serviceName?: string;
|
||||
|
||||
/** Browser-facing URL announced for the nav menu. */
|
||||
baseUrl?: string;
|
||||
|
||||
/** Service version, display only. */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface URLsResponse {
|
||||
@@ -38,73 +57,74 @@ export interface URLsResponse {
|
||||
|
||||
export class LeaderClient {
|
||||
private config: LeaderClientConfig;
|
||||
private infoFilePath: string;
|
||||
private locator: MetaCoreLocator;
|
||||
private leaderInfo: LeaderLockInfo | null = null;
|
||||
private watcher: FSWatcher | null = null;
|
||||
private onChangeCallbacks: (() => void)[] = [];
|
||||
private started = false;
|
||||
|
||||
// URL caching
|
||||
// Only used on the pinned path, where URLs still come from GET /urls.
|
||||
// A discovered core carries them in the announce itself.
|
||||
private cachedUrls: URLsResponse | null = null;
|
||||
private urlsCacheTime: number = 0;
|
||||
private readonly urlsCacheTTL: number = 5000; // 5 seconds
|
||||
private readonly urlsCacheTTL: number = 5000;
|
||||
|
||||
constructor(config: LeaderClientConfig) {
|
||||
this.config = config;
|
||||
this.infoFilePath = `${config.metaCorePath}/locks/kv-leader.info`;
|
||||
this.locator = new MetaCoreLocator({
|
||||
serviceName: config.serviceName ?? 'meta-fuse',
|
||||
baseUrl: config.baseUrl,
|
||||
version: config.version,
|
||||
metaCoreUrl: config.metaCoreUrl,
|
||||
enabled: process.env.ENABLE_UDP_DISCOVERY !== 'false' &&
|
||||
process.env.ENABLE_UDP_DISCOVERY !== '0',
|
||||
});
|
||||
this.locator.onChange(() => {
|
||||
this.cachedUrls = null;
|
||||
this.urlsCacheTime = 0;
|
||||
this.notifyChange();
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureStarted(): Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
await this.locator.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read API URL from file (plain text format)
|
||||
* Guard against a rollback reintroducing direct Redis exposure.
|
||||
* api-mediated-access PR D removed redisUrl; ALLOW_LEGACY_REDIS_URL=1
|
||||
* downgrades this to a warning during a deliberate temporary rollback.
|
||||
*/
|
||||
private async getApiUrlFromFile(): Promise<string | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.infoFilePath, 'utf-8');
|
||||
return content.trim() || null;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to read API URL from file: ${error.message}`);
|
||||
return null;
|
||||
private assertNoRedisUrl(redisUrl: string | undefined): void {
|
||||
if (!redisUrl) return;
|
||||
const msg =
|
||||
'meta-core still publishes redisUrl; direct Redis access was retired ' +
|
||||
'by the api-mediated-access lockdown. Verify meta-core version.';
|
||||
if (process.env.ALLOW_LEGACY_REDIS_URL === '1') {
|
||||
logger.warn('WARNING: ' + msg);
|
||||
} else {
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch URLs from meta-core /urls API with caching
|
||||
*/
|
||||
private async fetchUrls(apiUrl: string): Promise<URLsResponse | null> {
|
||||
// Check cache
|
||||
const now = Date.now();
|
||||
if (this.cachedUrls && (now - this.urlsCacheTime) < this.urlsCacheTTL) {
|
||||
return this.cachedUrls;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/urls`, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to fetch URLs: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await response.json() as URLsResponse;
|
||||
// api-mediated-access PR D: redisUrl is no longer published by
|
||||
// meta-core. If it shows up, an older build snuck back in —
|
||||
// fail loudly so the rollback can't go unnoticed. Set
|
||||
// ALLOW_LEGACY_REDIS_URL=1 to downgrade to a warning during a
|
||||
// deliberate temporary rollback.
|
||||
if (parsed.redisUrl) {
|
||||
const msg =
|
||||
'meta-core still publishes redisUrl; direct Redis access ' +
|
||||
'was retired by the api-mediated-access lockdown. Verify ' +
|
||||
'meta-core version.';
|
||||
if (process.env.ALLOW_LEGACY_REDIS_URL === '1') {
|
||||
logger.warn('WARNING: ' + msg);
|
||||
} else {
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
this.assertNoRedisUrl(parsed.redisUrl);
|
||||
this.cachedUrls = parsed;
|
||||
this.urlsCacheTime = now;
|
||||
return this.cachedUrls;
|
||||
@@ -115,23 +135,37 @@ export class LeaderClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read leader info from file and /urls API
|
||||
* Resolve meta-core's URLs — straight from the announce when discovered
|
||||
* (no HTTP hop needed), or over HTTP when pinned.
|
||||
*/
|
||||
async getUrls(): Promise<URLsResponse | null> {
|
||||
await this.ensureStarted();
|
||||
|
||||
const wire = this.locator.getUrls();
|
||||
if (wire && !this.config.metaCoreUrl) {
|
||||
return {
|
||||
hostname: wire.hostname,
|
||||
baseUrl: wire.baseUrl,
|
||||
apiUrl: wire.apiUrl,
|
||||
redisUrl: '',
|
||||
webdavUrl: wire.webdavUrl,
|
||||
webdavUrlInternal: wire.webdavUrlInternal,
|
||||
isLeader: true,
|
||||
};
|
||||
}
|
||||
|
||||
const apiUrl = this.locator.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
logger.error('No meta-core API URL available');
|
||||
return null;
|
||||
}
|
||||
return this.fetchUrls(apiUrl);
|
||||
}
|
||||
|
||||
async getLeaderInfo(): Promise<LeaderLockInfo | null> {
|
||||
try {
|
||||
// Read API URL from file (plain text)
|
||||
const apiUrl = await this.getApiUrlFromFile();
|
||||
if (!apiUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch full info from /urls API
|
||||
const urls = await this.fetchUrls(apiUrl);
|
||||
if (!urls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert URLsResponse to LeaderLockInfo
|
||||
const urls = await this.getUrls();
|
||||
if (!urls) return null;
|
||||
this.leaderInfo = {
|
||||
hostname: urls.hostname,
|
||||
baseUrl: urls.baseUrl,
|
||||
@@ -140,7 +174,7 @@ export class LeaderClient {
|
||||
webdavUrl: urls.webdavUrl,
|
||||
webdavUrlInternal: urls.webdavUrlInternal,
|
||||
timestamp: Date.now(),
|
||||
pid: 0 // Unknown for remote leader
|
||||
pid: 0
|
||||
};
|
||||
return this.leaderInfo;
|
||||
} catch (error: any) {
|
||||
@@ -149,138 +183,64 @@ export class LeaderClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis URL from leader info
|
||||
*/
|
||||
async getRedisUrl(): Promise<string | null> {
|
||||
const info = await this.getLeaderInfo();
|
||||
return info?.redisUrl ?? null;
|
||||
return (await this.getLeaderInfo())?.redisUrl ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebDAV URL from leader info (external, via nginx/HTTPS)
|
||||
*/
|
||||
async getWebdavUrl(): Promise<string | null> {
|
||||
const info = await this.getLeaderInfo();
|
||||
return info?.webdavUrl ?? null;
|
||||
return (await this.getLeaderInfo())?.webdavUrl ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get internal WebDAV URL from leader info (direct to port 9000)
|
||||
* Use this for container-to-container communication
|
||||
*/
|
||||
/** Internal WebDAV URL — use for container-to-container access. */
|
||||
async getWebdavUrlInternal(): Promise<string | null> {
|
||||
const info = await this.getLeaderInfo();
|
||||
return info?.webdavUrlInternal ?? null;
|
||||
return (await this.getLeaderInfo())?.webdavUrlInternal ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meta-core API URL from leader info
|
||||
*/
|
||||
async getApiUrl(): Promise<string | null> {
|
||||
const info = await this.getLeaderInfo();
|
||||
return info?.apiUrl ?? null;
|
||||
return (await this.getLeaderInfo())?.apiUrl ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call meta-core /urls API to get current URLs
|
||||
* Useful for getting URLs when you don't want to read the file directly
|
||||
*/
|
||||
async getUrls(): Promise<URLsResponse | null> {
|
||||
// First try using configured metaCoreUrl
|
||||
let apiUrl: string | null = this.config.metaCoreUrl ?? null;
|
||||
|
||||
// Fall back to reading from file
|
||||
if (!apiUrl) {
|
||||
apiUrl = await this.getApiUrlFromFile();
|
||||
}
|
||||
|
||||
if (!apiUrl) {
|
||||
logger.error('No meta-core API URL available');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.fetchUrls(apiUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for leader info to be available
|
||||
* Block until meta-core is reachable. Probes immediately then every 500ms,
|
||||
* matching the previous file-polling loop's timing exactly.
|
||||
*/
|
||||
async waitForLeader(timeoutMs: number = 30000): Promise<LeaderLockInfo> {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 500;
|
||||
await this.ensureStarted();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await this.locator.waitForCore(timeoutMs);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const info = await this.getLeaderInfo();
|
||||
if (info) {
|
||||
logger.info(`Leader found: ${info.hostname} at ${info.redisUrl}`);
|
||||
logger.info(`meta-core found: ${info.hostname} at ${info.apiUrl}`);
|
||||
return info;
|
||||
}
|
||||
|
||||
logger.debug('Waiting for leader...');
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
logger.debug('Waiting for meta-core...');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`No leader found within ${timeoutMs}ms`);
|
||||
throw new Error(`No meta-core found within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for leader changes
|
||||
* Previously an fs.watch on the lock directory. Discovery is always
|
||||
* listening, so this only has to make sure the node is running.
|
||||
*/
|
||||
startWatching(): void {
|
||||
if (this.watcher) {
|
||||
return; // Already watching
|
||||
}
|
||||
|
||||
const lockDir = dirname(this.infoFilePath);
|
||||
|
||||
try {
|
||||
this.watcher = watch(lockDir, (eventType, filename) => {
|
||||
if (filename === 'kv-leader.info') {
|
||||
logger.debug(`Lock file ${eventType}, invalidating cache...`);
|
||||
// Invalidate cache to force fresh API call
|
||||
this.cachedUrls = null;
|
||||
this.urlsCacheTime = 0;
|
||||
|
||||
this.getLeaderInfo().then(() => {
|
||||
this.notifyChange();
|
||||
}).catch((err) => logger.error(`Error reloading leader info: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
this.watcher.on('error', (error: any) => {
|
||||
logger.warn(`File watcher error: ${error.message}`);
|
||||
});
|
||||
|
||||
logger.info(`Watching for leader changes in ${lockDir}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to start watching: ${error.message}`);
|
||||
}
|
||||
void this.ensureStarted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching for leader changes
|
||||
*/
|
||||
stopWatching(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
this.watcher = null;
|
||||
logger.debug('Stopped watching for leader changes');
|
||||
}
|
||||
// No-op: the locator keeps listening until close().
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for leader changes
|
||||
*/
|
||||
onChange(callback: () => void): this {
|
||||
this.onChangeCallbacks.push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all change callbacks
|
||||
*/
|
||||
private notifyChange(): void {
|
||||
logger.debug('meta-core changed, invalidating cache...');
|
||||
for (const callback of this.onChangeCallbacks) {
|
||||
try {
|
||||
callback();
|
||||
@@ -290,18 +250,23 @@ export class LeaderClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached leader info (without re-reading file)
|
||||
*/
|
||||
getCachedLeaderInfo(): LeaderLockInfo | null {
|
||||
return this.leaderInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
/** Neighbours for the nav menu (one row per service name). */
|
||||
getNeighbors(): MeshNeighbor[] {
|
||||
return this.locator.getNeighbors();
|
||||
}
|
||||
|
||||
/** This service's own announce, so the menu can show itself. */
|
||||
self(): MeshNeighbor {
|
||||
return this.locator.self();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopWatching();
|
||||
this.onChangeCallbacks = [];
|
||||
void this.locator.stop();
|
||||
this.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user