diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md index 03c9bad..06a6498 100644 --- a/docs/roadmap/first-run-restore.md +++ b/docs/roadmap/first-run-restore.md @@ -146,7 +146,7 @@ Same components, other direction. After a **New install**, offer: *"Where should Worth doing as its own wizard step even without the restore branch. -## 7. On "upload the backup file" — why the format differs +## 6. On "upload the backup file" — why the format differs Worth being precise, because the mental model doesn't match the engines. restic, borg and kopia back up to a **repository** — a directory or a remote — not a single file. There is nothing to upload. The equivalents are: @@ -155,9 +155,9 @@ Worth being precise, because the mental model doesn't match the engines. restic, If a genuine single-file import is wanted, that is a **different feature**: a portable per-app export (`tar` of the app dir + manifest, optionally encrypted) that could be handed around and imported. Cheap to build on the manifest that already exists, but it is not what the backup engines produce and shouldn't be conflated with them. -## 6. Portable export — the single file people actually mean +## 7. Portable export — the single file people actually mean -§7 explains why "upload the backup file" does not match a restic repository. +§6 explains why "upload the backup file" does not match a restic repository. But the underlying want is real and worth serving directly: **one file, one app, hand it around**. diff --git a/init.sh b/init.sh index bb4f3a9..f93f0d1 100755 --- a/init.sh +++ b/init.sh @@ -480,20 +480,27 @@ initRestoreFromBackup() return 0 fi - echo "" - echo " Apps in this backup:" + local -a app_list=() local a - while IFS= read -r a; do printf ' %s\n' "$a"; done <<< "$apps" + while IFS= read -r a; do [[ -n "$a" ]] && app_list+=("$a"); done <<< "$apps" + + # Preflight before anything is written: each app's manifest is read out of + # its own snapshot and checked against this machine, so an app this version + # no longer ships, or one that will not fit, is skipped rather than restored + # into something that cannot start. echo "" - isQuestion "Restore all of them? [Y/n]:" + # No app list: preflight discovers the host's apps itself, so nothing is + # truncated by the CLI wrapper's nine-slot argv. + "${as_manager[@]}" libreportal restore preflight "$idx" "$source_host" + echo "" + + isQuestion "Continue? [Y/n]:" local yn; read -r yn; echo "" case "$yn" in - [nN]*) isNotice "Skipped. Restore them any time from the Backup page."; return 0 ;; + [nN]*) isNotice "Skipped. Restore any time from the Backup page."; return 0 ;; esac - local -a app_list=() - while IFS= read -r a; do [[ -n "$a" ]] && app_list+=("$a"); done <<< "$apps" - isNotice "Restoring ${#app_list[@]} apps — this takes a while." + isNotice "Restoring — this takes a while." "${as_manager[@]}" libreportal restore first-run bulk "$idx" "$source_host" "${app_list[@]}" # The wizard would ask for identity, domains and apps that the backup has diff --git a/scripts/cli/commands/restore/cli_restore_commands.sh b/scripts/cli/commands/restore/cli_restore_commands.sh index f1d172c..289543c 100755 --- a/scripts/cli/commands/restore/cli_restore_commands.sh +++ b/scripts/cli/commands/restore/cli_restore_commands.sh @@ -79,6 +79,20 @@ cliHandleRestoreCommands() ;; esac ;; + preflight) + # `restore preflight ` — read every app's + # manifest out of its snapshot and report what would happen, without + # writing anything. Used by the installer's restore path. + if [[ -z "$restore_type" || -z "$action" ]]; then + isNotice "Usage: restore preflight [app2 ...]" + return 1 + fi + local pf_idx="$action" pf_host="$name" + # LP_CLI_ARGS, not "$@": handlers are invoked with no arguments, so + # $@ here is empty. Elements 0..3 are restore/preflight/idx/host. + restorePreflightReport "$pf_idx" "$pf_host" "${LP_CLI_ARGS[@]:4}" + ;; + first-run) case "$action" in discover) @@ -89,8 +103,17 @@ cliHandleRestoreCommands() [[ -z "$name" || -z "$extra" ]] && { isNotice "Usage: restore first-run bulk [app2 ...]"; return; } local loc_idx="$name" local source_host="$extra" - shift 4 - restoreFirstRunBulk "$loc_idx" "$source_host" "$@" + # Same fix as `restore preflight`: `shift 4; "$@"` here was + # operating on an empty list, because the CLI dispatcher + # calls handlers without arguments — so a bulk restore + # silently received zero apps. Unset wrapper slots arrive as + # the literal "empty", so those are dropped too. + local -a _bulk_apps=() _b + for _b in "${LP_CLI_ARGS[@]:5}"; do + [[ -z "$_b" || "$_b" == "empty" ]] && continue + _bulk_apps+=("$_b") + done + restoreFirstRunBulk "$loc_idx" "$source_host" "${_bulk_apps[@]}" ;; *) cliShowRestoreHelp ;; esac diff --git a/scripts/restore/restore_preflight.sh b/scripts/restore/restore_preflight.sh new file mode 100644 index 0000000..b21bb12 --- /dev/null +++ b/scripts/restore/restore_preflight.sh @@ -0,0 +1,172 @@ +#!/bin/bash + +# Preflight for a first-run restore: check every app in a backup against THIS +# machine, before anything is written. +# +# The whole thing hinges on one fact — each app's snapshot carries its own +# .libreportal-manifest.json, and engineDumpFile pulls a single file out of a +# snapshot without restoring it. So we can read what an app needs (its size, +# where it used to live, which images it runs) and compare that to the machine +# in front of us, while it is still free to say no. +# +# What it catches, and why each matters more than a failed restore would: +# +# template missing restoring an app this version no longer ships produces a +# directory that can never start, and looks like a +# successful restore until the user tries to open it +# won't fit filling the disk mid-restore takes the OTHER apps with +# it, so this must be decided per app, up front +# location gone the manifest names a storage location this machine does +# not have; falling back is right, but silently is not +# +# Emits one record per app on stdout: +# +# \t\t\t +# +# verdict is ok | move | skip. Returns non-zero only if it could not read the +# repository at all — a skipped app is a finding, not a failure. + +# Human-readable bytes, for a report a person reads rather than parses. +_restorePfSize() +{ + local b="${1:-0}" + [[ "$b" =~ ^[0-9]+$ ]] || { printf '?'; return; } + if (( b >= 1099511627776 )); then printf '%sT' "$(( b / 1099511627776 ))" + elif (( b >= 1073741824 )); then printf '%sG' "$(( b / 1073741824 ))" + elif (( b >= 1048576 )); then printf '%sM' "$(( b / 1048576 ))" + else printf '%sK' "$(( b / 1024 ))"; fi +} + +# Pull one app's manifest out of its newest snapshot. Empty on failure — an +# older backup may predate manifests, and that is not a reason to refuse. +restorePreflightManifest() +{ + local idx="$1" app="$2" host="$3" + local snap; snap=$(engineSnapshotLatestId "$idx" "$app" "$host" 2>/dev/null) + [[ -n "$snap" ]] || return 1 + + # The manifest sits at the app dir's root, but that path is the SOURCE + # machine's — which is exactly what we cannot assume. Ask the snapshot where + # its own files are rather than guessing. + local base + base=$(storageSnapshotSourcePath "$idx" "$snap" "$app" 2>/dev/null) + [[ -n "$base" ]] || return 1 + + engineDumpFile "$idx" "$snap" "$base/.libreportal-manifest.json" 2>/dev/null +} + +# Check one app. Echoes a single verdict record. +restorePreflightApp() +{ + local idx="$1" app="$2" host="$3" + local manifest size_bytes size_h loc want_dir avail_kb need_kb + + manifest=$(restorePreflightManifest "$idx" "$app" "$host" 2>/dev/null) + size_bytes=$(printf '%s' "$manifest" | grep -o '"size_bytes":[0-9]*' | head -1 | cut -d: -f2) + loc=$(printf '%s' "$manifest" | grep -o '"location":"[^"]*"' | head -1 | cut -d'"' -f4) + size_h=$(_restorePfSize "${size_bytes:-0}") + [[ -z "$size_bytes" ]] && size_h="?" + + # 1. Does this version still ship the app? + if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then + printf 'skip\t%s\t%s\t%s\n' "$app" "$size_h" "this version no longer ships it" + return 0 + fi + + # 2. Where would it go, and does that location still exist? + local detail="" verdict="ok" + if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]]; then + if ! storageLocationPath "$loc" >/dev/null 2>&1; then + verdict="move" + detail="its old location \\"$loc\\" is not on this machine" + fi + fi + + want_dir=$(appDir "$app" 2>/dev/null) || { + printf 'skip\t%s\t%s\t%s\n' "$app" "$size_h" "its storage location is not mounted" + return 0 + } + + # 3. Will it fit? Checked per app: filling the disk part-way through a + # restore damages the apps that already landed. + if [[ -n "$size_bytes" && "$size_bytes" =~ ^[0-9]+$ ]]; then + need_kb=$(( size_bytes / 1024 )) + avail_kb=$(df -Pk "${want_dir%/*}" 2>/dev/null | awk 'NR==2 {print $4}') + if [[ -n "$avail_kb" ]] && (( avail_kb < need_kb )); then + printf 'skip\t%s\t%s\t%s\n' "$app" "$size_h" \ + "needs $size_h, $(( avail_kb / 1048576 ))G free where it would go" + return 0 + fi + fi + + if [[ "$verdict" == "move" ]]; then + printf 'move\t%s\t%s\t-> %s (%s)\n' "$app" "$size_h" "$(storageLocationName "${want_dir%/*}")" "$detail" + else + printf 'ok\t%s\t%s\t%s\n' "$app" "$size_h" "restores as-is" + fi + return 0 +} + +# Check a whole list. Prints the report and leaves the apps worth restoring in +# RESTORE_PREFLIGHT_OK. +restorePreflightReport() +{ + local idx="$1" host="$2"; shift 2 + + # Callers may pass an explicit list, but the CLI wrapper pads its argv to + # nine slots and fills the unset ones with the literal string "empty" + # (${5:-empty} … ${9:-empty} in /usr/local/lib/libreportal/libreportal). + # So a trailing slot arrives as a five-character app name, not a blank — + # which is why filtering on -n alone let five phantom apps through and + # reported them as "no longer shipped". + # + # That also caps any explicit list at five apps, so when nothing real is + # left we discover the host's apps from the repository instead: a whole-host + # restore then has no list to truncate. + local -a apps=() + local _a + for _a in "$@"; do + [[ -z "$_a" || "$_a" == "empty" ]] && continue + apps+=("$_a") + done + if (( ${#apps[@]} == 0 )); then + while IFS= read -r _a; do [[ -n "$_a" ]] && apps+=("$_a"); done \ + < <(migrateDiscoverApps "$host" "$idx" 2>/dev/null) + fi + + RESTORE_PREFLIGHT_OK=() + RESTORE_PREFLIGHT_SKIPPED=0 + + if (( ${#apps[@]} == 0 )); then + isNotice "No apps found for '$host' in this repository." + return 0 + fi + + isNotice "Checking ${#apps[@]} apps against this machine…" + echo "" + + local rec verdict app size detail + for app in "${apps[@]}"; do + rec=$(restorePreflightApp "$idx" "$app" "$host") + IFS=$'\t' read -r verdict app size detail <<< "$rec" + case "$verdict" in + skip) + printf ' \033[0;31m✗\033[0m %-16s %-6s %s\n' "$app" "$size" "skipped — $detail" + RESTORE_PREFLIGHT_SKIPPED=$(( RESTORE_PREFLIGHT_SKIPPED + 1 )) ;; + move) + printf ' \033[0;33m~\033[0m %-16s %-6s %s\n' "$app" "$size" "$detail" + RESTORE_PREFLIGHT_OK+=("$app") ;; + *) + printf ' \033[0;32m✓\033[0m %-16s %-6s %s\n' "$app" "$size" "$detail" + RESTORE_PREFLIGHT_OK+=("$app") ;; + esac + done + + echo "" + if (( RESTORE_PREFLIGHT_SKIPPED > 0 )); then + isNotice "${#RESTORE_PREFLIGHT_OK[@]} will restore, $RESTORE_PREFLIGHT_SKIPPED skipped." + else + isNotice "All ${#RESTORE_PREFLIGHT_OK[@]} will restore." + fi + return 0 +} diff --git a/scripts/source/files/arrays/files_restore.sh b/scripts/source/files/arrays/files_restore.sh index e347772..8993e25 100755 --- a/scripts/source/files/arrays/files_restore.sh +++ b/scripts/source/files/arrays/files_restore.sh @@ -8,5 +8,6 @@ restore_scripts=( "restore/restore_app_pick.sh" "restore/restore_app_start.sh" "restore/restore_first_run.sh" + "restore/restore_preflight.sh" ) diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 5a29078..bfa886e 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -901,7 +901,11 @@ declare -gA LP_FN_MAP=( [restoreFilesRehydratePreStart]="backup/files/backup_files.sh" [restoreFirstRunBulk]="restore/restore_first_run.sh" [restoreFirstRunDiscover]="restore/restore_first_run.sh" + [_restorePfSize]="restore/restore_preflight.sh" [restorePickSnapshot]="restore/restore_app_pick.sh" + [restorePreflightApp]="restore/restore_preflight.sh" + [restorePreflightManifest]="restore/restore_preflight.sh" + [restorePreflightReport]="restore/restore_preflight.sh" [_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh" [_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh" [_rocketchatError]="rocketchat/scripts/rocketchat_auth.sh" @@ -2125,7 +2129,11 @@ declare -gA LP_FN_ROOT=( [restoreFilesRehydratePreStart]="scripts" [restoreFirstRunBulk]="scripts" [restoreFirstRunDiscover]="scripts" + [_restorePfSize]="scripts" [restorePickSnapshot]="scripts" + [restorePreflightApp]="scripts" + [restorePreflightManifest]="scripts" + [restorePreflightReport]="scripts" [_rocketchatApi]="containers" [_rocketchatBaseUrl]="containers" [_rocketchatError]="containers" @@ -3386,7 +3394,11 @@ restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${ restoreFilesRehydratePreStart() { unset -f restoreFilesRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/files/backup_files.sh"; restoreFilesRehydratePreStart "$@"; } restoreFirstRunBulk() { unset -f restoreFirstRunBulk; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunBulk "$@"; } restoreFirstRunDiscover() { unset -f restoreFirstRunDiscover; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunDiscover "$@"; } +_restorePfSize() { unset -f _restorePfSize; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; _restorePfSize "$@"; } restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_scripts_dir}restore/restore_app_pick.sh"; restorePickSnapshot "$@"; } +restorePreflightApp() { unset -f restorePreflightApp; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightApp "$@"; } +restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightManifest "$@"; } +restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; } _rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; } _rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; } _rocketchatError() { unset -f _rocketchatError; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatError "$@"; } diff --git a/start.sh b/start.sh index ebbd186..9602f38 100755 --- a/start.sh +++ b/start.sh @@ -8,6 +8,11 @@ initial_command4="$4" initial_command5="$5" initial_command6="$6" initial_command7="$7" +# The full argument vector, for commands that take an open-ended list (restore +# preflight / first-run bulk take any number of app names). The CLI dispatcher +# calls its handlers with NO arguments, so "$@" and `shift` inside a handler +# operate on an empty list — which silently truncated those lists to nothing. +declare -a LP_CLI_ARGS=("$@") displayLibrePortalLogo() {