Backup script fixes
This commit is contained in:
@@ -37,6 +37,20 @@
|
|||||||
let
|
let
|
||||||
cfg = config.homey.backup;
|
cfg = config.homey.backup;
|
||||||
dataDir = config.homey.storage.mountPoint;
|
dataDir = config.homey.storage.mountPoint;
|
||||||
|
|
||||||
|
# The manual offload script backs up a mirror of the external HD onto a
|
||||||
|
# USB drive, so only extraPaths/extraExcludePaths actually under dataDir
|
||||||
|
# apply (e.g. eurovote's /var/lib/eurovote is OS state, out of scope here).
|
||||||
|
offloadDataPaths = lib.filter (lib.hasPrefix "${dataDir}/") cfg.extraPaths;
|
||||||
|
offloadExcludeDataPaths = lib.filter (lib.hasPrefix "${dataDir}/") cfg.extraExcludePaths;
|
||||||
|
|
||||||
|
offloadBackupScript = pkgs.replaceVars ../scripts/offload-backup.sh {
|
||||||
|
backupPaths = lib.concatMapStringsSep "\n " (p: ''"${p}"'') offloadDataPaths;
|
||||||
|
excludeArgs = lib.concatMapStringsSep "\n " (p: ''--exclude "${p}"'') offloadExcludeDataPaths;
|
||||||
|
};
|
||||||
|
|
||||||
|
offloadBackupPackage = pkgs.writeShellScriptBin "homey-offload-backup"
|
||||||
|
(builtins.readFile offloadBackupScript);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.backup = {
|
options.homey.backup = {
|
||||||
@@ -83,6 +97,14 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Manual offload script — installed system-wide so its path list stays
|
||||||
|
# in sync with extraPaths/extraExcludePaths on every deploy, instead of
|
||||||
|
# being hand-copied and left to drift. Run via `homey-offload-backup`
|
||||||
|
# in the dev shell.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
environment.systemPackages = [ offloadBackupPackage ];
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Secrets
|
# Secrets
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|||||||
+88
-43
@@ -1,57 +1,95 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# offload-backup.sh — back up /mnt/data directly to a USB drive using restic.
|
# offload-backup.sh — back up /mnt/data directly to a USB drive using restic.
|
||||||
#
|
#
|
||||||
# Run on the Pi (see homey-offload-backup in the dev shell).
|
# This is a Nix template, not a directly runnable script: the BACKUP_PATHS
|
||||||
# Scans for plugged-in USB partitions, lets you pick one, mounts it if needed,
|
# and EXCLUDE_ARGS arrays below are filled in by modules/backup.nix from
|
||||||
|
# the same homey.backup.extraPaths/extraExcludePaths every service module
|
||||||
|
# already contributes to the nightly S3 backup, so this list can't silently
|
||||||
|
# drift out of sync the way a hand-maintained copy did before. The built
|
||||||
|
# package is installed on the Pi as homey-offload-backup via
|
||||||
|
# environment.systemPackages — deploy with nixos-rebuild, then run it from
|
||||||
|
# the dev shell (homey-offload-backup, which just ssh's in and invokes the
|
||||||
|
# installed command).
|
||||||
|
#
|
||||||
|
# Scans for plugged-in USB partitions, lets you pick one, mounts it,
|
||||||
# initialises a restic repo on it, and runs a backup of all service data dirs.
|
# initialises a restic repo on it, and runs a backup of all service data dirs.
|
||||||
#
|
#
|
||||||
# The restic password is read from the sops-managed secret at runtime;
|
# The restic password is read from the sops-managed secret at runtime;
|
||||||
# no S3 credentials are needed — this is a direct local backup.
|
# no S3 credentials are needed — this is a direct local backup.
|
||||||
#
|
|
||||||
# Usage: sudo bash offload-backup.sh
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# A dropped SSH connection must not kill this mid-backup and skip the
|
||||||
|
# cleanup trap below — writing to the now-closed stdout pipe would
|
||||||
|
# otherwise deliver SIGPIPE and terminate the script immediately.
|
||||||
|
trap '' PIPE
|
||||||
|
|
||||||
REPO_NAME="homey-backup"
|
REPO_NAME="homey-backup"
|
||||||
DATA_DIR="/mnt/data"
|
DATA_DIR="/mnt/data"
|
||||||
PASSWORD_FILE="/run/secrets/restic/password"
|
PASSWORD_FILE="/run/secrets/restic/password"
|
||||||
|
|
||||||
BACKUP_PATHS=(
|
BACKUP_PATHS=(
|
||||||
"$DATA_DIR/openldap"
|
@backupPaths@
|
||||||
"$DATA_DIR/authelia"
|
|
||||||
"$DATA_DIR/gitea"
|
|
||||||
"$DATA_DIR/nextcloud"
|
|
||||||
"$DATA_DIR/jellyfin"
|
|
||||||
"$DATA_DIR/transmission"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
EXCLUDE_ARGS=(
|
EXCLUDE_ARGS=(
|
||||||
--exclude "$DATA_DIR/nextcloud/db"
|
|
||||||
--exclude "$DATA_DIR/restic-cache"
|
--exclude "$DATA_DIR/restic-cache"
|
||||||
|
@excludeArgs@
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Find USB partitions
|
# Find USB partitions
|
||||||
|
#
|
||||||
|
# TRAN (transport) is only reported on the parent disk, never on its
|
||||||
|
# partitions, so partitions are matched up to their disk via PKNAME. Already-
|
||||||
|
# mounted partitions are excluded so the main data drive (/mnt/data) can
|
||||||
|
# never be selected by accident.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "Scanning for USB drives..."
|
echo "Scanning for USB drives..."
|
||||||
mapfile -t USB_PARTS < <(
|
|
||||||
lsblk -o NAME,SIZE,TRAN,LABEL,MOUNTPOINT -rn \
|
mapfile -t LSBLK_LINES < <(lsblk -Ppo NAME,TYPE,TRAN,SIZE,LABEL,MOUNTPOINT,PKNAME)
|
||||||
| awk '$3 == "usb" && $2 != "" {print $1, $2, $4, $5}'
|
|
||||||
)
|
# Extracts KEY="value" from one lsblk -P line without eval, so a maliciously
|
||||||
|
# crafted filesystem label can't inject shell commands.
|
||||||
|
lsblk_field() {
|
||||||
|
local line="$1" key="$2"
|
||||||
|
[[ "$line" =~ ${key}=\"([^\"]*)\" ]] && printf '%s' "${BASH_REMATCH[1]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
declare -A DISK_TRAN
|
||||||
|
for line in "${LSBLK_LINES[@]}"; do
|
||||||
|
name=$(lsblk_field "$line" NAME)
|
||||||
|
type=$(lsblk_field "$line" TYPE)
|
||||||
|
tran=$(lsblk_field "$line" TRAN)
|
||||||
|
[ "$type" = "disk" ] && DISK_TRAN["$name"]="$tran"
|
||||||
|
done
|
||||||
|
|
||||||
|
USB_PARTS=()
|
||||||
|
for line in "${LSBLK_LINES[@]}"; do
|
||||||
|
name=$(lsblk_field "$line" NAME)
|
||||||
|
type=$(lsblk_field "$line" TYPE)
|
||||||
|
size=$(lsblk_field "$line" SIZE)
|
||||||
|
label=$(lsblk_field "$line" LABEL)
|
||||||
|
mountpoint=$(lsblk_field "$line" MOUNTPOINT)
|
||||||
|
pkname=$(lsblk_field "$line" PKNAME)
|
||||||
|
if [ "$type" = "part" ] \
|
||||||
|
&& [ "${DISK_TRAN[$pkname]:-}" = "usb" ] \
|
||||||
|
&& [ -z "$mountpoint" ]; then
|
||||||
|
USB_PARTS+=("$name|$size|$label")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [ "${#USB_PARTS[@]}" -eq 0 ]; then
|
if [ "${#USB_PARTS[@]}" -eq 0 ]; then
|
||||||
echo "No USB partitions found. Plug in a USB drive and try again." >&2
|
echo "No eligible USB partitions found (must be on a USB-attached disk and not already mounted)." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Available USB partitions:"
|
echo "Available USB partitions (already-mounted drives are hidden):"
|
||||||
for i in "${!USB_PARTS[@]}"; do
|
for i in "${!USB_PARTS[@]}"; do
|
||||||
read -r dev size label mount <<< "${USB_PARTS[$i]}"
|
IFS='|' read -r dev size label <<< "${USB_PARTS[$i]}"
|
||||||
label="${label:-(no label)}"
|
label="${label:-(no label)}"
|
||||||
mount="${mount:-(not mounted)}"
|
printf " [%d] %s %s label=%s\n" "$((i + 1))" "$dev" "$size" "$label"
|
||||||
printf " [%d] /dev/%s %s label=%s mount=%s\n" \
|
|
||||||
"$((i + 1))" "$dev" "$size" "$label" "$mount"
|
|
||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Select a partition [1-${#USB_PARTS[@]}]: " CHOICE
|
read -rp "Select a partition [1-${#USB_PARTS[@]}]: " CHOICE
|
||||||
@@ -63,33 +101,34 @@ if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] \
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
read -r SELECTED_DEV _ _ EXISTING_MOUNT <<< "${USB_PARTS[$((CHOICE - 1))]}"
|
IFS='|' read -r SELECTED_DEV _ SELECTED_LABEL <<< "${USB_PARTS[$((CHOICE - 1))]}"
|
||||||
SELECTED_DEV="/dev/$SELECTED_DEV"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Kernel device names (sdX) aren't stable across reboots/replugs — prefer the
|
||||||
# Mount if needed
|
# udev by-label symlink so the mount target doesn't depend on enumeration
|
||||||
# ---------------------------------------------------------------------------
|
# order, falling back to the kernel name if the drive has no label.
|
||||||
MOUNTED_HERE=false
|
if [ -n "$SELECTED_LABEL" ] && [ -e "/dev/disk/by-label/$SELECTED_LABEL" ]; then
|
||||||
MOUNT_DIR=""
|
SELECTED_DEV="/dev/disk/by-label/$SELECTED_LABEL"
|
||||||
|
|
||||||
if [ -n "$EXISTING_MOUNT" ]; then
|
|
||||||
MOUNT_DIR="$EXISTING_MOUNT"
|
|
||||||
echo "Using existing mount at $MOUNT_DIR"
|
|
||||||
else
|
|
||||||
MOUNT_DIR=$(mktemp -d)
|
|
||||||
echo "Mounting $SELECTED_DEV at $MOUNT_DIR..."
|
|
||||||
mount "$SELECTED_DEV" "$MOUNT_DIR"
|
|
||||||
MOUNTED_HERE=true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mount (selection above guarantees this partition isn't already mounted)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MOUNT_DIR=$(mktemp -d)
|
||||||
|
echo "Mounting $SELECTED_DEV at $MOUNT_DIR..."
|
||||||
|
mount "$SELECTED_DEV" "$MOUNT_DIR"
|
||||||
|
|
||||||
|
# Guarded against running twice: trapping both a signal (e.g. INT) and EXIT
|
||||||
|
# with the same handler means a signal-triggered cleanup runs again via the
|
||||||
|
# subsequent EXIT trap once set -e notices the interrupted command failed.
|
||||||
|
CLEANED_UP=false
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [ "$MOUNTED_HERE" = true ] && [ -n "$MOUNT_DIR" ]; then
|
$CLEANED_UP && return
|
||||||
echo "Unmounting $MOUNT_DIR..."
|
CLEANED_UP=true
|
||||||
umount "$MOUNT_DIR"
|
echo "Unmounting $MOUNT_DIR..."
|
||||||
rmdir "$MOUNT_DIR"
|
umount "$MOUNT_DIR"
|
||||||
fi
|
rmdir "$MOUNT_DIR"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Initialise restic repo if this is the first run
|
# Initialise restic repo if this is the first run
|
||||||
@@ -106,10 +145,16 @@ fi
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo "Backing up to $REPO..."
|
echo "Backing up to $REPO..."
|
||||||
|
# --parent latest: restic only auto-picks a parent snapshot when this run's
|
||||||
|
# path list exactly matches a previous snapshot's. BACKUP_PATHS grows as
|
||||||
|
# service modules are added/removed, so without this every such change
|
||||||
|
# would silently force a full unoptimized re-read of every path, not just
|
||||||
|
# the new ones.
|
||||||
restic -r "$REPO" \
|
restic -r "$REPO" \
|
||||||
--password-file "$PASSWORD_FILE" \
|
--password-file "$PASSWORD_FILE" \
|
||||||
--cache-dir "$DATA_DIR/restic-cache" \
|
--cache-dir "$DATA_DIR/restic-cache" \
|
||||||
backup \
|
backup \
|
||||||
|
--parent latest \
|
||||||
"${BACKUP_PATHS[@]}" \
|
"${BACKUP_PATHS[@]}" \
|
||||||
"${EXCLUDE_ARGS[@]}"
|
"${EXCLUDE_ARGS[@]}"
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ pkgs.mkShell {
|
|||||||
--flake .#pi-main
|
--flake .#pi-main
|
||||||
'')
|
'')
|
||||||
(pkgs.writeShellScriptBin "homey-offload-backup" ''
|
(pkgs.writeShellScriptBin "homey-offload-backup" ''
|
||||||
set -euo pipefail
|
# The script itself is built from modules/backup.nix and installed on
|
||||||
scp scripts/offload-backup.sh admin@192.168.1.100:/tmp/homey-offload-backup.sh
|
# the Pi via nixos-rebuild (see homey-deploy-rpi-main) — this just
|
||||||
ssh -t admin@192.168.1.100 'sudo bash /tmp/homey-offload-backup.sh; rm /tmp/homey-offload-backup.sh'
|
# invokes it. Redeploy first if you've changed which services
|
||||||
|
# contribute to homey.backup.extraPaths.
|
||||||
|
ssh -t admin@192.168.1.100 'sudo homey-offload-backup'
|
||||||
'')
|
'')
|
||||||
(pkgs.writeShellScriptBin "homey-backup-status" ''
|
(pkgs.writeShellScriptBin "homey-backup-status" ''
|
||||||
ssh admin@192.168.1.100 bash -s <<'ENDSSH'
|
ssh admin@192.168.1.100 bash -s <<'ENDSSH'
|
||||||
|
|||||||
Reference in New Issue
Block a user