/* Drift: a server whose config is no longer what the hub says it should be, usually because someone changed it in that server's own console. Three kinds: app an app's config is not the baseline plus the server's own local values settings the shared settings fields differ from the settings baseline lists the custom Blocked or Allowed list differs from the lists baseline `check` reads every server with the same rules as the compare views and keeps the result 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. An entry carries names and field names only, never a value. Two checks never run at once. The timer lives in server.ts, so the tests drive `check` themselves. When a webhook is given, a CHANGE of state is sent to it: `drift.found` for what newly differs and `drift.cleared` for what no longer does (an entry that became unreadable is not cleared: it is not known). The first state is the one loaded from drift.json, so a restart does not repeat notices. */ 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 { SettingsConfig } from './settings-config.ts' import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts' import type { Upstream } from './upstream.ts' import type { Webhook } from './webhook.ts' export type DriftStatus = 'differs' | 'unreadable' | 'not-installed' export type DriftType = 'app' | 'settings' | 'lists' export interface DriftEntry { kind: DriftType /** The app's name; absent for settings and lists. */ name?: string /** Same as `name` for an app (the console's matrix reads it). */ app?: string server: string since: string status: DriftStatus /** Setting names, or `blocked` / `allowed`. Never values. */ fields?: string[] } export interface DriftState { checkedAt: string | null /** Every entry, whatever its kind (the key is kept from stage 4). */ apps: DriftEntry[] } const STATUSES: DriftStatus[] = ['differs', 'unreadable', 'not-installed'] const TYPES: DriftType[] = ['app', 'settings', 'lists'] export const keyOf = (e: Pick): string => `${e.kind}\n${e.name ?? ''}\n${e.server}` /* An entry from drift.json. Before stage 8 `kind` held the status and `app` the name. */ export function readEntry(raw: unknown): DriftEntry | null { if (raw === null || typeof raw !== 'object') return null const e = raw as Record if (typeof e.server !== 'string' || typeof e.since !== 'string') return null const oldKind = typeof e.kind === 'string' && STATUSES.includes(e.kind as DriftStatus) const status = (oldKind ? e.kind : e.status) as DriftStatus if (!STATUSES.includes(status)) return null const kind = (oldKind || e.kind === undefined ? 'app' : e.kind) as DriftType if (!TYPES.includes(kind)) return null const name = typeof e.name === 'string' ? e.name : typeof e.app === 'string' ? e.app : undefined if (kind === 'app' && name === undefined) return null const fields = Array.isArray(e.fields) ? e.fields.filter((f): f is string => typeof f === 'string').slice(0, 100) : undefined return { kind, ...(name === undefined ? {} : { name }), ...(kind === 'app' ? { app: name } : {}), server: e.server, since: e.since, status, ...(fields === undefined || fields.length === 0 ? {} : { fields }), } } export class Drift { #config: HubConfig #upstream: Upstream #now: () => number #settings: SettingsConfig #webhook: Webhook | null #state: DriftState #running: Promise | null = null constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now, options: { webhook?: Webhook } = {}) { this.#config = config this.#upstream = upstream this.#now = now this.#settings = new SettingsConfig(config, upstream, now) this.#webhook = options.webhook ?? null 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 : []).map(readEntry).filter((e): e is DriftEntry => e !== null) 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 entries differ from their baseline, of any kind (unreadable ones are listed, not counted). */ count(): number { return this.#state.apps.filter((e) => e.status === '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: Omit[] = [] const appChecks = 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({ kind: 'app', name: a.app, app: a.app, server: server.id, status: 'unreadable' }) return } await Promise.all( apps.map(async (a) => { if (a.baseline === null) return const entry = { kind: 'app' as const, name: a.app, app: a.app, server: server.id } if (!installed.has(a.app)) return void found.push({ ...entry, status: '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({ ...entry, status: 'differs' }) } catch { found.push({ ...entry, status: 'unreadable' }) } }), ) }), ) const otherChecks = this.#settings.driftFindings().then( (list) => { for (const f of list) found.push({ kind: f.kind, server: f.server, status: f.status, ...(f.fields.length > 0 ? { fields: f.fields } : {}) }) }, () => undefined, ) await Promise.all([appChecks, otherChecks]) const at = new Date(this.#now()).toISOString() const before = new Map(this.#state.apps.map((e) => [`${keyOf(e)}\n${e.status}`, e.since])) const entries = found .map((f): DriftEntry => ({ ...f, since: before.get(`${keyOf(f)}\n${f.status}`) ?? at })) .sort((x, y) => x.kind.localeCompare(y.kind) || (x.name ?? '').localeCompare(y.name ?? '') || x.server.localeCompare(y.server)) const previous = this.#state.apps 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)}).`) } this.#notify(previous, entries) return this.#state } /* A notice for what newly differs and for what is back in line. Names and field names only. */ #notify(previous: DriftEntry[], now: DriftEntry[]): void { if (this.#webhook === null || !this.#webhook.configured) return const item = (e: DriftEntry): Record => ({ kind: e.kind, ...(e.name === undefined ? {} : { name: e.name }), server: e.server, since: e.since, ...(e.fields === undefined ? {} : { fields: e.fields }), }) const was = new Set(previous.filter((e) => e.status === 'differs').map(keyOf)) const nowDiffers = new Set(now.filter((e) => e.status === 'differs').map(keyOf)) const unknown = new Set(now.filter((e) => e.status === 'unreadable').map(keyOf)) const newly = now.filter((e) => e.status === 'differs' && !was.has(keyOf(e))) const cleared = previous.filter((e) => e.status === 'differs' && !nowDiffers.has(keyOf(e)) && !unknown.has(keyOf(e))) if (newly.length > 0) this.#webhook.send('drift.found', newly.map(item)) if (cleared.length > 0) this.#webhook.send('drift.cleared', cleared.map(item)) } }