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]>
173 lines
7.0 KiB
TypeScript
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 }
|
|
}
|