LibrePortal/scripts/storage/storage_restore_path.sh
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

98 lines
3.7 KiB
Bash

#!/bin/bash
# Resolving where a snapshot's data actually lives, and where it should land.
#
# The bug this exists to fix predates storage locations. Restore did:
#
# engineRestoreSnapshot "$idx" "$id" "/" "$containers_dir$app"
#
# — restore to / with an include filter built from the LOCAL containers root.
# restic reproduces a snapshot's absolute paths, so that only works when source
# and destination paths are byte-identical. LibrePortal has shipped three
# configurable roots for a while, so migrating from a host installed with
# --containers-dir=/mnt/ssd/apps onto a default host matched no include path and
# restored NOTHING, silently. Storage locations make that ordinary rather than
# rare.
#
# The fix is to take the source path from the SNAPSHOT rather than from local
# config, and to stage-and-move whenever it differs from where the app belongs
# here.
# The path an app occupied in a snapshot. restic records the backed-up paths on
# the snapshot itself, so ask the repository rather than guessing.
storageSnapshotSourcePath()
{
local idx="$1" snapshot_id="$2" app="$3"
local path
# Ask about this ONE snapshot. The first version passed the id to
# engineSnapshotsJson, whose second parameter is an app TAG filter — so it
# searched for `--tag app=<snapshot-id>`, matched nothing, and returned 1
# every single time. Nothing broke loudly, because both callers have a
# fallback: storageRestoreAppTo restored in place (reinstating the
# cross-root bug this file was written to fix) and the restore preflight
# reported every app's size as "?" while its fit and location checks passed
# unconditionally.
path=$(engineSnapshotPaths "$idx" "$snapshot_id" 2>/dev/null \
| grep -E "/${app}(/|$)" | head -1)
[[ -n "$path" ]] || return 1
printf '%s' "${path%/}"
}
# Restore an app from a snapshot to wherever it belongs on THIS host.
#
# Same path on both sides -> restore in place, exactly as before.
# Different -> restore into staging, then move the tree into position. The move
# goes through the root helper because the restored tree carries container
# sub-UIDs the manager cannot handle.
storageRestoreAppTo()
{
local idx="$1" snapshot_id="$2" app="$3"
local dest
if ! dest=$(appDir "$app"); then
isError "Cannot restore $app — its storage location is not mounted."
return 1
fi
local src
if ! src=$(storageSnapshotSourcePath "$idx" "$snapshot_id" "$app"); then
# Older snapshot, or an engine that does not report paths: fall back to
# the historical behaviour rather than refusing.
isNotice "Snapshot does not report its source path — restoring in place."
engineRestoreSnapshot "$idx" "$snapshot_id" "/" "$dest"
return $?
fi
if [[ "${src%/}" == "${dest%/}" ]]; then
engineRestoreSnapshot "$idx" "$snapshot_id" "/" "$src"
return $?
fi
isNotice "This snapshot was taken at '$src'; restoring to '$dest'."
local stage="${restore_dir%/}/relocate-$app.$$"
runInstallOp mkdir -p "$stage"
if ! engineRestoreSnapshot "$idx" "$snapshot_id" "$stage" "$src"; then
isError "Restore into staging failed."
runInstallOp rm -rf "$stage"
return 1
fi
# restic reproduces the full absolute path beneath --target.
local staged="$stage/${src#/}"
if [[ ! -d "$staged" ]]; then
isError "Restored tree not found at '$staged' — leaving staging in place for inspection."
return 1
fi
if ! runOwnership app-adopt "$app" "$staged" "${dest%/*}"; then
isError "Could not move the restored tree into '$dest' — it is still at '$staged'."
return 1
fi
runInstallOp rm -rf "$stage"
isSuccessful "Restored $app to $dest"
return 0
}