#!/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 }