From fe770ae699827454afb1c4dc9b144626eb25b6f3 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 26 May 2026 00:20:31 +0100 Subject: [PATCH] feat(backup): system-config snapshot + skip the reproducible WebUI; reserved-name docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (a) Docs: reserve tools/ scripts/ resources/ as LibrePortal folder names (apps must not bind-mount to them); document resources/ as the home for nest-able data AND for .sh payloads that execute on load (vs scripts/ for sourced functions); document the backup model (what's captured vs reproducible). (b) System-config backup so a bare-metal restore is self-sufficient — this is why the system root is its own tree. New scripts/backup/system/backup_system.sh: - backupSystemConfig snapshots /configs (global settings, WebUI creds, and the BACKUP-LOCATION creds — otherwise the keys to reach your own backups live only on the box) to every enabled location. Lightweight static-dir snapshot — it does NOT go through backupAppStart (no containers to quiesce / DBs to dump). - restic adapter resticBackupSystemToLocation (tag system=config) + dispatcher engineBackupSystem; restore via resticRestoreSystemLatest / engineRestoreSystemLatest + backupRestoreSystemConfig (restores to a STAGING dir — never auto-overwrites live config). - backupAllApps runs it after the app loop. WebUI exclusion: backupAllApps skips the 'libreportal' app — its frontend + generated JSON regenerate, and its only state (the login) is in the system config now captured above. Nothing in its data dir warrants a snapshot. Verified with stubs: app loop skips libreportal + invokes the system backup; the system backup dispatches to both locations; backup/restore function names pair with the dispatcher. NOTE: restic-only (the sole live engine adapter); end-to-end repo round-trip still needs a live box before being relied on. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- docs/DEVELOPMENT.md | 21 ++++++ scripts/backup/app/backup_app_all.sh | 25 +++++-- scripts/backup/engine/engine_dispatch.sh | 2 + scripts/backup/engine/restic_backup.sh | 42 ++++++++++++ scripts/backup/engine/restic_restore.sh | 23 +++++++ scripts/backup/system/backup_system.sh | 74 +++++++++++++++++++++ scripts/source/files/arrays/files_backup.sh | 1 + 7 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 scripts/backup/system/backup_system.sh diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 86aaae6..01ea01a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -177,10 +177,31 @@ is a dev-only escape hatch. `containers//tools/_.sh` (function `app`). - **Logic helpers** (anything that isn't a Tools-tab action — e.g. the auth adapter, post-install fixups): `containers//scripts/_*.sh`. + - **Static data / container payloads:** `containers//resources/` — it's + **pruned** by the container scan (never sourced), so it may nest freely AND is + the home for any `.sh` that *executes on load* (a payload run inside a + container, e.g. headscale's `tailscale.sh`). Rule: a sourced function file → + `scripts/`; a script that runs when invoked → `resources/`. - The container scan **live-sources** every `.sh` under `containers//` (`tools/` and `scripts/`), and `webui_tools.sh` **auto-merges** the `.tools.json`. So dropping the folder onto an install is all it takes — no central edits, no array regen. + - **Reserved folder names:** `tools/`, `scripts/`, `resources/` are + LibrePortal's. An app's compose must **not** bind-mount to `./tools`, + `./scripts`, or `./resources` — name app mount dirs anything else (`./data`, + `./config`, …) or nest them under `resources/`. (The scan is `maxdepth 3`, so + one sourced subfolder level is the cap — keep `scripts/` flat; name files + `_.sh` instead of nesting.) +- **What gets backed up** (so you know what's safe to regenerate vs. preserve): + per-app backups capture only the live data dir `//` — the + deployed compose, the live `.config` (settings + secrets), and the mounted + data (DBs dumped logically; raw DB dirs excluded). The **system config** + (`/configs` — global settings, WebUI creds, **backup-location creds**) + is captured by a separate system-config snapshot. **Not** backed up (reproducible + from the release): `scripts/`, `tools/`, install templates, and the `libreportal` + WebUI app itself (its frontend + generated JSON regenerate; its only state, the + login, lives in the system config). Restore = reinstall the code, then restore + the system config + each app's data dir. - **New runtime script?** Add it under `scripts//…` and run `scripts/source/files/generate_arrays.sh run` (or `libreportal regen arrays`) so it's sourced (build/standalone tooling under `scripts/release` and diff --git a/scripts/backup/app/backup_app_all.sh b/scripts/backup/app/backup_app_all.sh index cc62c73..45b4e0e 100644 --- a/scripts/backup/app/backup_app_all.sh +++ b/scripts/backup/app/backup_app_all.sh @@ -14,14 +14,27 @@ backupAllApps() app_names+=("$name") done < <(runInstallOp sqlite3 "$docker_dir/$db_file" "SELECT name FROM apps WHERE status = 1;") - if [[ ${#app_names[@]} -eq 0 ]]; then - isNotice "No installed applications found — nothing to back up" - return 0 - fi - + local done_count=0 for name in "${app_names[@]}"; do + # The libreportal WebUI app is reproducible — frontend + generated JSON + # regenerate on deploy, and its only state (the login) lives in the system + # config, captured by backupSystemConfig below. Nothing in its data dir is + # worth a snapshot. + if [[ "$name" == "libreportal" ]]; then + isNotice "Skipping '$name' — WebUI is reproducible; its state is in the system config" + continue + fi backupAppStart "$name" + ((done_count++)) done - isSuccessful "Backup pass complete — ${#app_names[@]} apps" + # System config snapshot (global settings, WebUI creds, backup-location creds) + # so a bare-metal restore is self-sufficient — this is why the system root is + # its own backup-able tree. The code/install tree is reproducible from the + # release, so it is deliberately NOT included. + if declare -f backupSystemConfig >/dev/null 2>&1; then + backupSystemConfig + fi + + isSuccessful "Backup pass complete — ${done_count} app(s) + system config" } diff --git a/scripts/backup/engine/engine_dispatch.sh b/scripts/backup/engine/engine_dispatch.sh index 4edb7db..d4a8917 100644 --- a/scripts/backup/engine/engine_dispatch.sh +++ b/scripts/backup/engine/engine_dispatch.sh @@ -48,6 +48,8 @@ engineEnvExport() { local i="$1"; engineDispatch "$(engineForLocation 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" "$@"; } engineSnapshotsJson() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")SnapshotsJson" "$i" "$@"; } diff --git a/scripts/backup/engine/restic_backup.sh b/scripts/backup/engine/restic_backup.sh index 409d14b..024abf4 100644 --- a/scripts/backup/engine/restic_backup.sh +++ b/scripts/backup/engine/restic_backup.sh @@ -60,6 +60,48 @@ resticBackupAppToLocation() return $rc } +resticBackupSystemToLocation() +{ + local idx="$1" + local source_path="${configs_dir%/}" + + if [[ ! -d "$source_path" ]]; then + isError "System config path missing: $source_path" + return 1 + fi + + resticEnvExport "$idx" || return 1 + + local host_tag="${CFG_INSTALL_NAME:-libreportal}" + local loc_name + loc_name=$(resticLocationName "$idx") + isNotice "Snapshotting system config → $loc_name" >&2 + local output + output=$(runBackupOp restic backup \ + --host "$host_tag" \ + --tag "system=config" \ + --tag "host=$host_tag" \ + --tag "engine=libreportal" \ + --exclude-caches \ + --json \ + "$source_path" 2>&1) + local rc=$? + + local snapshot_id + snapshot_id=$(echo "$output" | grep -o '"snapshot_id":"[^"]*"' | tail -1 | cut -d'"' -f4) + + if [[ $rc -eq 0 ]]; then + isSuccessful "System config backed up to $loc_name: ${snapshot_id:0:8}" >&2 + echo "$snapshot_id" + else + isError "System config backup to $loc_name failed" >&2 + echo "$output" | tail -10 >&2 + fi + + resticEnvUnset + return $rc +} + resticBackupAppAllLocations() { local app_name="$1" diff --git a/scripts/backup/engine/restic_restore.sh b/scripts/backup/engine/restic_restore.sh index 759fdf1..8dc44cf 100644 --- a/scripts/backup/engine/restic_restore.sh +++ b/scripts/backup/engine/restic_restore.sh @@ -44,3 +44,26 @@ resticRestoreAppLatest() local include_path="$containers_dir$app_name" resticRestoreSnapshot "$idx" "$snapshot_id" "$target_dir" "$include_path" } + +resticRestoreSystemLatest() +{ + local idx="$1" + local target_dir="$2" + local host="${3:-$CFG_INSTALL_NAME}" + + resticEnvExport "$idx" || return 1 + local snapshot_id + snapshot_id=$(runBackupOp restic snapshots \ + --tag "system=config" --host "$host" \ + --latest 1 --json --no-lock 2>/dev/null | \ + grep -o '"short_id":"[^"]*"' | head -1 | cut -d'"' -f4) + resticEnvUnset + + if [[ -z "$snapshot_id" ]]; then + isError "No system-config snapshot found in $(resticLocationName "$idx") for host=$host" + return 1 + fi + + # Whole-snapshot restore (the snapshot is just the config tree) into staging. + resticRestoreSnapshot "$idx" "$snapshot_id" "$target_dir" +} diff --git a/scripts/backup/system/backup_system.sh b/scripts/backup/system/backup_system.sh new file mode 100644 index 0000000..b60880d --- /dev/null +++ b/scripts/backup/system/backup_system.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# System-config backup. +# +# Snapshots the system config tree (/configs — global settings, WebUI +# credentials, and crucially the BACKUP-LOCATION credentials) to every enabled +# backup location, so a bare-metal restore is self-sufficient. Without this the +# location creds live only on the box: lose it and you can't even reach your own +# remote backups (chicken-and-egg). It is a lightweight, static-dir snapshot — no +# container quiescing or DB dumps (those are per-app concerns), so it does NOT go +# through backupAppStart. The install tree (code) is reproducible from the release +# and is deliberately NOT included; per-app data is handled by backupAppStart. + +backupSystemConfig() +{ + local source_path="${configs_dir%/}" + if [[ ! -d "$source_path" ]]; then + isNotice "System config dir not found ($source_path) — skipping system backup" + return 0 + fi + if [[ -z "$(resticEnabledLocations)" ]]; then + isNotice "No backup locations enabled — skipping system config backup" + return 0 + fi + + isHeader "Backing up system config" + engineEnsureAllLocationsReady + + local idx ok=0 fail=0 + while IFS= read -r idx; do + [[ -z "$idx" ]] && continue + if engineBackupSystem "$idx" >/dev/null; then + ok=$((ok + 1)) + else + fail=$((fail + 1)) + fi + done < <(resticEnabledLocations) + + if [[ $ok -eq 0 ]]; then + isError "System config backup failed on all locations" + return 1 + fi + if [[ $fail -gt 0 ]]; then + isNotice "System config backed up to $ok location(s), failed on $fail" + else + isSuccessful "System config backed up to $ok location(s)" + fi + return 0 +} + +# Restore the latest system-config snapshot from a location into a STAGING dir. +# Deliberately does NOT overwrite live config — recovering creds/settings is a +# review-then-copy step, never an automatic blast over a running control plane. +backupRestoreSystemConfig() +{ + local idx="${1:-}" + [[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1) + if [[ -z "$idx" ]]; then + isError "No enabled backup location to restore the system config from" + return 1 + fi + + local staging="${restore_dir%/}/system-config" + runFileOp mkdir -p "$staging" + + isHeader "Restoring system config (to staging — live config is untouched)" + if engineRestoreSystemLatest "$idx" "$staging"; then + isSuccessful "System config restored to: $staging" + isNotice "Review it, then copy what you need into ${configs_dir} (backup-location creds, logins, settings). Live config was NOT overwritten." + return 0 + fi + isError "System config restore failed" + return 1 +} diff --git a/scripts/source/files/arrays/files_backup.sh b/scripts/source/files/arrays/files_backup.sh index ac151f5..cb88f24 100755 --- a/scripts/source/files/arrays/files_backup.sh +++ b/scripts/source/files/arrays/files_backup.sh @@ -48,6 +48,7 @@ backup_scripts=( "backup/manifest/manifest_collect.sh" "backup/manifest/manifest_read.sh" "backup/manifest/manifest_write.sh" + "backup/system/backup_system.sh" "backup/verify/backup_verify.sh" )