LibrePortal/scripts/migrate/migrate_preflight.sh
librelad 32b2840d73 refactor(migrate)!: rewrite kernel — discover/preflight/apply with JSON progress
Phase 0 of the migration-system refresh. Replaces the 77-line
scripts/migrate/ with a properly-shaped kernel that Phase 1 (WebUI) and
Phase 3 (direct peer SSH) can both build on.

New module layout (6 files):
  migrate_progress.sh   — migrateEmit JSON-per-line helper; opt-in via
                          MIGRATE_JSON_PROGRESS=1, writes to fd 3 if open
                          (clean WebUI streaming channel) else stdout.
  migrate_discover.sh   — migrateDiscoverHosts / migrateDiscoverApps /
                          migrateDiscoverAppDetail (JSON {snapshots, latest_*}).
                          Old migrateDiscoverAppsForHost kept as back-compat.
  migrate_preflight.sh  — migratePreflight emits one JSON object with
                          snapshot{id,date}, destination{installed,running,
                          disk_free_kb}, collision{occurs,default_action,
                          pre_backup_default}, url_rewrite{default_action,
                          per_app_opt_out}, warnings[], errors[].
                          Exit 0 on usable preflight, 1 on hard error.
  migrate_url_rewrite.sh— Host-bound CFG_<APP>_* fields (URL/HOST/DOMAIN/
                          DOMAIN_PREFIX/HOSTNAME/PUBLIC_URL) get rewritten
                          from the destination's install-template after
                          restore — so a moved app stops claiming the
                          source's hostnames. Per-app opt-out via
                          CFG_<APP>_MIGRATE_URL_REWRITE=false. All other
                          fields (DB passwords, API keys, prefs) carry
                          over from the source unchanged.
  migrate_pre_backup.sh — migratePreBackupDestination takes a snapshot of
                          the destination's existing <app> (tagged
                          pre-migrate=<UTC timestamp>) before the wipe.
                          Default ON; opt-out with --no-pre-backup. Safety
                          net for the always-replace collision policy.
  migrate_apply.sh      — migrateApplyApp / migrateApplySystem. Parses
                          --no-pre-backup / --keep-urls / --json-progress
                          opts, runs preflight → pre-backup → restoreAppStart
                          (existing flow) → URL rewrite → re-deploy compose.
                          migrateApp / migrateSystem kept as shims so the
                          old CLI surface still works.

CLI dispatcher (cli_restore_commands.sh + cli_restore_header.sh):
  Existing 'restore migrate app/system/discover' calls all still work.
  New verbs:
    restore migrate list <host> [loc_idx]
    restore migrate preflight <host> <app> [loc_idx]   ← JSON, for the WebUI

Design choices baked in (per the spec):
  - Always-replace collision (no multi-install of an app), safety net is the
    on-by-default pre-migrate backup.
  - URL rewrite by host-bound suffix list, not per-field allowlist — works
    out-of-the-box for new apps without extra config.
  - migrateEmit fd-3 contract is what Phase 1's WebUI will stream; falls
    back to stdout in interactive CLI so dev/debug just works.
  - Transport-agnostic: nothing in this kernel knows whether the backup
    location is local/SSH/S3/Connect — engineSnapshotsJson + engineBackupApp
    do that, so Connect (the future blind-relay) plugs in as 'just another
    location kind' with zero kernel changes.

Smoke-tested: all 13 public functions register; JSON emit produces correct
escaping (quoted strings vs bare numerics) and respects MIGRATE_JSON_PROGRESS.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 17:22:54 +01:00

148 lines
5.8 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# Preflight for a cross-host migrate. Read-only: probes the destination, asks
# the backup location what it knows about the source/app, and produces a JSON
# object the WebUI (or a thoughtful CLI user) consumes before pressing "go."
#
# Args: <source_host> <app> [loc_idx]
#
# Output (single JSON object, one line):
# {
# "source_host": "homelab",
# "app": "linkding",
# "loc_idx": 1,
# "snapshot": {"id":"abc12345","date":"2026-05-26T10:30:00Z"} | null,
# "destination": {
# "app_installed": true,
# "app_running": true,
# "disk_free_kb": 50000000
# },
# "collision": {
# "occurs": true,
# "default_action": "replace",
# "pre_backup_default": true
# },
# "url_rewrite": {
# "default_action": "rewrite" | "keep",
# "per_app_opt_out": false
# },
# "warnings": [...],
# "errors": [...]
# }
#
# Exit codes: 0 on a usable preflight (even if warnings); 1 if the migrate
# can't proceed at all (no location, no snapshot, etc.) — errors[] is filled.
# Append a string to a JSON-array variable held by name. Used by migratePreflight
# to grow its warnings[]/errors[] without dragging in jq. Defined at file scope
# because bash doesn't allow `local funcname() {}` nested inside another function.
_migratePreflightAppend()
{
local __varname="$1"
local msg="$2"
local escaped="${msg//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
local current="${!__varname}"
if [[ "$current" == "[]" ]]; then
printf -v "$__varname" '[%s]' "\"$escaped\""
else
printf -v "$__varname" '%s,%s]' "${current%]}" "\"$escaped\""
fi
}
migratePreflight()
{
local source_host="$1"
local app="$2"
local idx="$3"
local warnings='[]'
local errors='[]'
if [[ -z "$source_host" || -z "$app" ]]; then
_migratePreflightAppend errors "source_host and app required"
printf '{"source_host":null,"app":null,"loc_idx":null,"snapshot":null,"destination":null,"collision":null,"url_rewrite":null,"warnings":%s,"errors":%s}\n' \
"$warnings" "$errors"
return 1
fi
if [[ -z "$idx" ]]; then
idx=$(resticEnabledLocations | head -1)
fi
if [[ -z "$idx" ]]; then
_migratePreflightAppend errors "No backup locations enabled — nowhere to pull from"
printf '{"source_host":"%s","app":"%s","loc_idx":null,"snapshot":null,"destination":null,"collision":null,"url_rewrite":null,"warnings":%s,"errors":%s}\n' \
"$source_host" "$app" "$warnings" "$errors"
return 1
fi
# ---- Snapshot probe -------------------------------------------------------
local snap_id="" snap_date=""
local snap_json
snap_json=$(engineSnapshotsJson "$idx" "$app" "$source_host" 2>/dev/null)
if [[ -n "$snap_json" && "$snap_json" != "[]" ]]; then
snap_id=$(printf '%s' "$snap_json" | grep -o '"short_id":"[^"]*"' | tail -1 | cut -d'"' -f4)
snap_date=$(printf '%s' "$snap_json" | grep -o '"time":"[^"]*"' | tail -1 | cut -d'"' -f4)
fi
local snapshot_json
if [[ -n "$snap_id" ]]; then
snapshot_json="{\"id\":\"$snap_id\",\"date\":\"$snap_date\"}"
else
snapshot_json="null"
_migratePreflightAppend errors "No snapshots found for app=$app on host=$source_host in $(resticLocationName "$idx")"
fi
# ---- Destination probe ----------------------------------------------------
local app_installed=false
local app_running=false
if [[ -d "$containers_dir$app" ]]; then
app_installed=true
if dockerCommandRun "docker ps --filter name=^${app}\$ --format '{{.Names}}' 2>/dev/null" | grep -q "^$app\$"; then
app_running=true
fi
fi
# df -P on the containers root, fourth col = free 1K-blocks. Best-effort.
local disk_free_kb
disk_free_kb=$(df -Pk "$containers_dir" 2>/dev/null | awk 'NR==2 {print $4}')
[[ -z "$disk_free_kb" ]] && disk_free_kb=0
local dest_json
dest_json="{\"app_installed\":$app_installed,\"app_running\":$app_running,\"disk_free_kb\":$disk_free_kb}"
# ---- Collision policy (we always replace; pre-backup defaults ON) ---------
local pre_backup_default=true
[[ "$app_installed" == "false" ]] && pre_backup_default=false # nothing to back up
local collision_json
collision_json="{\"occurs\":$app_installed,\"default_action\":\"replace\",\"pre_backup_default\":$pre_backup_default}"
# ---- URL rewrite policy ---------------------------------------------------
local url_default="rewrite"
local opt_out=false
if [[ "$(migrateUrlRewriteEnabled "$app")" == "false" ]]; then
url_default="keep"
opt_out=true
fi
local url_json
url_json="{\"default_action\":\"$url_default\",\"per_app_opt_out\":$opt_out}"
# ---- Soft warnings --------------------------------------------------------
# Disk free below 2× the destination's existing app folder is concerning.
if [[ "$app_installed" == "true" ]]; then
local existing_kb
existing_kb=$(du -sk "$containers_dir$app" 2>/dev/null | awk '{print $1}')
if [[ -n "$existing_kb" && "$disk_free_kb" -lt $((existing_kb * 2)) ]]; then
_migratePreflightAppend warnings "Disk free ($((disk_free_kb / 1024)) MB) is less than 2× existing $app size ($((existing_kb / 1024)) MB)"
fi
fi
if [[ "$app_running" == "true" ]]; then
_migratePreflightAppend warnings "$app is currently running on this host; migrate will stop it"
fi
printf '{"source_host":"%s","app":"%s","loc_idx":%s,"snapshot":%s,"destination":%s,"collision":%s,"url_rewrite":%s,"warnings":%s,"errors":%s}\n' \
"$source_host" "$app" "$idx" "$snapshot_json" "$dest_json" "$collision_json" "$url_json" \
"$warnings" "$errors"
[[ "$errors" == "[]" ]] && return 0 || return 1
}