Every saved config, and every earlier baseline, is listed per app. You can view it, diff it against a server's current config, and roll it back through preview and apply. Earlier baselines can be restored. A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with its effective config. Drift shows in the header and the matrix, with Accept as baseline and Revert to baseline. All baselines export to one file. Importing that file shows a dry run before it writes. Rolling back preselects, and diffs against, the server the version came from. The line-diff op "del" is renamed to "remove" for the language lint. Co-Authored-By: Claude Opus 5.5 <[email protected]>
135 lines
5.0 KiB
TypeScript
135 lines
5.0 KiB
TypeScript
/*
|
|
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<DriftState> | 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<DriftState>
|
|
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<DriftState> {
|
|
if (this.#running !== null) return this.#running
|
|
const run = this.#run().finally(() => {
|
|
this.#running = null
|
|
})
|
|
this.#running = run
|
|
return run
|
|
}
|
|
|
|
async #run(): Promise<DriftState> {
|
|
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<string>
|
|
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
|
|
}
|
|
}
|