LibrePortal/scripts/dev/lp-preflight-test
librelad 647b19cf4a restore: ask the repository about a snapshot by id, not by app tag
storageSnapshotSourcePath resolved a snapshot's source path with

    engineSnapshotsJson "$idx" "$snapshot_id"

but that function's second parameter is an app TAG filter. So it ran
`restic snapshots --tag app=<snapshot-id>`, matched nothing, and returned 1 —
every time, for every snapshot, since the file was written.

Nothing broke loudly, because both callers have a fallback:

  * storageRestoreAppTo fell through to "restoring in place", reinstating the
    exact cross-root bug the file exists to fix — restoring onto a host whose
    containers root differs from the source's matched no include path and
    restored nothing, silently
  * the first-run preflight never read a manifest, so every app reported size
    "?" and its fit and location checks passed unconditionally. Thirteen green
    ticks that had checked nothing.

Add engineSnapshotPaths: restic answers it with a positional snapshot id, kopia
by filtering its list. borg has no adapter on purpose — it rebuilds its listing
from archive metadata that carries no paths — so a missing adapter is a quiet
"no" and those callers keep their in-place fallback.

Add scripts/dev/lp-preflight-test, which pins the cases that must say NO: an
app too big for the disk, one this version no longer ships, one whose storage
location is gone, and a resolver that reaches for the app-tag filter again.
Verified against both historical bugs — reintroducing either fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:15:15 +01:00

105 lines
4.9 KiB
Bash
Executable File

#!/bin/bash
# Self-contained unit test for the first-run restore preflight
# (scripts/restore/restore_preflight.sh) and the snapshot path resolver it
# leans on (scripts/storage/storage_restore_path.sh). Runs against a throwaway
# tree in $TMPDIR — never touches a real install. Exits non-zero on failure.
#
# scripts/dev/lp-preflight-test
#
# It exists because this preflight has twice shipped checks that could not fail:
#
# * the manifest is pretty-printed, so `"size_bytes": 123` carries a space
# that a `"key":[0-9]*` pattern misses — size came back empty, and the fit
# check was skipped for every app
# * storageSnapshotSourcePath asked engineSnapshotsJson for a snapshot id,
# but that argument is an app TAG filter — so it matched nothing, returned
# 1 every time, and the manifest was never read at all
#
# Both failed silently and *looked* like a clean report: thirteen green ticks.
# So the cases worth keeping are the ones that must say NO.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
BASE="$(mktemp -d "${TMPDIR:-/tmp}/lp-preflight-test-XXXXXX")"
trap 'rm -rf "$BASE"' EXIT
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
has(){ if [[ "$2" == *"$3"* ]]; then echo " ok $1"; else echo " FAIL $1: '$2' lacks '$3'"; fail=1; fi; }
# --- a machine to check against ----------------------------------------------
install_containers_dir="$BASE/templates/"
mkdir -p "$BASE/templates/bookstack" "$BASE/templates/nextcloud" "$BASE/templates/jellyfin" "$BASE/data"
: > "$BASE/templates/bookstack/bookstack.config"
: > "$BASE/templates/nextcloud/nextcloud.config"
: > "$BASE/templates/jellyfin/jellyfin.config"
# note: no template for 'obsolete' — that is case 2
isNotice(){ :; }; isError(){ :; }; isSuccessful(){ :; }; isQuestion(){ :; }
appDir(){ printf '%s/data/%s' "$BASE" "$1"; }
storageLocationName(){ printf 'primary'; }
storageLocationPath(){ [[ "$1" == "here" ]] && { printf '%s/data' "$BASE"; return 0; }; return 1; }
migrateDiscoverApps(){ :; }
engineSnapshotLatestId(){ printf 'abc123'; }
source "$REPO/scripts/restore/restore_preflight.sh"
# Stubbed AFTER the source, or the real definition wins. Manifests are
# pretty-printed exactly as the real ones are — size_bytes at the top level,
# location under "storage" — because the spaces after those colons are the
# point of this fixture, not an accident.
_manifest(){
printf '{\n "app": "%s",\n "size_bytes": %s,\n "storage": {\n "location": "%s"\n }\n}\n' \
"$1" "$2" "$3"
}
restorePreflightManifest(){ _manifest "$2" "$MANIFEST_SIZE" "$MANIFEST_LOC"; }
echo "--- an app that restores as-is ---"
MANIFEST_LOC="default"; MANIFEST_SIZE=147483648
rec=$(restorePreflightApp 1 bookstack somehost)
chk "verdict" "$(cut -f1 <<< "$rec")" "ok"
chk "size parsed" "$(cut -f3 <<< "$rec")" "140M"
echo "--- an app this version no longer ships ---"
rec=$(restorePreflightApp 1 obsolete somehost)
chk "verdict" "$(cut -f1 <<< "$rec")" "skip"
has "reason" "$rec" "no longer ships"
echo "--- an app too big for the disk (the check that could not fail) ---"
MANIFEST_LOC="default"; MANIFEST_SIZE=9007199254740992 # 8 PiB
rec=$(restorePreflightApp 1 jellyfin somehost)
chk "verdict" "$(cut -f1 <<< "$rec")" "skip"
has "reason" "$rec" "needs 8192T"
echo "--- an app whose old storage location is gone ---"
MANIFEST_LOC="ssd"; MANIFEST_SIZE=1048576
rec=$(restorePreflightApp 1 nextcloud somehost)
chk "verdict" "$(cut -f1 <<< "$rec")" "move"
has "reason" "$rec" "not on this machine"
echo "--- a location this machine still has is NOT a move ---"
MANIFEST_LOC="here"; MANIFEST_SIZE=1048576
rec=$(restorePreflightApp 1 nextcloud somehost)
chk "verdict" "$(cut -f1 <<< "$rec")" "ok"
echo "--- storageSnapshotSourcePath picks the app's path from the snapshot ---"
# If this ever calls engineSnapshotsJson again it is passing a snapshot id into
# an app-tag filter, which is the bug. Make that loud rather than silent.
engineSnapshotsJson(){ echo " FAIL resolver used the app-tag filter again" >&2; fail=1; return 1; }
engineSnapshotPaths(){ printf '/mnt/ssd/apps/nextcloud\n/libreportal-containers/bookstack\n'; }
source "$REPO/scripts/storage/storage_restore_path.sh"
chk "matching app" "$(storageSnapshotSourcePath 1 abc123 nextcloud)" "/mnt/ssd/apps/nextcloud"
chk "other app" "$(storageSnapshotSourcePath 1 abc123 bookstack)" "/libreportal-containers/bookstack"
storageSnapshotSourcePath 1 abc123 grafana >/dev/null 2>&1 \
&& { echo " FAIL absent app should not resolve"; fail=1; } \
|| echo " ok absent app does not resolve"
# An engine with no adapter (borg) must fall back quietly, not error.
engineSnapshotPaths(){ return 1; }
storageSnapshotSourcePath 1 abc123 bookstack >/dev/null 2>&1 \
&& { echo " FAIL engine without paths should not resolve"; fail=1; } \
|| echo " ok engine without paths falls back"
echo ""
if (( fail )); then echo "FAILED"; exit 1; fi
echo "All preflight checks passed."