Hub stage 3: compare and sync app configs across servers

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]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 16:46:15 +07:00
1 parent 6acd2d656d
commit 0e05039202
22 files changed
+2434 -58

No files matched your search

+8 -2
View File
@@ -197,8 +197,8 @@ if (VARIANT === 'b') {
} }
const appConfigs = { const appConfigs = {
'Advanced Blocking': VARIANT === 'b' 'Advanced Blocking': VARIANT === 'b'
? { enableBlocking: true, blockingAnswerTtl: 60, networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] } ? { enableBlocking: true, blockingAnswerTtl: 60, localGroup: 'office', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] }
: { enableBlocking: true, blockingAnswerTtl: 30, networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] }, : { enableBlocking: true, blockingAnswerTtl: 30, localGroup: 'home', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] },
'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' }, 'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' },
'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } }, 'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } },
} }
@@ -307,6 +307,12 @@ function route(path, q) {
apps = update ? apps.map((a) => (a.name === name ? installed : a)) : [...apps, installed] apps = update ? apps.map((a) => (a.name === name ? installed : a)) : [...apps, installed]
return ok(update ? { updatedApp: installed } : { installedApp: installed }) return ok(update ? { updatedApp: installed } : { installedApp: installed })
} }
case 'apps/config/set': {
const name = q.get('name') ?? ''
if (!apps.some((a) => a.name === name)) return { status: 'error', errorMessage: `DNS application '${name}' was not found.` }
try { appConfigs[name] = JSON.parse(q.get('config') ?? '') } catch (e) { return { status: 'error', errorMessage: `The config is not valid JSON: ${e.message}` } }
return ok()
}
case 'apps/config/get': return ok({ config: appConfigs[q.get('name')] ? JSON.stringify(appConfigs[q.get('name')], null, 2) : JSON.stringify({ enabled: true }, null, 2) }) case 'apps/config/get': return ok({ config: appConfigs[q.get('name')] ? JSON.stringify(appConfigs[q.get('name')], null, 2) : JSON.stringify({ enabled: true }, null, 2) })
case 'apps/uninstall': apps = apps.filter((a) => a.name !== q.get('name')); return ok() case 'apps/uninstall': apps = apps.filter((a) => a.name !== q.get('name')); return ok()
case 'logs/list': return ok({ logFiles: [{ fileName: '2026-10-26', size: '412.8 KB' }, { fileName: '2026-10-25', size: '1.2 MB' }, { fileName: '2026-10-24', size: '980.4 KB' }] }) case 'logs/list': return ok({ logFiles: [{ fileName: '2026-10-26', size: '412.8 KB' }, { fileName: '2026-10-25', size: '1.2 MB' }, { fileName: '2026-10-24', size: '980.4 KB' }] })
+18
View File
@@ -309,6 +309,24 @@ docker compose rm -f technitium-hub # удалить контейнер
| Цифры «All servers» больше суммы на 1-2 клиента | Clients считается по каждому серверу отдельно: клиент, который ходит на оба сервера, учтён дважды. Это ожидаемо. | | Цифры «All servers» больше суммы на 1-2 клиента | Clients считается по каждому серверу отдельно: клиент, который ходит на оба сервера, учтён дважды. Это ожидаемо. |
| Нажатие на что-то пишет «The hub is read-only.» | Хаб меняет только приложения. Остальные изменения делаются в консоли нужного сервера. | | Нажатие на что-то пишет «The hub is read-only.» | Хаб меняет только приложения. Остальные изменения делаются в консоли нужного сервера. |
## Конфиги приложений: эталон и синхронизация
Чтобы конфиги приложений на серверах были одинаковыми, кроме полей, которые у каждого сервера свои (путь к базе, сети):
1. Для чтения конфигов `hub-reader` нужно право **View на Apps** (оно уже есть в таблице шага 1). Если сервер откажет, хаб попробует токен `hub-admin`.
2. Откройте Apps › All servers, в меню строки приложения выберите **Config…** (или нажмите значок «Different · N»). Читать сравнение могут все роли.
3. **Эталон.** Вкладка Baseline: **Import from mdns** (или другого сервера, на котором конфиг правильный). Эталон можно поправить в редакторе и сохранить. Если текст начинается с `{` или `[`, он должен быть корректным JSON, иначе хаб покажет ошибку.
4. **Локальные поля.** Вкладка Local fields: добавьте путь поля, например `sqliteDbPath` или `networks.0.cidr`, нажмите **Fill from the current server configs** (подставит текущие значения каждого сервера) и сохраните. Такие поля не сравниваются и не рассылаются: в матрице серверы, различающиеся только ими, показываются как «Same».
5. **Применение.** Кнопка **Apply…**: выберите серверы, посмотрите предпросмотр (что изменится, предупреждения, например «версии отличаются, сначала выровняйте версии»), нажмите **Apply**. По каждому серверу будет итог; **Retry failed** повторит только неудавшиеся.
Что хаб делает для безопасности:
- перед записью сохраняет прежний конфиг сервера в `/data/apps/<имя>/history/` (50 последних) и не пишет, если сохранить не удалось;
- если конфиг на сервере изменили после предпросмотра, этот сервер пропускается с пояснением;
- после записи читает конфиг обратно и проверяет, что он совпал; если сервер отклонил конфиг, остаётся старый;
- в журнал попадают кто, на каком сервере и размер с коротким хешем конфига, но не сам текст.
Файлы эталона и локальных полей лежат в `/data/apps/<имя>/` (`baseline.json`, `overrides.json`). Они содержат конфиги, поэтому том `/data` нужно беречь так же, как `users.json`.
## Приложения: установка, обновление, удаление ## Приложения: установка, обновление, удаление
Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**: Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**:
+32 -1
View File
@@ -83,7 +83,7 @@ Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logo
`logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized), and the hub's own `hub/audit/list` (role with `logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized), and the hub's own `hub/audit/list` (role with
`audit.read`). They are listed in `routes.ts` with a kind (`read`, `write-one`, `write-all`) and the capability they need. `audit.read`). They are listed in `routes.ts` with a kind (`read`, `write-one`, `write-all`) and the capability they need.
Everything else answers `403 The hub is read-only.` and is audited. `apps/config/get` and `apps/config/set` are not available yet. Everything else answers `403 The hub is read-only.` and is audited.
## App writes ## App writes
@@ -99,6 +99,37 @@ Every write goes to a server with its `<ID>_ADMIN_TOKEN`; a server without one a
the console's own single-server calls, forwarded to that server (store URLs checked as above). Refused without a node, and the console's own single-server calls, forwarded to that server (store URLs checked as above). Refused without a node, and
with `node=cluster`. with `node=cluster`.
- `apps/listStoreApps`: the first server's store, each app tagged with `installedOn`. - `apps/listStoreApps`: the first server's store, each app tagged with `installedOn`.
- `apps/config/get` (read, `node` required) and `apps/config/set` (`apps.write`, `node` required; the old config is saved to the
history first and the call is refused if that fails): the console's own per-server Config dialog.
## Config sync
Keeps one app's config the same on every server, except for the fields that must differ. Per app the hub stores, under
`${HUB_DATA}/apps/<app>/`:
- `baseline.json`: `{ app, text, importedFrom?, updatedAt, updatedBy }`. The config as TEXT, so non-JSON configs work too.
- `overrides.json`: `{ paths: [...], values: { <server>: { <path>: <value> } } }`. Paths are dotted (`db.path`,
`networks.0.cidr`); at most 50; only for a JSON baseline.
- `history/<time>__<server>__before-apply.txt` (and `before-uninstall`): the config a server had before the hub changed it
(50 files per app kept).
A server's effective config is the baseline with that server's local values put in (JSON, pretty-printed with 2 spaces). A local
path with no value for a server keeps the baseline value and is reported as a warning. Comparisons are by meaning (key order and
spacing do not count) and leave the local fields out, so two servers that differ only there are "Same" in the matrix, and the
matrix hash ignores them too.
| Endpoint | Method | Needs | Does |
|---|---|---|---|
| `hub/apps/config/compare?name=` | GET | read | Each server's live config, the baseline, the local fields, `inSync`, and the differences against the baseline and against the other servers. Config texts are returned here, to roles that can read. |
| `hub/apps/overrides/current` | POST `{name, paths}` | `apps.write` | The servers' current values of those paths. |
| `hub/apps/baseline/import` | POST `{name, server}` | `apps.write` | Baseline := that server's live config. |
| `hub/apps/baseline/save` | POST `{name, text}` | `apps.write` | Saves the baseline (1 MB at most). Text that starts with `{` or `[` must be valid JSON; the parse error is returned. |
| `hub/apps/overrides/save` | POST `{name, paths, values}` | `apps.write` | Saves the local fields. |
| `hub/apps/config/preview` | POST `{name, targets}` | `apps.write` | Per target: current and effective text, the diff, warnings (a missing local value, a version older than the newest installed, no admin token) and `currentHash`. |
| `hub/apps/config/apply` | POST `{name, targets, expect?}` | `apps.write` | Per target: re-reads the config; refuses if its hash differs from `expect[server]`; saves the old config to the history; `apps/config/set` with the admin token; reads it back and checks it. A server that rejects the config keeps its old one. |
Reading configs needs View on Apps for the `hub-reader` user (and `*_ADMIN_TOKEN` is used as a fallback if a server refuses the
read token). The audit log records who did what, with the size and a short hash of a config, never the text.
Before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history/<time>__<server>__before-uninstall.txt` Before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history/<time>__<server>__before-uninstall.txt`
(50 files per app are kept). If that cannot be done the uninstall does not happen. Every target's result is written to the (50 files per app are kept). If that cannot be done the uninstall does not happen. Every target's result is written to the
+338
View File
@@ -0,0 +1,338 @@
/*
Config sync for one app across the servers: compare, baseline, local fields, preview, apply.
The baseline (kept in the hub) plus a server's local values is that server's effective config.
Apply brings the selected servers to it: it re-reads the server, refuses if the config changed
since the preview, saves the old config to the history, writes with the server's ADMIN token and
reads the config back to check it. Config texts are returned to the caller (it is what they edit)
but never written to the audit log.
*/
import type { HubConfig, HubServer } from './config.ts'
import {
MAX_CONFIG,
cleanValues,
diffConfigs,
effectiveConfig,
importLocalValues,
looksLikeJson,
parseJson,
sameConfig,
textHash,
validatePaths,
type Diff,
} from './apps-sync.ts'
import { Upstream, UpstreamError } from './upstream.ts'
import { readBaseline, readOverrides, writeBaseline, writeHistory, writeOverrides, type Baseline, type Overrides } from './store.ts'
export class UserError extends Error {
constructor(message: string) {
super(message)
this.name = 'UserError'
}
}
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
export interface Live {
installed: boolean
version?: string
ok: boolean
error?: string
/** The server's config text; null when the app has none. */
text: string | null
}
export interface ServerView {
id: string
ok: boolean
error?: string
installed: boolean
writable: boolean
version?: string
text: string | null
hash: string
effective?: string
warnings: string[]
/** Null without a baseline. */
inSync: boolean | null
/** What applying would change: this server's config to its effective config. */
vsBaseline?: Diff
/** This server's config relative to each other server's, local fields left out. */
vsOthers: Record<string, Diff>
}
export interface TargetPreview {
server: string
ok: boolean
error?: string
writable: boolean
current: string | null
currentHash: string
effective: string
changes: Diff
unchanged: boolean
warnings: string[]
}
export interface TargetResult {
server: string
ok: boolean
message?: string
}
/* 9.10 is newer than 9.9: compared part by part, numerically. */
export function compareVersions(a: string, b: string): number {
const [x, y] = [a, b].map((v) => v.split('.').map((p) => Number.parseInt(p, 10) || 0))
for (let i = 0; i < Math.max(x.length, y.length); i++) {
const d = (x[i] ?? 0) - (y[i] ?? 0)
if (d !== 0) return d
}
return 0
}
export class AppsConfig {
#config: HubConfig
#upstream: Upstream
#now: () => number
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
this.#config = config
this.#upstream = upstream
this.#now = now
}
#server(id: string): HubServer {
const found = this.#config.servers.find((s) => s.id === id)
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
return found
}
#targets(ids: string[]): HubServer[] {
const unique = [...new Set(ids)]
if (unique.length === 0) throw new UserError('Choose at least one server.')
return unique.map((id) => this.#server(id))
}
/* The read token first, the admin token when the server refuses it. `fresh` skips the hub's cache. */
async #readConfig(server: HubServer, name: string, fresh: boolean): Promise<string | null> {
if (fresh) this.#upstream.invalidate(server.id)
let response: unknown
try {
response = await this.#upstream.call(server, 'apps/config/get', { name })
} catch (e) {
if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e
response = await this.#upstream.call(server, 'apps/config/get', { name }, server.adminToken)
}
const config = (response as { config?: unknown } | null)?.config
return typeof config === 'string' ? config : null
}
async #live(server: HubServer, name: string, fresh: boolean): Promise<Live> {
let version: string | undefined
try {
if (fresh) this.#upstream.invalidate(server.id)
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
const app = (list?.apps ?? []).find((a) => a.name === name)
if (app === undefined) return { installed: false, ok: true, text: null }
version = app.version
} catch (e) {
return { installed: false, ok: false, error: reason(e), text: null }
}
try {
return { installed: true, ok: true, version, text: await this.#readConfig(server, name, false) }
} catch (e) {
return { installed: true, ok: false, version, error: reason(e), text: null }
}
}
async #liveAll(name: string, fresh: boolean): Promise<Map<string, Live>> {
const entries = await Promise.all(this.#config.servers.map(async (s): Promise<[string, Live]> => [s.id, await this.#live(s, name, fresh)]))
return new Map(entries)
}
getBaseline(name: string): Baseline | null {
return readBaseline(this.#config.dataDir, name)
}
getOverrides(name: string): Overrides {
return readOverrides(this.#config.dataDir, name)
}
async compare(name: string): Promise<unknown> {
const baseline = this.getBaseline(name)
const overrides = this.getOverrides(name)
const live = await this.#liveAll(name, false)
const views: ServerView[] = this.#config.servers.map((s) => {
const l = live.get(s.id) as Live
const text = l.text
const view: ServerView = {
id: s.id,
ok: l.ok,
installed: l.installed,
writable: s.adminToken !== undefined,
text,
hash: textHash(text ?? ''),
warnings: [],
inSync: null,
vsOthers: {},
}
if (l.error !== undefined) view.error = l.error
if (l.version !== undefined) view.version = l.version
if (baseline !== null && l.installed && l.ok) {
const effective = effectiveConfig(baseline.text, overrides, s.id)
view.effective = effective.text
view.warnings = effective.warnings
view.inSync = sameConfig(text ?? '', effective.text)
view.vsBaseline = diffConfigs(text ?? '', effective.text, [])
}
return view
})
for (const v of views) {
if (!v.installed || !v.ok) continue
for (const o of views) {
if (o.id !== v.id && o.installed && o.ok) v.vsOthers[o.id] = diffConfigs(o.text ?? '', v.text ?? '', overrides.paths)
}
}
return { name, baseline, overrides, servers: views }
}
async importBaseline(name: string, serverId: string, user: string): Promise<{ detail: string }> {
const server = this.#server(serverId)
let text: string | null
try {
text = await this.#readConfig(server, name, true)
} catch (e) {
throw new UserError(`${serverId}: ${reason(e)}`)
}
if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}" to import.`)
if (text.length > MAX_CONFIG) throw new UserError('The config is larger than 1 MB.')
writeBaseline(this.#config.dataDir, { app: name, text, importedFrom: serverId, updatedAt: new Date(this.#now()).toISOString(), updatedBy: user })
return { detail: `from ${serverId}, ${text.length} bytes, ${textHash(text)}` }
}
saveBaseline(name: string, text: string, user: string): { detail: string } {
if (text.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.')
if (looksLikeJson(text)) {
const parsed = parseJson(text)
if (!parsed.ok) throw new UserError(`The baseline is not valid JSON: ${parsed.error}`)
}
const old = this.getBaseline(name)
writeBaseline(this.#config.dataDir, {
app: name,
text,
...(old?.importedFrom === undefined ? {} : { importedFrom: old.importedFrom }),
updatedAt: new Date(this.#now()).toISOString(),
updatedBy: user,
})
return { detail: `${text.length} bytes, ${textHash(text)}` }
}
saveOverrides(name: string, pathsRaw: unknown, valuesRaw: unknown): { detail: string } {
let paths: string[]
let values: Overrides['values']
try {
paths = validatePaths(pathsRaw)
values = cleanValues(valuesRaw ?? {}, paths, this.#config.servers.map((s) => s.id))
} catch (e) {
throw new UserError(reason(e))
}
const baseline = this.getBaseline(name)
if (paths.length > 0 && baseline !== null && (!looksLikeJson(baseline.text) || !parseJson(baseline.text).ok)) {
throw new UserError('The baseline is plain text, so it cannot have local fields.')
}
if (JSON.stringify(values).length > MAX_CONFIG) throw new UserError('The local values are larger than 1 MB.')
writeOverrides(this.#config.dataDir, name, { paths, values })
return { detail: `${paths.length} local field(s)` }
}
/* Reads each local path from the servers' live configs, for the "fill from current" button. */
async currentLocalValues(name: string, pathsRaw: unknown): Promise<Overrides['values']> {
let paths: string[]
try {
paths = validatePaths(pathsRaw)
} catch (e) {
throw new UserError(reason(e))
}
const live = await this.#liveAll(name, true)
return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths)
}
async preview(name: string, ids: string[]): Promise<{ targets: TargetPreview[] }> {
const targets = this.#targets(ids)
const baseline = this.getBaseline(name)
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
const overrides = this.getOverrides(name)
const live = await this.#liveAll(name, true)
const versions = [...live].filter(([, l]) => l.installed && l.version !== undefined).map(([id, l]) => ({ id, version: l.version as string }))
const newest = versions.reduce<{ id: string; version: string } | null>((best, v) => (best === null || compareVersions(v.version, best.version) > 0 ? v : best), null)
return {
targets: targets.map((s): TargetPreview => {
const l = live.get(s.id) as Live
const effective = effectiveConfig(baseline.text, overrides, s.id)
const warnings = [...effective.warnings]
if (s.adminToken === undefined) warnings.push('The hub has no admin token for this server, so it cannot write to it.')
if (l.installed && l.version !== undefined && newest !== null && compareVersions(l.version, newest.version) < 0) {
warnings.push(`Version ${l.version} here, ${newest.version} on ${newest.id}: versions differ, align versions first.`)
}
const current = l.text
const base = {
server: s.id,
writable: s.adminToken !== undefined,
current,
currentHash: textHash(current ?? ''),
effective: effective.text,
warnings,
}
if (!l.ok) return { ...base, ok: false, error: l.error ?? 'did not answer', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
if (!l.installed) return { ...base, ok: false, error: 'The app is not installed on this server.', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
const changes = diffConfigs(current ?? '', effective.text, [])
return { ...base, ok: true, changes, unchanged: sameConfig(current ?? '', effective.text) }
}),
}
}
async apply(name: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
const baseline = this.getBaseline(name)
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
const overrides = this.getOverrides(name)
const details: Record<string, string> = {}
const results = await Promise.all(
targets.map(async (s): Promise<TargetResult> => {
if (s.adminToken === undefined) return { server: s.id, ok: false, message: 'no admin token configured' }
try {
const l = await this.#live(s, name, true)
if (!l.ok) return { server: s.id, ok: false, message: l.error ?? 'did not answer' }
if (!l.installed) return { server: s.id, ok: false, message: 'the app is not installed on this server' }
const current = l.text ?? ''
if (expect[s.id] !== undefined && expect[s.id] !== textHash(current)) {
return { server: s.id, ok: false, message: 'the config changed on the server since the preview; run the preview again' }
}
const effective = effectiveConfig(baseline.text, overrides, s.id)
if (sameConfig(current, effective.text)) return { server: s.id, ok: true, message: 'Already in sync; nothing was written.' }
if (effective.text.length > MAX_CONFIG) return { server: s.id, ok: false, message: 'the config is larger than 1 MB' }
try {
writeHistory(this.#config.dataDir, name, s.id, current, 'before-apply', this.#now())
} catch (e) {
return { server: s.id, ok: false, message: `the old config could not be saved first (${reason(e)}); nothing was changed` }
}
await this.#upstream.write(s, 'apps/config/set', {}, new URLSearchParams({ name, config: effective.text }))
const after = await this.#readConfig(s, name, true)
details[s.id] = `${textHash(current)} to ${textHash(effective.text)}, ${effective.text.length} bytes`
if (after === null || !sameConfig(after, effective.text)) {
return { server: s.id, ok: false, message: 'the server accepted the config but reads back a different one' }
}
return { server: s.id, ok: true, message: 'Applied and verified.' }
} catch (e) {
return { server: s.id, ok: false, message: reason(e) }
}
}),
)
return { results, details }
}
}
+6 -2
View File
@@ -6,6 +6,8 @@ hold secrets.
*/ */
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import type { HubConfig, HubServer } from './config.ts' 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 { jsonDiff } from './json-diff.ts'
import { Upstream, UpstreamError } from './upstream.ts' import { Upstream, UpstreamError } from './upstream.ts'
@@ -114,7 +116,7 @@ export async function buildMatrix(config: HubConfig, upstream: Upstream): Promis
text = await readConfig(upstream, server, listed.name) text = await readConfig(upstream, server, listed.name)
if (text !== null && text !== '') { if (text !== null && text !== '') {
cell.configStatus = 'read' cell.configStatus = 'read'
cell.configHash = hash(text) cell.configHash = hash(comparable(text, readOverrides(config.dataDir, listed.name).paths))
} }
} catch (e) { } catch (e) {
cell.configStatus = 'unreadable' cell.configStatus = 'unreadable'
@@ -155,7 +157,9 @@ export async function buildMatrix(config: HubConfig, upstream: Upstream): Promis
const versionSet = new Set(copies.map((c) => c.cell.version ?? '')) const versionSet = new Set(copies.map((c) => c.cell.version ?? ''))
const versions = copies.length < 2 ? 'single' : versionSet.size === 1 ? 'same' : 'different' const versions = copies.length < 2 ? 'single' : versionSet.size === 1 ? 'same' : 'different'
const readable = copies.filter((c) => c.cell.configStatus !== 'unreadable').map((c) => c.text ?? '') // 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 hasUnknown = copies.some((c) => c.cell.configStatus === 'unreadable') || (warnings.length > 0 && copies.length < 2)
const differs = new Set(readable).size > 1 const differs = new Set(readable).size > 1
const configs: MatrixApp['configs'] = differs ? 'different' : hasUnknown ? 'unknown' : copies.length < 2 ? 'single' : 'same' const configs: MatrixApp['configs'] = differs ? 'different' : hasUnknown ? 'unknown' : copies.length < 2 ? 'single' : 'same'
+529
View File
@@ -0,0 +1,529 @@
// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
import {
comparable,
diffConfigs,
effectiveConfig,
importLocalValues,
lineDiff,
parsePath,
sameConfig,
stripLocal,
validatePaths,
} from './apps-sync.ts'
import { addUser, writeUsersFile } from './users.ts'
const json = (o: unknown): string => JSON.stringify(o, null, 2)
describe('effectiveConfig', () => {
const baseline = json({ enable: true, db: { path: '/base.db', keep: 30 }, nets: [{ name: 'lan', cidr: '10.0.0.0/8' }, { name: 'wan' }] })
it('puts the server local values over the baseline, nested and in arrays', () => {
const e = effectiveConfig(baseline, { paths: ['db.path', 'nets.0.cidr'], values: { a: { 'db.path': '/a.db', 'nets.0.cidr': '192.168.0.0/16' } } }, 'a')
expect(JSON.parse(e.text)).toEqual({ enable: true, db: { path: '/a.db', keep: 30 }, nets: [{ name: 'lan', cidr: '192.168.0.0/16' }, { name: 'wan' }] })
expect(e.warnings).toEqual([])
expect(e.text).toBe(JSON.stringify(JSON.parse(e.text), null, 2))
})
it('keeps the baseline value and warns when a server has no local value', () => {
const e = effectiveConfig(baseline, { paths: ['db.path'], values: { a: {} } }, 'b')
expect(JSON.parse(e.text).db.path).toBe('/base.db')
expect(e.warnings[0]).toMatch(/No local value set for "db.path" on b/)
})
it('leaves a field absent when it is local, not in the baseline and has no value', () => {
const e = effectiveConfig(baseline, { paths: ['extra.deep'], values: {} }, 'a')
expect(JSON.parse(e.text).extra).toBeUndefined()
expect(e.warnings).toEqual([])
})
it('creates the path when the local value is for a field the baseline lacks', () => {
const e = effectiveConfig(baseline, { paths: ['extra.deep'], values: { a: { 'extra.deep': 5 } } }, 'a')
expect(JSON.parse(e.text).extra).toEqual({ deep: 5 })
})
it('uses a text baseline as it is', () => {
const e = effectiveConfig('plain = 1\nother = 2', { paths: ['a'], values: {} }, 'a')
expect(e.text).toBe('plain = 1\nother = 2')
expect(e.warnings[0]).toMatch(/plain text/)
expect(effectiveConfig('plain', { paths: [], values: {} }, 'a').warnings).toEqual([])
})
it('never writes through __proto__', () => {
const e = effectiveConfig('{}', { paths: ['__proto__.polluted'], values: { a: { '__proto__.polluted': true } } }, 'a')
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
expect(e.warnings[0]).toMatch(/does not fit/)
})
})
describe('paths', () => {
it('parses dot paths and refuses odd ones', () => {
expect(parsePath('a.b.0.c')).toEqual(['a', 'b', '0', 'c'])
expect(parsePath('0.0.0.0/0')).toEqual(['0', '0', '0', '0/0'])
for (const bad of ['', '.a', 'a..b', 'a b', 'a[0]', 'a,b']) expect(parsePath(bad)).toBeNull()
expect(() => validatePaths(['a', 'a'])).toThrow(/twice/)
expect(() => validatePaths(Array.from({ length: 51 }, (_, i) => `p${i}`))).toThrow(/50/)
expect(() => validatePaths('a')).toThrow()
})
})
describe('comparisons that ignore local fields', () => {
const a = json({ shared: 1, db: { path: '/a' }, list: [1, 2] })
const b = json({ db: { path: '/b' }, list: [1, 2], shared: 1 })
it('treats two configs that differ only in local fields as the same', () => {
expect(sameConfig(a, b)).toBe(false)
expect(sameConfig(a, b, ['db.path'])).toBe(true)
expect(comparable(a, ['db.path'])).toBe(comparable(b, ['db.path']))
expect(comparable(a, [])).toBe(a)
})
it('removes array items from the end first', () => {
expect(stripLocal([1, 2, 3], ['0', '2'])).toEqual([2])
})
it('diffs JSON by field and leaves local fields out', () => {
const d = diffConfigs(json({ a: 1, b: 2, c: { d: 1 }, local: 1 }), json({ a: 1, b: 3, c: { d: 1, e: 2 }, f: 1, local: 2 }), ['local'])
expect(d).toEqual({
kind: 'json',
changes: [
{ path: 'b', type: 'changed', a: 2, b: 3 },
{ path: 'c.e', type: 'added', a: undefined, b: 2 },
{ path: 'f', type: 'added', a: undefined, b: 1 },
],
})
expect(diffConfigs(json({ x: 1 }), json({}), []).kind).toBe('json')
expect((diffConfigs(json({ x: 1 }), json({}), []) as { changes: { type: string }[] }).changes[0].type).toBe('removed')
})
it('diffs text by line', () => {
const d = diffConfigs('one\ntwo\nthree', 'one\n2\nthree\nfour')
expect(d.kind).toBe('text')
expect(d).toMatchObject({ changed: 3, truncated: false })
expect((d as { lines: { op: string; text: string }[] }).lines).toEqual([
{ op: 'same', text: 'one' },
{ op: 'del', text: 'two' },
{ op: 'add', text: '2' },
{ op: 'same', text: 'three' },
{ op: 'add', text: 'four' },
])
})
it('does not build a table for a huge text', () => {
const big = Array.from({ length: 2500 }, (_, i) => `line ${i}`).join('\n')
expect(lineDiff(big, big + '\nx')).toMatchObject({ truncated: true, lines: [] })
})
})
describe('importLocalValues', () => {
it('reads each server own value of each path', () => {
const values = importLocalValues({ a: json({ db: { path: '/a' } }), b: json({ db: { path: '/b' } }), c: 'not json', d: null }, ['db.path', 'missing'])
expect(values).toEqual({ a: { 'db.path': '/a' }, b: { 'db.path': '/b' }, c: {}, d: {} })
})
})
/* ---- With fake servers ---- */
interface Fake {
server: http.Server
url: string
apps: Record<string, { version: string; config: string | null }>
calls: { path: string; token: string; method: string; form: URLSearchParams }[]
rejectSet: string | null
mangleSet: boolean
denyRead: boolean
}
function fake(): Promise<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', apps: {}, calls: [], rejectSet: null, mangleSet: false, denyRead: false }
f.server.on('request', (req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
const token = url.searchParams.get('token') ?? ''
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
f.calls.push({ path: apiPath, token, method: req.method ?? '', form })
const name = url.searchParams.get('name') ?? form.get('name') ?? ''
res.setHeader('content-type', 'application/json')
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
if (apiPath === 'apps/list') return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, version: a.version })) })
if (apiPath === 'apps/config/get') {
if (f.denyRead && !token.startsWith('admin-')) return error('Access was denied.')
return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
}
if (apiPath === 'apps/config/set') {
if (!token.startsWith('admin-')) return error('Access was denied.')
if (f.rejectSet !== null) return error(f.rejectSet)
f.apps[name].config = f.mangleSet ? '{"mangled":true}' : form.get('config')
return ok()
}
ok()
})
})
f.server.listen(0, '127.0.0.1', () => {
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
resolve(f)
})
})
}
let a: Fake
let b: Fake
let dataDir: string
let staticDir: string
let hub: http.Server | undefined
let base: string
const tokens: Record<string, string> = {}
const CFG_A = json({ enable: true, ttl: 30, dbPath: '/data/a.db' })
const CFG_B = json({ enable: true, ttl: 60, dbPath: '/data/b.db' })
function reset(): void {
a.calls = []
b.calls = []
for (const f of [a, b]) {
f.rejectSet = null
f.mangleSet = false
f.denyRead = false
}
a.apps = { Blocker: { version: '1.0', config: CFG_A } }
b.apps = { Blocker: { version: '1.0', config: CFG_B } }
}
async function start(extra: Record<string, string> = {}): Promise<void> {
hub?.close()
hub = createHub(
parseConfig({
HUB_SERVERS: `a=${a.url},b=${b.url}`,
A_TOKEN: 'read-a',
B_TOKEN: 'read-b',
A_ADMIN_TOKEN: 'admin-a',
B_ADMIN_TOKEN: 'admin-b',
HUB_DATA: dataDir,
HUB_STATIC: staticDir,
HUB_CACHE_SECONDS: '0',
HUB_TIMEOUT_MS: '2000',
...extra,
}),
)
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
for (const role of ['viewer', 'apps-admin', 'admin']) {
const res = await fetch(`${base}/api/user/login`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: `user=${role}&pass=password-${role}`,
})
tokens[role] = ((await res.json()) as { token: string }).token
}
}
async function api(role: string, apiPath: string, init: { params?: Record<string, string>; json?: unknown; method?: string } = {}) {
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}` }
let body: string | undefined
if (init.json !== undefined) {
headers['content-type'] = 'application/json'
body = JSON.stringify(init.json)
}
const res = await fetch(`${base}/api/${apiPath}?${new URLSearchParams(init.params ?? {})}`, { method: init.method ?? (body === undefined ? 'GET' : 'POST'), headers, body })
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const dirOf = () => path.join(dataDir, 'apps', 'Blocker')
const history = () => readdirSync(path.join(dirOf(), 'history'))
const writes = (f: Fake) => f.calls.filter((c) => c.path === 'apps/config/set')
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-sync-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-sync-static-'))
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
users = addUser(users, 'admin', 'admin', 'password-admin')
writeUsersFile(dataDir, users)
})
afterAll(() => {
hub?.close()
a.server.close()
b.server.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
})
beforeEach(async () => {
reset()
rmSync(path.join(dataDir, 'apps'), { recursive: true, force: true })
rmSync(path.join(dataDir, 'audit.log'), { force: true })
await start()
})
afterEach(() => {
hub?.close()
})
const importFrom = (server: string) => api('apps-admin', 'hub/apps/baseline/import', { json: { name: 'Blocker', server } })
const saveOverrides = (paths: string[], values: Record<string, Record<string, unknown>>) => api('apps-admin', 'hub/apps/overrides/save', { json: { name: 'Blocker', paths, values } })
describe('baseline and local fields', () => {
it('has no baseline at first, and compare shows each server live config', async () => {
const r = await api('viewer', 'hub/apps/config/compare', { params: { name: 'Blocker' } })
expect(r.body.response.baseline).toBeNull()
const [sa, sb] = r.body.response.servers
expect(sa).toMatchObject({ id: 'a', installed: true, writable: true, version: '1.0', inSync: null, text: CFG_A })
expect(sb.text).toBe(CFG_B)
expect(sa.vsOthers.b.changes.map((c: any) => c.path)).toEqual(['ttl', 'dbPath'])
})
it('imports a baseline from a server and stores it as text', async () => {
const r = await importFrom('a')
expect(r.body.response.baseline).toMatchObject({ app: 'Blocker', text: CFG_A, importedFrom: 'a', updatedBy: 'apps-admin' })
const file = JSON.parse(readFileSync(path.join(dirOf(), 'baseline.json'), 'utf8'))
expect(file.text).toBe(CFG_A)
const cmp = (await api('viewer', 'hub/apps/config/compare', { params: { name: 'Blocker' } })).body.response
expect(cmp.servers[0].inSync).toBe(true)
expect(cmp.servers[1].inSync).toBe(false)
expect(cmp.servers[1].vsBaseline.changes.map((c: any) => c.path)).toEqual(['ttl', 'dbPath'])
})
it('refuses an import from a server with no config', async () => {
a.apps.Blocker.config = null
const r = await importFrom('a')
expect(r.code).toBe(400)
expect(r.body.errorMessage).toMatch(/no config/)
})
it('validates a JSON baseline and reports the parse error', async () => {
const bad = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: '{ "a": 1,, }' } })
expect(bad.code).toBe(400)
expect(bad.body.errorMessage).toMatch(/not valid JSON: .*position/)
const text = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'plain = text' } })
expect(text.body.response.baseline.text).toBe('plain = text')
})
it('limits the baseline to 1 MB', async () => {
const r = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'x'.repeat(1024 * 1024 + 1) } })
expect(r.code).toBe(400)
expect(r.body.errorMessage).toMatch(/1 MB/)
})
it('saves local fields, rejects bad paths and a text baseline', async () => {
await importFrom('a')
const ok = await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } })
expect(ok.body.response.overrides).toEqual({ paths: ['dbPath'], values: { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } } })
expect((await saveOverrides(['a b'], {})).code).toBe(400)
expect((await saveOverrides(['dbPath'], { zzz: {} })).code).toBe(400)
await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'plain' } })
const refused = await saveOverrides(['dbPath'], {})
expect(refused.body.errorMessage).toMatch(/plain text/)
})
it('fills local values from the current server configs', async () => {
const r = await api('apps-admin', 'hub/apps/overrides/current', { json: { name: 'Blocker', paths: ['dbPath', 'ttl'] } })
expect(r.body.response.values).toEqual({ a: { dbPath: '/data/a.db', ttl: 30 }, b: { dbPath: '/data/b.db', ttl: 60 } })
})
it('makes the matrix say Same when servers differ only in local fields', async () => {
const before = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
expect(before.configs).toBe('different')
expect(before.diffCount).toBe(2)
await saveOverrides(['ttl', 'dbPath'], {})
const after = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
expect(after.configs).toBe('same')
expect(after.servers.a.configHash).toBe(after.servers.b.configHash)
await saveOverrides(['dbPath'], {})
const some = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
expect(some).toMatchObject({ configs: 'different', diffCount: 1 })
})
it('refuses writers-only calls to a viewer', async () => {
for (const [p, json] of [
['hub/apps/baseline/import', { name: 'Blocker', server: 'a' }],
['hub/apps/baseline/save', { name: 'Blocker', text: '{}' }],
['hub/apps/overrides/save', { name: 'Blocker', paths: [], values: {} }],
['hub/apps/config/preview', { name: 'Blocker', targets: ['a'] }],
['hub/apps/config/apply', { name: 'Blocker', targets: ['a'] }],
] as const) {
const r = await api('viewer', p, { json })
expect([p, r.code]).toEqual([p, 403])
}
expect(writes(a)).toHaveLength(0)
})
})
describe('preview and apply', () => {
async function setup() {
await importFrom('a')
await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } })
}
it('previews what would change on each target, with the local values in place', async () => {
await setup()
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a', 'b'] } })
const [pa, pb] = r.body.response.targets
expect(pa).toMatchObject({ server: 'a', ok: true, unchanged: true, writable: true })
expect(pb).toMatchObject({ server: 'b', ok: true, unchanged: false, current: CFG_B })
expect(pb.changes.changes.map((c: any) => [c.path, c.a, c.b])).toEqual([['ttl', 60, 30]])
expect(JSON.parse(pb.effective)).toEqual({ enable: true, ttl: 30, dbPath: '/data/b.db' })
expect(pb.currentHash).toMatch(/^[0-9a-f]{16}$/)
})
it('warns about a missing local value, a version difference and a server without a token', async () => {
await importFrom('a')
await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' } })
b.apps.Blocker.version = '0.9'
await start({ B_ADMIN_TOKEN: '' })
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['b'] } })
const warnings: string[] = r.body.response.targets[0].warnings
expect(r.body.response.targets[0].writable).toBe(false)
expect(warnings.some((w) => /No local value set for "dbPath" on b/.test(w))).toBe(true)
expect(warnings.some((w) => /align versions first/.test(w))).toBe(true)
expect(warnings.some((w) => /no admin token/.test(w))).toBe(true)
})
it('needs a baseline', async () => {
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a'] } })
expect(r.code).toBe(400)
expect(r.body.errorMessage).toMatch(/no baseline/)
})
it('applies: saves the old config, writes with the admin token, verifies', async () => {
await setup()
const preview = (await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a', 'b'] } })).body.response.targets
const expectHashes = Object.fromEntries(preview.map((t: any) => [t.server, t.currentHash]))
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'], expect: expectHashes } })
const results = r.body.response.results
expect(results.find((x: any) => x.server === 'a')).toMatchObject({ ok: true, message: expect.stringMatching(/Already in sync/) })
expect(results.find((x: any) => x.server === 'b')).toEqual({ server: 'b', ok: true, message: 'Applied and verified.' })
expect(JSON.parse(b.apps.Blocker.config as string)).toEqual({ enable: true, ttl: 30, dbPath: '/data/b.db' })
expect(writes(a)).toHaveLength(0)
const [w] = writes(b)
expect(w.token).toBe('admin-b')
expect(w.method).toBe('POST')
expect(w.form.get('name')).toBe('Blocker')
const files = history()
expect(files).toHaveLength(1)
expect(files[0]).toMatch(/__b__before-apply\.txt$/)
expect(readFileSync(path.join(dirOf(), 'history', files[0]), 'utf8')).toBe(CFG_B)
expect((await api('viewer', 'hub/apps/matrix')).body.response.apps[0].configs).toBe('same')
})
it('refuses a target whose config changed since the preview', async () => {
await setup()
const preview = (await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['b'] } })).body.response.targets
b.apps.Blocker.config = json({ enable: false })
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'], expect: { b: preview[0].currentHash } } })
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
expect(r.body.response.results[0].message).toMatch(/changed on the server since the preview/)
expect(writes(b)).toHaveLength(0)
expect(b.apps.Blocker.config).toBe(json({ enable: false }))
})
it('reports a server that refuses the config and leaves it as it was', async () => {
await setup()
b.rejectSet = 'The config is invalid.'
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
expect(r.body.response.results[0]).toEqual({ server: 'b', ok: false, message: 'The config is invalid.' })
expect(b.apps.Blocker.config).toBe(CFG_B)
})
it('handles one target failing and the other succeeding', async () => {
await setup()
a.apps.Blocker.config = json({ enable: false })
b.rejectSet = 'Disk full.'
const r = await api('admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'] } })
expect(r.body.response.results.map((x: any) => [x.server, x.ok])).toEqual([['a', true], ['b', false]])
expect(JSON.parse(a.apps.Blocker.config as string).ttl).toBe(30)
})
it('says so when the server reads back something else', async () => {
await setup()
b.mangleSet = true
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
expect(r.body.response.results[0].ok).toBe(false)
expect(r.body.response.results[0].message).toMatch(/reads back a different one/)
})
it('does not write to a server without an admin token', async () => {
await setup()
await start({ B_ADMIN_TOKEN: '' })
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
expect(r.body.response.results[0]).toEqual({ server: 'b', ok: false, message: 'no admin token configured' })
expect(writes(b)).toHaveLength(0)
})
it('keeps config texts out of the audit log', async () => {
await setup()
await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: '{ "bad": ,, "secret-baseline-text": 1 }' } })
await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'] } })
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
for (const text of ['/data/a.db', '/data/b.db', 'secret-baseline-text', 'admin-a', 'admin-b']) expect(raw).not.toContain(text)
const entries = (await api('admin', 'hub/audit/list', { params: { limit: '50' } })).body.response.entries as Record<string, string>[]
const applied = entries.filter((e) => e.action === 'apps.config-apply')
expect(applied).toHaveLength(2)
expect(applied.find((e) => e.server === 'b')).toMatchObject({ user: 'apps-admin', target: 'Blocker', outcome: 'ok' })
expect(applied.find((e) => e.server === 'b')?.detail).toMatch(/[0-9a-f]{16} to [0-9a-f]{16}/)
expect(entries.some((e) => e.action === 'apps.baseline-import' && e.outcome === 'ok')).toBe(true)
expect(entries.some((e) => e.action === 'apps.baseline-save' && e.outcome === 'refused' && e.detail === 'invalid baseline')).toBe(true)
})
it('keeps at most 50 history files', async () => {
await setup()
mkdirSync(path.join(dirOf(), 'history'), { recursive: true })
for (let i = 0; i < 55; i++) writeFileSync(path.join(dirOf(), 'history', `2020-01-01T00-00-${String(i).padStart(2, '0')}.000Z__b__before-apply.txt`), 'old')
await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
expect(history()).toHaveLength(50)
})
})
describe('single-server config', () => {
it('reads a config from one node with the read token and needs a node', async () => {
const r = await api('viewer', 'apps/config/get', { params: { node: 'a', name: 'Blocker' } })
expect(r.body.response.config).toBe(CFG_A)
expect(a.calls.at(-1)?.token).toBe('read-a')
expect((await api('viewer', 'apps/config/get', { params: { name: 'Blocker' } })).code).toBe(400)
expect((await api('viewer', 'apps/config/get', { params: { node: 'cluster', name: 'Blocker' } })).code).toBe(400)
})
it('sets a config on one node with the admin token, saving the old one first', async () => {
const r = await api('apps-admin', 'apps/config/set', {
params: { node: 'b' },
method: 'POST',
json: { name: 'Blocker', config: '{"enable":false}' },
})
expect(r.body.status).toBe('ok')
const [w] = writes(b)
expect(w.token).toBe('admin-b')
expect(b.apps.Blocker.config).toBe('{"enable":false}')
expect(history()).toHaveLength(1)
expect(readFileSync(path.join(dirOf(), 'history', history()[0]), 'utf8')).toBe(CFG_B)
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
expect(raw).toContain('apps.config-set')
expect(raw).not.toContain('"enable":false')
})
it('refuses the write without a node, to a viewer, and when the old config cannot be saved', async () => {
expect((await api('apps-admin', 'apps/config/set', { json: { name: 'Blocker', config: '{}' } })).code).toBe(400)
expect((await api('viewer', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })).code).toBe(403)
a.denyRead = true
a.apps.Blocker.config = 'unchanged'
// The admin token can still read, so make the read fail by removing the app.
delete a.apps.Blocker
const r = await api('apps-admin', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })
expect(r.body.status).toBe('error')
expect(r.body.errorMessage).toMatch(/nothing was changed/)
expect(writes(a)).toHaveLength(0)
})
})
+273
View File
@@ -0,0 +1,273 @@
/*
Pure functions behind the config sync: the effective config of a server (the baseline plus
that server's local values), comparisons that leave the local fields out, and the diffs the
console shows. No I/O here.
*/
import { createHash } from 'node:crypto'
import { jsonDiff } from './json-diff.ts'
import type { Overrides } from './store.ts'
export const MAX_CONFIG = 1024 * 1024
export const MAX_PATHS = 50
const MAX_LINES = 2000
type Json = unknown
export type Parsed = { ok: true; value: Json } | { ok: false; error: string }
export function parseJson(text: string): Parsed {
try {
return { ok: true, value: JSON.parse(text) }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : 'invalid JSON' }
}
}
/** A config that starts like JSON must be JSON; anything else is treated as plain text. */
export const looksLikeJson = (text: string): boolean => /^\s*[{[]/.test(text)
/* ---- Paths: `a.b.0.c`; a numeric segment indexes an array. ---- */
const SEGMENT = /^[^.\s,[\]]+$/
export function parsePath(path: string): string[] | null {
if (path.length === 0 || path.length > 200) return null
const segments = path.split('.')
return segments.every((s) => SEGMENT.test(s)) ? segments : null
}
export function validatePaths(paths: unknown): string[] {
if (!Array.isArray(paths)) throw new Error('paths must be a list.')
if (paths.length > MAX_PATHS) throw new Error(`At most ${MAX_PATHS} local fields.`)
const seen = new Set<string>()
for (const p of paths) {
if (typeof p !== 'string' || parsePath(p) === null) throw new Error(`"${String(p)}" is not a field path such as a.b.0.c.`)
if (seen.has(p)) throw new Error(`"${p}" is listed twice.`)
seen.add(p)
}
return paths as string[]
}
const isContainer = (v: Json): v is Record<string, Json> | Json[] => v !== null && typeof v === 'object'
const isIndex = (s: string): boolean => /^\d+$/.test(s)
export function getAt(root: Json, segments: string[]): { found: boolean; value?: Json } {
let at: Json = root
for (const s of segments) {
if (!isContainer(at)) return { found: false }
if (Array.isArray(at)) {
if (!isIndex(s) || Number(s) >= at.length) return { found: false }
at = at[Number(s)]
} else {
if (!Object.hasOwn(at, s)) return { found: false }
at = at[s]
}
}
return { found: true, value: at }
}
/* Sets a value, making the containers on the way when they are missing. Returns false if the path cannot be set. */
export function setAt(root: Json, segments: string[], value: Json): boolean {
let at: Json = root
for (let i = 0; i < segments.length; i++) {
const s = segments[i]
const last = i === segments.length - 1
if (!isContainer(at)) return false
if (Array.isArray(at)) {
if (!isIndex(s)) return false
const index = Number(s)
if (index > at.length) return false
if (last) at[index] = value
else {
if (at[index] === undefined) at[index] = isIndex(segments[i + 1]) ? [] : {}
at = at[index]
}
} else {
if (s === '__proto__') return false
if (last) at[s] = value
else {
if (!Object.hasOwn(at, s) || !isContainer(at[s])) at[s] = isIndex(segments[i + 1]) ? [] : {}
at = at[s]
}
}
}
return true
}
export function removeAt(root: Json, segments: string[]): void {
const parent = getAt(root, segments.slice(0, -1))
const last = segments[segments.length - 1]
if (!parent.found || !isContainer(parent.value)) return
if (Array.isArray(parent.value)) {
if (isIndex(last) && Number(last) < parent.value.length) parent.value.splice(Number(last), 1)
} else delete parent.value[last]
}
const clone = <T>(value: T): T => structuredClone(value)
/* A copy of the value without the local fields. Removing array items shifts the rest, so the deepest and highest go first. */
export function stripLocal(value: Json, paths: string[]): Json {
const copy = clone(value)
const sorted = paths
.map((p) => parsePath(p))
.filter((s): s is string[] => s !== null)
.sort((a, b) => b.length - a.length || b.join('.').localeCompare(a.join('.'), undefined, { numeric: true }))
for (const segments of sorted) removeAt(copy, segments)
return copy
}
function sortKeys(value: Json): Json {
if (Array.isArray(value)) return value.map(sortKeys)
if (isContainer(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys((value as Record<string, Json>)[k])]))
return value
}
/*
A string two configs can be compared by: for JSON, the config without the local fields with its keys
sorted; for anything else, the text. Without local fields it is the text itself, as before.
*/
export function comparable(text: string, paths: string[]): string {
if (paths.length === 0) return text
const parsed = parseJson(text)
if (!parsed.ok) return text
return JSON.stringify(sortKeys(stripLocal(parsed.value, paths)))
}
export const textHash = (text: string): string => createHash('sha256').update(text).digest('hex').slice(0, 16)
/* ---- The effective config of one server ---- */
export interface Effective {
text: string
warnings: string[]
}
export function effectiveConfig(baselineText: string, overrides: Overrides, server: string): Effective {
const parsed = parseJson(baselineText)
if (!parsed.ok || !looksLikeJson(baselineText)) {
const warnings = overrides.paths.length > 0 ? ['The baseline is plain text, so local fields cannot be applied; the baseline is used as it is.'] : []
return { text: baselineText, warnings }
}
const value = clone(parsed.value)
const warnings: string[] = []
const mine = overrides.values[server] ?? {}
for (const path of overrides.paths) {
const segments = parsePath(path)
if (segments === null) continue
if (Object.hasOwn(mine, path)) {
if (!setAt(value, segments, clone(mine[path]))) warnings.push(`Local field "${path}" does not fit the baseline structure and was skipped.`)
} else if (getAt(value, segments).found) {
warnings.push(`No local value set for "${path}" on ${server}; the baseline value is used.`)
}
// No local value and not in the baseline: nothing to remove, the field stays absent.
}
return { text: JSON.stringify(value, null, 2), warnings }
}
/* The same config? JSON is compared by meaning (key order and spacing do not count), anything else by text. */
export function sameConfig(a: string, b: string, paths: string[] = []): boolean {
const [pa, pb] = [parseJson(a), parseJson(b)]
if (pa.ok && pb.ok && looksLikeJson(a) && looksLikeJson(b)) return jsonDiff(stripLocal(pa.value, paths), stripLocal(pb.value, paths)).length === 0
return a.replace(/\r\n/g, '\n').trimEnd() === b.replace(/\r\n/g, '\n').trimEnd()
}
/* ---- Diffs ---- */
export interface FieldChange {
path: string
type: 'added' | 'removed' | 'changed'
a?: Json
b?: Json
}
export interface LineOp {
op: 'same' | 'add' | 'del'
text: string
}
export type Diff =
| { kind: 'json'; changes: FieldChange[] }
| { kind: 'text'; lines: LineOp[]; truncated: boolean; changed: number }
/* A to B: `added` means B has it and A does not. Local fields are left out of both. */
export function diffConfigs(a: string, b: string, localPaths: string[] = []): Diff {
const [pa, pb] = [parseJson(a), parseJson(b)]
if (pa.ok && pb.ok && looksLikeJson(a) && looksLikeJson(b)) {
const changes = jsonDiff(stripLocal(pa.value, localPaths), stripLocal(pb.value, localPaths)).map((d): FieldChange => {
const type = d.a === undefined ? 'added' : d.b === undefined ? 'removed' : 'changed'
return { path: d.path === '' ? '(whole config)' : d.path, type, a: d.a, b: d.b }
})
return { kind: 'json', changes }
}
return { kind: 'text', ...lineDiff(a, b) }
}
export function lineDiff(a: string, b: string): { lines: LineOp[]; truncated: boolean; changed: number } {
const [x, y] = [a.replace(/\r\n/g, '\n').split('\n'), b.replace(/\r\n/g, '\n').split('\n')]
if (x.length > MAX_LINES || y.length > MAX_LINES) {
const set = new Set(x)
const changed = y.filter((l) => !set.has(l)).length + x.filter((l) => !new Set(y).has(l)).length
return { lines: [], truncated: true, changed }
}
// Longest common subsequence table, filled from the end.
const w = y.length + 1
const table = new Uint16Array((x.length + 1) * w)
for (let i = x.length - 1; i >= 0; i--) {
for (let j = y.length - 1; j >= 0; j--) {
table[i * w + j] = x[i] === y[j] ? table[(i + 1) * w + j + 1] + 1 : Math.max(table[(i + 1) * w + j], table[i * w + j + 1])
}
}
const lines: LineOp[] = []
let i = 0
let j = 0
while (i < x.length || j < y.length) {
if (i < x.length && j < y.length && x[i] === y[j]) {
lines.push({ op: 'same', text: x[i] })
i++
j++
} else if (j < y.length && (i === x.length || table[i * w + j + 1] > table[(i + 1) * w + j])) {
lines.push({ op: 'add', text: y[j++] })
} else lines.push({ op: 'del', text: x[i++] })
}
return { lines, truncated: false, changed: lines.filter((l) => l.op !== 'same').length }
}
export const diffIsEmpty = (d: Diff): boolean => (d.kind === 'json' ? d.changes.length === 0 : d.changed === 0)
/* ---- Local values ---- */
/*
Fills each server's current value of each local path from its live config. A server whose config is
not JSON, or that has no such field, gets no entry for it.
*/
export function importLocalValues(configs: Record<string, string | null>, paths: string[]): Overrides['values'] {
const out: Overrides['values'] = {}
for (const [server, text] of Object.entries(configs)) {
out[server] = {}
if (text === null) continue
const parsed = parseJson(text)
if (!parsed.ok) continue
for (const path of paths) {
const segments = parsePath(path)
if (segments === null) continue
const found = getAt(parsed.value, segments)
if (found.found) out[server][path] = clone(found.value)
}
}
return out
}
/* Local values for paths that are listed, from servers that exist; anything else is dropped. */
export function cleanValues(values: unknown, paths: string[], servers: string[]): Overrides['values'] {
if (values === null || typeof values !== 'object' || Array.isArray(values)) throw new Error('values must be an object of servers.')
const out: Overrides['values'] = {}
for (const [server, mine] of Object.entries(values as Record<string, unknown>)) {
if (!servers.includes(server)) throw new Error(`Unknown server: ${server}.`)
if (mine === null || typeof mine !== 'object' || Array.isArray(mine)) throw new Error(`The values of ${server} must be an object of paths.`)
out[server] = {}
for (const [path, value] of Object.entries(mine as Record<string, unknown>)) {
if (paths.includes(path)) out[server][path] = value
}
}
return out
}
+2 -2
View File
@@ -499,9 +499,9 @@ describe('single-server writes', () => {
expect(Buffer.from(parseMultipart(w.body, w.contentType).files[0].data).equals(Buffer.from(bytes))).toBe(true) expect(Buffer.from(parseMultipart(w.body, w.contentType).files[0].data).equals(Buffer.from(bytes))).toBe(true)
}) })
it('keeps config get and set refused for now', async () => { it('needs a node for a config read or write', async () => {
for (const p of ['apps/config/get', 'apps/config/set']) { for (const p of ['apps/config/get', 'apps/config/set']) {
expect((await api('admin', p, { params: { node: 'a', name: 'Blocker' } })).code).toBe(403) expect((await api('admin', p, { params: { name: 'Blocker' } })).code).toBe(400)
} }
}) })
+19 -26
View File
@@ -9,16 +9,17 @@ hub that changes anything, so the rules are strict:
the uninstall is refused if that cannot be done; the uninstall is refused if that cannot be done;
* every target's result is audited (the caller does that with `Audit`). * every target's result is audited (the caller does that with `Audit`).
*/ */
import { createHash } from 'node:crypto'
import { mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { HubConfig, HubServer } from './config.ts' import type { HubConfig, HubServer } from './config.ts'
import type { UploadedFile } from './multipart.ts' import type { UploadedFile } from './multipart.ts'
import { MAX_CONFIG } from './apps-sync.ts'
import { safeName, writeHistory } from './store.ts'
import { Upstream } from './upstream.ts' import { Upstream } from './upstream.ts'
export { safeName }
export type StoreAction = 'install-store' | 'update-store' | 'uninstall' export type StoreAction = 'install-store' | 'update-store' | 'uninstall'
export type FileAction = 'install-file' | 'update-file' export type FileAction = 'install-file' | 'update-file'
export type AppAction = StoreAction | FileAction export type AppAction = StoreAction | FileAction | 'config-set'
export interface TargetResult { export interface TargetResult {
server: string server: string
@@ -26,14 +27,13 @@ export interface TargetResult {
message?: string message?: string
} }
export const KEEP_HISTORY = 50
const API: Record<AppAction, string> = { const API: Record<AppAction, string> = {
'install-store': 'apps/downloadAndInstall', 'install-store': 'apps/downloadAndInstall',
'update-store': 'apps/downloadAndUpdate', 'update-store': 'apps/downloadAndUpdate',
uninstall: 'apps/uninstall', uninstall: 'apps/uninstall',
'install-file': 'apps/install', 'install-file': 'apps/install',
'update-file': 'apps/update', 'update-file': 'apps/update',
'config-set': 'apps/config/set',
} }
const DONE: Record<AppAction, string> = { const DONE: Record<AppAction, string> = {
@@ -42,6 +42,7 @@ const DONE: Record<AppAction, string> = {
uninstall: 'Uninstalled.', uninstall: 'Uninstalled.',
'install-file': 'Installed from the file.', 'install-file': 'Installed from the file.',
'update-file': 'Updated from the file.', 'update-file': 'Updated from the file.',
'config-set': 'Config saved.',
} }
/** The audit/api action for an upstream write path, or null when it is not an app write. */ /** The audit/api action for an upstream write path, or null when it is not an app write. */
@@ -50,13 +51,6 @@ export function actionForPath(apiPath: string): AppAction | null {
return null return null
} }
/* A folder name for an app: letters, digits and . _ - only, with a short hash when anything had to change. */
export function safeName(name: string): string {
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^\.+/, '_').slice(0, 80)
if (cleaned === name && cleaned !== '') return cleaned
return `${cleaned === '' ? '_' : cleaned}-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
}
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed') const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
export class AppsWriter { export class AppsWriter {
@@ -83,29 +77,19 @@ export class AppsWriter {
} }
/* Saves the app's current config next to the hub's data; throws if it cannot. */ /* Saves the app's current config next to the hub's data; throws if it cannot. */
async saveHistory(server: HubServer, name: string): Promise<string> { async saveHistory(server: HubServer, name: string, kind: 'before-uninstall' | 'before-apply' = 'before-uninstall'): Promise<string> {
if (server.adminToken === undefined) throw new Error('no admin token configured') if (server.adminToken === undefined) throw new Error('no admin token configured')
this.#upstream.invalidate(server.id) this.#upstream.invalidate(server.id)
const response = (await this.#upstream.call(server, 'apps/config/get', { name }, server.adminToken)) as { config?: unknown } | null const response = (await this.#upstream.call(server, 'apps/config/get', { name }, server.adminToken)) as { config?: unknown } | null
const text = typeof response?.config === 'string' ? response.config : '' const text = typeof response?.config === 'string' ? response.config : ''
const dir = path.join(this.#config.dataDir, 'apps', safeName(name), 'history') return writeHistory(this.#config.dataDir, name, server.id, text, kind, this.#now())
mkdirSync(dir, { recursive: true })
const stamp = new Date(this.#now()).toISOString().replace(/:/g, '-')
const file = path.join(dir, `${stamp}__${safeName(server.id)}__before-uninstall.txt`)
const temp = `${file}.${process.pid}.tmp`
writeFileSync(temp, text, { mode: 0o600 })
renameSync(temp, file)
// Oldest first by name (the name starts with the time); keep the newest KEEP_HISTORY.
const files = readdirSync(dir).filter((f) => f.endsWith('.txt')).sort()
for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true })
return file
} }
/* /*
One write on one server. `url` must already be checked against the store; `file` is the zip One write on one server. `url` must already be checked against the store; `file` is the zip
for the two file actions. Returns the server's response, or throws. for the two file actions. Returns the server's response, or throws.
*/ */
async perform(server: HubServer, action: AppAction, name: string, url?: string, file?: UploadedFile): Promise<unknown> { async perform(server: HubServer, action: AppAction, name: string, url?: string, file?: UploadedFile, config?: string): Promise<unknown> {
if (server.adminToken === undefined) throw new Error('no admin token configured') if (server.adminToken === undefined) throw new Error('no admin token configured')
switch (action) { switch (action) {
case 'install-store': case 'install-store':
@@ -118,6 +102,15 @@ export class AppsWriter {
throw new Error(`the config could not be saved before the uninstall (${reason(e)}); nothing was uninstalled`) throw new Error(`the config could not be saved before the uninstall (${reason(e)}); nothing was uninstalled`)
} }
return await this.#upstream.write(server, API[action], { name }) return await this.#upstream.write(server, API[action], { name })
case 'config-set': {
if (config === undefined || config.length > MAX_CONFIG) throw new Error('the config is missing or larger than 1 MB')
try {
await this.saveHistory(server, name, 'before-apply')
} catch (e) {
throw new Error(`the old config could not be saved first (${reason(e)}); nothing was changed`)
}
return await this.#upstream.write(server, API[action], {}, new URLSearchParams({ name, config }))
}
case 'install-file': case 'install-file':
case 'update-file': { case 'update-file': {
if (file === undefined) throw new Error('no zip file') if (file === undefined) throw new Error('no zip file')
+11 -1
View File
@@ -33,12 +33,22 @@ export const ROUTES: Record<string, Route> = {
'hub/audit/list': read('audit.read'), 'hub/audit/list': read('audit.read'),
'hub/apps/matrix': read(), 'hub/apps/matrix': read(),
'apps/listStoreApps': read(), 'apps/listStoreApps': read(),
// Writes. They go to a server's ADMIN token only; see apps-write.ts. apps/config/set waits for a later stage. // Writes. They go to a server's ADMIN token only; see apps-write.ts.
'apps/downloadAndInstall': writeOne('apps.write'), 'apps/downloadAndInstall': writeOne('apps.write'),
'apps/downloadAndUpdate': writeOne('apps.write'), 'apps/downloadAndUpdate': writeOne('apps.write'),
'apps/uninstall': writeOne('apps.write'), 'apps/uninstall': writeOne('apps.write'),
'apps/install': writeOne('apps.write'), 'apps/install': writeOne('apps.write'),
'apps/update': writeOne('apps.write'), 'apps/update': writeOne('apps.write'),
// Config sync (apps-config.ts). Compare shows live configs to anyone who can read; the rest needs apps.write.
'apps/config/get': read(),
'apps/config/set': writeOne('apps.write'),
'hub/apps/config/compare': read(),
'hub/apps/config/preview': read('apps.write'),
'hub/apps/overrides/current': read('apps.write'),
'hub/apps/baseline/import': writeAll('apps.write'),
'hub/apps/baseline/save': writeAll('apps.write'),
'hub/apps/overrides/save': writeAll('apps.write'),
'hub/apps/config/apply': writeAll('apps.write'),
'hub/apps/run': writeAll('apps.write'), 'hub/apps/run': writeAll('apps.write'),
'hub/apps/upload': writeAll('apps.write'), 'hub/apps/upload': writeAll('apps.write'),
} }
+93 -5
View File
@@ -24,6 +24,7 @@ import { routeFor } from './routes.ts'
import { buildMatrix } from './apps-matrix.ts' import { buildMatrix } from './apps-matrix.ts'
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts' import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts' import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
import { AppsConfig, UserError } from './apps-config.ts'
import { import {
mergeQueryLogs, mergeQueryLogs,
mergeSettled, mergeSettled,
@@ -38,6 +39,8 @@ import {
type Json = Record<string, unknown> type Json = Record<string, unknown>
const MAX_BODY = 64 * 1024 const MAX_BODY = 64 * 1024
/* The config endpoints carry config texts (up to 1 MB). */
const MAX_CONFIG_BODY = 2 * 1024 * 1024
const TYPES: Record<string, string> = { const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
@@ -72,17 +75,17 @@ function send(res: http.ServerResponse, code: number, body: unknown): void {
const fail = (errorMessage: string): Json => ({ status: 'error', errorMessage }) const fail = (errorMessage: string): Json => ({ status: 'error', errorMessage })
async function readForm(req: http.IncomingMessage): Promise<URLSearchParams> { async function readForm(req: http.IncomingMessage, limit: number = MAX_BODY): Promise<URLSearchParams> {
if (req.method !== 'POST') return new URLSearchParams() if (req.method !== 'POST') return new URLSearchParams()
const chunks: Buffer[] = [] const chunks: Buffer[] = []
let size = 0 let size = 0
for await (const chunk of req) { for await (const chunk of req) {
size += (chunk as Buffer).length size += (chunk as Buffer).length
if (size > MAX_BODY) break if (size > limit) break
chunks.push(chunk as Buffer) chunks.push(chunk as Buffer)
} }
const type = String(req.headers['content-type'] ?? '') const type = String(req.headers['content-type'] ?? '')
if (size > MAX_BODY) return new URLSearchParams() if (size > limit) return new URLSearchParams()
const text = Buffer.concat(chunks).toString('utf8') const text = Buffer.concat(chunks).toString('utf8')
if (type.includes('application/x-www-form-urlencoded')) return new URLSearchParams(text) if (type.includes('application/x-www-form-urlencoded')) return new URLSearchParams(text)
if (type.includes('application/json')) { if (type.includes('application/json')) {
@@ -93,6 +96,7 @@ async function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
for (const [k, v] of Object.entries(data)) { for (const [k, v] of Object.entries(data)) {
if (Array.isArray(v)) out.set(k, v.map(String).join(',')) if (Array.isArray(v)) out.set(k, v.map(String).join(','))
else if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out.set(k, String(v)) else if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out.set(k, String(v))
else if (v !== null && typeof v === 'object') out.set(k, JSON.stringify(v))
} }
} catch { } catch {
// Not JSON: no parameters. // Not JSON: no parameters.
@@ -131,6 +135,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
const limiter = new LoginLimiter() const limiter = new LoginLimiter()
const audit = new Audit(config.dataDir) const audit = new Audit(config.dataDir)
const writer = new AppsWriter(config, upstream) const writer = new AppsWriter(config, upstream)
const appsConfig = new AppsConfig(config, upstream)
const byId = new Map(config.servers.map((s) => [s.id, s])) const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */ /* Ask every server; the ones that fail become warnings (see mergeSettled). */
@@ -243,6 +248,74 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
return file return file
} }
/* A value from a JSON-encoded parameter, or the fallback. */
function jsonParam(value: string | undefined, fallback: unknown): unknown {
if (value === undefined || value === '') return fallback
try {
return JSON.parse(value)
} catch {
throw new UserError('A parameter is not valid JSON.')
}
}
function userFailure(res: http.ServerResponse, e: unknown): void {
if (e instanceof UserError) send(res, 400, fail(e.message))
else throw e
}
/* compare, preview and the live local values: no change is made. */
async function configRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const name = params.name ?? ''
try {
if (name === '') throw new UserError('An app name is required.')
if (apiPath === 'hub/apps/config/compare') send(res, 200, ok(await appsConfig.compare(name)))
else if (apiPath === 'hub/apps/config/preview') {
const preview = await appsConfig.preview(name, csv(params.targets))
audit.log({ user: who.user, role: who.role, ip, action: 'apps.config-preview', target: name, outcome: 'ok', detail: csv(params.targets).join(',') })
send(res, 200, ok(preview))
} else if (apiPath === 'hub/apps/overrides/current') {
send(res, 200, ok({ values: await appsConfig.currentLocalValues(name, csv(params.paths)) }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
userFailure(res, e)
}
}
/* baseline import and save, local fields save, apply. */
async function configWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const name = params.name ?? ''
const base = { user: who.user, role: who.role, ip, target: name }
const action = apiPath.replace('hub/apps/', 'apps.').replace(/\//g, '-')
try {
if (name === '' || name.length > 200) throw new UserError('An app name is required.')
if (apiPath === 'hub/apps/baseline/import') {
const done = await appsConfig.importBaseline(name, params.server ?? '', who.user)
audit.log({ ...base, action, server: params.server, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ baseline: appsConfig.getBaseline(name) }))
} else if (apiPath === 'hub/apps/baseline/save') {
const done = appsConfig.saveBaseline(name, params.text ?? '', who.user)
audit.log({ ...base, action, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ baseline: appsConfig.getBaseline(name) }))
} else if (apiPath === 'hub/apps/overrides/save') {
const done = appsConfig.saveOverrides(name, csv(params.paths), jsonParam(params.values, {}))
audit.log({ ...base, action, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ overrides: appsConfig.getOverrides(name) }))
} else if (apiPath === 'hub/apps/config/apply') {
const expect = jsonParam(params.expect, {}) as Record<string, string>
const done = await appsConfig.apply(name, csv(params.targets), expect)
for (const r of done.results) {
audit.log({ ...base, action: 'apps.config-apply', server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server]].filter(Boolean).join(' | ') })
}
send(res, 200, ok({ results: done.results }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
if (e instanceof UserError) {
audit.log({ ...base, action, outcome: 'refused', detail: apiPath.endsWith('baseline/save') ? 'invalid baseline' : e.message.slice(0, 120) })
}
userFailure(res, e)
}
}
/* The write endpoints. The role has already been checked against the route's capability. */ /* The write endpoints. The role has already been checked against the route's capability. */
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise<void> { async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise<void> {
if (kind === 'write-all') { if (kind === 'write-all') {
@@ -250,6 +323,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
send(res, 405, fail('Use POST.')) send(res, 405, fail('Use POST.'))
return return
} }
if (apiPath.startsWith('hub/apps/baseline/') || apiPath.startsWith('hub/apps/overrides/') || apiPath === 'hub/apps/config/apply') {
await configWrite(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/apps/run') { if (apiPath === 'hub/apps/run') {
const action = params.action const action = params.action
if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') { if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') {
@@ -324,7 +401,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
} }
url = fromStore ?? undefined url = fromStore ?? undefined
} }
response = await writer.perform(server, action, name, url, file) response = await writer.perform(server, action, name, url, file, params.config)
result = { server: server.id, ok: true } result = { server: server.id, ok: true }
} catch (e) { } catch (e) {
result = { server: server.id, ok: false, message: e instanceof Error ? e.message : 'failed' } result = { server: server.id, ok: false, message: e instanceof Error ? e.message : 'failed' }
@@ -335,7 +412,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> { async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
// A multipart body (an app zip) is read later, once the caller is known to be allowed to send it. // A multipart body (an app zip) is read later, once the caller is known to be allowed to send it.
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req) // A config body may be large, but only for a signed-in caller (token in the header or the query).
const large = (apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set')
&& sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req, large ? MAX_CONFIG_BODY : MAX_BODY)
const params: Params = {} const params: Params = {}
for (const [k, v] of url.searchParams) params[k] = v for (const [k, v] of url.searchParams) params[k] = v
for (const [k, v] of form) params[k] = v for (const [k, v] of form) params[k] = v
@@ -409,6 +489,14 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await write(req, res, apiPath, route.kind, params, who, ip) await write(req, res, apiPath, route.kind, params, who, ip)
return return
} }
if (apiPath.startsWith('hub/apps/config/') || apiPath === 'hub/apps/overrides/current') {
await configRead(res, apiPath, params, who, ip)
return
}
if (apiPath === 'apps/config/get' && !byId.has(params.node ?? '')) {
send(res, 400, fail('Choose one server: a config is read from a single server.'))
return
}
if (apiPath === 'hub/apps/matrix') { if (apiPath === 'hub/apps/matrix') {
try { try {
const { matrix, warnings } = await buildMatrix(config, upstream) const { matrix, warnings } = await buildMatrix(config, upstream)
+109
View File
@@ -0,0 +1,109 @@
/*
What the hub keeps per app under `${HUB_DATA}/apps/<app>/`:
baseline.json the desired config, as TEXT (some configs are not JSON)
overrides.json the fields that stay local to each server, and their values
history/ the config a server had before the hub changed it (50 files kept)
Every write is a temp file and a rename, mode 0600: these files hold configs.
*/
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
export const KEEP_HISTORY = 50
/* A folder name for an app: letters, digits and . _ - only, with a short hash when anything had to change. */
export function safeName(name: string): string {
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^\.+/, '_').slice(0, 80)
if (cleaned === name && cleaned !== '') return cleaned
return `${cleaned === '' ? '_' : cleaned}-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
}
export const appDir = (dataDir: string, app: string): string => path.join(dataDir, 'apps', safeName(app))
export function writeAtomic(file: string, text: string): void {
mkdirSync(path.dirname(file), { recursive: true })
const temp = `${file}.${process.pid}.tmp`
try {
writeFileSync(temp, text, { mode: 0o600 })
renameSync(temp, file)
} catch (e) {
rmSync(temp, { force: true })
throw e
}
}
export interface Baseline {
app: string
text: string
importedFrom?: string
updatedAt: string
updatedBy: string
}
export interface Overrides {
/** Dot paths (`a.b.0.c`) of the fields that are not synchronised. */
paths: string[]
/** server -> path -> value */
values: Record<string, Record<string, unknown>>
}
export const EMPTY_OVERRIDES: Overrides = { paths: [], values: {} }
function readJson(file: string): unknown {
if (!existsSync(file)) return null
try {
return JSON.parse(readFileSync(file, 'utf8'))
} catch {
return null
}
}
export function readBaseline(dataDir: string, app: string): Baseline | null {
const data = readJson(path.join(appDir(dataDir, app), 'baseline.json')) as Partial<Baseline> | null
if (data === null || typeof data.text !== 'string') return null
return {
app,
text: data.text,
...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}),
updatedAt: String(data.updatedAt ?? ''),
updatedBy: String(data.updatedBy ?? ''),
}
}
export function writeBaseline(dataDir: string, baseline: Baseline): void {
writeAtomic(path.join(appDir(dataDir, baseline.app), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')
}
export function readOverrides(dataDir: string, app: string): Overrides {
const data = readJson(path.join(appDir(dataDir, app), 'overrides.json')) as Partial<Overrides> | null
if (data === null || !Array.isArray(data.paths)) return { paths: [], values: {} }
const values = data.values !== null && typeof data.values === 'object' ? data.values : {}
return { paths: data.paths.filter((p): p is string => typeof p === 'string'), values }
}
export function writeOverrides(dataDir: string, app: string, overrides: Overrides): void {
writeAtomic(path.join(appDir(dataDir, app), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n')
}
/*
Saves a server's config as it was before the hub changed it. The name starts with the time, so
sorting the names sorts the files by age; the oldest beyond KEEP_HISTORY are deleted.
*/
export function writeHistory(
dataDir: string,
app: string,
server: string,
text: string,
kind: 'before-uninstall' | 'before-apply',
now: number,
): string {
const dir = path.join(appDir(dataDir, app), 'history')
const stamp = new Date(now).toISOString().replace(/:/g, '-')
const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`)
writeAtomic(file, text)
const files = readdirSync(dir).filter((f) => f.endsWith('.txt')).sort()
for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true })
return file
}
+1 -1
View File
@@ -63,7 +63,7 @@ export class Upstream {
the outcome, what the hub remembers of this server is dropped afterwards, so the next read the outcome, what the hub remembers of this server is dropped afterwards, so the next read
shows the state the write left. `body` is a multipart upload (the app zip). shows the state the write left. `body` is a multipart upload (the app zip).
*/ */
async write(server: HubServer, path: string, params: Params = {}, body?: FormData): Promise<unknown> { async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams): Promise<unknown> {
if (server.adminToken === undefined) throw new UpstreamError('rejected', 'no admin token configured') if (server.adminToken === undefined) throw new UpstreamError('rejected', 'no admin token configured')
try { try {
const init: RequestInit = body === undefined ? { method: 'POST' } : { method: 'POST', body } const init: RequestInit = body === undefined ? { method: 'POST' } : { method: 'POST', body }
+98 -1
View File
@@ -142,8 +142,9 @@ export function setAppConfig(
token: string | null, token: string | null,
name: string, name: string,
config: string, config: string,
node = '',
): Promise<ApiOutcome> { ): Promise<ApiOutcome> {
return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config } }) return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config }, node })
} }
/* /*
@@ -261,3 +262,99 @@ export function uploadHubApp(
form.append('fileAppZip', file) form.append('fileAppZip', file)
return apiRequest('hub/apps/upload', { token, form }) return apiRequest('hub/apps/upload', { token, form })
} }
/*
Config sync behind a hub (hub/apps-config.ts): the comparison of one app's config across
servers, the baseline kept in the hub, the fields that stay local to each server, and the
preview and apply that bring servers to baseline plus their own local values.
*/
export interface FieldChange {
path: string
type: 'added' | 'removed' | 'changed'
a?: unknown
b?: unknown
}
export interface LineOp {
op: 'same' | 'add' | 'del'
text: string
}
export type ConfigDiff =
| { kind: 'json'; changes: FieldChange[] }
| { kind: 'text'; lines: LineOp[]; truncated: boolean; changed: number }
export interface Baseline {
app: string
text: string
importedFrom?: string
updatedAt: string
updatedBy: string
}
export interface Overrides {
paths: string[]
values: Record<string, Record<string, unknown>>
}
export interface ServerConfigView {
id: string
ok: boolean
error?: string
installed: boolean
writable: boolean
version?: string
text: string | null
hash: string
effective?: string
warnings: string[]
/** Null without a baseline. */
inSync: boolean | null
vsBaseline?: ConfigDiff
vsOthers: Record<string, ConfigDiff>
}
export interface ConfigCompare {
name: string
baseline: Baseline | null
overrides: Overrides
servers: ServerConfigView[]
}
export interface TargetPreview {
server: string
ok: boolean
error?: string
writable: boolean
current: string | null
currentHash: string
effective: string
changes: ConfigDiff
unchanged: boolean
warnings: string[]
}
export const getConfigCompare = (token: string | null, name: string): Promise<ApiOutcome<{ response: ConfigCompare }>> =>
apiRequest('hub/apps/config/compare', { token, body: { name } })
export const importBaseline = (token: string | null, name: string, server: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
apiRequest('hub/apps/baseline/import', { token, method: 'POST', body: { name, server } })
export const saveBaseline = (token: string | null, name: string, text: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
apiRequest('hub/apps/baseline/save', { token, method: 'POST', body: { name, text } })
export const saveOverrides = (token: string | null, name: string, overrides: Overrides): Promise<ApiOutcome<{ response: { overrides: Overrides } }>> =>
apiRequest('hub/apps/overrides/save', {
token,
method: 'POST',
body: { name, paths: overrides.paths.join(','), values: JSON.stringify(overrides.values) },
})
export const getCurrentLocalValues = (token: string | null, name: string, paths: string[]): Promise<ApiOutcome<{ response: { values: Overrides['values'] } }>> =>
apiRequest('hub/apps/overrides/current', { token, method: 'POST', body: { name, paths: paths.join(',') } })
export const previewConfig = (token: string | null, name: string, targets: string[]): Promise<ApiOutcome<{ response: { targets: TargetPreview[] } }>> =>
apiRequest('hub/apps/config/preview', { token, method: 'POST', body: { name, targets: targets.join(',') } })
export const applyConfig = (token: string | null, name: string, targets: string[], expect: Record<string, string>): Promise<RunOutcome> =>
apiRequest('hub/apps/config/apply', { token, method: 'POST', body: { name, targets: targets.join(','), expect: JSON.stringify(expect) } })
+4 -1
View File
@@ -25,6 +25,7 @@ export function AppConfig({
name, name,
initialConfig, initialConfig,
onSaved, onSaved,
node = '',
}: { }: {
open: boolean open: boolean
onOpenChange: (o: boolean) => void onOpenChange: (o: boolean) => void
@@ -32,6 +33,8 @@ export function AppConfig({
name: string name: string
initialConfig: string initialConfig: string
onSaved: (name: string) => void onSaved: (name: string) => void
/** Behind a hub: the one server it goes to. */
node?: string
}) { }) {
const [config, setConfig] = useState(initialConfig) const [config, setConfig] = useState(initialConfig)
const [alert, setAlert] = useState<AlertState | null>(null) const [alert, setAlert] = useState<AlertState | null>(null)
@@ -47,7 +50,7 @@ export function AppConfig({
async function save() { async function save() {
setBusy(true) setBusy(true)
const outcome = await setAppConfig(token, name, config) const outcome = await setAppConfig(token, name, config, node)
setBusy(false) setBusy(false)
if (outcome.kind !== 'ok') { if (outcome.kind !== 'ok') {
+281
View File
@@ -0,0 +1,281 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AppConfigSync } from './AppConfigSync'
import { AppsMatrix } from './AppsMatrix'
import * as api from '../../api/apps'
import type { ConfigCompare, ServerConfigView } from '../../api/apps'
afterEach(() => vi.restoreAllMocks())
const A = JSON.stringify({ ttl: 30, db: '/a.db' }, null, 2)
const B = JSON.stringify({ ttl: 60, db: '/b.db' }, null, 2)
function view(id: string, over: Partial<ServerConfigView> = {}): ServerConfigView {
return { id, ok: true, installed: true, writable: true, version: '1.0', text: A, hash: 'h-' + id, warnings: [], inSync: null, vsOthers: {}, ...over }
}
/* mdns is the baseline; ndns differs in ttl and in the local db path. */
const WITH_BASELINE: ConfigCompare = {
name: 'Blocker',
baseline: { app: 'Blocker', text: A, importedFrom: 'mdns', updatedAt: '2026-10-31T08:00:00Z', updatedBy: 'alice' },
overrides: { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } },
servers: [
view('mdns', { inSync: true, vsBaseline: { kind: 'json', changes: [] } }),
view('ndns', {
text: B,
inSync: false,
vsBaseline: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }, { path: 'db', type: 'changed', a: '/x.db', b: '/b.db' }] },
}),
],
}
const NO_BASELINE: ConfigCompare = {
name: 'Blocker',
baseline: null,
overrides: { paths: [], values: {} },
servers: [
view('mdns', { vsOthers: { ndns: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }] } } }),
view('ndns', { text: B }),
],
}
function mockCompare(data: ConfigCompare) {
return vi.spyOn(api, 'getConfigCompare').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
}
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
function mount(canWrite: boolean) {
const onChanged = vi.fn()
render(<AppConfigSync token="t" name="Blocker" canWrite={canWrite} onClose={() => {}} onChanged={onChanged} />)
return { onChanged }
}
describe('Compare', () => {
it('shows a skeleton while loading, then the badges and the fields that differ', async () => {
mockCompare(WITH_BASELINE)
mount(false)
expect(screen.getByRole('status', { name: 'Loading the configs' })).toBeInTheDocument()
expect(await screen.findByText('In sync')).toBeInTheDocument()
expect(screen.getByText('Differs')).toBeInTheDocument()
const dialog = screen.getByRole('dialog', { name: 'App config: Blocker' })
expect(within(dialog).getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument()
expect(within(dialog).getByText('ttl')).toBeInTheDocument()
expect(within(dialog).getByText('60')).toBeInTheDocument()
expect(within(dialog).getByText('expected 30')).toBeInTheDocument()
// The local field is marked.
const dbRow = within(dialog).getByText('db').closest('tr') as HTMLElement
expect(within(dbRow).getByText('Local')).toBeInTheDocument()
})
it('compares the servers to each other when there is no baseline', async () => {
mockCompare(NO_BASELINE)
mount(false)
expect(await screen.findByText('No baseline yet')).toBeInTheDocument()
expect(screen.getAllByText('No baseline').length).toBeGreaterThan(0)
expect(screen.getByText('ttl')).toBeInTheDocument()
expect(screen.getByText('30')).toBeInTheDocument()
expect(screen.getByText('60')).toBeInTheDocument()
})
it('shows lines for a text config', async () => {
mockCompare({
...WITH_BASELINE,
servers: [
view('mdns', { inSync: true, vsBaseline: { kind: 'text', lines: [], truncated: false, changed: 0 } }),
view('ndns', { inSync: false, vsBaseline: { kind: 'text', lines: [{ op: 'same', text: 'a = 1' }, { op: 'del', text: 'b = 2' }, { op: 'add', text: 'b = 3' }], truncated: false, changed: 2 } }),
],
})
mount(false)
const lines = await screen.findByLabelText('Line differences')
expect(within(lines).getByText(/b = 3/)).toHaveAttribute('data-op', 'add')
expect(within(lines).getByText(/b = 2/)).toHaveAttribute('data-op', 'del')
})
it('shows an error with Retry', async () => {
const spy = vi.spyOn(api, 'getConfigCompare').mockResolvedValueOnce({ kind: 'error', message: 'No server answered.' } as never)
mount(false)
expect(await screen.findByText(/Could not read the configs/)).toHaveTextContent('No server answered.')
spy.mockResolvedValue({ kind: 'ok', data: { response: WITH_BASELINE } } as never)
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('In sync')).toBeInTheDocument()
})
})
describe('role gating', () => {
it('gives a reader no editing controls', async () => {
mockCompare(WITH_BASELINE)
mount(false)
await screen.findByText('In sync')
expect(screen.queryByRole('button', { name: 'Apply…' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('tab', { name: 'Baseline' }))
expect(screen.getByLabelText('Baseline text')).toHaveAttribute('readonly')
expect(screen.queryByRole('button', { name: 'Save baseline' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('tab', { name: 'Local fields' }))
expect(screen.getByLabelText('db on mdns')).toHaveAttribute('readonly')
expect(screen.queryByRole('button', { name: 'Save local fields' })).not.toBeInTheDocument()
})
it('tells a reader who sees no baseline to ask, without import buttons', async () => {
mockCompare(NO_BASELINE)
mount(false)
await screen.findByText('No baseline yet')
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
expect(screen.getByText(/apps administrator/)).toBeInTheDocument()
})
it('offers a writer the import from each server when there is no baseline', async () => {
const spy = mockCompare(NO_BASELINE)
const imp = vi.spyOn(api, 'importBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: WITH_BASELINE.baseline } } } as never)
const { onChanged } = mount(true)
await userEvent.click(await screen.findByRole('button', { name: 'Import from mdns' }))
expect(imp).toHaveBeenCalledWith('t', 'Blocker', 'mdns')
await waitFor(() => expect(spy.mock.calls.length).toBeGreaterThan(1))
expect(onChanged).toHaveBeenCalled()
expect(await screen.findByText('Baseline imported from mdns.')).toBeInTheDocument()
})
})
describe('Baseline and Local fields', () => {
it('shows who updated the baseline and a validation error from the hub inline', async () => {
mockCompare(WITH_BASELINE)
const save = vi.spyOn(api, 'saveBaseline').mockResolvedValue({ kind: 'error', message: 'The baseline is not valid JSON: Unexpected token at position 4' } as never)
mount(true)
await userEvent.click(await screen.findByRole('tab', { name: 'Baseline' }))
expect(screen.getByText(/Imported from mdns\. Updated by alice/)).toBeInTheDocument()
const box = screen.getByLabelText('Baseline text')
await userEvent.clear(box)
await userEvent.type(box, 'x')
await userEvent.click(screen.getByRole('button', { name: 'Save baseline' }))
expect(save).toHaveBeenCalledWith('t', 'Blocker', 'x')
expect(await screen.findByText(/not valid JSON: Unexpected token at position 4/)).toBeInTheDocument()
})
it('adds a local field, fills the values from the servers and saves them', async () => {
mockCompare({ ...WITH_BASELINE, overrides: { paths: [], values: {} } })
const current = vi.spyOn(api, 'getCurrentLocalValues').mockResolvedValue({ kind: 'ok', data: { response: { values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } } } } as never)
const save = vi.spyOn(api, 'saveOverrides').mockResolvedValue({ kind: 'ok', data: { response: { overrides: WITH_BASELINE.overrides } } } as never)
mount(true)
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
expect(screen.getByText('No local fields yet.')).toBeInTheDocument()
await userEvent.type(screen.getByLabelText('New local field'), 'db')
await userEvent.click(screen.getByRole('button', { name: 'Add field' }))
await userEvent.click(screen.getByRole('button', { name: 'Fill from the current server configs' }))
expect(current).toHaveBeenCalledWith('t', 'Blocker', ['db'])
expect(await screen.findByDisplayValue('"/b.db"')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
expect(save).toHaveBeenCalledWith('t', 'Blocker', { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } })
})
it('refuses a value that is not JSON, without calling the hub', async () => {
mockCompare(WITH_BASELINE)
const save = vi.spyOn(api, 'saveOverrides')
mount(true)
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
const box = screen.getByLabelText('db on mdns')
await userEvent.clear(box)
await userEvent.type(box, 'no quotes')
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
expect(await screen.findByText(/db on mdns is not valid JSON/)).toBeInTheDocument()
expect(save).not.toHaveBeenCalled()
})
})
describe('Apply', () => {
it('picks servers, previews, applies with the preview hashes, and retries only what failed', async () => {
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: true }] })
const preview = vi.spyOn(api, 'previewConfig').mockResolvedValue({
kind: 'ok',
data: {
response: {
targets: [
{ server: 'ndns', ok: true, writable: true, current: B, currentHash: 'hash-ndns', effective: A, unchanged: false, warnings: ['Version 0.9 here, 1.0 on mdns: versions differ, align versions first.'], changes: WITH_BASELINE.servers[1].vsBaseline },
],
},
},
} as never)
const apply = vi
.spyOn(api, 'applyConfig')
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: false, message: 'The config is invalid.' }]))
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Applied and verified.' }]))
const { onChanged } = mount(true)
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
// Only the server that differs is ticked.
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked()
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
expect(preview).toHaveBeenCalledWith('t', 'Blocker', ['ndns'])
const review = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: review' })
expect(within(review).getByText('Will change')).toBeInTheDocument()
expect(within(review).getByText(/align versions first/)).toBeInTheDocument()
expect(within(review).getByText('ttl')).toBeInTheDocument()
expect(apply).not.toHaveBeenCalled()
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
expect(apply).toHaveBeenLastCalledWith('t', 'Blocker', ['ndns'], { ndns: 'hash-ndns' })
const results = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: results' })
expect(within(results).getByText('The config is invalid.')).toBeInTheDocument()
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
expect(apply).toHaveBeenCalledTimes(2)
await waitFor(() => expect(within(screen.getByRole('dialog')).getByText('Applied and verified.')).toBeInTheDocument())
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
await waitFor(() => expect(onChanged).toHaveBeenCalled())
})
it('cannot apply without a baseline, and lists a server without a token as disabled', async () => {
mockCompare(NO_BASELINE)
mount(true)
await screen.findByText('No baseline yet')
expect(screen.getByRole('button', { name: 'Apply…' })).toBeDisabled()
})
it('disables a server the hub cannot write to in the picker', async () => {
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: false }] })
mount(true)
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
expect(within(picker).getByText('No admin token configured on the hub')).toBeInTheDocument()
})
it('shows a review failure from the hub', async () => {
mockCompare(WITH_BASELINE)
vi.spyOn(api, 'previewConfig').mockResolvedValue({ kind: 'error', message: 'This app has no baseline yet.' } as never)
mount(true)
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })).getByRole('button', { name: 'Preview' }))
expect(await screen.findByText('This app has no baseline yet.')).toBeInTheDocument()
})
})
describe('from the matrix', () => {
const DATA: api.AppsMatrixData = {
servers: [{ id: 'mdns', ok: true, writable: true }, { id: 'ndns', ok: true, writable: true }],
apps: [
{
name: 'Blocker', description: '', configs: 'different', diffCount: 2, versions: 'same',
servers: { mdns: { installed: true, version: '1.0', configStatus: 'read' }, ndns: { installed: true, version: '1.0', configStatus: 'read' } },
},
],
}
it('opens the config view from the row menu for a reader and from the Different badge', async () => {
vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
const compare = mockCompare(WITH_BASELINE)
render(<AppsMatrix token="t" />)
await userEvent.click(await screen.findByRole('button', { name: 'Actions: Blocker' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Config…' }))
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
expect(compare).toHaveBeenCalledWith('t', 'Blocker')
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: 'Compare the configs of Blocker' }))
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
})
})
+392
View File
@@ -0,0 +1,392 @@
import { useCallback, useEffect, useState } from 'react'
import {
applyConfig,
getConfigCompare,
getCurrentLocalValues,
importBaseline,
previewConfig,
saveBaseline,
saveOverrides,
type ConfigCompare,
type ServerConfigView,
} from '../../api/apps'
import { Alert } from '../../ui/Alert'
import { Button } from '../../ui/Button'
import { Dialog } from '../../ui/Dialog'
import { Empty, Failure } from '../../ui/Empty'
import { Input, LabeledTextarea } from '../../ui/Field'
import { Tag } from '../../ui/Tag'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { DiffView, FieldTable, fieldRows } from './ConfigDiffView'
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
/*
One app's config across the servers, behind a hub: compare them, keep a baseline in the hub,
mark the fields that stay local to each server, then preview and apply. Everyone who can read
sees the comparison; the editors and Apply are for roles that may change apps.
*/
type Tab = 'compare' | 'baseline' | 'local'
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
function ServerBadge({ s, hasBaseline }: { s: ServerConfigView; hasBaseline: boolean }) {
if (!s.ok) return <Tag tone="dan">Did not answer</Tag>
if (!s.installed) return <Tag>Not installed</Tag>
if (!hasBaseline || s.inSync === null) return <Tag>No baseline</Tag>
return s.inSync ? <Tag tone="ok">In sync</Tag> : <Tag tone="warn">Differs</Tag>
}
export function AppConfigSync({
token,
name,
canWrite,
onClose,
onChanged,
}: {
token: string | null
name: string
canWrite: boolean
onClose: () => void
onChanged: () => void
}) {
const [data, setData] = useState<ConfigCompare | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [tab, setTab] = useState<Tab>('compare')
const [request, setRequest] = useState<RunRequest | null>(null)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [saved, setSaved] = useState<string | null>(null)
// The editors.
const [baselineText, setBaselineText] = useState('')
const [paths, setPaths] = useState<string[]>([])
const [values, setValues] = useState<Record<string, Record<string, string>>>({})
const [newPath, setNewPath] = useState('')
const load = useCallback(async () => {
const outcome = await getConfigCompare(token, name)
if (outcome.kind !== 'ok') {
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
return
}
const d = outcome.data.response
setData(d)
setFailure(null)
setBaselineText(d.baseline?.text ?? '')
setPaths(d.overrides.paths)
setValues(
Object.fromEntries(d.servers.map((s) => [s.id, Object.fromEntries(d.overrides.paths.flatMap((p) => (Object.hasOwn(d.overrides.values[s.id] ?? {}, p) ? [[p, JSON.stringify(d.overrides.values[s.id][p])]] : [])))])),
)
}, [token, name])
useEffect(() => {
void load()
}, [load])
const hasBaseline = data?.baseline != null
async function run(call: () => Promise<{ kind: string; message?: string }>, done: string) {
setBusy(true)
setProblem(null)
setSaved(null)
const outcome = await call()
setBusy(false)
if (outcome.kind !== 'ok') {
setProblem(outcome.kind === 'error' ? (outcome.message ?? 'Failed.') : 'The session ended.')
return
}
setSaved(done)
await load()
onChanged()
}
function saveLocal() {
const parsed: Record<string, Record<string, unknown>> = {}
for (const [server, mine] of Object.entries(values)) {
parsed[server] = {}
for (const [path, text] of Object.entries(mine)) {
if (!paths.includes(path) || text.trim() === '') continue
try {
parsed[server][path] = JSON.parse(text)
} catch {
setProblem(`The value of ${path} on ${server} is not valid JSON (a string needs quotes).`)
return
}
}
}
void run(() => saveOverrides(token, name, { paths, values: parsed }), 'Local fields saved.')
}
async function fill() {
setBusy(true)
setProblem(null)
const outcome = await getCurrentLocalValues(token, name, paths)
setBusy(false)
if (outcome.kind !== 'ok') {
setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
return
}
setValues(Object.fromEntries(Object.entries(outcome.data.response.values).map(([s, mine]) => [s, Object.fromEntries(Object.entries(mine).map(([p, v]) => [p, JSON.stringify(v)]))])))
}
function startApply() {
if (data === null) return
const servers = data.servers
setRequest({
title: `Apply the baseline of ${name}`,
intro: 'Each server gets the baseline plus its own local values. You see what changes before anything is written.',
confirmLabel: 'Preview',
servers: pickerServers(
servers.map((s) => ({ id: s.id, writable: s.writable, ok: s.ok })),
Object.fromEntries(servers.filter((s) => s.ok && !s.installed).map((s) => [s.id, 'Not installed'])),
),
preselected: servers.filter((s) => s.installed && s.inSync === false).map((s) => s.id),
review: async (targets) => {
const outcome = await previewConfig(token, name, targets)
if (outcome.kind !== 'ok') return { kind: 'error', message: outcome.kind === 'error' ? outcome.message : 'The session ended.' }
const previews = outcome.data.response.targets
return {
kind: 'ok',
confirmLabel: 'Apply',
context: Object.fromEntries(previews.filter((p) => p.ok).map((p) => [p.server, p.currentHash])),
body: (
<div className="flex flex-col gap-6">
{previews.map((p) => (
<section key={p.server} aria-label={`Preview ${p.server}`} className="flex flex-col gap-3">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{p.server}
{!p.ok ? <Tag tone="dan">Cannot apply</Tag> : p.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
</h3>
{p.error !== undefined && <Alert type="danger" title="Error">{p.error}</Alert>}
{p.warnings.map((w) => (
<Alert key={w} type="warning" title="Warning">
{w}
</Alert>
))}
{p.ok && !p.unchanged && <DiffView diff={p.changes} from="Now" to="After apply" />}
</section>
))}
</div>
),
}
},
run: (targets, context) => applyConfig(token, name, targets, (context ?? {}) as Record<string, string>),
})
}
const importButtons = (data?.servers ?? []).filter((s) => s.installed && s.ok)
return (
<>
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
size="wide"
title={`App config: ${name}`}
actions={
canWrite ? (
<Button variant="primary" disabled={!hasBaseline || busy} onClick={startApply}>
Apply…
</Button>
) : undefined
}
>
{failure !== null && data === null ? (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the configs. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
) : data === null ? (
<div className="flex flex-col gap-3" role="status" aria-label="Loading the configs">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as Tab)}
// The project has no `data-horizontal` variant, so the component's own flex-col never applies.
className="flex-col"
>
<TabsList variant="line">
<TabsTrigger value="compare">Compare</TabsTrigger>
<TabsTrigger value="baseline">Baseline</TabsTrigger>
<TabsTrigger value="local">Local fields</TabsTrigger>
</TabsList>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{saved !== null && <Alert type="success" title="Saved." onDismiss={() => setSaved(null)}>{saved}</Alert>}
<TabsContent value="compare" className="flex flex-col gap-4 pt-3">
<div className="flex flex-wrap items-center gap-4">
{data.servers.map((s) => (
<span key={s.id} className="flex items-center gap-2 text-sm">
<span className="font-medium">{s.id}</span>
{s.version !== undefined && <span className="text-xs text-muted-foreground">v{s.version}</span>}
<ServerBadge s={s} hasBaseline={hasBaseline} />
</span>
))}
</div>
{data.servers.filter((s) => s.error !== undefined).map((s) => (
<Alert key={s.id} type="warning" title={s.id}>
{s.error}
</Alert>
))}
{!hasBaseline && (
<Empty
title="No baseline yet"
actions={
canWrite ? (
<span className="flex flex-wrap justify-center gap-2">
{importButtons.map((s) => (
<Button key={s.id} variant="primary" disabled={busy} onClick={() => void run(() => importBaseline(token, name, s.id), `Baseline imported from ${s.id}.`)}>
Import from {s.id}
</Button>
))}
</span>
) : undefined
}
>
The baseline is the config every server should have. Import it from a server that is right, then mark the fields that must differ per server as local.
{!canWrite && ' An apps administrator can set it up.'}
</Empty>
)}
<CompareBody data={data} />
</TabsContent>
<TabsContent value="baseline" className="flex flex-col gap-3 pt-3">
<p className="m-0 text-xs text-muted-foreground">
{data.baseline === null
? 'No baseline saved.'
: `${data.baseline.importedFrom !== undefined ? `Imported from ${data.baseline.importedFrom}. ` : ''}Updated by ${data.baseline.updatedBy} at ${when(data.baseline.updatedAt)}.`}
</p>
<LabeledTextarea
label="Baseline text"
mono
rows={16}
spellCheck={false}
readOnly={!canWrite}
value={baselineText}
onChange={(e) => setBaselineText(e.target.value)}
/>
{canWrite && (
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy} onClick={() => void run(() => saveBaseline(token, name, baselineText), 'Baseline saved.')}>
Save baseline
</Button>
{importButtons.map((s) => (
<Button key={s.id} disabled={busy} onClick={() => void run(() => importBaseline(token, name, s.id), `Baseline imported from ${s.id}.`)}>
Import from {s.id}
</Button>
))}
</div>
)}
</TabsContent>
<TabsContent value="local" className="flex flex-col gap-4 pt-3">
<p className="m-0 text-xs text-muted-foreground">
Local fields are not synchronised: each server keeps its own value. Name a field by its path, for example <code>db.path</code> or <code>networks.0.cidr</code>. A value is JSON, so a string needs quotes.
</p>
{paths.length === 0 && <p className="m-0 text-sm text-muted-foreground">No local fields yet.</p>}
{paths.map((p) => (
<fieldset key={p} className="m-0 flex flex-col gap-2 border-0 p-0">
<legend className="flex items-center gap-2 p-0 font-mono text-xs">
{p}
{canWrite && (
<Button size="sm" onClick={() => setPaths((old) => old.filter((x) => x !== p))}>
Remove {p}
</Button>
)}
</legend>
<div className="flex flex-wrap gap-3">
{data.servers.map((s) => (
<label key={s.id} className="flex min-w-48 flex-1 flex-col gap-1 text-xs">
<span>
{p} on {s.id}
</span>
<Input
mono
readOnly={!canWrite}
aria-label={`${p} on ${s.id}`}
placeholder="(no local value)"
value={values[s.id]?.[p] ?? ''}
onChange={(e) => setValues((old) => ({ ...old, [s.id]: { ...(old[s.id] ?? {}), [p]: e.target.value } }))}
/>
</label>
))}
</div>
</fieldset>
))}
{canWrite && (
<>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs">
<span>New local field</span>
<Input mono aria-label="New local field" placeholder="db.path" value={newPath} onChange={(e) => setNewPath(e.target.value)} />
</label>
<Button
disabled={newPath.trim() === '' || paths.includes(newPath.trim())}
onClick={() => {
setPaths((old) => [...old, newPath.trim()])
setNewPath('')
}}
>
Add field
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={saveLocal}>
Save local fields
</Button>
<Button disabled={busy || paths.length === 0} onClick={() => void fill()}>
Fill from the current server configs
</Button>
</div>
</>
)}
</TabsContent>
</Tabs>
)}
</Dialog>
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) {
void load()
onChanged()
}
}}
/>
)}
</>
)
}
/* The differences, as a field table for JSON and as line diffs for text. */
function CompareBody({ data }: { data: ConfigCompare }) {
if (fieldRows(data) !== null) return <FieldTable data={data} />
const live = data.servers.filter((s) => s.installed && s.ok)
return (
<div className="flex flex-col gap-4">
{live.map((s) => {
const diff = data.baseline !== null ? s.vsBaseline : Object.values(s.vsOthers)[0]
return diff === undefined ? null : (
<section key={s.id} aria-label={`Differences ${s.id}`} className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">{s.id}</h3>
<DiffView diff={diff} from={data.baseline !== null ? 'Now' : 'Other'} to={data.baseline !== null ? 'Expected' : s.id} />
</section>
)
})}
{live.length === 0 && <p className="m-0 text-sm text-muted-foreground">Nothing to compare: the app is not installed on any server that answered.</p>}
</div>
)
}
+2 -2
View File
@@ -195,7 +195,7 @@ export function Apps({
// cluster's primary node. `config` can come null; the editor is left empty. // cluster's primary node. `config` can come null; the editor is left empty.
async function openConfig(name: string) { async function openConfig(name: string) {
setBusy(name) setBusy(name)
const outcome = await getAppConfig(token, name) const outcome = await getAppConfig(token, name, requestNode)
setBusy(null) setBusy(null)
if (outcome.kind !== 'ok') { if (outcome.kind !== 'ok') {
@@ -363,7 +363,6 @@ export function Apps({
app={app} app={app}
busy={busy === app.name} busy={busy === app.name}
readOnly={!mayChange} readOnly={!mayChange}
hideConfig={hub}
onConfig={() => void openConfig(app.name)} onConfig={() => void openConfig(app.name)}
onUpdate={() => setModal({ kind: 'update', name: app.name })} onUpdate={() => setModal({ kind: 'update', name: app.name })}
onStoreUpdate={() => void updateFromStore(app)} onStoreUpdate={() => void updateFromStore(app)}
@@ -419,6 +418,7 @@ export function Apps({
onOpenChange={(o) => setModal(o && modal?.kind === 'config' ? modal : null)} onOpenChange={(o) => setModal(o && modal?.kind === 'config' ? modal : null)}
token={token} token={token}
name={modal?.kind === 'config' ? modal.name : ''} name={modal?.kind === 'config' ? modal.name : ''}
node={requestNode}
initialConfig={modal?.kind === 'config' ? modal.config : ''} initialConfig={modal?.kind === 'config' ? modal.config : ''}
onSaved={(name) => { onSaved={(name) => {
setModal(null) setModal(null)
+20 -4
View File
@@ -9,6 +9,7 @@ import { Segmented } from '../../ui/Segmented'
import { Menu } from '../../ui/Menu' import { Menu } from '../../ui/Menu'
import { Tag } from '../../ui/Tag' import { Tag } from '../../ui/Tag'
import { RunFlow, pickerServers, type RunRequest } from './RunFlow' import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
import { AppConfigSync } from './AppConfigSync'
import { Tooltip } from '../../ui/Tooltip' import { Tooltip } from '../../ui/Tooltip'
import { head } from '../../ui/Table' import { head } from '../../ui/Table'
@@ -49,12 +50,16 @@ function unknownReason(app: MatrixApp, data: AppsMatrixData): string {
return [...reasons].join('; ') || 'The configs could not be compared.' return [...reasons].join('; ') || 'The configs could not be compared.'
} }
function ConfigState({ app, data }: { app: MatrixApp; data: AppsMatrixData }) { function ConfigState({ app, data, onOpen }: { app: MatrixApp; data: AppsMatrixData; onOpen: () => void }) {
switch (app.configs) { switch (app.configs) {
case 'same': case 'same':
return <Tag tone="ok">Same</Tag> return <Tag tone="ok">Same</Tag>
case 'different': case 'different':
return <Tag tone="warn">{app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`}</Tag> return (
<button type="button" className="cursor-pointer border-0 bg-transparent p-0" aria-label={`Compare the configs of ${app.name}`} onClick={onOpen}>
<Tag tone="warn">{app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`}</Tag>
</button>
)
case 'single': case 'single':
return <Badge variant="outline">Single server</Badge> return <Badge variant="outline">Single server</Badge>
default: default:
@@ -85,6 +90,7 @@ function ServerCell({ app, id, data }: { app: MatrixApp; id: string; data: AppsM
export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: string | null; canWrite?: boolean; refresh?: number }) { export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: string | null; canWrite?: boolean; refresh?: number }) {
const [data, setData] = useState<AppsMatrixData | null>(null) const [data, setData] = useState<AppsMatrixData | null>(null)
const [request, setRequest] = useState<RunRequest | null>(null) const [request, setRequest] = useState<RunRequest | null>(null)
const [configApp, setConfigApp] = useState<string | null>(null)
/** What the store offers, by app: needed to know whether "Align versions" can be done. */ /** What the store offers, by app: needed to know whether "Align versions" can be done. */
const [store, setStore] = useState<Record<string, string> | null>(null) const [store, setStore] = useState<Record<string, string> | null>(null)
const [failure, setFailure] = useState<string | null>(null) const [failure, setFailure] = useState<string | null>(null)
@@ -194,6 +200,11 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
<Menu label={`Actions: ${app.name}`}> <Menu label={`Actions: ${app.name}`}>
{(close) => ( {(close) => (
<> <>
<Button size="sm" onClick={() => { close(); setConfigApp(app.name) }}>
Config…
</Button>
{canWrite && (
<>
<Button size="sm" disabled={outdated.length === 0} onClick={() => { close(); open('update-store', `Update ${app.name}`, 'Update', outdated, installed, 'Not installed') }}> <Button size="sm" disabled={outdated.length === 0} onClick={() => { close(); open('update-store', `Update ${app.name}`, 'Update', outdated, installed, 'Not installed') }}>
Update on all Update on all
</Button> </Button>
@@ -214,6 +225,8 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
> >
Uninstall from… Uninstall from…
</Button> </Button>
</>
)}
</> </>
)} )}
</Menu> </Menu>
@@ -237,8 +250,8 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
cell: (a) => <ServerCell app={a} id={s.id} data={data} />, cell: (a) => <ServerCell app={a} id={s.id} data={data} />,
}), }),
), ),
{ id: 'config', ...head({ children: 'Config' }), cell: (a) => <ConfigState app={a} data={data} /> }, { id: 'config', ...head({ children: 'Config' }), cell: (a) => <ConfigState app={a} data={data} onOpen={() => setConfigApp(a.name)} /> },
...(canWrite ? [{ id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (a: MatrixApp) => actions(a, data) }] : []), { id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (a) => actions(a, data) },
] ]
return ( return (
@@ -270,6 +283,9 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
<DataTable data={rows} columns={columns} getRowId={(a) => a.name} /> <DataTable data={rows} columns={columns} getRowId={(a) => a.name} />
)} )}
</Panel> </Panel>
{configApp !== null && (
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void load()} />
)}
{request !== null && ( {request !== null && (
<RunFlow <RunFlow
request={request} request={request}
+9 -5
View File
@@ -56,11 +56,15 @@ async function openMenu(app: string) {
} }
describe('matrix actions', () => { describe('matrix actions', () => {
it('has no menu for a reader and one per row for a writer', async () => { it('gives a reader a menu with Config only and a writer all the actions', async () => {
mockReads() mockReads()
const { unmount } = render(<AppsMatrix token="t" />) const { unmount } = render(<AppsMatrix token="t" />)
await screen.findByText('Blocker') await openMenu('Blocker')
expect(screen.queryByRole('button', { name: /^Actions:/ })).not.toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Config…' })).toBeInTheDocument()
for (const name of ['Update on all', 'Install on missing servers', 'Align versions', 'Uninstall from…']) {
expect(screen.queryByRole('menuitem', { name })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
}
unmount() unmount()
render(<AppsMatrix token="t" canWrite />) render(<AppsMatrix token="t" canWrite />)
@@ -251,7 +255,7 @@ describe('Apps behind a hub, for writers', () => {
expect(upload).toHaveBeenCalledWith('t', 'install', 'Mine', file, ['mdns']) expect(upload).toHaveBeenCalledWith('t', 'install', 'Mine', file, ['mdns'])
}) })
it('brings the single-server actions back for a writer, without Config, and sends them to that node', async () => { it('brings the single-server actions back for a writer, Config included, and sends them to that node', async () => {
mockReads() mockReads()
localStorage.setItem('hubAppsNode', 'mdns') localStorage.setItem('hubAppsNode', 'mdns')
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never) vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
@@ -262,7 +266,7 @@ describe('Apps behind a hub, for writers', () => {
expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Actions: Split Horizon/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Actions: Split Horizon/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Config' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Config' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Store Update' })) await userEvent.click(screen.getByRole('button', { name: 'Store Update' }))
expect(store).toHaveBeenCalledWith('t', 'Split Horizon', 'https://x/s.zip', 'mdns') expect(store).toHaveBeenCalledWith('t', 'Split Horizon', 'https://x/s.zip', 'mdns')
}) })
+131
View File
@@ -0,0 +1,131 @@
import type { ConfigDiff, ConfigCompare, FieldChange } from '../../api/apps'
import { DataTable, type Col } from '../../ui/DataTable'
import { head } from '../../ui/Table'
import { Tag } from '../../ui/Tag'
/* A value as one short line: JSON for anything that is not a string. */
export function show(value: unknown): string {
if (value === undefined) return '(absent)'
const text = typeof value === 'string' ? value : JSON.stringify(value)
return text.length > 90 ? `${text.slice(0, 87)}...` : text
}
/* Is this field (or a parent of it) one of the local ones? */
export const isLocal = (path: string, localPaths: string[]): boolean => localPaths.some((p) => path === p || path.startsWith(`${p}.`))
const LINE_CLASS = {
same: 'text-muted-foreground',
add: 'bg-success/10 text-foreground',
del: 'bg-destructive/10 text-foreground',
} as const
const MARK = { same: ' ', add: '+', del: '-' } as const
/* The changes of one config to another: fields for JSON, lines for text. */
export function DiffView({ diff, localPaths = [], from = 'Now', to = 'Expected' }: { diff: ConfigDiff; localPaths?: string[]; from?: string; to?: string }) {
if (diff.kind === 'text') {
if (diff.truncated) return <p className="m-0 text-sm text-muted-foreground">Too long to show line by line: about {diff.changed} lines differ.</p>
if (diff.changed === 0) return <p className="m-0 text-sm text-muted-foreground">No differences.</p>
return (
<pre className="m-0 max-h-80 overflow-auto rounded-md border border-border font-mono text-xs leading-5" aria-label="Line differences">
{diff.lines.map((l, i) => (
<div key={i} className={`px-3 whitespace-pre-wrap ${LINE_CLASS[l.op]}`} data-op={l.op}>
{MARK[l.op]} {l.text}
</div>
))}
</pre>
)
}
if (diff.changes.length === 0) return <p className="m-0 text-sm text-muted-foreground">No differences.</p>
const columns: Col<FieldChange>[] = [
{
id: 'path',
...head({ children: 'Field' }),
cell: (c) => (
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
{c.path}
{isLocal(c.path, localPaths) && <Tag tone="info">Local</Tag>}
</span>
),
},
{ id: 'a', ...head({ children: from }), cell: (c) => <span className="font-mono text-xs">{show(c.a)}</span> },
{ id: 'b', ...head({ children: to }), cell: (c) => <span className="font-mono text-xs">{show(c.b)}</span> },
]
return <DataTable data={diff.changes} columns={columns} getRowId={(c) => c.path} />
}
interface Row {
path: string
cells: Record<string, { now?: unknown; expected?: unknown; differs: boolean }>
}
/*
The fields that differ, one row each and one column per server. With a baseline each server is
compared to its own effective config (its local values in place); without one, every server is
compared to the first.
*/
export function fieldRows(data: ConfigCompare): Row[] | null {
const live = data.servers.filter((s) => s.installed && s.ok)
const rows = new Map<string, Row>()
const cell = (path: string, server: string) => {
const row = rows.get(path) ?? { path, cells: {} }
rows.set(path, row)
return (row.cells[server] ??= { differs: false })
}
if (data.baseline !== null) {
if (live.some((s) => s.vsBaseline?.kind === 'text')) return null
for (const s of live) {
if (s.vsBaseline?.kind !== 'json') continue
for (const c of s.vsBaseline.changes) Object.assign(cell(c.path, s.id), { now: c.a, expected: c.b, differs: true })
}
return [...rows.values()]
}
const reference = live[0]
if (reference === undefined) return []
for (const o of live.slice(1)) {
const diff = reference.vsOthers[o.id]
if (diff === undefined) continue
if (diff.kind === 'text') return null
for (const c of diff.changes) {
Object.assign(cell(c.path, reference.id), { now: c.b, differs: true })
Object.assign(cell(c.path, o.id), { now: c.a, differs: true })
}
}
return [...rows.values()]
}
export function FieldTable({ data }: { data: ConfigCompare }) {
const rows = fieldRows(data)
if (rows === null) return null
if (rows.length === 0) return <p className="m-0 text-sm text-muted-foreground">No differences between the servers.</p>
const servers = data.servers.filter((s) => s.installed && s.ok)
const withBaseline = data.baseline !== null
const columns: Col<Row>[] = [
{
id: 'path',
...head({ children: 'Field' }),
cell: (r) => (
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
{r.path}
{isLocal(r.path, data.overrides.paths) && <Tag tone="info">Local</Tag>}
</span>
),
},
...servers.map(
(s): Col<Row> => ({
id: `s-${s.id}`,
...head({ children: s.id }),
cell: (r) => {
const c = r.cells[s.id]
if (c === undefined || !c.differs) return <span className="text-xs text-muted-foreground">{withBaseline ? 'Matches' : 'Same'}</span>
return (
<span className="flex flex-col gap-0.5 font-mono text-xs">
<span>{show(c.now)}</span>
{withBaseline && <span className="text-muted-foreground">expected {show(c.expected)}</span>}
</span>
)
},
}),
),
]
return <DataTable data={rows} columns={columns} getRowId={(r) => r.path} />
}
+58 -5
View File
@@ -26,7 +26,12 @@ export interface RunRequest {
servers: PickerServer[] servers: PickerServer[]
/** The servers ticked at the start. Only those without a reason count. */ /** The servers ticked at the start. Only those without a reason count. */
preselected: string[] preselected: string[]
run: (targets: string[]) => Promise<RunOutcome> /*
A review step between the picker and the change (the config sync uses it): it reads what
would happen on the picked servers and draws it. `context` is handed back to `run`.
*/
review?: (targets: string[]) => Promise<{ kind: 'ok'; body: ReactNode; context: unknown; confirmLabel?: string } | { kind: 'error'; message: string }>
run: (targets: string[], context?: unknown) => Promise<RunOutcome>
} }
/* What stops a server from being a target: it did not answer, or the hub has no admin token for it. */ /* What stops a server from being a target: it did not answer, or the hub has no admin token for it. */
@@ -40,7 +45,7 @@ export function pickerServers(
}) })
} }
type Stage = 'pick' | 'running' | 'results' type Stage = 'pick' | 'reviewing' | 'review' | 'running' | 'results'
export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) { export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) {
const [stage, setStage] = useState<Stage>('pick') const [stage, setStage] = useState<Stage>('pick')
@@ -50,12 +55,28 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
const [results, setResults] = useState<TargetResult[]>([]) const [results, setResults] = useState<TargetResult[]>([])
const [failure, setFailure] = useState<string | null>(null) const [failure, setFailure] = useState<string | null>(null)
const [lastTargets, setLastTargets] = useState<string[]>([]) const [lastTargets, setLastTargets] = useState<string[]>([])
const [reviewed, setReviewed] = useState<{ body: ReactNode; context: unknown; confirmLabel?: string } | null>(null)
const [reviewFailure, setReviewFailure] = useState<string | null>(null)
async function go(targets: string[]) { async function next(targets: string[]) {
if (request.review === undefined) return go(targets)
setStage('reviewing')
setReviewFailure(null)
const outcome = await request.review(targets)
if (outcome.kind === 'ok') setReviewed(outcome)
else {
setReviewed(null)
setReviewFailure(outcome.message)
}
setLastTargets(targets)
setStage('review')
}
async function go(targets: string[], context: unknown = reviewed?.context) {
setStage('running') setStage('running')
setFailure(null) setFailure(null)
setLastTargets(targets) setLastTargets(targets)
const outcome = await request.run(targets) const outcome = await request.run(targets, context)
if (outcome.kind === 'ok') { if (outcome.kind === 'ok') {
const fresh = outcome.data.response.results const fresh = outcome.data.response.results
// A retry replaces only the rows it re-ran. // A retry replaces only the rows it re-ran.
@@ -77,7 +98,7 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
title={request.title} title={request.title}
close="Cancel" close="Cancel"
actions={ actions={
<Button variant="primary" disabled={picked.length === 0} onClick={() => void go(picked)}> <Button variant="primary" disabled={picked.length === 0} onClick={() => void next(picked)}>
{request.confirmLabel} {request.confirmLabel}
</Button> </Button>
} }
@@ -99,6 +120,38 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
) )
} }
if (stage === 'reviewing') {
return (
<Dialog open onOpenChange={() => {}} title={request.title} close="Close">
<Loading>Reading {lastTargets.join(', ')}…</Loading>
</Dialog>
)
}
if (stage === 'review') {
return (
<Dialog
open
onOpenChange={() => onClose(false)}
title={`${request.title}: review`}
size="wide"
close="Cancel"
actions={
<>
<Button onClick={() => setStage('pick')}>Back</Button>
{reviewed !== null && (
<Button variant="primary" onClick={() => void go(lastTargets)}>
{reviewed.confirmLabel ?? request.confirmLabel}
</Button>
)}
</>
}
>
{reviewFailure !== null ? <Failure>{reviewFailure}</Failure> : reviewed?.body}
</Dialog>
)
}
if (stage === 'running') { if (stage === 'running') {
return ( return (
<Dialog open onOpenChange={() => {}} title={request.title} close="Close"> <Dialog open onOpenChange={() => {}} title={request.title} close="Close">