/* Drift: a server whose config is no longer what the hub says it should be (the baseline plus the server's own local values), usually because someone changed it in that server's own console. `check` reads every server's config for every app that has a baseline, with the read token, and compares it with the effective config as the sync does. The result is kept in memory and in `${HUB_DATA}/drift.json`; `since` is the first check that saw the same problem, and it is dropped once the problem is gone. Two checks never run at once. The timer lives in server.ts, so the tests drive `check` themselves. */ import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { HubConfig } from './config.ts' import { effectiveConfig, sameConfig } from './apps-sync.ts' import { readConfigText } from './apps-config.ts' import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts' import type { Upstream } from './upstream.ts' export type DriftKind = 'differs' | 'unreadable' | 'not-installed' export interface DriftEntry { app: string server: string since: string kind: DriftKind } export interface DriftState { checkedAt: string | null apps: DriftEntry[] } const KINDS: DriftKind[] = ['differs', 'unreadable', 'not-installed'] export class Drift { #config: HubConfig #upstream: Upstream #now: () => number #state: DriftState #running: Promise | null = null constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) { this.#config = config this.#upstream = upstream this.#now = now this.#state = this.#load() } #file(): string { return path.join(this.#config.dataDir, 'drift.json') } #load(): DriftState { try { if (!existsSync(this.#file())) return { checkedAt: null, apps: [] } const data = JSON.parse(readFileSync(this.#file(), 'utf8')) as Partial const apps = (Array.isArray(data.apps) ? data.apps : []).filter( (e): e is DriftEntry => typeof e?.app === 'string' && typeof e.server === 'string' && typeof e.since === 'string' && KINDS.includes(e.kind), ) return { checkedAt: typeof data.checkedAt === 'string' ? data.checkedAt : null, apps } } catch { return { checkedAt: null, apps: [] } } } get(): DriftState & { checking: boolean } { return { ...this.#state, checking: this.#running !== null } } /** How many servers differ from their baseline (the other kinds are listed, not counted). */ count(): number { return this.#state.apps.filter((e) => e.kind === 'differs').length } /* Runs a check, or joins the one that is already running. */ check(): Promise { if (this.#running !== null) return this.#running const run = this.#run().finally(() => { this.#running = null }) this.#running = run return run } async #run(): Promise { const apps = listBaselineApps(this.#config.dataDir).map((app) => ({ app, baseline: readBaseline(this.#config.dataDir, app), overrides: readOverrides(this.#config.dataDir, app), })) const found: { app: string; server: string; kind: DriftKind }[] = [] await Promise.all( this.#config.servers.map(async (server) => { if (apps.length === 0) return this.#upstream.invalidate(server.id) let installed: Set try { const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string }[] } | null installed = new Set((list?.apps ?? []).map((a) => a.name)) } catch { for (const a of apps) found.push({ app: a.app, server: server.id, kind: 'unreadable' }) return } await Promise.all( apps.map(async (a) => { if (a.baseline === null) return if (!installed.has(a.app)) return void found.push({ app: a.app, server: server.id, kind: 'not-installed' }) try { const text = (await readConfigText(this.#upstream, server, a.app)) ?? '' const effective = effectiveConfig(a.baseline.text, a.overrides, server.id).text if (!sameConfig(text, effective)) found.push({ app: a.app, server: server.id, kind: 'differs' }) } catch { found.push({ app: a.app, server: server.id, kind: 'unreadable' }) } }), ) }), ) const at = new Date(this.#now()).toISOString() const before = new Map(this.#state.apps.map((e) => [`${e.app}\n${e.server}\n${e.kind}`, e.since])) const entries = found .map((f): DriftEntry => ({ ...f, since: before.get(`${f.app}\n${f.server}\n${f.kind}`) ?? at })) .sort((x, y) => x.app.localeCompare(y.app) || x.server.localeCompare(y.server)) this.#state = { checkedAt: at, apps: entries } try { writeAtomic(this.#file(), JSON.stringify(this.#state, null, 2) + '\n') } catch (e) { console.error(`hub: drift.json could not be written (${e instanceof Error ? e.message : String(e)}).`) } return this.#state } }