LibrePortal/scripts/restore/restore_first_run.sh
librelad a361e38562 Wizard: New install or Restore from backup
The wizard's first question is now "is this a new server, or a replacement for
one?", which §2 of the roadmap described and nothing implemented. Start asks,
and the answer selects one of two disjoint step sets:

  new      Start > Experience > Identity > Domains > Storage > Backups
                 > Import > Recommended > (Metrics)
  restore  Start > Source > Contents > Rebuild

Disjoint deliberately. A restore is never asked for an install name, domains or
an app list — the backup answers all three, and asking invites someone to type
an answer that is about to be written over. The test asserts non-overlap in
both directions, not just that the restore steps appear.

Source collects the repository the way the Backup page does, minus everything
that only means something for a place you write TO: no retention, no schedule,
no enable toggle. The password leaves through the one-shot secret:<ref> channel
and is cleared from the DOM, and the test asserts the value never appears in
the payload — that payload reaches a task command line, and tasks are recorded
world-readable.

Contents is the reconciliation, rendered: apps with sizes, and each domain with
a verdict, checked through the same /api/setup/dns-check the Domains step uses
rather than adding a second way to ask. Plus the offer to leave the strays out
until DNS is repointed.

Rebuild runs `restore rebuild`: settings first (they carry every other
repository's credentials), then domains, then apps with no explicit list so
bulk discovers and re-preflights them itself.

Inserting Start shifted every step index by one. validateStep was a chain of
idx === 1 … idx === 6, carrying a comment that already explained which earlier
insertions had moved them — it is keyed on the step name now.
lp-storage-step-test had the same pin and did not survive: it called
validateStep(3) for Storage, which had become Domains, and reported that
nothing blocked. That reads exactly like validation being broken. Tests look
their step up by name now too.

Also: locationRemove's fix means a failed connect can finally clean up after
itself, so a wrong password no longer leaves a dead destination behind on every
retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:04:43 +01:00

154 lines
6.0 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)
if restoreAppStart "$app" "latest" "$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}"
if [[ -z "$idx" ]]; then
isError "restoreWebuiRebuild requires a backup location"
return 1
fi
isHeader "Rebuilding from backup"
# --- settings first ------------------------------------------------------
isNotice "Restoring settings and credentials…"
if backupRestoreSystemConfig "$idx" >/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
}