LibrePortal/scripts/backup/engine/engine_dispatch.sh
librelad 226ebe1717 backup: give borg and kopia the same ownership mapping as restic
The user-namespace prefix that lets an unprivileged restore put back a file's
original owner was only wired into restic. borg extract and kopia snapshot
restore run as the same backup user with the same lack of CAP_CHOWN, so both
lost <container-uid>:<backup-user> exactly the way restic did — an app whose
data comes back owned by the backup user cannot write it, which is how grafana
kept dying with "attempt to write a readonly database".

borg is quieter about it than restic: it does not print an "ignoring error"
line at all, so there was nothing to notice.

Move the prefix to engine_dispatch.sh as backupUsernsPrefix — it was never
restic-specific — and use it from all three engines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:20:31 +01:00

193 lines
8.4 KiB
Bash

#!/bin/bash
# Per-location engine dispatcher. Resolves the engine for a given location
# (CFG_BACKUP_LOC_N_ENGINE → CFG_BACKUP_ENGINE → 'restic'), then forwards to
# the engine adapter's `<engine><FunctionName>` implementation. Adapters live
# in scripts/backup/engine/<engine>_*.sh; today restic_*.sh is the only one.
engineForLocation()
{
local idx="$1"
local var="CFG_BACKUP_LOC_${idx}_ENGINE"
local e="${!var}"
[[ -z "$e" ]] && e="${CFG_BACKUP_ENGINE:-restic}"
echo "$e"
}
engineKnownIds()
{
# List adapter implementations discovered by looking for the canonical
# `<engine>BackupAppToLocation` function name registered at source time.
compgen -A function 2>/dev/null | grep -oE '^[a-z]+BackupAppToLocation$' | sed 's/BackupAppToLocation//' | sort -u
}
engineDispatch()
{
# Internal helper: call $1=<engine><FunctionName> with the remaining args.
# Falls back with a clear error if the adapter doesn't implement it.
local fn="$1"
shift
if ! declare -f "$fn" >/dev/null 2>&1; then
isError "Backup engine has no '$fn' implementation"
return 1
fi
"$fn" "$@"
}
# Transparent per-refresh memoiser for read-only remote pulls. The WebUI backup
# refresh reads the same restic data from several generators per location — the
# snapshot list (dashboard/snapshots/app-status/migrate) and repo stats
# (locations + dashboard) — which on a remote (SSH) repo is one round-trip each.
# When LP_SNAP_CACHE_DIR is set (webui_updater wraps the refresh chain with it),
# the first successful pull for a cache key is written to a file the siblings
# reuse; empty/failed pulls fall through so a transient error is never cached.
# Unset dir → every call runs live.
_engineCachedPull() {
local _key="$1"; shift
if [[ -n "${LP_SNAP_CACHE_DIR:-}" ]]; then
local _cf="${LP_SNAP_CACHE_DIR}/${_key}"
[[ -s "$_cf" ]] && { cat "$_cf"; return 0; }
local _out _rc
_out=$("$@"); _rc=$?
[[ $_rc -eq 0 && -n "$_out" ]] && printf '%s' "$_out" > "$_cf" 2>/dev/null
printf '%s' "$_out"
return $_rc
fi
"$@"
}
# ---- Idx-scoped dispatchers ----------------------------------------------------
# Local/removable-drive safety guard runs before init, readiness, and any backup
# write (see backupLocationLocalGuard) — refuses to write when a REQUIRE_MOUNT
# drive isn't mounted, so restic never fills the system disk.
engineInitLocation() { local i="$1"; backupLocationLocalGuard "$i" || return 1; engineDispatch "$(engineForLocation "$i")InitLocation" "$i"; }
engineEnsureLocationReady() { local i="$1"; backupLocationLocalGuard "$i" || return 1; engineDispatch "$(engineForLocation "$i")EnsureLocationReady" "$i"; }
enginePasswordEnsure() { local i="$1"; engineDispatch "$(engineForLocation "$i")PasswordEnsure" "$i"; }
engineLocationUri() { local i="$1"; engineDispatch "$(engineForLocation "$i")LocationUri" "$i"; }
engineLocationStats() { local i="$1"; _engineCachedPull "stats_${i}.json" engineDispatch "$(engineForLocation "$i")LocationStats" "$i"; }
engineEnvExport() { local i="$1"; engineDispatch "$(engineForLocation "$i")EnvExport" "$i"; }
engineEnvUnset() { local i="$1"; engineDispatch "$(engineForLocation "${i:-1}")EnvUnset"; }
engineBackupApp() { local i="$1"; shift; backupLocationLocalGuard "$i" || return 1; engineDispatch "$(engineForLocation "$i")BackupAppToLocation" "$i" "$@"; }
engineBackupSystem() { local i="$1"; shift; backupLocationLocalGuard "$i" || return 1; engineDispatch "$(engineForLocation "$i")BackupSystemToLocation" "$i" "$@"; }
engineRestoreSystemLatest() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")RestoreSystemLatest" "$i" "$@"; }
engineRestoreSnapshot() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")RestoreSnapshot" "$i" "$@"; }
engineSnapshotLatestId() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")SnapshotLatestId" "$i" "$@"; }
# Whole-repo snapshot list. Only the unfiltered pull (no extra args) is memoised
# — the four generators that read it that way per location share one round-trip;
# filtered/parameterised calls always run live.
engineSnapshotsJson() {
local i="$1"; shift
if [[ $# -eq 0 ]]; then
_engineCachedPull "snapshots_${i}.json" engineDispatch "$(engineForLocation "$i")SnapshotsJson" "$i"
return $?
fi
engineDispatch "$(engineForLocation "$i")SnapshotsJson" "$i" "$@"
}
engineSystemSnapshotsJson() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")SystemSnapshotsJson" "$i" "$@"; }
engineSnapshotListFiles() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")SnapshotListFiles" "$i" "$@"; }
engineForgetApp() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")ForgetApp" "$i" "$@"; }
engineForgetSystem() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")ForgetSystem" "$i" "$@"; }
engineCheckLocation() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")CheckLocation" "$i" "$@"; }
engineDumpFile() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")DumpFile" "$i" "$@"; }
# The paths a single snapshot was taken from. Not every engine can answer: borg
# reconstructs its listing from archive metadata that carries no paths, so it
# has no adapter on purpose. A missing adapter is therefore a quiet "no" rather
# than engineDispatch's error — callers fall back to restoring in place, which
# is correct for borg and merely conservative elsewhere.
# Prefix that lets an unprivileged restore put back the ownership a file had
# when it was backed up. Engine-neutral: restic, borg and kopia all extract as
# the backup user, and all three lose <container-uid>:<backup-user> without it.
#
# The mapping itself lives in backup/engine/restic-userns-exec, because it needs
# three id ranges at once and `unshare` takes one per option. The checks here
# only decide whether to reach for it; the helper re-checks and falls back to
# running the command plainly if anything is missing.
backupUsernsPrefix()
{
local usr="${docker_install_user:-dockerinstall}"
local helper="${install_scripts_dir%/}/backup/engine/restic-userns-exec"
[[ -r "$helper" ]] || return 0
command -v unshare >/dev/null 2>&1 || return 0
command -v newuidmap >/dev/null 2>&1 || return 0
command -v newgidmap >/dev/null 2>&1 || return 0
# No subuid range (rooted mode, or a hand-rolled account) — nothing to map,
# so leave the call exactly as it was rather than guess.
grep -q "^${usr}:" /etc/subuid 2>/dev/null || return 0
grep -q "^${usr}:" /etc/subgid 2>/dev/null || return 0
printf '%s\n' bash "$helper"
}
engineSnapshotPaths() {
local i="$1"; shift
local fn; fn="$(engineForLocation "$i")SnapshotPaths"
declare -f "$fn" >/dev/null 2>&1 || return 1
"$fn" "$i" "$@"
}
# ---- Aggregate helpers (iterate enabled locations) ---------------------------
engineInstallAll()
{
if ! declare -f resticEnabledLocations >/dev/null 2>&1; then
isError "engineInstallAll: location helpers not loaded yet"
return 1
fi
declare -A seen
local idx engine fn
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
engine=$(engineForLocation "$idx")
[[ -n "${seen[$engine]}" ]] && continue
seen[$engine]=1
fn="${engine}Install"
if declare -f "$fn" >/dev/null 2>&1; then
"$fn"
fi
done < <(resticEnabledLocations)
}
engineInitAllLocations()
{
isHeader "Backup Location Initialization"
local idx
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
engineInitLocation "$idx"
done < <(resticEnabledLocations)
}
engineEnsureAllLocationsReady()
{
engineInstallAll
local idx
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
engineEnsureLocationReady "$idx"
done < <(resticEnabledLocations)
}
engineForgetAppAllLocations()
{
local app="$1"
local idx
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
engineForgetApp "$idx" "$app"
done < <(resticEnabledLocations)
}
engineCheckAllLocations()
{
local pct="$1"
local idx
local failed=0
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
engineCheckLocation "$idx" "$pct" || failed=$((failed + 1))
done < <(resticEnabledLocations)
return $failed
}