LibrePortal/scripts/backup/app/backup_app_start.sh
librelad a86c142e74 fix(backup): honest failures, rootless docker in capture paths, speedtest key capture
- backup_files.sh / backup_db.sh: every docker exec/run in the capture,
  sidecar-discovery, rehydrate and DB-import paths now goes through
  runFileOp — bare docker can't reach the rootless daemon socket, which
  made live capture fail (and silently bounce containers) on every
  rootless install, and would have broken DB restores the same way.
- capture/rehydrate stderr is kept and printed on failure instead of
  being discarded, with a clear message when the image has no tar.
- backup_app_start.sh: when no location produced a complete snapshot the
  backup now returns 1 — the task is marked failed instead of logging a
  nonexistent/incomplete backup as a success and skipping verification.
- restic engine: on restic exit 3 the orphan incomplete snapshot is
  called out explicitly so nobody restores it believing it is whole.
- speedtest: capture /config through the container (root-owned TLS key
  and logrotate state are unreadable from the host), which also flips
  its auto strategy to live — no more container stop per backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:31:46 +01:00

174 lines
6.0 KiB
Bash
Executable File

#!/bin/bash
backupAppStart()
{
local app_name="$1"
local stored_app_name="$app_name"
if [[ -z "$app_name" ]]; then
isError "backupAppStart called with empty app_name"
return 1
fi
if [[ ! -d "$containers_dir$app_name" ]]; then
isError "Cannot back up '$app_name' — not installed at $containers_dir$app_name"
return 1
fi
if [[ -z "$(resticEnabledLocations)" ]]; then
isError "No backup locations enabled — configure at least one on the Locations page before running backups."
return 1
fi
engineEnsureAllLocationsReady
isHeader "Backing up $stored_app_name"
((menu_number++))
echo ""
echo "---- $menu_number. Running pre-backup hook (if present)"
echo ""
backupAppRunHook "$stored_app_name" pre
local strategy
strategy=$(backupResolveStrategy "$stored_app_name")
((menu_number++))
echo ""
echo "---- $menu_number. Quiescing container(s) for $stored_app_name (strategy: $strategy)"
echo ""
if [[ "$strategy" == "pause-snapshot-unpause" ]]; then
dockerComposePause "$stored_app_name" 2>/dev/null || dockerComposeDown "$stored_app_name"
elif [[ "$strategy" == "live" ]]; then
isNotice "Live strategy — containers stay running; databases dumped + private files captured via their containers"
if ! backupDbDump "$stored_app_name" || ! backupFilesCapture "$stored_app_name"; then
isError "Live capture failed — falling back to stop-snapshot-start for safety"
runFileOp rm -rf "${containers_dir:?}$stored_app_name/.lp-backup"
strategy="stop-snapshot-start"
dockerComposeDown "$stored_app_name"
fi
else
dockerComposeDown "$stored_app_name"
fi
((menu_number++))
echo ""
echo "---- $menu_number. Writing backup manifest"
echo ""
local manifest_sha
manifest_sha=$(manifestWrite "$stored_app_name")
checkSuccess "Manifest written (sha: ${manifest_sha:0:8})"
((menu_number++))
echo ""
echo "---- $menu_number. Snapshotting to all enabled locations"
echo ""
# On the live path the raw DB data dirs (superseded by the dumps) and the
# private file trees (superseded by the container-side captures) are excluded
# so the snapshot carries only the consistent copies. Other strategies
# quiesced the app, so keep everything.
backup_exclude_paths=""
if [[ "$strategy" == "live" ]]; then
backup_exclude_paths=$(printf '%s\n%s\n' \
"$(backupDbExcludePaths "$stored_app_name")" \
"$(backupFilesExcludePaths "$stored_app_name")")
fi
local primary_snapshot_id=""
local primary_idx=""
local first_loc=true
local snap_ok=0 snap_failed=0
local idx
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
local snap_id
snap_id=$(engineBackupApp "$idx" "$stored_app_name" "$manifest_sha")
if [[ -n "$snap_id" ]]; then
snap_ok=$((snap_ok + 1))
if [[ "$first_loc" == true ]]; then
primary_snapshot_id="$snap_id"
primary_idx="$idx"
first_loc=false
fi
else
snap_failed=$((snap_failed + 1))
fi
done < <(resticEnabledLocations)
((menu_number++))
echo ""
echo "---- $menu_number. Restarting container(s) for $stored_app_name"
echo ""
if [[ "$strategy" == "pause-snapshot-unpause" ]]; then
dockerComposeUnpause "$stored_app_name" 2>/dev/null || dockerComposeUp "$stored_app_name"
elif [[ "$strategy" != "live" ]]; then
dockerComposeUp "$stored_app_name"
fi
((menu_number++))
echo ""
echo "---- $menu_number. Running post-backup hook (if present)"
echo ""
backupAppRunHook "$stored_app_name" post
# Containers are back up; now be honest about the snapshot result. With no
# good snapshot on any location there is nothing to verify, retain, or
# record — returning non-zero here is what marks the task failed instead of
# logging a backup that doesn't exist (or is missing files) as a success.
if [[ $snap_ok -eq 0 ]]; then
isError "Backup of $stored_app_name FAILED — no complete snapshot was created on any location (see errors above)"
menu_number=0
return 1
fi
if [[ $snap_failed -gt 0 ]]; then
isNotice "Backup of $stored_app_name succeeded on $snap_ok location(s) but failed on $snap_failed — check the errors above"
fi
if [[ "$CFG_BACKUP_VERIFY_AFTER" == "true" && -n "$primary_snapshot_id" ]]; then
((menu_number++))
echo ""
echo "---- $menu_number. Verifying snapshot integrity"
echo ""
backupVerifySnapshot "$primary_idx" "$primary_snapshot_id" "$stored_app_name"
fi
((menu_number++))
echo ""
echo "---- $menu_number. Applying retention policy"
echo ""
engineForgetAppAllLocations "$stored_app_name"
((menu_number++))
echo ""
echo "---- $menu_number. Logging backup into database"
echo ""
databaseBackupInsert "$stored_app_name"
if [[ "$CFG_REQUIREMENT_WEBUI" == "true" ]]; then
((menu_number++))
echo ""
echo "---- $menu_number. Updating WebUI backup data"
echo ""
webuiGenerateBackupLocations
webuiGenerateBackupDashboard
webuiGenerateBackupSnapshots all
webuiGenerateBackupAppStatus "$stored_app_name"
# Signal the throttled routine refresh (webuiLibrePortalUpdate) that a
# new snapshot exists, so its next pass does a full remote pull to
# reconcile the bits this targeted regen skips (migrate + other apps'
# status) even while the refresh window is otherwise closed.
touch "/tmp/libreportal_webui_backup_dirty" 2>/dev/null || true
fi
echo ""
echo "A backup of $stored_app_name has been taken on $current_date at $current_time" >> "$logs_dir$backup_log_file"
echo ""
menu_number=0
}
backupSchedule()
{
local app_name="$1"
backupAppStart "$app_name"
}