Hub stage 4: config history, rollback, drift check, baseline export and import
Every saved config, and every earlier baseline, is listed per app. You can view it, diff it against a server's current config, and roll it back through preview and apply. Earlier baselines can be restored. A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with its effective config. Drift shows in the header and the matrix, with Accept as baseline and Revert to baseline. All baselines export to one file. Importing that file shows a dry run before it writes. Rolling back preselects, and diffs against, the server the version came from. The line-diff op "del" is renamed to "remove" for the language lint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e05039202
commit
57e492cceb
29 files changed
+2294
-125
No files matched your search
+73
-7
@@ -25,6 +25,7 @@ import { buildMatrix } from './apps-matrix.ts'
|
||||
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
|
||||
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
|
||||
import { AppsConfig, UserError } from './apps-config.ts'
|
||||
import { Drift } from './drift.ts'
|
||||
import {
|
||||
mergeQueryLogs,
|
||||
mergeSettled,
|
||||
@@ -41,6 +42,8 @@ type Json = Record<string, unknown>
|
||||
const MAX_BODY = 64 * 1024
|
||||
/* The config endpoints carry config texts (up to 1 MB). */
|
||||
const MAX_CONFIG_BODY = 2 * 1024 * 1024
|
||||
/* A file of all baselines. */
|
||||
const MAX_IMPORT_BODY = 5 * 1024 * 1024
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -94,7 +97,7 @@ async function readForm(req: http.IncomingMessage, limit: number = MAX_BODY): Pr
|
||||
try {
|
||||
const data = JSON.parse(text) as Record<string, unknown>
|
||||
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.some((x) => x !== null && typeof x === 'object') ? JSON.stringify(v) : v.map(String).join(','))
|
||||
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))
|
||||
}
|
||||
@@ -131,7 +134,8 @@ function clientIp(req: http.IncomingMessage, trustProxy: boolean): string {
|
||||
|
||||
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap)): http.Server {
|
||||
const upstream = new Upstream(config)
|
||||
const sessions = new Sessions(config, users)
|
||||
const drift = new Drift(config, upstream)
|
||||
const sessions = new Sessions(config, users, Date.now, () => drift.count())
|
||||
const limiter = new LoginLimiter()
|
||||
const audit = new Audit(config.dataDir)
|
||||
const writer = new AppsWriter(config, upstream)
|
||||
@@ -267,12 +271,31 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
async function configRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
|
||||
const name = params.name ?? ''
|
||||
try {
|
||||
if (apiPath === 'hub/apps/export') {
|
||||
const file = appsConfig.exportAll()
|
||||
audit.log({ user: who.user, role: who.role, ip, action: 'apps.export', outcome: 'ok', detail: `${file.apps.length} app(s)` })
|
||||
const text = JSON.stringify(file, null, 2) + '\n'
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-disposition': `attachment; filename="hub-baselines-${file.exportedAt.slice(0, 10)}.json"`,
|
||||
'cache-control': 'no-store',
|
||||
'content-length': Buffer.byteLength(text),
|
||||
})
|
||||
res.end(text)
|
||||
return
|
||||
}
|
||||
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/history/list') {
|
||||
send(res, 200, ok({ entries: appsConfig.historyList(name) }))
|
||||
} else if (apiPath === 'hub/apps/history/get') {
|
||||
send(res, 200, ok(appsConfig.historyGet(name, params.id ?? '')))
|
||||
} else if (apiPath === 'hub/apps/history/diff') {
|
||||
send(res, 200, ok(await appsConfig.historyDiff(name, params.id ?? '', params.server ?? '')))
|
||||
} 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.'))
|
||||
@@ -284,9 +307,16 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
/* 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 base = { user: who.user, role: who.role, ip, target: name === '' ? undefined : name }
|
||||
const action = apiPath.replace('hub/apps/', 'apps.').replace(/\//g, '-')
|
||||
try {
|
||||
if (apiPath === 'hub/apps/import') {
|
||||
const done = appsConfig.importAll(params.data ?? '', params.apply === 'true', who.user)
|
||||
const count = (status: string) => done.apps.filter((a) => a.status === status).length
|
||||
audit.log({ ...base, action: 'apps.import', outcome: 'ok', detail: `${done.applied ? 'applied' : 'dry run'}: ${count('new')} new, ${count('changed')} changed, ${count('same')} same` })
|
||||
send(res, 200, ok(done))
|
||||
return
|
||||
}
|
||||
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)
|
||||
@@ -296,6 +326,22 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
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/baseline/restore') {
|
||||
const done = appsConfig.restoreBaseline(name, params.id ?? '', who.user)
|
||||
audit.log({ ...base, action, outcome: 'ok', detail: done.detail })
|
||||
send(res, 200, ok({ baseline: appsConfig.getBaseline(name) }))
|
||||
} else if (apiPath === 'hub/apps/drift/accept') {
|
||||
const done = await appsConfig.acceptDrift(name, params.server ?? '', who.user)
|
||||
audit.log({ ...base, action: 'apps.drift-accept', server: params.server, outcome: 'ok', detail: done.detail })
|
||||
await drift.check()
|
||||
send(res, 200, ok({ baseline: appsConfig.getBaseline(name), overrides: appsConfig.getOverrides(name) }))
|
||||
} else if (apiPath === 'hub/apps/history/rollback') {
|
||||
const expect = jsonParam(params.expect, {}) as Record<string, string>
|
||||
const done = await appsConfig.rollback(name, params.id ?? '', csv(params.targets), expect)
|
||||
for (const r of done.results) {
|
||||
audit.log({ ...base, action: 'apps.history-rollback', 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 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 })
|
||||
@@ -323,7 +369,13 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
send(res, 405, fail('Use POST.'))
|
||||
return
|
||||
}
|
||||
if (apiPath.startsWith('hub/apps/baseline/') || apiPath.startsWith('hub/apps/overrides/') || apiPath === 'hub/apps/config/apply') {
|
||||
if (apiPath === 'hub/drift/check') {
|
||||
const state = await drift.check()
|
||||
audit.log({ user: who.user, role: who.role, ip, action: 'drift.check', outcome: 'ok', detail: `${state.apps.length} finding(s), ${drift.count()} differ` })
|
||||
send(res, 200, ok(drift.get()))
|
||||
return
|
||||
}
|
||||
if (apiPath.startsWith('hub/apps/baseline/') || apiPath.startsWith('hub/apps/overrides/') || apiPath.startsWith('hub/apps/history/') || apiPath.startsWith('hub/apps/drift/') || apiPath === 'hub/apps/import' || apiPath === 'hub/apps/config/apply') {
|
||||
await configWrite(res, apiPath, params, who, ip)
|
||||
return
|
||||
}
|
||||
@@ -415,7 +467,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
// 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 form = isMultipart(req) ? new URLSearchParams() : await readForm(req, !large ? MAX_BODY : apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY)
|
||||
const params: Params = {}
|
||||
for (const [k, v] of url.searchParams) params[k] = v
|
||||
for (const [k, v] of form) params[k] = v
|
||||
@@ -489,10 +541,14 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
await write(req, res, apiPath, route.kind, params, who, ip)
|
||||
return
|
||||
}
|
||||
if (apiPath.startsWith('hub/apps/config/') || apiPath === 'hub/apps/overrides/current') {
|
||||
if (apiPath.startsWith('hub/apps/config/') || apiPath.startsWith('hub/apps/history/') || apiPath === 'hub/apps/overrides/current' || apiPath === 'hub/apps/export') {
|
||||
await configRead(res, apiPath, params, who, ip)
|
||||
return
|
||||
}
|
||||
if (apiPath === 'hub/drift/get') {
|
||||
send(res, 200, ok(drift.get()))
|
||||
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
|
||||
@@ -616,7 +672,17 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
}
|
||||
})()
|
||||
})
|
||||
server.on('close', () => clearInterval(sweeper))
|
||||
// The drift check: once shortly after start, then every HUB_DRIFT_MINUTES. Off when that is 0.
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
if (config.driftMinutes > 0) {
|
||||
const run = () => void drift.check().catch((e: unknown) => console.error(`hub: drift check failed (${e instanceof Error ? e.message : String(e)}).`))
|
||||
timers.push(setTimeout(run, 5000), setInterval(run, config.driftMinutes * 60_000))
|
||||
for (const t of timers) t.unref()
|
||||
}
|
||||
server.on('close', () => {
|
||||
clearInterval(sweeper)
|
||||
for (const t of timers) clearTimeout(t)
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user