The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
511 lines
25 KiB
TypeScript
511 lines
25 KiB
TypeScript
/*
|
|
Profiles (stage 8): a named bundle of what a server should have, kept in `${HUB_DATA}/profiles/`,
|
|
to bring a new or reset server in line in one go.
|
|
|
|
A profile holds, as the admin chose when it was made from the hub's current baselines:
|
|
|
|
apps store apps only (never a zip): the name and the version they had, and for each the hub's
|
|
baseline config at that moment
|
|
settings the SHARED fields of the settings baseline (host fields never travel), secrets as the
|
|
baseline file keeps them
|
|
lists the custom Blocked and Allowed lists baseline
|
|
|
|
Applying a profile to a server runs four steps in order, and a failed step stops the later ones for
|
|
that server (the other servers go on):
|
|
|
|
apps install the apps that are missing, from THAT server's own store listing (apps-write.ts)
|
|
configs the profile's config of each app plus that server's own local values; the old config
|
|
goes to the history first (apps-config.ts)
|
|
settings the changed shared fields, through the same checks as a per-server settings/set
|
|
lists the missing domains; extras only when asked
|
|
|
|
`from` restarts a server at a given step (the retry of a failed run), so what already worked is not
|
|
done again. Everything here is for admins. A view never carries a secret and never an app config.
|
|
*/
|
|
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import type { HubConfig, HubServer } from './config.ts'
|
|
import { MAX_CONFIG, looksLikeJson, parseJson, type Diff } from './apps-sync.ts'
|
|
import { UserError, compareVersions, type AppsConfig } from './apps-config.ts'
|
|
import type { AppsWriter } from './apps-write.ts'
|
|
import { isFieldName, isBuiltInLocal, MAX_LIST, SECRET_PLACEHOLDER, maskSettings } from './settings-sync.ts'
|
|
import type { SettingsConfig } from './settings-config.ts'
|
|
import { listBaselineApps, readBaseline, readListsBaseline, readSettingsBaseline, safeName, writeAtomic } from './store.ts'
|
|
import type { Upstream } from './upstream.ts'
|
|
|
|
export const STEPS = ['apps', 'configs', 'settings', 'lists'] as const
|
|
export type Step = (typeof STEPS)[number]
|
|
|
|
export interface ProfileApp {
|
|
name: string
|
|
/** The version the app had when the profile was made. */
|
|
version: string
|
|
/** The hub's baseline config of the app then; null when it had none. */
|
|
config: string | null
|
|
}
|
|
|
|
export interface Profile {
|
|
name: string
|
|
createdAt: string
|
|
updatedAt: string
|
|
updatedBy: string
|
|
apps: ProfileApp[]
|
|
settings: Record<string, unknown> | null
|
|
lists: { blocked: string[]; allowed: string[] } | null
|
|
}
|
|
|
|
export interface StepResult {
|
|
step: Step
|
|
/* `skipped`: done by an earlier run. `not-run`: an earlier step failed. */
|
|
status: 'ok' | 'failed' | 'skipped' | 'not-run'
|
|
message: string
|
|
}
|
|
|
|
export interface ApplyResult {
|
|
server: string
|
|
ok: boolean
|
|
message: string
|
|
steps: StepResult[]
|
|
}
|
|
|
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}$/
|
|
const MAX_PROFILES = 50
|
|
const MAX_APPS = 100
|
|
const MAX_SETTINGS_BYTES = 1024 * 1024
|
|
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
|
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
|
|
const hasPlaceholder = (value: unknown): boolean => JSON.stringify(value).includes(JSON.stringify(SECRET_PLACEHOLDER))
|
|
|
|
/* The stored shape, checked field by field. Used for files on disk and for imports. */
|
|
export function cleanProfile(raw: unknown, fallbackName?: string): Profile {
|
|
if (!isObject(raw)) throw new UserError('A profile must be an object.')
|
|
const name = typeof raw.name === 'string' ? raw.name : (fallbackName ?? '')
|
|
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
|
if (!Array.isArray(raw.apps) || raw.apps.length > MAX_APPS) throw new UserError(`"${name}": the apps must be a list of at most ${MAX_APPS}.`)
|
|
const seen = new Set<string>()
|
|
const apps = raw.apps.map((a): ProfileApp => {
|
|
if (!isObject(a) || typeof a.name !== 'string' || a.name === '' || a.name.length > 200 || seen.has(a.name)) throw new UserError(`"${name}": an app has no valid, unique name.`)
|
|
seen.add(a.name)
|
|
const version = typeof a.version === 'string' ? a.version.slice(0, 40) : ''
|
|
const config = typeof a.config === 'string' ? a.config : null
|
|
if (config !== null) {
|
|
if (config.length > MAX_CONFIG) throw new UserError(`"${name}": the config of "${a.name}" is larger than 1 MB.`)
|
|
if (looksLikeJson(config) && !parseJson(config).ok) throw new UserError(`"${name}": the config of "${a.name}" is not valid JSON.`)
|
|
}
|
|
return { name: a.name, version, config }
|
|
})
|
|
let settings: Profile['settings'] = null
|
|
if (raw.settings !== null && raw.settings !== undefined) {
|
|
if (!isObject(raw.settings)) throw new UserError(`"${name}": the settings must be an object.`)
|
|
if (JSON.stringify(raw.settings).length > MAX_SETTINGS_BYTES) throw new UserError(`"${name}": the settings are larger than 1 MB.`)
|
|
if (hasPlaceholder(raw.settings)) throw new UserError(`"${name}": the settings hold ${SECRET_PLACEHOLDER} where a secret should be. Export the profile from the hub that has it.`)
|
|
settings = {}
|
|
for (const [k, v] of Object.entries(raw.settings)) {
|
|
if (!isFieldName(k)) throw new UserError(`"${name}": "${k.slice(0, 40)}" is not a setting name.`)
|
|
// Host fields never travel.
|
|
if (!isBuiltInLocal(k)) settings[k] = v
|
|
}
|
|
}
|
|
let lists: Profile['lists'] = null
|
|
if (raw.lists !== null && raw.lists !== undefined) {
|
|
const l = raw.lists as { blocked?: unknown; allowed?: unknown }
|
|
if (!isObject(raw.lists) || !Array.isArray(l.blocked) || !Array.isArray(l.allowed)) throw new UserError(`"${name}": the lists need a blocked and an allowed array.`)
|
|
const clean = (v: unknown[]): string[] => {
|
|
if (v.length > MAX_LIST || v.some((d) => typeof d !== 'string' || d.length > 253)) throw new UserError(`"${name}": a list is longer than ${MAX_LIST} domains or holds a bad entry.`)
|
|
return [...new Set(v as string[])].sort()
|
|
}
|
|
lists = { blocked: clean(l.blocked), allowed: clean(l.allowed) }
|
|
}
|
|
const text = (v: unknown): string => (typeof v === 'string' ? v : '')
|
|
return { name, createdAt: text(raw.createdAt), updatedAt: text(raw.updatedAt), updatedBy: text(raw.updatedBy), apps, settings, lists }
|
|
}
|
|
|
|
const shape = (p: Profile): string => JSON.stringify({ apps: p.apps, settings: p.settings, lists: p.lists })
|
|
|
|
export class Profiles {
|
|
#config: HubConfig
|
|
#upstream: Upstream
|
|
#appsConfig: AppsConfig
|
|
#settings: SettingsConfig
|
|
#writer: AppsWriter
|
|
#now: () => number
|
|
|
|
constructor(config: HubConfig, upstream: Upstream, appsConfig: AppsConfig, settings: SettingsConfig, writer: AppsWriter, now: () => number = Date.now) {
|
|
this.#config = config
|
|
this.#upstream = upstream
|
|
this.#appsConfig = appsConfig
|
|
this.#settings = settings
|
|
this.#writer = writer
|
|
this.#now = now
|
|
}
|
|
|
|
#dir(): string {
|
|
return path.join(this.#config.dataDir, 'profiles')
|
|
}
|
|
|
|
#file(name: string): string {
|
|
return path.join(this.#dir(), `${safeName(name)}.json`)
|
|
}
|
|
|
|
#read(name: string): Profile | null {
|
|
const file = this.#file(name)
|
|
if (!existsSync(file)) return null
|
|
try {
|
|
const p = cleanProfile(JSON.parse(readFileSync(file, 'utf8')))
|
|
return p.name === name ? p : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
#all(): Profile[] {
|
|
if (!existsSync(this.#dir())) return []
|
|
const out: Profile[] = []
|
|
for (const f of readdirSync(this.#dir())) {
|
|
if (!f.endsWith('.json')) continue
|
|
try {
|
|
out.push(cleanProfile(JSON.parse(readFileSync(path.join(this.#dir(), f), 'utf8'))))
|
|
} catch {
|
|
// A file that is not a profile is left alone.
|
|
}
|
|
}
|
|
return out.sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
|
|
#require(name: string): Profile {
|
|
const found = this.#read(name)
|
|
if (found === null) throw new UserError(`There is no profile named "${name.slice(0, 60)}".`)
|
|
return found
|
|
}
|
|
|
|
#write(p: Profile): void {
|
|
writeAtomic(this.#file(p.name), JSON.stringify(p, null, 2) + '\n')
|
|
}
|
|
|
|
/* What the console lists: counts, no config and no value. */
|
|
list(): { name: string; apps: number; configs: number; settings: number | null; lists: { blocked: number; allowed: number } | null; updatedAt: string; updatedBy: string }[] {
|
|
return this.#all().map((p) => ({
|
|
name: p.name,
|
|
apps: p.apps.length,
|
|
configs: p.apps.filter((a) => a.config !== null).length,
|
|
settings: p.settings === null ? null : Object.keys(p.settings).length,
|
|
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
|
updatedAt: p.updatedAt,
|
|
updatedBy: p.updatedBy,
|
|
}))
|
|
}
|
|
|
|
/* One profile for the console: secrets masked, no app config text. */
|
|
get(name: string): unknown {
|
|
const p = this.#require(name)
|
|
return {
|
|
name: p.name,
|
|
createdAt: p.createdAt,
|
|
updatedAt: p.updatedAt,
|
|
updatedBy: p.updatedBy,
|
|
apps: p.apps.map((a) => ({ name: a.name, version: a.version, hasConfig: a.config !== null, bytes: a.config === null ? 0 : a.config.length })),
|
|
settings: p.settings === null ? null : maskSettings(p.settings),
|
|
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
|
}
|
|
}
|
|
|
|
delete(name: string): void {
|
|
this.#require(name)
|
|
rmSync(this.#file(name), { force: true })
|
|
}
|
|
|
|
/* ---- Making one from the hub's current baselines ---- */
|
|
|
|
async #installedVersions(): Promise<Map<string, string>> {
|
|
const versions = new Map<string, string>()
|
|
let answered = 0
|
|
await Promise.all(
|
|
this.#config.servers.map(async (s) => {
|
|
try {
|
|
this.#upstream.invalidate(s.id)
|
|
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
|
answered++
|
|
for (const a of list?.apps ?? []) {
|
|
const have = versions.get(a.name)
|
|
const v = typeof a.version === 'string' ? a.version : ''
|
|
if (have === undefined || compareVersions(v, have) > 0) versions.set(a.name, v)
|
|
}
|
|
} catch {
|
|
// A server that does not answer adds nothing.
|
|
}
|
|
}),
|
|
)
|
|
if (answered === 0) throw new UserError('No server answered, so the installed apps cannot be read.')
|
|
return versions
|
|
}
|
|
|
|
async #storeNames(): Promise<Set<string>> {
|
|
const names = new Set<string>()
|
|
let answered = 0
|
|
await Promise.all(
|
|
this.#config.servers.map(async (s) => {
|
|
try {
|
|
const r = (await this.#upstream.call(s, 'apps/listStoreApps')) as { storeApps?: { name: string }[] } | null
|
|
answered++
|
|
for (const a of r?.storeApps ?? []) names.add(a.name)
|
|
} catch {
|
|
// Same.
|
|
}
|
|
}),
|
|
)
|
|
if (answered === 0) throw new UserError('No server answered, so the store listing cannot be read.')
|
|
return names
|
|
}
|
|
|
|
/*
|
|
Makes or replaces a profile from the hub's current baselines. `apps`: the names to take (undefined
|
|
takes every app that has a baseline). Settings and lists are taken whole when asked.
|
|
*/
|
|
async snapshot(name: string, parts: { apps?: string[]; settings: boolean; lists: boolean }, user: string): Promise<{ replaced: boolean; detail: string }> {
|
|
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
|
const appNames = parts.apps ?? listBaselineApps(this.#config.dataDir)
|
|
if (appNames.length === 0 && !parts.settings && !parts.lists) throw new UserError('Choose at least one part: apps, settings or lists.')
|
|
if (appNames.length > MAX_APPS) throw new UserError(`At most ${MAX_APPS} apps in a profile.`)
|
|
const existing = this.#read(name)
|
|
if (existing === null && this.#all().length >= MAX_PROFILES) throw new UserError(`At most ${MAX_PROFILES} profiles.`)
|
|
|
|
let apps: ProfileApp[] = []
|
|
if (appNames.length > 0) {
|
|
const [versions, store] = await Promise.all([this.#installedVersions(), this.#storeNames()])
|
|
apps = [...new Set(appNames)].map((app) => {
|
|
const version = versions.get(app)
|
|
if (version === undefined) throw new UserError(`"${app}" is not installed on any server, so its version is not known.`)
|
|
if (!store.has(app)) throw new UserError(`"${app}" is not in the store listing. A profile holds store apps only, not apps installed from a file.`)
|
|
return { name: app, version, config: readBaseline(this.#config.dataDir, app)?.text ?? null }
|
|
})
|
|
}
|
|
let settings: Profile['settings'] = null
|
|
if (parts.settings) {
|
|
const baseline = readSettingsBaseline(this.#config.dataDir)
|
|
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
|
const extra = this.#settings.extraLocal()
|
|
settings = Object.fromEntries(Object.entries(baseline.settings).filter(([k]) => !isBuiltInLocal(k) && !extra.includes(k)))
|
|
}
|
|
let lists: Profile['lists'] = null
|
|
if (parts.lists) {
|
|
const baseline = readListsBaseline(this.#config.dataDir)
|
|
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
|
lists = { blocked: [...baseline.blocked], allowed: [...baseline.allowed] }
|
|
}
|
|
const at = new Date(this.#now()).toISOString()
|
|
this.#write({ name, createdAt: existing?.createdAt || at, updatedAt: at, updatedBy: user, apps, settings, lists })
|
|
return {
|
|
replaced: existing !== null,
|
|
detail: `${existing === null ? 'created' : 'replaced'}: ${apps.length} app(s), settings ${settings === null ? 'no' : Object.keys(settings).length + ' fields'}, lists ${lists === null ? 'no' : 'yes'}`,
|
|
}
|
|
}
|
|
|
|
/* ---- Export and import ---- */
|
|
|
|
exportFile(name?: string): { hub: string; type: string; version: number; exportedAt: string; profiles: Profile[] } {
|
|
const profiles = name === undefined || name === '' ? this.#all() : [this.#require(name)]
|
|
return { hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt: new Date(this.#now()).toISOString(), profiles }
|
|
}
|
|
|
|
importFile(raw: string, apply: boolean, user: string): { profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]; applied: boolean } {
|
|
const parsed = parseJson(raw)
|
|
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
|
|
const file = parsed.value as { hub?: unknown; type?: unknown; version?: unknown; profiles?: unknown }
|
|
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub' || file.type !== 'profiles') throw new UserError('This is not a profiles file of the hub.')
|
|
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
|
|
if (!Array.isArray(file.profiles) || file.profiles.length > MAX_PROFILES) throw new UserError(`The file has no list of profiles, or has more than ${MAX_PROFILES}.`)
|
|
const plan: { profile: Profile; status: 'new' | 'changed' | 'same' }[] = []
|
|
const seen = new Set<string>()
|
|
for (const item of file.profiles) {
|
|
const profile = cleanProfile(item)
|
|
if (seen.has(profile.name)) throw new UserError(`"${profile.name}" is in the file twice.`)
|
|
seen.add(profile.name)
|
|
const old = this.#read(profile.name)
|
|
plan.push({ profile, status: old === null ? 'new' : shape(old) === shape(profile) ? 'same' : 'changed' })
|
|
}
|
|
const total = this.#all().filter((p) => !seen.has(p.name)).length + plan.length
|
|
if (total > MAX_PROFILES) throw new UserError(`That would make more than ${MAX_PROFILES} profiles.`)
|
|
if (apply) {
|
|
const at = new Date(this.#now()).toISOString()
|
|
for (const p of plan) {
|
|
if (p.status === 'same') continue
|
|
const old = this.#read(p.profile.name)
|
|
this.#write({ ...p.profile, createdAt: old?.createdAt || at, updatedAt: at, updatedBy: user })
|
|
}
|
|
}
|
|
return { profiles: plan.map((p) => ({ name: p.profile.name, status: p.status, apps: p.profile.apps.length, settings: p.profile.settings !== null, lists: p.profile.lists !== null })), applied: apply }
|
|
}
|
|
|
|
/* ---- Preview ---- */
|
|
|
|
#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))
|
|
}
|
|
|
|
async #look(server: HubServer): Promise<{ installed: Map<string, string>; store: Map<string, string> }> {
|
|
this.#upstream.invalidate(server.id)
|
|
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
|
const store = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; version?: string }[] } | null
|
|
return {
|
|
installed: new Map((list?.apps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
|
store: new Map((store?.storeApps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
|
}
|
|
}
|
|
|
|
/* What applying the profile would change on each target. Nothing is written. */
|
|
async preview(name: string, ids: string[], removeExtras: boolean): Promise<{ profile: string; targets: unknown[] }> {
|
|
const profile = this.#require(name)
|
|
const targets = this.#targets(ids)
|
|
const out = await Promise.all(
|
|
targets.map(async (s) => {
|
|
const writable = s.adminToken !== undefined
|
|
const base = { server: s.id, writable }
|
|
try {
|
|
const { installed, store } = await this.#look(s)
|
|
const apps = profile.apps.map((a) => {
|
|
const have = installed.get(a.name)
|
|
if (have === undefined) {
|
|
const offered = store.get(a.name)
|
|
return offered === undefined
|
|
? { name: a.name, state: 'not-in-store' as const, profileVersion: a.version }
|
|
: { name: a.name, state: 'install' as const, profileVersion: a.version, storeVersion: offered }
|
|
}
|
|
return { name: a.name, state: have === a.version ? ('present' as const) : ('version-differs' as const), profileVersion: a.version, installedVersion: have }
|
|
})
|
|
const configs = await Promise.all(
|
|
profile.apps
|
|
.filter((a) => a.config !== null)
|
|
.map(async (a) => {
|
|
if (!installed.has(a.name)) return { name: a.name, state: 'after-install' as const }
|
|
try {
|
|
const p = (await this.#appsConfig.preview(a.name, [s.id], a.config as string)).targets[0]
|
|
if (!p.ok) return { name: a.name, state: 'unreadable' as const, error: p.error }
|
|
return p.unchanged ? { name: a.name, state: 'same' as const } : { name: a.name, state: 'differs' as const, changes: p.changes as Diff }
|
|
} catch (e) {
|
|
return { name: a.name, state: 'unreadable' as const, error: reason(e) }
|
|
}
|
|
}),
|
|
)
|
|
let settings: unknown = null
|
|
if (profile.settings !== null) {
|
|
const p = (await this.#settings.preview([s.id], undefined, { settings: profile.settings, updatedAt: '', updatedBy: '' })).targets[0]
|
|
settings = p.ok ? { fields: p.changes.changes.map((c) => c.path), secretFields: p.secretFields, unchanged: p.unchanged, changes: p.changes, warnings: p.warnings } : { error: p.error }
|
|
}
|
|
let lists: unknown = null
|
|
if (profile.lists !== null) {
|
|
const p = (await this.#settings.listsPreview([s.id], removeExtras, { ...profile.lists, updatedAt: '', updatedBy: '' })).targets[0]
|
|
lists = p.ok ? { blocked: p.blocked, allowed: p.allowed, status: p.status, tooMany: p.tooMany } : { error: p.error }
|
|
}
|
|
return { ...base, ok: true, apps, configs, settings, lists, warnings: writable ? [] : ['The hub has no admin token for this server, so it cannot write to it.'] }
|
|
} catch (e) {
|
|
return { ...base, ok: false, error: reason(e), apps: [], configs: [], settings: null, lists: null, warnings: [] }
|
|
}
|
|
}),
|
|
)
|
|
return { profile: profile.name, targets: out }
|
|
}
|
|
|
|
/* ---- Apply ---- */
|
|
|
|
async #run(profile: Profile, s: HubServer, step: Step, removeExtras: boolean): Promise<{ ok: boolean; message: string }> {
|
|
const failed = (message: string) => ({ ok: false, message })
|
|
if (step === 'apps') {
|
|
this.#upstream.invalidate(s.id)
|
|
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string }[] } | null
|
|
const have = new Set((list?.apps ?? []).map((a) => a.name))
|
|
const installed: string[] = []
|
|
for (const a of profile.apps) {
|
|
if (have.has(a.name)) continue
|
|
const outcome = await this.#writer.runMany('install-store', a.name, [s.id])
|
|
if ('error' in outcome) return failed(`${a.name}: ${outcome.error}`)
|
|
const r = outcome.results[0]
|
|
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'could not be installed'}`)
|
|
installed.push(a.name)
|
|
}
|
|
return { ok: true, message: installed.length === 0 ? 'Every app is already installed.' : `Installed ${installed.join(', ')}.` }
|
|
}
|
|
if (step === 'configs') {
|
|
let written = 0
|
|
let same = 0
|
|
for (const a of profile.apps) {
|
|
if (a.config === null) continue
|
|
const done = await this.#appsConfig.apply(a.name, [s.id], {}, a.config)
|
|
const r = done.results[0]
|
|
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'failed'}`)
|
|
if (/nothing was written/.test(r.message ?? '')) same++
|
|
else written++
|
|
}
|
|
return { ok: true, message: `${written} config(s) applied, ${same} already in sync.` }
|
|
}
|
|
if (step === 'settings') {
|
|
const done = await this.#settings.apply([s.id], {}, undefined, { settings: profile.settings ?? {}, updatedAt: '', updatedBy: '' })
|
|
const r = done.results[0]
|
|
return { ok: r.ok, message: r.message ?? '' }
|
|
}
|
|
const done = await this.#settings.listsApply([s.id], removeExtras, { ...(profile.lists ?? { blocked: [], allowed: [] }), updatedAt: '', updatedBy: '' })
|
|
const r = done.results[0]
|
|
return { ok: r.ok, message: r.message ?? '' }
|
|
}
|
|
|
|
#stepsOf(profile: Profile): Step[] {
|
|
return STEPS.filter((step) => (step === 'apps' ? profile.apps.length > 0 : step === 'configs' ? profile.apps.some((a) => a.config !== null) : step === 'settings' ? profile.settings !== null : profile.lists !== null))
|
|
}
|
|
|
|
async #applyOne(profile: Profile, s: HubServer, from: Step | undefined, removeExtras: boolean): Promise<ApplyResult> {
|
|
const steps: StepResult[] = []
|
|
const plan = this.#stepsOf(profile)
|
|
if (plan.length === 0) return { server: s.id, ok: true, message: 'The profile has nothing to apply.', steps }
|
|
if (s.adminToken === undefined) {
|
|
return { server: s.id, ok: false, message: 'no admin token configured', steps: plan.map((step, i) => ({ step, status: i === 0 ? ('failed' as const) : ('not-run' as const), message: i === 0 ? 'no admin token configured' : 'An earlier step failed.' })) }
|
|
}
|
|
const start = from === undefined ? 0 : Math.max(0, plan.indexOf(from))
|
|
let stopped: Step | null = null
|
|
for (const [i, step] of plan.entries()) {
|
|
if (stopped !== null) {
|
|
steps.push({ step, status: 'not-run', message: 'An earlier step failed.' })
|
|
continue
|
|
}
|
|
if (i < start) {
|
|
steps.push({ step, status: 'skipped', message: 'Done by an earlier run.' })
|
|
continue
|
|
}
|
|
let outcome: { ok: boolean; message: string }
|
|
try {
|
|
outcome = await this.#run(profile, s, step, removeExtras)
|
|
} catch (e) {
|
|
outcome = { ok: false, message: reason(e) }
|
|
}
|
|
steps.push({ step, status: outcome.ok ? 'ok' : 'failed', message: outcome.message })
|
|
if (!outcome.ok) stopped = step
|
|
}
|
|
return {
|
|
server: s.id,
|
|
ok: stopped === null,
|
|
message: stopped === null ? 'Profile applied.' : `Stopped at ${stopped}: ${steps.find((x) => x.step === stopped)?.message ?? 'failed'}`.slice(0, 300),
|
|
steps,
|
|
}
|
|
}
|
|
|
|
/* Several servers go on in parallel; the steps of one server go one after the other. */
|
|
async apply(name: string, ids: string[], options: { removeExtras: boolean; from: Record<string, string> }): Promise<{ results: ApplyResult[] }> {
|
|
const profile = this.#require(name)
|
|
const targets = this.#targets(ids)
|
|
const results = await Promise.all(
|
|
targets.map((s) => {
|
|
const from = (STEPS as readonly string[]).includes(options.from[s.id]) ? (options.from[s.id] as Step) : undefined
|
|
return this.#applyOne(profile, s, from, options.removeExtras)
|
|
}),
|
|
)
|
|
return { results }
|
|
}
|
|
}
|