Files
technitium-console/hub/apps-config.ts
T
DenozordecandClaude Opus 5.5 0e05039202 Hub stage 3: compare and sync app configs across servers
The hub keeps a baseline per app in /data/apps/<app>/baseline.json. Local
fields (overrides.json) are left out of the sync and out of the matrix
comparison. Each server gets the baseline plus its own values.

Applying goes target picker, preview with per-server diffs and warnings,
then apply. Apply refuses a server whose config changed since the
preview, saves the old config to history, then reads the new one back
to check it.

Large config bodies are read only for signed-in callers. The App config
dialog has Compare, Baseline and Local fields tabs and an empty state
that offers an import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:46:15 +07:00

339 lines
14 KiB
TypeScript

/*
Config sync for one app across the servers: compare, baseline, local fields, preview, apply.
The baseline (kept in the hub) plus a server's local values is that server's effective config.
Apply brings the selected servers to it: it re-reads the server, refuses if the config changed
since the preview, saves the old config to the history, writes with the server's ADMIN token and
reads the config back to check it. Config texts are returned to the caller (it is what they edit)
but never written to the audit log.
*/
import type { HubConfig, HubServer } from './config.ts'
import {
MAX_CONFIG,
cleanValues,
diffConfigs,
effectiveConfig,
importLocalValues,
looksLikeJson,
parseJson,
sameConfig,
textHash,
validatePaths,
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'
export class UserError extends Error {
constructor(message: string) {
super(message)
this.name = 'UserError'
}
}
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
export interface Live {
installed: boolean
version?: string
ok: boolean
error?: string
/** The server's config text; null when the app has none. */
text: string | null
}
export interface ServerView {
id: string
ok: boolean
error?: string
installed: boolean
writable: boolean
version?: string
text: string | null
hash: string
effective?: string
warnings: string[]
/** Null without a baseline. */
inSync: boolean | null
/** What applying would change: this server's config to its effective config. */
vsBaseline?: Diff
/** This server's config relative to each other server's, local fields left out. */
vsOthers: Record<string, Diff>
}
export interface TargetPreview {
server: string
ok: boolean
error?: string
writable: boolean
current: string | null
currentHash: string
effective: string
changes: Diff
unchanged: boolean
warnings: string[]
}
export interface TargetResult {
server: string
ok: boolean
message?: string
}
/* 9.10 is newer than 9.9: compared part by part, numerically. */
export function compareVersions(a: string, b: string): number {
const [x, y] = [a, b].map((v) => v.split('.').map((p) => Number.parseInt(p, 10) || 0))
for (let i = 0; i < Math.max(x.length, y.length); i++) {
const d = (x[i] ?? 0) - (y[i] ?? 0)
if (d !== 0) return d
}
return 0
}
export class AppsConfig {
#config: HubConfig
#upstream: Upstream
#now: () => number
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
this.#config = config
this.#upstream = upstream
this.#now = now
}
#server(id: string): HubServer {
const found = this.#config.servers.find((s) => s.id === id)
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
return found
}
#targets(ids: string[]): HubServer[] {
const unique = [...new Set(ids)]
if (unique.length === 0) throw new UserError('Choose at least one server.')
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
}
async #live(server: HubServer, name: string, fresh: boolean): Promise<Live> {
let version: string | undefined
try {
if (fresh) this.#upstream.invalidate(server.id)
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
const app = (list?.apps ?? []).find((a) => a.name === name)
if (app === undefined) return { installed: false, ok: true, text: null }
version = app.version
} catch (e) {
return { installed: false, ok: false, error: reason(e), text: null }
}
try {
return { installed: true, ok: true, version, text: await this.#readConfig(server, name, false) }
} catch (e) {
return { installed: true, ok: false, version, error: reason(e), text: null }
}
}
async #liveAll(name: string, fresh: boolean): Promise<Map<string, Live>> {
const entries = await Promise.all(this.#config.servers.map(async (s): Promise<[string, Live]> => [s.id, await this.#live(s, name, fresh)]))
return new Map(entries)
}
getBaseline(name: string): Baseline | null {
return readBaseline(this.#config.dataDir, name)
}
getOverrides(name: string): Overrides {
return readOverrides(this.#config.dataDir, name)
}
async compare(name: string): Promise<unknown> {
const baseline = this.getBaseline(name)
const overrides = this.getOverrides(name)
const live = await this.#liveAll(name, false)
const views: ServerView[] = this.#config.servers.map((s) => {
const l = live.get(s.id) as Live
const text = l.text
const view: ServerView = {
id: s.id,
ok: l.ok,
installed: l.installed,
writable: s.adminToken !== undefined,
text,
hash: textHash(text ?? ''),
warnings: [],
inSync: null,
vsOthers: {},
}
if (l.error !== undefined) view.error = l.error
if (l.version !== undefined) view.version = l.version
if (baseline !== null && l.installed && l.ok) {
const effective = effectiveConfig(baseline.text, overrides, s.id)
view.effective = effective.text
view.warnings = effective.warnings
view.inSync = sameConfig(text ?? '', effective.text)
view.vsBaseline = diffConfigs(text ?? '', effective.text, [])
}
return view
})
for (const v of views) {
if (!v.installed || !v.ok) continue
for (const o of views) {
if (o.id !== v.id && o.installed && o.ok) v.vsOthers[o.id] = diffConfigs(o.text ?? '', v.text ?? '', overrides.paths)
}
}
return { name, baseline, overrides, servers: views }
}
async importBaseline(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}" 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 })
return { detail: `from ${serverId}, ${text.length} bytes, ${textHash(text)}` }
}
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,
})
return { detail: `${text.length} bytes, ${textHash(text)}` }
}
saveOverrides(name: string, pathsRaw: unknown, valuesRaw: unknown): { detail: string } {
let paths: string[]
let values: Overrides['values']
try {
paths = validatePaths(pathsRaw)
values = cleanValues(valuesRaw ?? {}, paths, this.#config.servers.map((s) => s.id))
} catch (e) {
throw new UserError(reason(e))
}
const baseline = this.getBaseline(name)
if (paths.length > 0 && baseline !== null && (!looksLikeJson(baseline.text) || !parseJson(baseline.text).ok)) {
throw new UserError('The baseline is plain text, so it cannot have local fields.')
}
if (JSON.stringify(values).length > MAX_CONFIG) throw new UserError('The local values are larger than 1 MB.')
writeOverrides(this.#config.dataDir, name, { paths, values })
return { detail: `${paths.length} local field(s)` }
}
/* Reads each local path from the servers' live configs, for the "fill from current" button. */
async currentLocalValues(name: string, pathsRaw: unknown): Promise<Overrides['values']> {
let paths: string[]
try {
paths = validatePaths(pathsRaw)
} catch (e) {
throw new UserError(reason(e))
}
const live = await this.#liveAll(name, true)
return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths)
}
async preview(name: string, ids: string[]): Promise<{ targets: TargetPreview[] }> {
const targets = this.#targets(ids)
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 live = await this.#liveAll(name, true)
const versions = [...live].filter(([, l]) => l.installed && l.version !== undefined).map(([id, l]) => ({ id, version: l.version as string }))
const newest = versions.reduce<{ id: string; version: string } | null>((best, v) => (best === null || compareVersions(v.version, best.version) > 0 ? v : best), null)
return {
targets: targets.map((s): TargetPreview => {
const l = live.get(s.id) as Live
const effective = effectiveConfig(baseline.text, overrides, s.id)
const warnings = [...effective.warnings]
if (s.adminToken === undefined) warnings.push('The hub has no admin token for this server, so it cannot write to it.')
if (l.installed && l.version !== undefined && newest !== null && compareVersions(l.version, newest.version) < 0) {
warnings.push(`Version ${l.version} here, ${newest.version} on ${newest.id}: versions differ, align versions first.`)
}
const current = l.text
const base = {
server: s.id,
writable: s.adminToken !== undefined,
current,
currentHash: textHash(current ?? ''),
effective: effective.text,
warnings,
}
if (!l.ok) return { ...base, ok: false, error: l.error ?? 'did not answer', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
if (!l.installed) return { ...base, ok: false, error: 'The app is not installed on this server.', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
const changes = diffConfigs(current ?? '', effective.text, [])
return { ...base, ok: true, changes, unchanged: sameConfig(current ?? '', effective.text) }
}),
}
}
async apply(name: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
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> = {}
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' }
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 }))
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 }
}
}