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>
This commit is contained in:
1 parent
392b644faa
commit
9742f0fe69
32 files changed
+3123
-106
No files matched your search
+101
-32
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user