/* `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 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 { 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() 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() } } 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 = {} 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 } }