LibrePortal/scripts/storage/storage_restore_path.sh
librelad 8fad6c6a4d fix(restore): take the source path from the snapshot, not local config
Restore built its restic include filter from THIS host's containers root:

    engineRestoreSnapshot "$idx" "$id" "/" "$containers_dir$app"

restic reproduces a snapshot's absolute paths, so that only works when
both sides agree byte-for-byte. LibrePortal has shipped configurable
roots for a while, so restoring a snapshot taken on a host installed with
--containers-dir=/mnt/ssd/apps onto a default host matched no include
path and restored NOTHING — with no error, because an include filter that
matches nothing is not a failure. Storage locations turn that from a rare
cross-host case into an ordinary one.

storageSnapshotSourcePath asks the repository where the app actually
lived. storageRestoreAppTo restores in place when that agrees with where
the app belongs here, and stages-then-moves when it does not — which is
also what makes "restore this app onto a different disk" possible at all.
Both restore_app_start.sh and resticRestoreAppLatest go through it, and
both fall back to the old behaviour when a snapshot does not report its
paths, so older snapshots restore exactly as before.

The move into place runs as root (app-adopt) for the same reason app-move
does: a restored tree carries container sub-UIDs the manager cannot
recreate. Staging is constrained to the restore/migrate area and the
destination is validated against the root-owned registry, so neither end
is taken on trust from the caller.

The manifest now records where an app lived — location name, path and fs
uuid. The name is what travels, since a path means nothing on the other
host; the rest is for diagnostics and for answering "is this the same
disk?" during a migrate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:39:35 +01:00

97 lines
3.4 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 json path
json=$(engineSnapshotsJson "$idx" "$snapshot_id" 2>/dev/null) || return 1
# "paths":["/libreportal-containers/bookstack"]
path=$(printf '%s' "$json" \
| grep -o '"paths":\[[^]]*\]' \
| head -1 \
| grep -o '"/[^"]*"' \
| tr -d '"' \
| 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
}