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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6acd2d656d
commit
0e05039202
22 files changed
+2434
-58
No files matched your search
+19
-26
@@ -9,16 +9,17 @@ hub that changes anything, so the rules are strict:
|
||||
the uninstall is refused if that cannot be done;
|
||||
* 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 { UploadedFile } from './multipart.ts'
|
||||
import { MAX_CONFIG } from './apps-sync.ts'
|
||||
import { safeName, writeHistory } from './store.ts'
|
||||
import { Upstream } from './upstream.ts'
|
||||
|
||||
export { safeName }
|
||||
|
||||
export type StoreAction = 'install-store' | 'update-store' | 'uninstall'
|
||||
export type FileAction = 'install-file' | 'update-file'
|
||||
export type AppAction = StoreAction | FileAction
|
||||
export type AppAction = StoreAction | FileAction | 'config-set'
|
||||
|
||||
export interface TargetResult {
|
||||
server: string
|
||||
@@ -26,14 +27,13 @@ export interface TargetResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const KEEP_HISTORY = 50
|
||||
|
||||
const API: Record<AppAction, string> = {
|
||||
'install-store': 'apps/downloadAndInstall',
|
||||
'update-store': 'apps/downloadAndUpdate',
|
||||
uninstall: 'apps/uninstall',
|
||||
'install-file': 'apps/install',
|
||||
'update-file': 'apps/update',
|
||||
'config-set': 'apps/config/set',
|
||||
}
|
||||
|
||||
const DONE: Record<AppAction, string> = {
|
||||
@@ -42,6 +42,7 @@ const DONE: Record<AppAction, string> = {
|
||||
uninstall: 'Uninstalled.',
|
||||
'install-file': 'Installed 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. */
|
||||
@@ -50,13 +51,6 @@ export function actionForPath(apiPath: string): AppAction | 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')
|
||||
|
||||
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. */
|
||||
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')
|
||||
this.#upstream.invalidate(server.id)
|
||||
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 dir = path.join(this.#config.dataDir, 'apps', safeName(name), 'history')
|
||||
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
|
||||
return writeHistory(this.#config.dataDir, name, server.id, text, kind, this.#now())
|
||||
}
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
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')
|
||||
switch (action) {
|
||||
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`)
|
||||
}
|
||||
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 'update-file': {
|
||||
if (file === undefined) throw new Error('no zip file')
|
||||
|
||||
Reference in new issue
Block a user