Files
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 <noreply@anthropic.com>
2026-10-31 16:46:15 +07:00

173 lines
7.0 KiB
TypeScript

/*
`hub/apps/matrix`: which DNS apps are installed on which server, with what version, and
whether their configs agree. Read-only. The config texts are compared here and never
leave: the answer carries a short hash per copy and the comparison, because a config may
hold secrets.
*/
import { createHash } from 'node:crypto'
import type { HubConfig, HubServer } from './config.ts'
import { comparable } from './apps-sync.ts'
import { readOverrides } from './store.ts'
import { jsonDiff } from './json-diff.ts'
import { Upstream, UpstreamError } from './upstream.ts'
export interface ServerCell {
installed: boolean
version?: string
updateAvailable?: boolean
updateVersion?: string
configStatus: 'none' | 'read' | 'unreadable'
configHash?: string
configError?: string
}
export interface MatrixApp {
name: string
description: string
servers: Record<string, ServerCell>
configs: 'same' | 'different' | 'single' | 'unknown'
diffCount?: number
versions: 'same' | 'different' | 'single'
}
export interface Matrix {
servers: { id: string; ok: boolean; writable: boolean; error?: string }[]
apps: MatrixApp[]
}
interface Listed {
name: string
description?: string
version?: string
updateAvailable?: boolean
updateVersion?: string
}
const hash = (text: string): string => createHash('sha256').update(text).digest('hex').slice(0, 16)
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
/* The read token first; when the server refuses it, once more with the admin token if there is one. */
async function readConfig(upstream: Upstream, server: HubServer, name: string): Promise<string | null> {
const params = { name }
let response: unknown
try {
response = await upstream.call(server, 'apps/config/get', params)
} catch (e) {
if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e
response = await upstream.call(server, 'apps/config/get', params, server.adminToken)
}
const config = (response as { config?: unknown } | null)?.config
return typeof config === 'string' ? config : null
}
/* How many places differ between the readable configs. JSON: differing leaf paths; else differing lines. */
export function countDifferences(texts: string[]): number {
if (texts.length < 2) return 0
let parsed: unknown[] | null = []
for (const text of texts) {
try {
parsed.push(JSON.parse(text))
} catch {
parsed = null
break
}
}
if (parsed !== null) {
const paths = new Set<string>()
for (let i = 1; i < parsed.length; i++) for (const d of jsonDiff(parsed[0], parsed[i])) paths.add(d.path)
return paths.size
}
const lines = texts.map((t) => t.split(/\r?\n/).map((l) => l.trim()))
let most = 0
for (let i = 1; i < lines.length; i++) {
const [a, b] = [new Set(lines[0]), new Set(lines[i])]
let n = 0
for (const l of a) if (!b.has(l)) n++
for (const l of b) if (!a.has(l)) n++
most = Math.max(most, n)
}
return most
}
/* The matrix and the servers that did not answer, as `hubWarnings` lines. */
export async function buildMatrix(config: HubConfig, upstream: Upstream): Promise<{ matrix: Matrix; warnings: string[] }> {
type Copy = { cell: ServerCell; text: string | null }
const perServer = await Promise.all(
config.servers.map(async (server) => {
let list: Listed[]
try {
const response = (await upstream.call(server, 'apps/list')) as { apps?: Listed[] } | null
list = response?.apps ?? []
} catch (e) {
return { server, error: reason(e), apps: new Map<string, { listed: Listed } & Copy>() }
}
const entries = await Promise.all(
list.map(async (listed): Promise<[string, { listed: Listed } & Copy]> => {
const cell: ServerCell = {
installed: true,
...(listed.version === undefined ? {} : { version: listed.version }),
...(listed.updateAvailable === undefined ? {} : { updateAvailable: listed.updateAvailable }),
...(listed.updateVersion === undefined ? {} : { updateVersion: listed.updateVersion }),
configStatus: 'none',
}
let text: string | null = null
try {
text = await readConfig(upstream, server, listed.name)
if (text !== null && text !== '') {
cell.configStatus = 'read'
cell.configHash = hash(comparable(text, readOverrides(config.dataDir, listed.name).paths))
}
} catch (e) {
cell.configStatus = 'unreadable'
cell.configError = reason(e)
text = null
}
return [listed.name, { listed, cell, text }]
}),
)
return { server, error: undefined, apps: new Map(entries) }
}),
)
const servers = perServer.map((p) => (p.error === undefined
? { id: p.server.id, ok: true, writable: p.server.adminToken !== undefined }
: { id: p.server.id, ok: false, writable: p.server.adminToken !== undefined, error: p.error }))
const warnings = perServer.filter((p) => p.error !== undefined).map((p) => `${p.server.id}: ${p.error}`)
if (warnings.length === perServer.length) throw new Error(`No server answered. ${warnings.join('; ')}`.trim())
const names = [...new Set(perServer.flatMap((p) => [...p.apps.keys()]))].sort((a, b) => a.localeCompare(b))
const apps: MatrixApp[] = names.map((name) => {
const cells: Record<string, ServerCell> = {}
const copies: { id: string; cell: ServerCell; text: string | null }[] = []
let description = ''
for (const p of perServer) {
const found = p.apps.get(name)
if (found !== undefined) {
cells[p.server.id] = found.cell
copies.push({ id: p.server.id, cell: found.cell, text: found.text })
if (description === '' && found.listed.description) description = found.listed.description
} else if (p.error !== undefined) {
cells[p.server.id] = { installed: false, configStatus: 'unreadable', configError: `${p.server.id} did not answer` }
} else {
cells[p.server.id] = { installed: false, configStatus: 'none' }
}
}
const versionSet = new Set(copies.map((c) => c.cell.version ?? ''))
const versions = copies.length < 2 ? 'single' : versionSet.size === 1 ? 'same' : 'different'
// Fields marked local do not count: two servers that differ only there are the same.
const localPaths = readOverrides(config.dataDir, name).paths
const readable = copies.filter((c) => c.cell.configStatus !== 'unreadable').map((c) => comparable(c.text ?? '', localPaths))
const hasUnknown = copies.some((c) => c.cell.configStatus === 'unreadable') || (warnings.length > 0 && copies.length < 2)
const differs = new Set(readable).size > 1
const configs: MatrixApp['configs'] = differs ? 'different' : hasUnknown ? 'unknown' : copies.length < 2 ? 'single' : 'same'
const app: MatrixApp = { name, description, servers: cells, configs, versions }
if (differs) app.diffCount = countDifferences(readable)
return app
})
return { matrix: { servers, apps }, warnings }
}