hub/apps/matrix reads apps/list and each app's config on every server (the read token first, the admin token once if a server refuses) and returns per app and server the version, available update and a config hash, plus whether versions and configs match and how many JSON leaf paths differ. Config texts never leave the hub. In hub mode the Apps section has the server selector: All servers shows the matrix with filters for differing versions, differing configs and updates; one server shows its list without write actions. The dev mock takes --variant a|b with differing apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
6.6 KiB
TypeScript
167 lines
6.6 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 { 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; 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(text)
|
|
}
|
|
} 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 } : { id: p.server.id, ok: false, 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'
|
|
|
|
const readable = copies.filter((c) => c.cell.configStatus !== 'unreadable').map((c) => c.text ?? '')
|
|
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 }
|
|
}
|