Hub stage 8: settings drift, webhook notices, profiles
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s

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>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 02:11:19 +07:00
1 parent 392b644faa
commit 9742f0fe69
32 files changed
+3123 -106

No files matched your search

+101 -32
View File
@@ -1,54 +1,96 @@
/*
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.
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:
`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.
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'
/*
Not yet (stage 7): settings are not part of this check yet. A `settings` kind would need `DriftEntry.app`
(and `count()`, which feeds the header badge) to stand for something other than an app. For now the
Settings and Blocking compare views (settings-config.ts) are the way to see a server that moved away
from the settings baseline.
*/
export type DriftKind = 'differs' | 'unreadable' | 'not-installed'
export type DriftStatus = 'differs' | 'unreadable' | 'not-installed'
export type DriftType = 'app' | 'settings' | 'lists'
export interface DriftEntry {
app: string
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
kind: DriftKind
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 KINDS: DriftKind[] = ['differs', 'unreadable', 'not-installed']
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) {
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()
}
@@ -60,9 +102,7 @@ export class Drift {
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),
)
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: [] }
@@ -73,9 +113,9 @@ export class Drift {
return { ...this.#state, checking: this.#running !== null }
}
/** How many servers differ from their baseline (the other kinds are listed, not counted). */
/** 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.kind === 'differs').length
return this.#state.apps.filter((e) => e.status === 'differs').length
}
/* Runs a check, or joins the one that is already running. */
@@ -94,9 +134,9 @@ export class Drift {
baseline: readBaseline(this.#config.dataDir, app),
overrides: readOverrides(this.#config.dataDir, app),
}))
const found: { app: string; server: string; kind: DriftKind }[] = []
const found: Omit<DriftEntry, 'since'>[] = []
await Promise.all(
const appChecks = Promise.all(
this.#config.servers.map(async (server) => {
if (apps.length === 0) return
this.#upstream.invalidate(server.id)
@@ -105,36 +145,65 @@ export class Drift {
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' })
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
if (!installed.has(a.app)) return void found.push({ app: a.app, server: server.id, kind: 'not-installed' })
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({ app: a.app, server: server.id, kind: 'differs' })
if (!sameConfig(text, effective)) found.push({ ...entry, status: 'differs' })
} catch {
found.push({ app: a.app, server: server.id, kind: 'unreadable' })
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) => [`${e.app}\n${e.server}\n${e.kind}`, e.since]))
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(`${f.app}\n${f.server}\n${f.kind}`) ?? at }))
.sort((x, y) => x.app.localeCompare(y.app) || x.server.localeCompare(y.server))
.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))
}
}