Files
technitium-console/hub/apps-matrix.ts
T
DenozordecandClaude Opus 5.5 6acd2d656d Hub stage 2: install, update and uninstall apps on several servers
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>
2026-10-31 16:11:40 +07:00

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 }
}