#!/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 `` implementation. Adapters live # in scripts/backup/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 # `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= 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 : 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 }