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>
148 lines
5.8 KiB
Bash
148 lines
5.8 KiB
Bash
#!/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
|
||
}
|