#!/bin/bash # Adopt a staged system config into the live one — the step that makes a # first-run restore actually restore anything. # # backupRestoreSystemConfig deliberately only STAGES: overwriting the config of # a running control plane is not something to do automatically, and that # caution is right. But it left first-run restore claiming a success it had not # delivered — the installer printed "Settings restored" while the backup # locations, domains and logins sat in a staging directory the user would have # had to find and copy by hand. Nothing in the tree ever adopted them. # # So adoption is its own step, allowed only where the caution does not apply: # a machine with nothing on it yet. # # WHAT IS NOT ADOPTED matters as much as what is. A backup describes a machine # that no longer exists, and some of what it says is about that machine rather # than about the user: # # general_docker_install the container user and its generated password, # made by THIS install; taking the old one leaves # the config naming an account that does not exist # network_ports port allocations, re-rolled per install # network_docker, interface and rootless wiring, decided by this # network_rootless machine's hardware and kernel # storage/locations the OLD machine's drives. App placement is already # reconciled per app from the snapshot manifests # (§3 of first-run-restore.md); adopting a registry # of drives this box does not have would make every # one of those lookups resolve to a phantom. # # Everything else is the user's: their backup repositories and the credentials # to reach them, their domains, their logins, their settings. # The files worth carrying across, relative to the config root. _restoreAdoptAllowList() { cat <<'LIST' backup/backup_engine backup/backup_general backup/backup_retention network/network_domains network/network_dns network/network_whitelist network/network_firewall security/security_logins security/security_ssh webui/webui_logins webui/webui_updater general/general_basic general/general_core general/general_libreportal general/general_mail general/general_notifications general/general_terminal general/general_catalogs LIST } # Subtrees whose members are indexed, so an allow-list of filenames cannot name # them. backup/locations is the one the whole feature turns on: it holds every # repository and its credentials, and "one password you remember unlocks the # rest" is exactly what a first-run restore promises. # # storage/locations is deliberately NOT here — see the header. _restoreAdoptAllowDirs() { cat <<'LIST' backup/locations LIST } # Is this machine still empty enough for adoption to be safe? # # Adoption overwrites live config, so this guard is the only thing standing # between "restore onto a blank box" and "overwrite a working install". It # therefore FAILS CLOSED: anything it cannot establish counts as not-first-run. # # The first version did the opposite and was wrong in exactly the way this # project keeps being wrong. It globbed the containers directory directly — but # the manager can traverse that directory without being able to list it, so the # glob came back as the literal '*', the loop body skipped it as "not a # directory", and the function fell out of the bottom returning "yes, first # run" on a machine with three apps on it. A check whose failure mode is to not # run reads exactly like a check that passed. restoreAdoptIsFirstRun() { local base="${containers_dir%/}" [[ -n "$base" ]] || return 1 # A completed setup means someone has already used this install. local lock="$base/libreportal/frontend/data/.setup_complete" runFileOp test -f "$lock" 2>/dev/null && return 1 # Listed through the container user, which owns the tree — the manager # cannot read it directly. `find -print` failing is indistinguishable from # an empty directory, so ask for the directory itself as a sentinel: if # that does not come back, the listing did not work and we must not # conclude the machine is empty. local listing listing=$(runFileOp find "$base" -mindepth 0 -maxdepth 1 -type d 2>/dev/null) if ! grep -qxF "$base" <<< "$listing"; then isNotice "Could not list $base to check whether this machine is already in use." return 1 fi local d name while IFS= read -r d; do [[ -z "$d" || "$d" == "$base" ]] && continue name="$(basename "$d")" [[ "$name" == "libreportal" ]] && continue return 1 done <<< "$listing" return 0 } # Adopt the staged tree. Prints what it took and what it deliberately left. # # restoreSystemAdopt [--force] # # --force exists for a deliberate "overwrite this install with that backup" and # says so loudly; without it, a populated machine is refused rather than # silently half-merged. restoreSystemAdopt() { local staging="${1:-}" force="${2:-}" if [[ -z "$staging" ]]; then staging="${restore_dir%/}/system-config" fi if [[ ! -d "$staging" ]]; then isError "No staged system config at '$staging' — run 'libreportal restore system ' first." return 1 fi if ! restoreAdoptIsFirstRun && [[ "$force" != "--force" ]]; then isError "This machine already has apps or a completed setup — refusing to overwrite its config." isNotice "Adopting a backup's config over a running install is not reversible from here." isNotice "If that is really what you want: libreportal restore adopt \"$staging\" --force" return 1 fi # The staged tree may nest the config root one level down, depending on how # the snapshot recorded its absolute paths. Find the level that actually # holds the config directories rather than assuming either shape. local root="" local probe for probe in "$staging" "$staging/configs"; do if runFileOp test -d "$probe/backup" 2>/dev/null || runFileOp test -d "$probe/general" 2>/dev/null; then root="$probe"; break fi done if [[ -z "$root" ]]; then # Fall back to a search: restic restores under the source's absolute # path, so the config root can be several levels down. root=$(runFileOp find "$staging" -maxdepth 6 -type d -name configs -print -quit 2>/dev/null) fi if [[ -z "$root" ]]; then isError "Could not find a config tree inside '$staging'." return 1 fi isHeader "Adopting settings from the backup" local rel took=0 missed=0 local -a taken=() absent=() while IFS= read -r rel; do [[ -z "$rel" ]] && continue if runOwnership config-adopt "$root" "$rel" 2>/dev/null; then taken+=("$rel"); took=$((took + 1)) else absent+=("$rel"); missed=$((missed + 1)) fi done < <(_restoreAdoptAllowList) local reldir while IFS= read -r reldir; do [[ -z "$reldir" ]] && continue if runOwnership config-adopt-tree "$root" "$reldir" 2>/dev/null; then took=$((took + 1)) else # Not fatal on its own, but this is the one people came for, so it # is said out loud rather than counted quietly among the misses. isNotice "No '$reldir' in this backup — backup repositories were not restored." fi done < <(_restoreAdoptAllowDirs) if (( took == 0 )); then isError "Nothing could be adopted from '$root' — the staged tree may be empty or unreadable." return 1 fi isSuccessful "Adopted $took config file(s), including your backup repositories and their credentials." if (( missed > 0 )); then # Not a failure: an older backup legitimately predates some of these. isNotice "$missed not present in this backup (older backups do not carry every file)." fi isNotice "Left alone on purpose: this machine's docker user, port allocations and storage registry — those describe this box, not the backup." # The config cache is stale the moment these land, and every later step # (domain checks, location listing) reads through it. storageCacheReset 2>/dev/null || true return 0 }