The Manual Backup screen in the Hub always listed the Node / Server +
Desktop items (nix-bitcoin secrets, /var/lib system service data, and the
database/blockchain caveat), and the backup script mirrored /var/lib and
counted it in the free-space estimate even on the Desktop Only role.
Desktop Only systems run no server or Bitcoin services and have no
internal second data drive, so none of that applies.
Hub UI (support.js):
- 'What gets backed up' is role-aware: Desktop Only lists only the NixOS
configuration (/etc/nixos) and home directory (/home)
- Database/blockchain note hidden on Desktop Only
- Intro copy corrected: external USB copy is a second location on
Desktop Only (no internal second drive); third-location wording kept
for Node / Server + Desktop
Backup script (sovran-hub-backup.sh):
- Desktop Only runs 2 stages (1/2 /etc/nixos, 2/2 /home); secrets and
/var/lib stages no longer run on that role
- Free-space estimate skips /var/lib on Desktop Only
- BACKUP_MANIFEST.txt sources/exclusions/limitations/restore guidance
and blockchain note are role-aware
- Completion message role-aware ('second, external location' on
Desktop Only); header comments updated
Node and Server + Desktop behavior is unchanged. Added CHANGELOG entry.
Verified: bash -n / node --check, 6 role-detection cases, manifest
generation for both role groups (non-desktop output identical to
before), and simulated UI renders for all three roles.
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
636 lines
24 KiB
Bash
Executable File
636 lines
24 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ── Sovran Hub External Backup Script ────────────────────────────
|
|
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
|
# Designed for the Hub web UI (no GUI dependencies).
|
|
#
|
|
# On Server + Desktop and Node systems, your Sovran Pro already backs up
|
|
# your data automatically to its internal second drive (BTCEcoandBackup at
|
|
# /run/media/Second_Drive); this script stores a copy in a third location.
|
|
# Desktop Only systems have no internal second drive, so the external copy
|
|
# is the second location.
|
|
#
|
|
# What gets mirrored depends on the system role:
|
|
# - Node / Server + Desktop: /etc/nixos, /etc/nix-bitcoin-secrets,
|
|
# /home, and /var/lib (minus databases, blockchain data, logs, caches).
|
|
# - Desktop Only: /etc/nixos and /home. Desktop Only runs no server or
|
|
# Bitcoin services, so there are no nix-bitcoin secrets or system
|
|
# service data to back up.
|
|
#
|
|
# The external drive must be formatted as ext4. Files are stored as
|
|
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
|
# Later runs update the same mirror and only transfer changed or new
|
|
# files, making repeat backups fast.
|
|
#
|
|
# PostgreSQL and MariaDB/MySQL databases are NOT included. Bitcoin
|
|
# blockchain and Electrs index data are NOT included (they live on
|
|
# the internal second drive).
|
|
#
|
|
# Usage:
|
|
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
|
|
# (or run with no env var to auto-detect the first external USB drive)
|
|
|
|
set -euo pipefail
|
|
|
|
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
|
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
|
MEDIA_ROOT="/run/media"
|
|
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
|
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
|
SECOND_DRIVE_MOUNT="/run/media/Second_Drive"
|
|
SAFETY_MARGIN_BYTES=$((1024 * 1024 * 1024))
|
|
|
|
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
|
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
|
INTERNAL_MOUNTS=("$SECOND_DRIVE_MOUNT" "/boot/efi" "/")
|
|
|
|
FAILED_ALREADY=0
|
|
BACKUP_COMPLETE=0
|
|
RSYNC_WARNINGS=()
|
|
|
|
# Stable rsync mirror sub-path under the target drive. Not timestamped
|
|
# so later runs update the same destination and only transfer new or changed files.
|
|
BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current"
|
|
|
|
# ── Logging helpers ──────────────────────────────────────────────
|
|
|
|
log() {
|
|
local msg
|
|
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
|
echo "$msg" | tee -a "$BACKUP_LOG"
|
|
}
|
|
|
|
set_status() {
|
|
echo "$1" > "$BACKUP_STATUS"
|
|
}
|
|
|
|
fail() {
|
|
FAILED_ALREADY=1
|
|
log "ERROR: $*"
|
|
set_status "FAILED"
|
|
exit 1
|
|
}
|
|
|
|
cleanup() {
|
|
local rc=$?
|
|
|
|
# Release the concurrency lock file descriptor if it was opened
|
|
if [[ -n "${LOCK_FD:-}" ]]; then
|
|
exec {LOCK_FD}>&- 2>/dev/null || true
|
|
fi
|
|
|
|
if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then
|
|
return
|
|
fi
|
|
|
|
if [[ "$FAILED_ALREADY" -eq 0 ]]; then
|
|
log "ERROR: Backup terminated unexpectedly (exit code $rc)."
|
|
set_status "FAILED"
|
|
fi
|
|
|
|
# Mark the backup directory as incomplete so failed runs are identifiable
|
|
if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR:-}" && ! -f "${BACKUP_DIR:-}/BACKUP_COMPLETE" ]]; then
|
|
touch "${BACKUP_DIR}/INCOMPLETE" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
trap cleanup EXIT
|
|
trap 'exit 1' INT TERM
|
|
|
|
require_cmd() {
|
|
local cmd="$1"
|
|
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
|
|
}
|
|
|
|
# ── Check whether a mount point is an internal drive ────────────
|
|
|
|
is_internal() {
|
|
local mnt="$1"
|
|
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
|
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ── Use lsblk to find the first genuine external USB drive ───────
|
|
|
|
find_external_drive() {
|
|
local target=""
|
|
|
|
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
|
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
|
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
|
[[ -n "$mountpoint" ]] || continue
|
|
|
|
local skip=0
|
|
for lbl in "${INTERNAL_LABELS[@]}"; do
|
|
[[ "$label" == "$lbl" ]] && skip=1 && break
|
|
done
|
|
[[ "$skip" -eq 1 ]] && continue
|
|
|
|
is_internal "$mountpoint" && continue
|
|
|
|
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
|
target="$mountpoint"
|
|
break
|
|
fi
|
|
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
|
python3 -c "
|
|
import sys, json
|
|
|
|
def flatten(devs):
|
|
for d in devs:
|
|
yield d
|
|
for c in d.get('children', []):
|
|
yield from flatten([c])
|
|
|
|
data = json.load(sys.stdin)
|
|
for d in flatten(data.get('blockdevices', [])):
|
|
print('\\t'.join([
|
|
d.get('type') or '',
|
|
str(d.get('hotplug') or '0'),
|
|
str(d.get('rm') or '0'),
|
|
d.get('label') or '',
|
|
d.get('mountpoint') or '',
|
|
]))
|
|
" 2>/dev/null || true)
|
|
|
|
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
|
while IFS= read -r -d '' mnt; do
|
|
is_internal "$mnt" && continue
|
|
if mountpoint -q "$mnt" 2>/dev/null; then
|
|
target="$mnt"
|
|
break
|
|
fi
|
|
done < <(find "$MEDIA_ROOT" -mindepth 2 -maxdepth 2 -type d -print0 2>/dev/null)
|
|
fi
|
|
|
|
echo "$target"
|
|
}
|
|
|
|
# ── Detect the configured system role ───────────────────────────
|
|
|
|
detect_role() {
|
|
local role="server_plus_desktop"
|
|
|
|
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
|
local r
|
|
r=$(python3 -c \
|
|
"import json,sys; d=json.load(open(sys.argv[1])); print(d.get('role',''))" \
|
|
"$HUB_CONFIG_JSON" 2>/dev/null || true)
|
|
if [[ -n "$r" ]]; then
|
|
echo "$r"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
|
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
|
role="desktop"
|
|
elif grep -q 'roles\.node = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
|
role="node"
|
|
fi
|
|
fi
|
|
|
|
echo "$role"
|
|
}
|
|
|
|
validate_target_mount() {
|
|
local target="$1"
|
|
[[ "$target" == "${MEDIA_ROOT}/"* ]] || fail "Target '$target' must be mounted under $MEDIA_ROOT."
|
|
[[ -d "$target" ]] || fail "Target path '$target' does not exist."
|
|
mountpoint -q "$target" || fail "Target path '$target' is not a mount point."
|
|
|
|
local fstype=""
|
|
fstype=$(findmnt -n -o FSTYPE -T "$target" 2>/dev/null || true)
|
|
[[ -n "$fstype" ]] || fail "Could not determine filesystem type for '$target'."
|
|
|
|
if [[ "$fstype" != "ext4" ]]; then
|
|
fail "Target '$target' must be formatted as ext4 (detected filesystem: $fstype). Manual Backup requires an ext4-formatted external drive for Linux metadata preservation. exFAT, FAT32, and NTFS are not supported."
|
|
fi
|
|
|
|
local write_test
|
|
write_test="$target/.sovran-write-test-$$"
|
|
if ! ( : > "$write_test" && echo "ok" >> "$write_test" && rm -f "$write_test" ); then
|
|
fail "Target '$target' is not writable."
|
|
fi
|
|
|
|
log "Verified backup target filesystem: $fstype"
|
|
}
|
|
|
|
estimate_path_bytes() {
|
|
local path="$1"
|
|
shift || true
|
|
[[ -e "$path" ]] || {
|
|
echo 0
|
|
return
|
|
}
|
|
|
|
local size
|
|
size=$(du -s -B1 -x "$@" "$path" 2>/dev/null | awk '{print $1}' || true)
|
|
[[ -n "$size" ]] || size=0
|
|
echo "$size"
|
|
}
|
|
|
|
# ── Sync one source tree to its backup destination ───────────────
|
|
# Usage: sync_tree <label> <allow_vanished> <source> <destination> [rsync options...]
|
|
#
|
|
# allow_vanished: "yes" means rsync exit 24 (vanished files) is nonfatal.
|
|
# Used for /home only — files may disappear while the desktop is active.
|
|
# All other nonzero exit codes are always fatal.
|
|
#
|
|
# Before every rsync call this helper:
|
|
# 1. Re-verifies $TARGET is still a mount point (fails if drive disconnected).
|
|
# 2. Verifies the destination path remains beneath $BACKUP_DIR and $BACKUP_DIR
|
|
# remains beneath $TARGET (safe-path check).
|
|
# 3. Creates the full destination directory hierarchy with mkdir -p so that
|
|
# rsync never fails trying to create a directory whose parent is absent.
|
|
sync_tree() {
|
|
local label="$1"
|
|
local allow_vanished="$2"
|
|
local source="$3"
|
|
local destination="$4"
|
|
shift 4
|
|
# Remaining "$@" are rsync options (--exclude, etc.)
|
|
|
|
# ── Re-verify the external drive is still mounted ────────────────
|
|
mountpoint -q "$TARGET" 2>/dev/null || \
|
|
fail "Stage $label: external drive '$TARGET' is no longer mounted. Refusing to write."
|
|
|
|
# ── Verify path safety ────────────────────────────────────────────
|
|
# BACKUP_DIR must remain beneath TARGET.
|
|
case "$BACKUP_DIR" in
|
|
"$TARGET"/*) ;;
|
|
*) fail "Stage $label: BACKUP_DIR '$BACKUP_DIR' is outside TARGET '$TARGET'." ;;
|
|
esac
|
|
# Destination must remain beneath BACKUP_DIR.
|
|
case "$destination" in
|
|
"$BACKUP_DIR"/*|"$BACKUP_DIR") ;;
|
|
*) fail "Stage $label: destination '$destination' is outside BACKUP_DIR '$BACKUP_DIR'. Refusing to write." ;;
|
|
esac
|
|
|
|
# ── Create complete destination directory hierarchy ───────────────
|
|
# This is the fix for the production failure:
|
|
# rsync: [Receiver] mkdir ".../current/etc/nixos" failed: No such file or directory
|
|
# mkdir -p creates all intermediate parents (e.g. current/etc/) before rsync runs.
|
|
mkdir -p -- "$destination" || \
|
|
fail "Stage $label: failed to create destination directory '$destination' (source: '$source')."
|
|
|
|
local rsync_err_tmp
|
|
rsync_err_tmp="$(mktemp /tmp/sovran-rsync-err.XXXXXX)"
|
|
|
|
local rc=0
|
|
rsync \
|
|
--archive \
|
|
--acls \
|
|
--xattrs \
|
|
--hard-links \
|
|
--numeric-ids \
|
|
--one-file-system \
|
|
--partial \
|
|
"$@" "$source" "$destination" 2>"$rsync_err_tmp" || rc=$?
|
|
|
|
if [[ -s "$rsync_err_tmp" ]]; then
|
|
while IFS= read -r rline; do
|
|
log "rsync: $rline"
|
|
done < "$rsync_err_tmp"
|
|
fi
|
|
rm -f "$rsync_err_tmp"
|
|
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
return 0
|
|
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
|
log "NOTE: $label — some files vanished during sync (normal on an active desktop). Your important data is backed up."
|
|
RSYNC_WARNINGS+=("$label: some files vanished during sync (rsync exit 24 — normal on active desktop)")
|
|
return 0
|
|
else
|
|
fail "rsync failed for $label (exit code $rc). See the rsync errors above."
|
|
fi
|
|
}
|
|
|
|
# ── Initialise log file ──────────────────────────────────────────
|
|
|
|
: > "$BACKUP_LOG"
|
|
set_status "RUNNING"
|
|
|
|
log "=== Sovran_SystemsOS External Hub Backup ==="
|
|
log "Starting backup process…"
|
|
|
|
# ── Acquire exclusive run lock ────────────────────────────────────
|
|
# Prevents two simultaneous backup runs (e.g. from double-click or
|
|
# stale RUNNING status after a Hub restart).
|
|
|
|
LOCK_FILE="/var/lock/sovran-hub-backup.lock"
|
|
# Note: exec {LOCK_FD}>>file requires bash 4.1+ (NixOS provides bash 5.x).
|
|
exec {LOCK_FD}>>"$LOCK_FILE" 2>/dev/null || \
|
|
fail "Cannot open lock file: $LOCK_FILE. Ensure /var/lock is writable."
|
|
flock --nonblock "$LOCK_FD" 2>/dev/null || \
|
|
fail "Another backup is already running. Wait for it to complete or check $BACKUP_STATUS."
|
|
|
|
require_cmd rsync
|
|
require_cmd findmnt
|
|
require_cmd lsblk
|
|
require_cmd mountpoint
|
|
require_cmd df
|
|
require_cmd du
|
|
require_cmd awk
|
|
require_cmd find
|
|
require_cmd hostname
|
|
require_cmd date
|
|
require_cmd python3
|
|
require_cmd flock
|
|
|
|
# ── Detect system role ───────────────────────────────────────────
|
|
|
|
ROLE="$(detect_role)"
|
|
case "$ROLE" in
|
|
desktop) ROLE_LABEL="Desktop Only" ;;
|
|
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
|
|
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
|
|
*) ROLE_LABEL="$ROLE" ;;
|
|
esac
|
|
log "Detected role: $ROLE_LABEL"
|
|
|
|
# Backup scope depends on the role. Desktop Only systems run no server or
|
|
# Bitcoin services, so only the NixOS configuration and home directory are
|
|
# mirrored (2 stages). Node and Server + Desktop systems also mirror the
|
|
# nix-bitcoin secrets and /var/lib system service data (4 stages).
|
|
if [[ "$ROLE" == "desktop" ]]; then
|
|
TOTAL_STAGES=2
|
|
HOME_STAGE_NUM=2
|
|
log "Desktop Only role: backing up the NixOS configuration (/etc/nixos) and home directory (/home) only."
|
|
else
|
|
TOTAL_STAGES=4
|
|
HOME_STAGE_NUM=3
|
|
fi
|
|
|
|
# ── Detect target drive ──────────────────────────────────────────
|
|
|
|
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
|
TARGET="$BACKUP_TARGET"
|
|
if is_internal "$TARGET"; then
|
|
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
|
fi
|
|
log "Using specified backup target: $TARGET"
|
|
else
|
|
log "Auto-detecting external USB drives…"
|
|
TARGET="$(find_external_drive)"
|
|
if [[ -z "$TARGET" ]]; then
|
|
fail "No external USB drive detected. Please plug in an ext4-formatted USB drive and try again."
|
|
fi
|
|
log "Detected external drive: $TARGET"
|
|
fi
|
|
|
|
validate_target_mount "$TARGET"
|
|
|
|
# ── Set up stable backup destination ────────────────────────────
|
|
# Subsequent runs update the same mirror, transferring only new or changed files.
|
|
|
|
BACKUP_DIR="${TARGET}/${BACKUP_SUBPATH}"
|
|
mkdir -p -- "$BACKUP_DIR"
|
|
|
|
# Remove any stale BACKUP_COMPLETE left by a previous successful run.
|
|
# The new run will re-earn it only after all stages succeed.
|
|
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
|
|
# Write an INCOMPLETE marker immediately; replaced by BACKUP_COMPLETE only
|
|
# after all rsync stages and manifest write succeed. Failed or interrupted
|
|
# runs keep this marker so they are clearly identifiable.
|
|
touch "$BACKUP_DIR/INCOMPLETE"
|
|
log "Backup destination: $BACKUP_DIR"
|
|
|
|
# ── Estimate required free space ─────────────────────────────────
|
|
# PostgreSQL/MariaDB raw directories and Bitcoin/Electrs data are excluded
|
|
# from the estimate to avoid inflating the required size.
|
|
|
|
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
|
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
|
|
|
# nix-bitcoin secrets and /var/lib system service data exist only on the
|
|
# Node and Server + Desktop roles — they are skipped entirely on Desktop Only.
|
|
SECRETS_BYTES=0
|
|
VAR_LIB_BYTES=0
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
|
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
|
--exclude='postgresql' \
|
|
--exclude='mysql' \
|
|
--exclude='mariadb' \
|
|
--exclude='bitcoind' \
|
|
--exclude='electrs' \
|
|
--exclude='*/log' \
|
|
--exclude='*/logs' \
|
|
--exclude='*/cache' \
|
|
--exclude='*/tmp')
|
|
fi
|
|
|
|
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
|
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
|
# Later incremental runs need far less space, but a conservative first-run
|
|
# check protects against running out of space mid-backup.
|
|
REQUIRED_BYTES=$(( ESTIMATED_BYTES + (ESTIMATED_BYTES / 5) + SAFETY_MARGIN_BYTES ))
|
|
|
|
FREE_BYTES=$(df -B1 --output=avail "$TARGET" | tail -1 | tr -d ' ')
|
|
FREE_GB=$(( FREE_BYTES / 1024 / 1024 / 1024 ))
|
|
REQUIRED_GB=$(( REQUIRED_BYTES / 1024 / 1024 / 1024 ))
|
|
|
|
log "Estimated backup size: $(( ESTIMATED_BYTES / 1024 / 1024 / 1024 )) GB"
|
|
log "Required free space (with safety margin): ${REQUIRED_GB} GB"
|
|
log "Free space on drive: ${FREE_GB} GB"
|
|
|
|
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
|
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
|
|
|
# ── Stage 1: NixOS configuration ────────────────────────────────
|
|
|
|
log ""
|
|
log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
|
|
if [[ -d /etc/nixos ]]; then
|
|
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
|
log "Stage 1 complete."
|
|
else
|
|
log "WARNING: /etc/nixos not found — skipping."
|
|
fi
|
|
|
|
# ── Stage 2: Secrets ────────────────────────────────────────────
|
|
# Only applies to the Node and Server + Desktop roles. Desktop Only systems
|
|
# run no nix-bitcoin services, so there are no secrets to back up and this
|
|
# stage does not exist for them.
|
|
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
log ""
|
|
log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
|
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
|
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
|
else
|
|
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
|
fi
|
|
log "Stage 2 complete."
|
|
fi
|
|
|
|
# ── Home directory ──────────────────────────────────────────────
|
|
# Stage 2/2 on Desktop Only, stage 3/4 on Node and Server + Desktop.
|
|
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
|
|
# because the desktop may be active and files can disappear between the
|
|
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
|
|
|
log ""
|
|
log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
|
|
if [[ -d /home ]]; then
|
|
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
|
--exclude='.cache/' \
|
|
--exclude='.local/share/Trash/' \
|
|
--exclude='Trash/' \
|
|
--exclude='.mozilla/firefox/*/cache2/' \
|
|
--exclude='.mozilla/firefox/*/startupCache/' \
|
|
--exclude='.mozilla/firefox/*/thumbnails/' \
|
|
--exclude='.config/google-chrome/*/Cache/' \
|
|
--exclude='.config/google-chrome/*/Code Cache/' \
|
|
--exclude='.config/chromium/*/Cache/' \
|
|
--exclude='.config/chromium/*/Code Cache/' \
|
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
|
--exclude='.local/share/baloo/' \
|
|
--exclude='.thumbnails/' \
|
|
--exclude='.xsession-errors' \
|
|
--exclude='.xsession-errors.old'
|
|
log "Stage ${HOME_STAGE_NUM} complete."
|
|
else
|
|
log "WARNING: /home not found — skipping."
|
|
fi
|
|
|
|
# ── Stage 4: System data ────────────────────────────────────────
|
|
# Only applies to the Node and Server + Desktop roles — Desktop Only systems
|
|
# run no server services, so /var/lib holds no service data worth mirroring.
|
|
# PostgreSQL/MariaDB raw database directories are excluded. Application
|
|
# databases must be backed up separately with native database tools.
|
|
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
|
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
log ""
|
|
log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
|
|
if [[ -d /var/lib ]]; then
|
|
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
|
--exclude='postgresql/' \
|
|
--exclude='mysql/' \
|
|
--exclude='mariadb/' \
|
|
--exclude='bitcoind/' \
|
|
--exclude='electrs/' \
|
|
--exclude='*/log/' \
|
|
--exclude='*/logs/' \
|
|
--exclude='*/cache/' \
|
|
--exclude='*/tmp/'
|
|
log "Stage 4 complete."
|
|
else
|
|
log "WARNING: /var/lib not found — skipping."
|
|
fi
|
|
fi
|
|
|
|
# ── Generate manifest ────────────────────────────────────────────
|
|
|
|
log ""
|
|
log "Generating BACKUP_MANIFEST.txt …"
|
|
MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
|
|
|
{
|
|
echo "Sovran_SystemsOS Backup Manifest"
|
|
echo "Updated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
echo "Hostname: $(hostname)"
|
|
echo "Role: $ROLE_LABEL"
|
|
echo "Target: $TARGET"
|
|
echo ""
|
|
echo "Backup type: Live rsync mirror (directly browsable files)"
|
|
echo "Location: ${BACKUP_DIR}"
|
|
echo ""
|
|
echo "Source paths mirrored:"
|
|
echo "- /etc/nixos → current/etc/nixos/"
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
|
fi
|
|
echo "- /home → current/home/"
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
echo "- /var/lib → current/var/lib/"
|
|
fi
|
|
echo ""
|
|
echo "Exclusions:"
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
|
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
|
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
|
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
|
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
|
|
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
|
|
fi
|
|
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
|
|
echo ""
|
|
echo "Important limitations:"
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
|
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
|
echo " applications, their data must be backed up separately using native tools."
|
|
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
|
|
echo " reconstructable or stored on the internal second drive."
|
|
fi
|
|
echo "- This is a live file-level mirror, not a transactional database backup."
|
|
echo " Files being written during the backup may be in an inconsistent state."
|
|
echo ""
|
|
echo "Restore guidance:"
|
|
echo "- Files are directly browsable on the backup drive under: ${BACKUP_DIR}"
|
|
echo "- To restore a directory:"
|
|
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
|
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
|
fi
|
|
echo "- To copy individual files:"
|
|
echo " sudo cp -a current/home/username/ /home/username/"
|
|
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
|
echo " hardware-configuration.nix for the new hardware before rebuilding."
|
|
echo ""
|
|
echo "Nonfatal warnings:"
|
|
if [[ "${#RSYNC_WARNINGS[@]}" -eq 0 ]]; then
|
|
echo "- none"
|
|
else
|
|
for warning in "${RSYNC_WARNINGS[@]}"; do
|
|
echo "- $warning"
|
|
done
|
|
fi
|
|
if [[ "$ROLE" != "desktop" ]]; then
|
|
echo ""
|
|
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
|
echo "from manual external backup because they already live on the internal second drive"
|
|
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
|
fi
|
|
} > "$MANIFEST_FILE"
|
|
|
|
log "Manifest written to $MANIFEST_FILE"
|
|
|
|
# ── Done ─────────────────────────────────────────────────────────
|
|
|
|
log ""
|
|
if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
|
|
log "Backup completed with nonfatal warnings:"
|
|
for warning in "${RSYNC_WARNINGS[@]}"; do
|
|
log " WARNING: $warning"
|
|
done
|
|
log "Your important data is backed up. The warnings above indicate files that"
|
|
log "vanished during backup, which is normal on an active desktop."
|
|
log ""
|
|
fi
|
|
if [[ "$ROLE" == "desktop" ]]; then
|
|
log "All Finished! Your data is now backed up to a second, external location."
|
|
else
|
|
log "All Finished! Your data is now backed up to a third location."
|
|
fi
|
|
log "Files are directly browsable on the drive under: ${BACKUP_DIR}"
|
|
log "Please eject the drive safely before removing it from your Sovran Pro."
|
|
|
|
# Remove incomplete marker and write completion marker only after all work succeeds.
|
|
# A later successful run will update the same mirror and replace any INCOMPLETE state.
|
|
rm -f "$BACKUP_DIR/INCOMPLETE"
|
|
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
|
|
BACKUP_COMPLETE=1
|
|
set_status "SUCCESS"
|