Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
6.7 KiB
TypeScript
169 lines
6.7 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; 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(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, 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'
|
|
|
|
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 }
|
|
}
|