Zones gets "All servers" in hub mode: one table of every zone on every server with its type and serial, marking zones that are on one server only or whose serials or types differ. A zone can be copied to another server (export, create, import; Overwrite must be ticked for a zone that is already there) or made a Secondary there (the source's zone transfer and notify lists gain the target, then the target zone is created). Settings and Blocking can be compared across servers against a baseline kept in the hub. Host fields stay local, secrets are compared inside the hub only and never shown or logged, and an apply sends only the changed shared fields after saving each server's settings to history. Blocked and Allowed lists are compared as sets; extras are removed only when asked. Co-Authored-By: Claude Opus 5.5 <[email protected]>
141 lines
5.3 KiB
TypeScript
141 lines
5.3 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'
|
|
|
|
/*
|
|
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 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
|
|
}
|
|
}
|