LibrePortal/scripts/migrate/migrate_preflight.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

148 lines
5.8 KiB
Bash
Raw 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 "$(appDir "$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 "$(appDir "$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
}