LibrePortal/scripts/restore/restore_first_run.sh
librelad 6018250526 Choose which snapshot to restore, per app and for the settings
A snapshot is one app's data, or the settings tree — never a machine. A
four-snapshot repository is typically two apps plus two versions of the
settings, not four backups to pick between. So the choice belongs on Contents,
after unlocking, where each snapshot has a name and a date rather than being a
hash.

Every row with more than one snapshot gets a picker, defaulting to the newest.
A row with one shows its date as text: a dropdown holding a single entry is a
control that cannot be operated, and it makes a repository with one backup look
like it is hiding something.

The chain already supported this. restorePickSnapshot has always passed any
value that is not the string "latest" straight through as an id; nothing ever
offered the choice. What was missing:

  - restoreInspect returns every snapshot per app and for the settings, not
    just the newest.
  - restoreFirstRunBulk reads an optional RESTORE_SNAPSHOT_CHOICE map instead
    of hardcoding "latest". An associative array rather than an argument,
    because the CLI wrapper pads argv to nine slots and a per-app map cannot
    survive it; the map reaches the host as base64 JSON, validated at the route
    against restic short ids and app names since both hit a command line.
  - backupRestoreSystemConfig takes a snapshot AND a host.

That host was a real bug. It defaulted to this machine's install name, which is
right for "recover my own settings" and wrong for a rebuild — the snapshots
carry the name of the machine being rebuilt FROM. It surfaced the moment a
restore adopted a config with a different install name and the next lookup
found nothing at all.

Verified by restoring both settings snapshots and diffing: 28bedbb0 brings back
a config carrying example.com, cc5b6bcf one with no domains.

Two CSS traps on the picker: appearance stayed `auto`, so the browser painted
its own control and ignored the colours entirely while the computed styles
looked right; and a `background:` shorthand later in the rule silently reset the
background-image, wiping the arrow set three lines above it.

lp-restore-adopt-test asserted configs/* were mode 0755 and started failing on
configs/webui, which libreportal-ownership sets to 0751:container on purpose —
tighter, and perfectly traversable. It asserts "the container user can traverse
it" now. A test that pins an incidental number reports a regression every time
someone improves the thing it is watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:44:21 +01:00

193 lines
8.1 KiB
Bash

#!/bin/bash
restoreFirstRunDiscover()
{
local idx="$1"
if ! resticLocationEnabled "$idx"; then
isError "Location $idx is not enabled"
return 1
fi
resticEnvExport "$idx" || return 1
# Via runBackupOp rather than its own sudo: this was the one backup-engine
# call bypassing that funnel, so it silently missed the -E fix for sudo-rs
# (and the -H that puts restic's cache under the backup user's HOME).
runBackupOp restic snapshots --tag engine=libreportal --json --no-lock 2>/dev/null
local rc=$?
resticEnvUnset
return $rc
}
# Restore a host's apps onto this machine.
#
# With no app list this is a WHOLE-HOST restore: the apps are discovered from
# the repository and filtered through the preflight. Both halves matter.
#
# Discovery, because an explicit list has to survive the CLI wrapper's fixed
# positional slots to get here — a 13-app restore arrived as four, restored
# those, and reported success. The wrapper now forwards the real argv, but a
# whole-host restore that never builds a list cannot be truncated at all.
#
# The preflight, because the installer prints its report in a separate process,
# so the decision it made there is gone by the time this runs. Without
# re-applying it, an app the user was told would be skipped — one this version
# no longer ships, or one too big for the disk — gets restored anyway.
restoreFirstRunBulk()
{
local idx="$1"
local source_host="$2"
shift 2
local apps_to_restore=("$@")
local -i preflighted=0
if [[ ${#apps_to_restore[@]} -eq 0 ]]; then
restorePreflightReport "$idx" "$source_host" >/dev/null 2>&1
apps_to_restore=("${RESTORE_PREFLIGHT_OK[@]}")
preflighted=1
fi
if [[ ${#apps_to_restore[@]} -eq 0 ]]; then
isError "No apps to restore for '$source_host' in this repository"
return 1
fi
isHeader "First-run bulk restore from $(resticLocationName "$idx") (host=$source_host)"
(( preflighted )) && isNotice "Restoring ${#apps_to_restore[@]} apps the preflight approved."
# Count what actually landed. A per-app failure must not be reported as a
# complete restore — that is how "4 apps restored" read as success when
# nine had gone missing.
#
# An app can also come back only half-running: continue-on-error (the
# default) lets a failed compose-up log and carry on, so restoreAppStart
# still returns 0. That is how a restore reported thirteen successes while
# stoat's livekit had lost a port race and four containers that depended on
# it exited 101. checkSuccess appends every such failure to error_report.log,
# so watch that file grow across each app and name the noisy ones.
local _errlog="${logs_dir%/}/error_report.log"
_restoreErrLines() { wc -l < "$_errlog" 2>/dev/null || echo 0; }
local app
local -i ok=0 bad=0 before=0 after=0
local -a failed=() noisy=()
for app in "${apps_to_restore[@]}"; do
before=$(_restoreErrLines)
# Which snapshot of this app. "latest" unless the caller named one:
# restorePickSnapshot passes anything else straight through as an id,
# so the chain has always supported a point-in-time restore — until now
# nothing offered the choice.
#
# Read from an associative array the caller may set rather than an
# argument, because the CLI wrapper pads argv to nine slots and a
# per-app map cannot survive that.
local _snap="latest"
if declare -p RESTORE_SNAPSHOT_CHOICE >/dev/null 2>&1; then
[[ -n "${RESTORE_SNAPSHOT_CHOICE[$app]:-}" ]] && _snap="${RESTORE_SNAPSHOT_CHOICE[$app]}"
fi
[[ "$_snap" != "latest" ]] && isNotice " $app: restoring the snapshot from ${RESTORE_SNAPSHOT_TIME[$app]:-$_snap}"
if restoreAppStart "$app" "$_snap" "$idx" "$source_host"; then
ok=$(( ok + 1 ))
after=$(_restoreErrLines)
(( after > before )) && noisy+=("$app")
else
bad=$(( bad + 1 )); failed+=("$app")
fi
done
unset -f _restoreErrLines
if (( bad > 0 )); then
isError "First-run restore finished with failures — $ok of ${#apps_to_restore[@]} restored"
isNotice "Failed: ${failed[*]}"
(( ${#noisy[@]} )) && isNotice "Restored but reported errors: ${noisy[*]}"
return 1
fi
if (( ${#noisy[@]} )); then
isSuccessful "First-run restore complete — $ok apps restored"
isNotice "${#noisy[@]} reported errors while starting: ${noisy[*]}"
isNotice "They are restored, but check them: $_errlog"
return 0
fi
isSuccessful "First-run restore complete — $ok apps restored"
return 0
}
# The WebUI's rebuild: adopt the settings, reconcile the domains, restore the
# apps. Same order the installer uses and for the same reason — the system
# config carries every other backup location's credentials, so one password the
# user remembers unlocks the rest, and only then are apps worth restoring.
#
# restoreWebuiRebuild <location-idx> <host> [drop-domains]
#
# Called from a task, so its output is the progress the user watches.
restoreWebuiRebuild()
{
local idx="${1:-}" host="${2:-}" drop="${3:-no}" choice_b64="${4:-}"
if [[ -z "$idx" ]]; then
isError "restoreWebuiRebuild requires a backup location"
return 1
fi
# Which snapshot of each thing the user picked on the Contents step.
# Base64 JSON, because a per-app map cannot survive the CLI wrapper's
# nine positional slots.
declare -gA RESTORE_SNAPSHOT_CHOICE=()
declare -gA RESTORE_SNAPSHOT_TIME=()
local _sys_snap=""
if [[ -n "$choice_b64" && "$choice_b64" != "empty" ]]; then
local _json _k _v
_json=$(printf '%s' "$choice_b64" | base64 -d 2>/dev/null)
if [[ -n "$_json" ]] && jq -e . >/dev/null 2>&1 <<< "$_json"; then
_sys_snap=$(jq -r '.system // ""' <<< "$_json")
while IFS=$'\t' read -r _k _v; do
[[ -z "$_k" ]] && continue
RESTORE_SNAPSHOT_CHOICE["$_k"]="$_v"
done < <(jq -r '(.apps // {}) | to_entries[] | "\(.key)\t\(.value)"' <<< "$_json")
while IFS=$'\t' read -r _k _v; do
[[ -z "$_k" ]] && continue
RESTORE_SNAPSHOT_TIME["$_k"]="$_v"
done < <(jq -r '(.times // {}) | to_entries[] | "\(.key)\t\(.value)"' <<< "$_json")
else
isNotice "Could not read which snapshots were chosen — restoring the newest of each."
fi
fi
isHeader "Rebuilding from backup"
# --- settings first ------------------------------------------------------
isNotice "Restoring settings and credentials…"
# "$host", not this machine's name: the snapshots carry the name of the
# machine being rebuilt FROM.
if backupRestoreSystemConfig "$idx" "$_sys_snap" "$host" >/dev/null 2>&1; then
# --force: the WebUI is only reachable at all because this machine has
# a working install on it, so restoreAdoptIsFirstRun will say no. The
# user asked for this explicitly on the Rebuild step, which is the
# confirmation the guard exists to require.
if restoreSystemAdopt "" --force; then
isSuccessful "Settings and backup repositories restored"
else
isNotice "Settings were staged but could not be adopted — apps will still be restored."
fi
else
isNotice "No system config in this backup — apps will still be restored."
fi
# --- domains -------------------------------------------------------------
restoreDomainReport || true
if [[ "$drop" == "yes" || "$drop" == "true" ]]; then
restoreDomainsDropElsewhere || true
fi
# --- apps ----------------------------------------------------------------
# No app list, deliberately: bulk discovers the host's apps and re-applies
# the preflight itself. Passing a list here is what let a 13-app restore
# arrive as four and still report success.
isNotice "Restoring apps — this takes a while."
restoreFirstRunBulk "$idx" "$host"
isSuccessful "Rebuild complete"
return 0
}