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]]):
<app>_migrate_pre() — runs before stop+wipe
<app>_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 <librelad@digitalangels.vip>
196 lines
7.3 KiB
Bash
196 lines
7.3 KiB
Bash
#!/bin/bash
|
|
|
|
# Apply a cross-host migrate. Wraps restoreAppStart with the migrate-specific
|
|
# scaffolding (pre-backup of destination, URL rewrite of host-bound CFG_*
|
|
# fields, structured progress) so the WebUI can drive it cleanly.
|
|
#
|
|
# Two entry points:
|
|
# migrateApplyApp <source_host> <app> [loc_idx] [opts...]
|
|
# migrateApplySystem <source_host> [loc_idx] [opts...]
|
|
#
|
|
# Opts (any order, all defaults match the WebUI defaults):
|
|
# --no-pre-backup Skip the safety snapshot of destination's <app>
|
|
# --keep-urls Don't rewrite host-bound CFG_<APP>_* fields
|
|
# --json-progress Emit one JSON record per stage on fd 3 / stdout
|
|
# (sets MIGRATE_JSON_PROGRESS=1 for nested helpers)
|
|
#
|
|
# Exit: 0 on success, non-zero if the migrate failed at any stage.
|
|
|
|
# Strip our --foo opts out of the positional args and set MIGRATE_OPT_* flags.
|
|
# Mutates the caller's $1.. via printf-and-reread; returns the cleaned list
|
|
# via the global _MIGRATE_REMAINING_ARGS array.
|
|
_migrateParseOpts()
|
|
{
|
|
MIGRATE_OPT_PRE_BACKUP=1
|
|
MIGRATE_OPT_KEEP_URLS=0
|
|
MIGRATE_OPT_JSON_PROGRESS=0
|
|
_MIGRATE_REMAINING_ARGS=()
|
|
|
|
local a
|
|
for a in "$@"; do
|
|
case "$a" in
|
|
--no-pre-backup) MIGRATE_OPT_PRE_BACKUP=0 ;;
|
|
--keep-urls) MIGRATE_OPT_KEEP_URLS=1 ;;
|
|
--json-progress) MIGRATE_OPT_JSON_PROGRESS=1; export MIGRATE_JSON_PROGRESS=1 ;;
|
|
--) : ;; # arg separator, ignore
|
|
*) _MIGRATE_REMAINING_ARGS+=("$a") ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
migrateApplyApp()
|
|
{
|
|
_migrateParseOpts "$@"
|
|
local source_host="${_MIGRATE_REMAINING_ARGS[0]}"
|
|
local app="${_MIGRATE_REMAINING_ARGS[1]}"
|
|
local idx="${_MIGRATE_REMAINING_ARGS[2]}"
|
|
|
|
if [[ -z "$source_host" || -z "$app" ]]; then
|
|
isError "migrateApplyApp: source_host and app required"
|
|
return 1
|
|
fi
|
|
|
|
[[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1)
|
|
if [[ -z "$idx" ]]; then
|
|
isError "No backup locations enabled — cannot migrate"
|
|
return 1
|
|
fi
|
|
|
|
local started_at
|
|
started_at=$(date +%s)
|
|
isHeader "Migrate $app from host=$source_host → this host"
|
|
migrateEmit phase=start status=running app="$app" source_host="$source_host" loc_idx="$idx"
|
|
|
|
# ---- 1. Preflight (informational; we already trust the caller) ------------
|
|
migrateEmit phase=preflight status=running
|
|
local pf
|
|
pf=$(migratePreflight "$source_host" "$app" "$idx")
|
|
local pf_rc=$?
|
|
if (( pf_rc != 0 )); then
|
|
isError "Preflight failed: $pf"
|
|
migrateEmit phase=preflight status=failed detail="$pf"
|
|
return 1
|
|
fi
|
|
migrateEmit phase=preflight status=complete
|
|
|
|
# ---- 2. Pre-migrate backup of destination (default ON) --------------------
|
|
if (( MIGRATE_OPT_PRE_BACKUP )); then
|
|
migratePreBackupDestination "$app" "$idx"
|
|
else
|
|
isNotice "Pre-migrate backup skipped (--no-pre-backup)"
|
|
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
|
|
# host_filter arg makes it pull the SOURCE's snapshot, not this host's.
|
|
migrateEmit phase=restore status=running app="$app" source_host="$source_host"
|
|
if ! restoreAppStart "$app" "latest" "$idx" "$source_host"; then
|
|
isError "Restore stage failed for $app"
|
|
migrateEmit phase=restore status=failed app="$app"
|
|
return 1
|
|
fi
|
|
migrateEmit phase=restore status=complete app="$app"
|
|
|
|
# ---- 4. URL rewrite (default ON) ------------------------------------------
|
|
if (( MIGRATE_OPT_KEEP_URLS )); then
|
|
isNotice "URL rewrite skipped (--keep-urls)"
|
|
migrateEmit phase=url-rewrite status=skipped reason=user-opt-out app="$app"
|
|
else
|
|
migrateEmit phase=url-rewrite status=running app="$app"
|
|
migrateApplyUrlRewrite "$app"
|
|
# restoreAppStart already restarted the app once; if URL rewrite
|
|
# changed anything, a quick re-deploy of the compose picks them up.
|
|
# dockerComposeUpdateAndStartApp is idempotent (per the project's
|
|
# idempotent-setup preference) so re-running is cheap.
|
|
if declare -f dockerComposeUpdateAndStartApp >/dev/null 2>&1; then
|
|
dockerComposeUpdateAndStartApp "$app" install >/dev/null 2>&1 || true
|
|
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))
|
|
isSuccessful "Migrate of $app from $source_host complete in ${duration}s"
|
|
migrateEmit phase=done status=complete app="$app" duration_seconds="$duration"
|
|
return 0
|
|
}
|
|
|
|
migrateApplySystem()
|
|
{
|
|
_migrateParseOpts "$@"
|
|
local source_host="${_MIGRATE_REMAINING_ARGS[0]}"
|
|
local idx="${_MIGRATE_REMAINING_ARGS[1]}"
|
|
|
|
if [[ -z "$source_host" ]]; then
|
|
isError "migrateApplySystem: source_host required"
|
|
return 1
|
|
fi
|
|
[[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1)
|
|
if [[ -z "$idx" ]]; then
|
|
isError "No backup locations enabled — cannot migrate"
|
|
return 1
|
|
fi
|
|
|
|
isHeader "Migrate every app from host=$source_host → this host"
|
|
migrateEmit phase=system-start status=running source_host="$source_host" loc_idx="$idx"
|
|
|
|
local apps=()
|
|
while IFS= read -r a; do
|
|
[[ -n "$a" ]] && apps+=("$a")
|
|
done < <(migrateDiscoverApps "$source_host" "$idx")
|
|
|
|
if (( ${#apps[@]} == 0 )); then
|
|
isError "No apps found for host=$source_host in $(resticLocationName "$idx")"
|
|
migrateEmit phase=system-start status=failed reason=no-apps
|
|
return 1
|
|
fi
|
|
|
|
isNotice "Apps to migrate: ${apps[*]}"
|
|
migrateEmit phase=system-plan status=info apps_count="${#apps[@]}"
|
|
|
|
# Forward the same opts to each per-app run. Note we re-pass the parsed
|
|
# flags as literal --foo so each migrateApplyApp re-parses cleanly.
|
|
local forwarded=()
|
|
(( MIGRATE_OPT_PRE_BACKUP == 0 )) && forwarded+=(--no-pre-backup)
|
|
(( MIGRATE_OPT_KEEP_URLS == 1 )) && forwarded+=(--keep-urls)
|
|
(( MIGRATE_OPT_JSON_PROGRESS == 1 )) && forwarded+=(--json-progress)
|
|
|
|
local ok=0 fail=0
|
|
local a
|
|
for a in "${apps[@]}"; do
|
|
if migrateApplyApp "${forwarded[@]}" "$source_host" "$a" "$idx"; then
|
|
((ok++))
|
|
else
|
|
((fail++))
|
|
fi
|
|
done
|
|
|
|
isSuccessful "System migrate finished: $ok ok / $fail failed"
|
|
migrateEmit phase=system-done status=complete ok="$ok" failed="$fail"
|
|
(( fail == 0 ))
|
|
}
|
|
|
|
# ---- Backwards-compat shims ---------------------------------------------------
|
|
# Pre-existing callers (cli_restore_commands.sh, restoreFirstRunBulk, etc.)
|
|
# used the old names with the old arg order. Forward to the new functions.
|
|
migrateApp()
|
|
{
|
|
# Old signature: migrateApp <app_name> <source_host> [idx]
|
|
local app="$1"; local source_host="$2"; local idx="$3"
|
|
migrateApplyApp "$source_host" "$app" "$idx"
|
|
}
|
|
|
|
migrateSystem()
|
|
{
|
|
# Old signature: migrateSystem <source_host> [idx]
|
|
migrateApplySystem "$@"
|
|
}
|