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]>
339 lines
14 KiB
TypeScript
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 }
|
|
}
|
|
}
|
|
|