Files
technitium-console/hub/drift.ts
T
DenozordecandClaude Opus 5.5 392b644faa Hub stage 7: compare and align zones, settings and blocking
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]>
2026-11-01 01:20:05 +07:00

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