diff --git a/scripts/cli/commands/restore/cli_restore_commands.sh b/scripts/cli/commands/restore/cli_restore_commands.sh index 7304274..f1d172c 100755 --- a/scripts/cli/commands/restore/cli_restore_commands.sh +++ b/scripts/cli/commands/restore/cli_restore_commands.sh @@ -44,17 +44,35 @@ cliHandleRestoreCommands() migrate) case "$action" in app) - [[ -z "$name" ]] && { isNotice "Usage: restore migrate app [loc_idx]"; return; } - [[ -z "$extra" ]] && { isNotice "Usage: restore migrate app [loc_idx]"; return; } - migrateApp "$name" "$extra" "$extra2" + # restore migrate app [loc_idx] [opts...] + # opts: --no-pre-backup, --keep-urls, --json-progress + [[ -z "$name" ]] && { isNotice "Usage: restore migrate app [loc_idx] [opts]"; return; } + [[ -z "$extra" ]] && { isNotice "Usage: restore migrate app [loc_idx] [opts]"; return; } + shift 4 # consume restore_type, action, name, extra + local app_idx="$1"; shift 2>/dev/null || true + migrateApplyApp "$extra" "$name" "$app_idx" "$@" ;; system) - [[ -z "$name" ]] && { isNotice "Usage: restore migrate system [loc_idx]"; return; } - migrateSystem "$name" "$extra" + # restore migrate system [loc_idx] [opts...] + [[ -z "$name" ]] && { isNotice "Usage: restore migrate system [loc_idx] [opts]"; return; } + shift 3 + migrateApplySystem "$name" "$@" ;; discover) + # restore migrate discover [loc_idx] migrateDiscoverHosts "$name" ;; + list) + # restore migrate list [loc_idx] + [[ -z "$name" ]] && { isNotice "Usage: restore migrate list [loc_idx]"; return; } + migrateDiscoverApps "$name" "$extra" + ;; + preflight) + # restore migrate preflight [loc_idx] + # Emits a single JSON object describing the planned migrate. + [[ -z "$name" || -z "$extra" ]] && { isNotice "Usage: restore migrate preflight [loc_idx]"; return; } + migratePreflight "$name" "$extra" "$extra2" + ;; *) isNotice "Invalid migrate action: $action" cliShowRestoreHelp diff --git a/scripts/cli/commands/restore/cli_restore_header.sh b/scripts/cli/commands/restore/cli_restore_header.sh index 6c1d6cc..f87b43f 100755 --- a/scripts/cli/commands/restore/cli_restore_header.sh +++ b/scripts/cli/commands/restore/cli_restore_header.sh @@ -13,15 +13,24 @@ cliShowRestoreHelp() echo " Restore the latest system-config snapshot into a staging dir" echo " (review-then-copy; never overwrites live config). Default: first location." echo "" - echo "restore migrate app [loc_idx]" - echo " Restore one app's backup taken on another host (cross-host migrate)." - echo "" - echo "restore migrate system [loc_idx]" - echo " Restore every app backed up on a source host." - echo "" echo "restore migrate discover [loc_idx]" echo " List the source hosts that have backups in a location." echo "" + echo "restore migrate list [loc_idx]" + echo " List the apps that has backed up in a location." + echo "" + echo "restore migrate preflight [loc_idx]" + echo " Probe what a migrate would do (snapshot id, disk, collision," + echo " URL-rewrite decision). Emits a single JSON object — used by the" + echo " WebUI before showing the confirm modal." + echo "" + echo "restore migrate app [loc_idx] [opts]" + echo " Restore one app's backup taken on another host (cross-host migrate)." + echo " Opts: --no-pre-backup --keep-urls --json-progress" + echo "" + echo "restore migrate system [loc_idx] [opts]" + echo " Restore every app backed up on a source host. Same opts as above." + echo "" echo "restore first-run discover [loc_idx]" echo " Discover LibrePortal backups in a location (fresh-install flow)." echo "" diff --git a/scripts/migrate/migrate_app.sh b/scripts/migrate/migrate_app.sh deleted file mode 100644 index 35f23a4..0000000 --- a/scripts/migrate/migrate_app.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -migrateApp() -{ - local app_name="$1" - local source_host="$2" - local idx="$3" - - if [[ -z "$app_name" || -z "$source_host" ]]; then - isError "migrateApp requires app_name and source_host" - return 1 - fi - - isHeader "Migrating $app_name from host=$source_host" - - [[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1) - restoreAppStart "$app_name" "latest" "$idx" "$source_host" -} diff --git a/scripts/migrate/migrate_apply.sh b/scripts/migrate/migrate_apply.sh new file mode 100644 index 0000000..0064955 --- /dev/null +++ b/scripts/migrate/migrate_apply.sh @@ -0,0 +1,189 @@ +#!/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 [loc_idx] [opts...] +# migrateApplySystem [loc_idx] [opts...] +# +# Opts (any order, all defaults match the WebUI defaults): +# --no-pre-backup Skip the safety snapshot of destination's +# --keep-urls Don't rewrite host-bound CFG__* 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 + + # ---- 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 + + 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 [idx] + local app="$1"; local source_host="$2"; local idx="$3" + migrateApplyApp "$source_host" "$app" "$idx" +} + +migrateSystem() +{ + # Old signature: migrateSystem [idx] + migrateApplySystem "$@" +} diff --git a/scripts/migrate/migrate_discover.sh b/scripts/migrate/migrate_discover.sh index b52b142..0ab86fd 100644 --- a/scripts/migrate/migrate_discover.sh +++ b/scripts/migrate/migrate_discover.sh @@ -1,19 +1,87 @@ #!/bin/bash -migrateDiscoverHosts() +# Discovery helpers for cross-host migrate. All read-only — they query a +# backup location's snapshot index, never touch live state. +# +# Three views, increasing specificity: +# migrateDiscoverHosts — which hosts have snapshots in this location? +# migrateDiscoverApps — which apps does have in this location? +# migrateDiscoverAppDetail — newest snapshot + count for one host/app. + +# Resolve to the first enabled location when idx is unset. Returns "" if +# nothing is enabled — callers should treat that as "nowhere to discover from." +_migrateResolveLocation() { local idx="$1" - [[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1) - [[ -z "$idx" ]] && return 1 - - engineSnapshotsJson "$idx" | grep -o '"hostname":"[^"]*"' | sort -u | cut -d'"' -f4 + if [[ -z "$idx" ]]; then + idx=$(resticEnabledLocations | head -1) + fi + echo "$idx" } +# List hostnames with at least one snapshot in the given location. One per line. +migrateDiscoverHosts() +{ + local idx + idx=$(_migrateResolveLocation "$1") + [[ -z "$idx" ]] && return 1 + + engineSnapshotsJson "$idx" 2>/dev/null \ + | grep -o '"hostname":"[^"]*"' \ + | sort -u \ + | cut -d'"' -f4 +} + +# List apps backed up by in this location. One slug per line. +migrateDiscoverApps() +{ + local idx + idx=$(_migrateResolveLocation "$2") + local host="$1" + [[ -z "$idx" || -z "$host" ]] && return 1 + + engineSnapshotsJson "$idx" "" "$host" 2>/dev/null \ + | grep -o '"app=[^"]*"' \ + | sort -u \ + | sed 's/"app=\(.*\)"/\1/' +} + +# JSON detail for one host+app: snapshot count + latest id/date. +# Output: {"host":"…","app":"…","snapshots":N,"latest_id":"…","latest_date":"…"} +# Missing snapshots → snapshots=0, latest_*=null. +migrateDiscoverAppDetail() +{ + local host="$1" + local app="$2" + local idx + idx=$(_migrateResolveLocation "$3") + [[ -z "$idx" || -z "$host" || -z "$app" ]] && return 1 + + local json + json=$(engineSnapshotsJson "$idx" "$app" "$host" 2>/dev/null) + + local count + count=$(printf '%s' "$json" | grep -oc '"short_id":"' || echo 0) + + local latest_id="null" + local latest_date="null" + if (( count > 0 )); then + # Restic prints snapshots in creation order — last one is newest. + latest_id="\"$(printf '%s' "$json" \ + | grep -o '"short_id":"[^"]*"' | tail -1 | cut -d'"' -f4)\"" + latest_date="\"$(printf '%s' "$json" \ + | grep -o '"time":"[^"]*"' | tail -1 | cut -d'"' -f4)\"" + fi + + printf '{"host":"%s","app":"%s","loc_idx":%s,"snapshots":%s,"latest_id":%s,"latest_date":%s}\n' \ + "$host" "$app" "$idx" "$count" "$latest_id" "$latest_date" +} + +# Back-compat shim — older callers (and the existing CLI) used this name with +# (idx, host) arg order. New code should call migrateDiscoverApps directly. migrateDiscoverAppsForHost() { local idx="$1" local host="$2" - [[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1) - - engineSnapshotsJson "$idx" "" "$host" | grep -o '"app=[^"]*"' | sort -u | sed 's/"app=\(.*\)"/\1/' + migrateDiscoverApps "$host" "$idx" } diff --git a/scripts/migrate/migrate_pre_backup.sh b/scripts/migrate/migrate_pre_backup.sh new file mode 100644 index 0000000..86ba3d3 --- /dev/null +++ b/scripts/migrate/migrate_pre_backup.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Pre-migrate backup. Before the destination app gets wiped and replaced with +# the source's snapshot, take a fresh local backup of the destination's app — +# tagged with `pre-migrate=` so it's easy to find for rollback if +# the migrate misbehaves. Best-effort: emits a notice and continues if no +# backup location is available (the caller already warned the user via +# preflight). +# +# Args: (loc_idx defaults to first enabled) +# Returns: 0 always (a missing pre-backup must never block the migrate; the +# user already saw and confirmed the preflight that warned about it). + +migratePreBackupDestination() +{ + local app="$1" + local idx="$2" + + if [[ -z "$app" ]]; then + isError "migratePreBackupDestination: app required" + migrateEmit phase=pre-backup status=skipped reason=no-app + return 0 + fi + + # Only meaningful if the destination actually has this app installed. + if [[ ! -d "$containers_dir$app" ]]; then + isNotice "No existing $app on destination — pre-migrate backup skipped" + migrateEmit phase=pre-backup status=skipped reason=no-existing-app app="$app" + return 0 + fi + + if [[ -z "$idx" ]]; then + idx=$(resticEnabledLocations | head -1) + fi + if [[ -z "$idx" ]]; then + isNotice "No backup locations enabled — pre-migrate backup skipped" + migrateEmit phase=pre-backup status=skipped reason=no-backup-location app="$app" + return 0 + fi + + local stamp + stamp=$(date -u +%Y%m%dT%H%M%SZ) + isNotice "Pre-migrate backup of destination $app → $(resticLocationName "$idx") (tag pre-migrate=$stamp)" + migrateEmit phase=pre-backup status=running app="$app" loc_idx="$idx" stamp="$stamp" + + # engineBackupApp takes (idx, app, [extra_tags...]). The pre-migrate tag + # makes the snapshot trivially findable later: restic snapshots --tag pre-migrate + if engineBackupApp "$idx" "$app" "pre-migrate=$stamp"; then + isSuccessful "Pre-migrate backup of $app complete" + migrateEmit phase=pre-backup status=complete app="$app" stamp="$stamp" + else + isNotice "Pre-migrate backup of $app FAILED — continuing migrate anyway (you confirmed)" + migrateEmit phase=pre-backup status=failed app="$app" stamp="$stamp" + fi + + return 0 +} diff --git a/scripts/migrate/migrate_preflight.sh b/scripts/migrate/migrate_preflight.sh new file mode 100644 index 0000000..5dd6bae --- /dev/null +++ b/scripts/migrate/migrate_preflight.sh @@ -0,0 +1,147 @@ +#!/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: [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 +} diff --git a/scripts/migrate/migrate_progress.sh b/scripts/migrate/migrate_progress.sh new file mode 100644 index 0000000..31ffa80 --- /dev/null +++ b/scripts/migrate/migrate_progress.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# Structured progress emit for migrate flows. Phase 1's WebUI streams these +# JSON-per-line records and renders them. When MIGRATE_JSON_PROGRESS=1 the +# helper writes machine-readable JSON to fd 3 (or stdout if fd 3 isn't open); +# otherwise it's a no-op so the human CLI output stays clean. +# +# Usage: +# migrateEmit phase=preflight status=running +# migrateEmit phase=restore status=running pct=50 +# migrateEmit phase=done status=complete duration_seconds=42 +# +# Keys with spaces or special chars should be passed pre-quoted (a=foo\ bar) +# or via the explicit setter form (rare — most callers use the simple kv form). +migrateEmit() +{ + [[ "$MIGRATE_JSON_PROGRESS" != "1" ]] && return 0 + + local pair + local first=1 + local out='{' + for pair in "$@"; do + local k="${pair%%=*}" + local v="${pair#*=}" + # Numeric values stay bare; everything else gets quoted + escaped. + local rendered + if [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then + rendered="$v" + else + local escaped="${v//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + escaped="${escaped//$'\n'/\\n}" + escaped="${escaped//$'\t'/\\t}" + rendered="\"$escaped\"" + fi + if (( first )); then + out+="\"$k\":$rendered" + first=0 + else + out+=",\"$k\":$rendered" + fi + done + out+='}' + + # fd 3 is the structured channel — Phase 1 opens it. If it's not open + # (interactive CLI), fall back to stdout so dev/debug runs still see it. + if { true >&3; } 2>/dev/null; then + printf '%s\n' "$out" >&3 + else + printf '%s\n' "$out" + fi +} diff --git a/scripts/migrate/migrate_system.sh b/scripts/migrate/migrate_system.sh deleted file mode 100644 index d035c4e..0000000 --- a/scripts/migrate/migrate_system.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash - -migrateSystem() -{ - local source_host="$1" - local idx="$2" - - if [[ -z "$source_host" ]]; then - isError "migrateSystem requires source_host" - return 1 - fi - - [[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1) - - isHeader "Migrating entire system from host=$source_host" - - local apps_json - apps_json=$(engineSnapshotsJson "$idx" "" "$source_host") - if [[ -z "$apps_json" || "$apps_json" == "[]" ]]; then - isError "No snapshots found in $(resticLocationName "$idx") for host=$source_host" - return 1 - fi - - local apps=() - while IFS= read -r app; do - [[ -n "$app" ]] && apps+=("$app") - done < <(echo "$apps_json" | grep -o '"app=[^"]*"' | sort -u | sed 's/"app=\(.*\)"/\1/') - - if [[ ${#apps[@]} -eq 0 ]]; then - isError "Could not parse app list from snapshots" - return 1 - fi - - isNotice "Apps to migrate: ${apps[*]}" - for app in "${apps[@]}"; do - migrateApp "$app" "$source_host" "$idx" - done - - isSuccessful "System migration complete — ${#apps[@]} apps" -} diff --git a/scripts/migrate/migrate_url_rewrite.sh b/scripts/migrate/migrate_url_rewrite.sh new file mode 100644 index 0000000..cfe155d --- /dev/null +++ b/scripts/migrate/migrate_url_rewrite.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# Cross-host migrate inherits the source's CFG__* values via the restored +# config file. Most of them (DB passwords, API keys, user preferences) are +# data and must survive the move. A small set are host-bound — they describe +# where the app lives — and would 404 or break TLS if we kept them pointing +# at the source. This module identifies and rewrites those. +# +# Host-bound fields are detected by suffix (URL, HOST, DOMAIN, DOMAIN_PREFIX), +# and the rewrite source-of-truth is the destination's install-template +# config (containers//.config under the install tree) — that's what +# a fresh install on this box would produce. +# +# Per-app opt-out: set CFG__MIGRATE_URL_REWRITE="false" in the app's +# install-template config to skip the rewrite (e.g. for apps with hardcoded +# URLs that mustn't change). + +# Field-name suffixes treated as host-bound. Compared after stripping the +# CFG__ prefix. +_MIGRATE_HOST_BOUND_SUFFIXES=(URL HOST DOMAIN DOMAIN_PREFIX HOSTNAME PUBLIC_URL) + +# Echo "true" or "false" — should we rewrite host-bound URLs for this app? +# Defaults true unless the app explicitly opts out. +migrateUrlRewriteEnabled() +{ + local app="$1" + local template_config="$install_containers_dir$app/$app.config" + [[ ! -f "$template_config" ]] && { echo "true"; return; } + + local opt_key="CFG_${app^^}_MIGRATE_URL_REWRITE" + local val + val=$(grep -E "^${opt_key}=" "$template_config" 2>/dev/null \ + | head -1 | cut -d'=' -f2- | tr -d '"' | tr -d "'") + if [[ "$val" == "false" || "$val" == "no" || "$val" == "0" ]]; then + echo "false" + else + echo "true" + fi +} + +# Read the destination's install template, find host-bound CFG__* keys, +# and overwrite the same keys in the deployed (just-restored) config with the +# template's values. Anything not in the host-bound list is left alone — the +# source's data/preferences survive. +# +# Args: +# Returns: count of fields rewritten (emitted as `migrate.url_rewrite.fields`). +migrateApplyUrlRewrite() +{ + local app="$1" + [[ -z "$app" ]] && { isError "migrateApplyUrlRewrite: app required"; return 1; } + + if [[ "$(migrateUrlRewriteEnabled "$app")" != "true" ]]; then + isNotice "URL rewrite disabled by app config — keeping source URLs for $app" + migrateEmit phase=url-rewrite status=skipped reason=app-opt-out app="$app" + return 0 + fi + + local template="$install_containers_dir$app/$app.config" + local deployed="$containers_dir$app/$app.config" + + if [[ ! -f "$template" || ! -f "$deployed" ]]; then + isNotice "URL rewrite: missing template or deployed config for $app — skipping" + migrateEmit phase=url-rewrite status=skipped reason=missing-config app="$app" + return 0 + fi + + local rewritten=0 + local key value bare_suffix + while IFS='=' read -r key value; do + [[ -z "$key" ]] && continue + [[ "$key" =~ ^# ]] && continue + [[ ! "$key" =~ ^CFG_ ]] && continue + + bare_suffix="${key##*_}" + # Also catch the two-word _DOMAIN_PREFIX / _PUBLIC_URL cases. + local two_word="${key%_*}" + two_word="${two_word##*_}_${bare_suffix}" + + local is_host_bound=0 + local suffix + for suffix in "${_MIGRATE_HOST_BOUND_SUFFIXES[@]}"; do + if [[ "$bare_suffix" == "$suffix" || "$two_word" == "$suffix" ]]; then + is_host_bound=1 + break + fi + done + (( is_host_bound )) || continue + + # Strip surrounding quotes; keep whatever the template specifies, even + # if empty (a deliberately-blank URL is a valid state). + value="${value%\"}"; value="${value#\"}" + value="${value%\'}"; value="${value#\'}" + + updateConfigOption "$key" "$value" "$deployed" >/dev/null 2>&1 + ((rewritten++)) + migrateEmit phase=url-rewrite status=field app="$app" key="$key" new_value="$value" + done < <(grep -E '^CFG_' "$template" 2>/dev/null) + + isSuccessful "URL rewrite: $rewritten host-bound field(s) repointed to this host for $app" + migrateEmit phase=url-rewrite status=complete app="$app" fields="$rewritten" +} diff --git a/scripts/source/files/arrays/files_migrate.sh b/scripts/source/files/arrays/files_migrate.sh index 5a1e17a..8376026 100755 --- a/scripts/source/files/arrays/files_migrate.sh +++ b/scripts/source/files/arrays/files_migrate.sh @@ -4,8 +4,11 @@ # Do not edit manually - run './scripts/source/files/generate_arrays.sh run' to regenerate migrate_scripts=( - "migrate/migrate_app.sh" + "migrate/migrate_apply.sh" "migrate/migrate_discover.sh" - "migrate/migrate_system.sh" + "migrate/migrate_pre_backup.sh" + "migrate/migrate_preflight.sh" + "migrate/migrate_progress.sh" + "migrate/migrate_url_rewrite.sh" )