LibrePortal/scripts/storage/storage_restore_path.sh
librelad c9779d6581 restore: create staging as the principal that writes to it
`restore system` reported

    ✓ Success System config restored to: /libreportal-system/restore/system-config

for a directory that did not exist. Nothing had been written — on the step the
whole restore ordering depends on, since the system config carries every other
backup location's credentials.

Restore stages through $SYSTEM_DIR, which the manager owns, but the thing that
writes into the staging tree is restic, and runBackupOp runs it as the container
user. Both call sites created the directory as the wrong principal, in opposite
directions:

  backupRestoreSystemConfig  runFileOp mkdir  -> container user; denied on the
                             0751 manager-owned restore_dir, and unchecked
  storageRestoreAppTo        runInstallOp mkdir -> manager; restic could then
                             not create anything beneath it

Restic reports a permission denial as "ignoring error ..." and still exits 0, so
the callers' success checks were satisfied either way.

libreportal-ownership gains restore-stage (creates it cowner:MANAGER 0750 —
owner writes, manager traverses to confirm and review) and restore-unstage
(removes it; neither principal can, so staging trees simply accumulated). Both
confine the path to one component directly under the restore/migrate area.
footprint_version 8 -> 9.

backupRestoreSystemConfig now verifies the tree landed as the user that wrote
it, because the manager cannot read inside its own staging directory.

Verified on a live install: system config stages 57 real files, and the
relocation branch of storageRestoreAppTo ran for the first time — speedtest
restored from a snapshot taken at /libreportal-containers/speedtest into
/libreportal-alt/speedtest via stage-and-move, staging cleaned up afterwards.

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

104 lines
4.1 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'."
# Created by root and handed to the container user: restic writes here via
# runBackupOp, so a manager-owned staging dir meant every file failed with
# "mkdir … permission denied" while restic still exited 0.
local stage="${restore_dir%/}/relocate-$app.$$"
if ! runOwnership restore-stage "$stage"; then
isError "Could not create staging at $stage"
return 1
fi
if ! engineRestoreSnapshot "$idx" "$snapshot_id" "$stage" "$src"; then
isError "Restore into staging failed."
runOwnership restore-unstage "$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
runOwnership restore-unstage "$stage"
isSuccessful "Restored $app to $dest"
return 0
}