Hub stage 4: config history, rollback, drift check, baseline export and import

Every saved config, and every earlier baseline, is listed per app. You
can view it, diff it against a server's current config, and roll it back
through preview and apply. Earlier baselines can be restored.

A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with
its effective config. Drift shows in the header and the matrix, with
Accept as baseline and Revert to baseline. All baselines export to one
file. Importing that file shows a dry run before it writes.

Rolling back preselects, and diffs against, the server the version came
from. The line-diff op "del" is renamed to "remove" for the language lint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 19:57:33 +07:00
1 parent 0e05039202
commit 57e492cceb
29 files changed
+2294 -125

No files matched your search

+229 -55
View File
@@ -10,6 +10,7 @@ but never written to the audit log.
import type { HubConfig, HubServer } from './config.ts'
import {
MAX_CONFIG,
acceptServerConfig,
cleanValues,
diffConfigs,
effectiveConfig,
@@ -22,7 +23,19 @@ import {
type Diff,
} from './apps-sync.ts'
import { Upstream, UpstreamError } from './upstream.ts'
import { readBaseline, readOverrides, writeBaseline, writeHistory, writeOverrides, type Baseline, type Overrides } from './store.ts'
import {
listBaselineApps,
listHistory,
readBaseline,
readHistory,
readOverrides,
writeBaseline,
writeHistory,
writeOverrides,
type Baseline,
type HistoryEntry,
type Overrides,
} from './store.ts'
export class UserError extends Error {
constructor(message: string) {
@@ -90,6 +103,19 @@ export function compareVersions(a: string, b: string): number {
return 0
}
/* The read token first, the admin token when the server refuses it. */
export async function readConfigText(upstream: Upstream, server: HubServer, name: string): Promise<string | null> {
let response: unknown
try {
response = await upstream.call(server, 'apps/config/get', { name })
} catch (e) {
if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e
response = await upstream.call(server, 'apps/config/get', { name }, server.adminToken)
}
const config = (response as { config?: unknown } | null)?.config
return typeof config === 'string' ? config : null
}
export class AppsConfig {
#config: HubConfig
#upstream: Upstream
@@ -113,18 +139,9 @@ export class AppsConfig {
return unique.map((id) => this.#server(id))
}
/* The read token first, the admin token when the server refuses it. `fresh` skips the hub's cache. */
async #readConfig(server: HubServer, name: string, fresh: boolean): Promise<string | null> {
if (fresh) this.#upstream.invalidate(server.id)
let response: unknown
try {
response = await this.#upstream.call(server, 'apps/config/get', { name })
} catch (e) {
if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e
response = await this.#upstream.call(server, 'apps/config/get', { name }, server.adminToken)
}
const config = (response as { config?: unknown } | null)?.config
return typeof config === 'string' ? config : null
return readConfigText(this.#upstream, server, name)
}
async #live(server: HubServer, name: string, fresh: boolean): Promise<Live> {
@@ -206,24 +223,36 @@ export class AppsConfig {
}
if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}" to import.`)
if (text.length > MAX_CONFIG) throw new UserError('The config is larger than 1 MB.')
writeBaseline(this.#config.dataDir, { app: name, text, importedFrom: serverId, updatedAt: new Date(this.#now()).toISOString(), updatedBy: user })
this.#setBaseline(name, text, serverId, user)
return { detail: `from ${serverId}, ${text.length} bytes, ${textHash(text)}` }
}
/* Writes the baseline; the text it replaces goes to the history first (when it differs). */
#setBaseline(name: string, text: string, importedFrom: string | undefined, user: string): void {
const old = this.getBaseline(name)
if (old !== null && old.text !== text) {
try {
writeHistory(this.#config.dataDir, name, 'baseline', old.text, 'before-save', this.#now())
} catch (e) {
throw new UserError(`The old baseline could not be saved first (${reason(e)}); nothing was changed.`)
}
}
writeBaseline(this.#config.dataDir, {
app: name,
text,
...(importedFrom === undefined ? {} : { importedFrom }),
updatedAt: new Date(this.#now()).toISOString(),
updatedBy: user,
})
}
saveBaseline(name: string, text: string, user: string): { detail: string } {
if (text.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.')
if (looksLikeJson(text)) {
const parsed = parseJson(text)
if (!parsed.ok) throw new UserError(`The baseline is not valid JSON: ${parsed.error}`)
}
const old = this.getBaseline(name)
writeBaseline(this.#config.dataDir, {
app: name,
text,
...(old?.importedFrom === undefined ? {} : { importedFrom: old.importedFrom }),
updatedAt: new Date(this.#now()).toISOString(),
updatedBy: user,
})
this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user)
return { detail: `${text.length} bytes, ${textHash(text)}` }
}
@@ -292,47 +321,192 @@ export class AppsConfig {
}
}
async apply(name: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
/*
Brings one server to `desired`: re-read, refuse if it changed since `expectHash`, save the old
config to the history, write with the admin token, read back and check.
*/
async #applyText(s: HubServer, name: string, desired: (current: string) => string, expectHash: string | undefined): Promise<{ result: TargetResult; detail?: string }> {
if (s.adminToken === undefined) return { result: { server: s.id, ok: false, message: 'no admin token configured' } }
try {
const l = await this.#live(s, name, true)
if (!l.ok) return { result: { server: s.id, ok: false, message: l.error ?? 'did not answer' } }
if (!l.installed) return { result: { server: s.id, ok: false, message: 'the app is not installed on this server' } }
const current = l.text ?? ''
if (expectHash !== undefined && expectHash !== textHash(current)) {
return { result: { server: s.id, ok: false, message: 'the config changed on the server since the preview; run the preview again' } }
}
const text = desired(current)
if (sameConfig(current, text)) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } }
if (text.length > MAX_CONFIG) return { result: { server: s.id, ok: false, message: 'the config is larger than 1 MB' } }
try {
writeHistory(this.#config.dataDir, name, s.id, current, 'before-apply', this.#now())
} catch (e) {
return { result: { server: s.id, ok: false, message: `the old config could not be saved first (${reason(e)}); nothing was changed` } }
}
await this.#upstream.write(s, 'apps/config/set', {}, new URLSearchParams({ name, config: text }))
const after = await this.#readConfig(s, name, true)
const detail = `${textHash(current)} to ${textHash(text)}, ${text.length} bytes`
if (after === null || !sameConfig(after, text)) {
return { result: { server: s.id, ok: false, message: 'the server accepted the config but reads back a different one' }, detail }
}
return { result: { server: s.id, ok: true, message: 'Applied and verified.' }, detail }
} catch (e) {
return { result: { server: s.id, ok: false, message: reason(e) } }
}
}
async #applyMany(
name: string,
ids: string[],
desiredFor: (s: HubServer) => (current: string) => string,
expect: Record<string, string>,
): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
const done = await Promise.all(targets.map((s) => this.#applyText(s, name, desiredFor(s), expect[s.id])))
const details: Record<string, string> = {}
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
return { results: done.map((d) => d.result), details }
}
async apply(name: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const baseline = this.getBaseline(name)
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
const overrides = this.getOverrides(name)
const details: Record<string, string> = {}
return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect)
}
const results = await Promise.all(
targets.map(async (s): Promise<TargetResult> => {
if (s.adminToken === undefined) return { server: s.id, ok: false, message: 'no admin token configured' }
try {
const l = await this.#live(s, name, true)
if (!l.ok) return { server: s.id, ok: false, message: l.error ?? 'did not answer' }
if (!l.installed) return { server: s.id, ok: false, message: 'the app is not installed on this server' }
const current = l.text ?? ''
if (expect[s.id] !== undefined && expect[s.id] !== textHash(current)) {
return { server: s.id, ok: false, message: 'the config changed on the server since the preview; run the preview again' }
}
const effective = effectiveConfig(baseline.text, overrides, s.id)
if (sameConfig(current, effective.text)) return { server: s.id, ok: true, message: 'Already in sync; nothing was written.' }
if (effective.text.length > MAX_CONFIG) return { server: s.id, ok: false, message: 'the config is larger than 1 MB' }
/* ---- History ---- */
try {
writeHistory(this.#config.dataDir, name, s.id, current, 'before-apply', this.#now())
} catch (e) {
return { server: s.id, ok: false, message: `the old config could not be saved first (${reason(e)}); nothing was changed` }
}
await this.#upstream.write(s, 'apps/config/set', {}, new URLSearchParams({ name, config: effective.text }))
historyList(name: string): HistoryEntry[] {
return listHistory(this.#config.dataDir, name)
}
const after = await this.#readConfig(s, name, true)
details[s.id] = `${textHash(current)} to ${textHash(effective.text)}, ${effective.text.length} bytes`
if (after === null || !sameConfig(after, effective.text)) {
return { server: s.id, ok: false, message: 'the server accepted the config but reads back a different one' }
}
return { server: s.id, ok: true, message: 'Applied and verified.' }
} catch (e) {
return { server: s.id, ok: false, message: reason(e) }
}
}),
)
return { results, details }
#entry(name: string, id: string): { entry: HistoryEntry; text: string } {
const entry = this.historyList(name).find((e) => e.id === id)
const text = entry === undefined ? null : readHistory(this.#config.dataDir, name, id)
if (entry === undefined || text === null) throw new UserError('There is no such history entry.')
return { entry, text }
}
historyGet(name: string, id: string): { entry: HistoryEntry; text: string } {
return this.#entry(name, id)
}
/* A saved version against a server's config now: what rolling back would change. */
async historyDiff(name: string, id: string, serverId: string): Promise<unknown> {
const { entry, text } = this.#entry(name, id)
const server = this.#server(serverId)
const l = await this.#live(server, name, true)
if (!l.ok || !l.installed) throw new UserError(l.ok ? `The app is not installed on ${serverId}.` : `${serverId}: ${l.error ?? 'did not answer'}`)
const current = l.text ?? ''
return {
entry,
server: serverId,
writable: server.adminToken !== undefined,
current,
currentHash: textHash(current),
version: text,
changes: diffConfigs(current, text, []),
unchanged: sameConfig(current, text),
}
}
async rollback(name: string, id: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const { entry, text } = this.#entry(name, id)
if (entry.kind === 'before-save') throw new UserError('That entry is an old baseline. Restore it as the baseline instead.')
return this.#applyMany(name, ids, () => () => text, expect)
}
restoreBaseline(name: string, id: string, user: string): { detail: string } {
const { entry, text } = this.#entry(name, id)
if (entry.kind !== 'before-save') throw new UserError('Only an old baseline can be restored as the baseline.')
this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user)
return { detail: `entry ${entry.hash}, ${text.length} bytes` }
}
/*
"Accept as baseline" for a drifted server: its config becomes the baseline, except that local
fields keep the baseline's values and the server's own values go into the overrides.
*/
async acceptDrift(name: string, serverId: string, user: string): Promise<{ detail: string }> {
const server = this.#server(serverId)
let text: string | null
try {
text = await this.#readConfig(server, name, true)
} catch (e) {
throw new UserError(`${serverId}: ${reason(e)}`)
}
if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}".`)
const overrides = this.getOverrides(name)
let next
try {
next = acceptServerConfig(text, this.getBaseline(name)?.text ?? null, overrides, serverId)
} catch (e) {
throw new UserError(reason(e))
}
this.#setBaseline(name, next.baselineText, serverId, user)
if (overrides.paths.length > 0) writeOverrides(this.#config.dataDir, name, next.overrides)
return { detail: `from ${serverId}, ${next.baselineText.length} bytes, ${textHash(next.baselineText)}` }
}
/* ---- Export and import of all baselines ---- */
exportAll(): { hub: string; version: number; exportedAt: string; apps: { app: string; baseline: Baseline; overrides: Overrides }[] } {
const apps = listBaselineApps(this.#config.dataDir).flatMap((app) => {
const baseline = this.getBaseline(app)
return baseline === null ? [] : [{ app, baseline, overrides: this.getOverrides(app) }]
})
return { hub: 'technitium-hub', version: 1, exportedAt: new Date(this.#now()).toISOString(), apps }
}
/* Validates a file and says what it would change; with `apply` also writes baselines and overrides (never to a server). */
importAll(raw: string, apply: boolean, user: string): { apps: { app: string; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[]; applied: boolean } {
const parsed = parseJson(raw)
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
const file = parsed.value as { hub?: unknown; version?: unknown; apps?: unknown }
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub') throw new UserError('This is not a baselines file of the hub.')
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
if (!Array.isArray(file.apps) || file.apps.length > 500) throw new UserError('The file has no list of apps, or has more than 500.')
const known = this.#config.servers.map((s) => s.id)
const plan: { app: string; text: string; overrides: Overrides; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[] = []
const seen = new Set<string>()
for (const item of file.apps as Record<string, unknown>[]) {
const app = item?.app
const baseline = item?.baseline as { text?: unknown } | null | undefined
if (typeof app !== 'string' || app === '' || app.length > 200 || seen.has(app)) throw new UserError('An app in the file has no valid, unique name.')
seen.add(app)
if (typeof baseline?.text !== 'string') throw new UserError(`"${app}" has no baseline text.`)
if (baseline.text.length > MAX_CONFIG) throw new UserError(`The baseline of "${app}" is larger than 1 MB.`)
if (looksLikeJson(baseline.text) && !parseJson(baseline.text).ok) throw new UserError(`The baseline of "${app}" is not valid JSON.`)
const rawOverrides = (item.overrides ?? { paths: [], values: {} }) as { paths?: unknown; values?: unknown }
let paths: string[]
try {
paths = validatePaths(rawOverrides.paths ?? [])
} catch (e) {
throw new UserError(`"${app}": ${reason(e)}`)
}
if (paths.length > 0 && !looksLikeJson(baseline.text)) throw new UserError(`"${app}": a plain text baseline cannot have local fields.`)
const values = rawOverrides.values !== null && typeof rawOverrides.values === 'object' && !Array.isArray(rawOverrides.values) ? (rawOverrides.values as Record<string, unknown>) : {}
const ignoredServers = Object.keys(values).filter((id) => !known.includes(id))
let cleaned: Overrides['values']
try {
cleaned = cleanValues(Object.fromEntries(Object.entries(values).filter(([id]) => known.includes(id))), paths, known)
} catch (e) {
throw new UserError(`"${app}": ${reason(e)}`)
}
const overrides: Overrides = { paths, values: cleaned }
const old = this.getBaseline(app)
const oldOverrides = this.getOverrides(app)
const status = old === null ? 'new' : old.text === baseline.text && JSON.stringify(oldOverrides) === JSON.stringify(overrides) ? 'same' : 'changed'
plan.push({ app, text: baseline.text, overrides, status, ignoredServers })
}
if (apply) {
for (const p of plan) {
if (p.status === 'same') continue
this.#setBaseline(p.app, p.text, this.getBaseline(p.app)?.importedFrom, user)
writeOverrides(this.#config.dataDir, p.app, p.overrides)
}
}
return { apps: plan.map(({ app, status, ignoredServers }) => ({ app, status, ignoredServers })), applied: apply }
}
}