The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
8.9 KiB
TypeScript
210 lines
8.9 KiB
TypeScript
/*
|
|
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<DriftEntry, 'kind' | 'name' | 'server'>): 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<string, unknown>
|
|
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<DriftState> | 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<DriftState>
|
|
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<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: Omit<DriftEntry, 'since'>[] = []
|
|
|
|
const appChecks = 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({ 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<string, unknown> => ({
|
|
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))
|
|
}
|
|
}
|