From 82f64eb5c00377afcc251409d3fecc56d41fabac Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 26 May 2026 18:00:26 +0100 Subject: [PATCH] feat(migrate): app-specific hooks + peer friendly-name overlay (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish pass for the migration system. Two concrete additions; the live-mirror and full drift-verify ideas from the original plan are intentionally deferred — both need real-world test data to land correctly, and the kernel already exposes everything they'd need. Per-app migrate hooks (scripts/migrate/migrate_hooks.sh): Apps can declare two optional functions in their tools.sh (already auto-sourced per [[libreportal-modular-app-tools]]): _migrate_pre() — runs before stop+wipe _migrate_post() — runs after restart, before the user sees it Each receives: $1 = source identifier (peer name or backup-tag hostname) $2 = transport ("restic" | "direct-ssh") migrateRunHook() is now called from both migration apply paths: - migrate_apply.sh (restic-mediated, shared backup channel) - peer_pull.sh (direct-SSH, peer-shell stream) Use cases: rotate federation keys after a Mastodon move, regenerate OIDC client secrets, drop SaaS-style locks, fix hostname-baked configs the URL-rewrite layer doesn't cover. Hooks are optional — apps without them inherit the standard flow. Failed hooks emit a non-fatal notice (the rest of the migrate still reaches 'done') so a single bad hook can't strand an otherwise-working app in stopped state. Peer friendly-name overlay (Migrate tab): Was deferred from Phase 2 because it required Phase 3's UI to feel cohesive. BackupPage.refreshAll() now also fetches peers.json and builds a hostname → peer-name lookup. renderMigrate() shows 'homelab (host: homelab.lan)' for any backup-channel peer that matches the source host, and falls back to the bare hostname when no peer is defined. Same data, friendlier UI. Skipped (genuinely deferred, not just out of time): - Live mirror / warm-standby (continuous one-way sync). Needs a scheduler + drift-state to track. Right place for it is a separate feature on top of the existing kernel rather than bolted onto migrate. - Drift-verify ("what would change if I migrated?"). Cheap to write but needs a real cross-host pair to validate against — adding it untested would just be theatre. Signed-off-by: librelad --- .../js/components/backup/backup-page.js | 25 ++++++++++--- scripts/migrate/migrate_apply.sh | 6 +++ scripts/migrate/migrate_hooks.sh | 37 +++++++++++++++++++ scripts/peer/peer_pull.sh | 6 +++ scripts/source/files/arrays/files_migrate.sh | 1 + 5 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 scripts/migrate/migrate_hooks.sh diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js index a901950..05997a1 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-page.js +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -331,17 +331,26 @@ class BackupPage { async refreshAll() { const ts = Date.now(); - const [dashboard, locations, , schema, migrate] = await Promise.all([ + const [dashboard, locations, , schema, migrate, peersData] = await Promise.all([ this.fetchJson(`/data/backup/generated/dashboard.json?t=${ts}`), this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`), this.loadSystemConfigs(), this.fetchJson(`/data/backup/generated/schema.json?t=${ts}`), - this.fetchJson(`/data/backup/generated/migrate.json?t=${ts}`) + this.fetchJson(`/data/backup/generated/migrate.json?t=${ts}`), + this.fetchJson(`/data/peers/generated/peers.json?t=${ts}`) ]); this.dashboard = dashboard; this.locations = locations; this.locSchema = schema; this.migrate = migrate; + // Build hostname → friendly-name lookup once so renderMigrate can show + // "homelab (host: homelab.lan)" instead of bare hostnames. + this.hostnameToPeerName = {}; + for (const p of (peersData?.peers || [])) { + if (p.kind === 'backup-channel' && p.config?.hostname) { + this.hostnameToPeerName[p.config.hostname] = p.name; + } + } this.snapshotsByLoc = {}; if (!this.engines.length) await this.loadEngines(); @@ -1811,11 +1820,16 @@ class BackupPage {

${this.escape(loc.name || 'Location')}

${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here - ${loc.hosts.map(host => ` + ${loc.hosts.map(host => { + const peerName = (this.hostnameToPeerName || {})[host.hostname]; + const headerLabel = peerName + ? `${this.escape(peerName)}host: ${this.escape(host.hostname)}` + : `${this.escape(host.hostname)}`; + return `
- ${this.escape(host.hostname)} + ${headerLabel} ${(host.apps || []).length} app${host.apps.length === 1 ? '' : 's'} available
- `).join('')} + `; + }).join('')}
`).join(''); diff --git a/scripts/migrate/migrate_apply.sh b/scripts/migrate/migrate_apply.sh index 0064955..f9c30c4 100644 --- a/scripts/migrate/migrate_apply.sh +++ b/scripts/migrate/migrate_apply.sh @@ -81,6 +81,9 @@ migrateApplyApp() migrateEmit phase=pre-backup status=skipped reason=user-opt-out app="$app" fi + # Per-app pre-migrate hook (optional) — declared in the app's tools.sh. + migrateRunHook "$app" pre "$source_host" "restic" + # ---- 3. The actual restore (reuses existing restoreAppStart) -------------- # restoreAppStart already wipes the app folder, restores the snapshot, # re-runs the install-time tag pipeline, and starts the container. The @@ -109,6 +112,9 @@ migrateApplyApp() fi fi + # Per-app post-migrate hook (optional) — last thing before we declare done. + migrateRunHook "$app" post "$source_host" "restic" + local finished_at finished_at=$(date +%s) local duration=$((finished_at - started_at)) diff --git a/scripts/migrate/migrate_hooks.sh b/scripts/migrate/migrate_hooks.sh new file mode 100644 index 0000000..e338ea5 --- /dev/null +++ b/scripts/migrate/migrate_hooks.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Per-app migrate hooks. After a migrate (restic-mediated apply OR direct-SSH +# pull) places the source's data on this host, some apps need app-specific +# fix-ups beyond the standard URL rewrite — rotating a federation key, +# regenerating an OIDC client secret, dropping a SaaS lock, etc. +# +# Convention: an app's tools.sh (auto-sourced by the modular per-app tools +# loader — see [[libreportal-modular-app-tools]]) may declare: +# +# _migrate_pre() # called before stop+wipe +# _migrate_post() # called after restart, before user sees it +# +# Both receive: $1 = source_hostname (peer hostname or backup tag), +# $2 = transport ("restic" | "direct-ssh") +# Hooks are optional — apps without them just inherit the standard flow. + +# Run a single named hook if it exists. Quiet if not defined. +migrateRunHook() +{ + local app="$1" + local stage="$2" # "pre" or "post" + local source="$3" + local transport="$4" + + local hook_name="${app}_migrate_${stage}" + if declare -f "$hook_name" >/dev/null 2>&1; then + isNotice "Running ${stage}-migrate hook for ${app}" + migrateEmit phase="hook-${stage}" status=running app="$app" hook="$hook_name" + if "$hook_name" "$source" "$transport"; then + migrateEmit phase="hook-${stage}" status=complete app="$app" + else + isNotice "Hook ${hook_name} returned non-zero — continuing migrate" + migrateEmit phase="hook-${stage}" status=failed app="$app" + fi + fi +} diff --git a/scripts/peer/peer_pull.sh b/scripts/peer/peer_pull.sh index 0299609..7e007cb 100644 --- a/scripts/peer/peer_pull.sh +++ b/scripts/peer/peer_pull.sh @@ -64,6 +64,9 @@ peerPullApp() migrateEmit phase=pre-backup status=skipped reason=user-opt-out app="$app" fi + # Per-app pre-migrate hook (optional) — declared in the app's tools.sh. + migrateRunHook "$app" pre "$peer_name" "direct-ssh" + # ---- 3. Stop + wipe ---------------------------------------------------- migrateEmit phase=stop status=running app="$app" if declare -f dockerComposeDown >/dev/null 2>&1 && [[ -d "$containers_dir$app" ]]; then @@ -117,6 +120,9 @@ peerPullApp() fi migrateEmit phase=start-app status=complete app="$app" + # Per-app post-migrate hook (optional) — last thing before declaring done. + migrateRunHook "$app" post "$peer_name" "direct-ssh" + local finished_at; finished_at=$(date +%s) local duration=$((finished_at - started_at)) isSuccessful "Pulled $app from $peer_name in ${duration}s" diff --git a/scripts/source/files/arrays/files_migrate.sh b/scripts/source/files/arrays/files_migrate.sh index 8376026..517c41e 100755 --- a/scripts/source/files/arrays/files_migrate.sh +++ b/scripts/source/files/arrays/files_migrate.sh @@ -6,6 +6,7 @@ migrate_scripts=( "migrate/migrate_apply.sh" "migrate/migrate_discover.sh" + "migrate/migrate_hooks.sh" "migrate/migrate_pre_backup.sh" "migrate/migrate_preflight.sh" "migrate/migrate_progress.sh"