Files
technitium-console/hub/drift.ts
T
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
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>
2026-11-01 02:11:19 +07:00

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))
}
}