docker-stacks-backup: add init command, output formatting, progress indicators, and bugfixes

New features:
  - init command: inject default x-backup config into compose files
  - --dir flag: scan a custom directory for stacks
  - --to flag: override restore target path

Bugfixes:
  - resolve_docker_volume: no longer double-prefixes volume names
  - metadata has_volumes now correctly tracks volume state
  - warn_named_volumes message now accurate about backup-volumes setting

Output formatting:
  - Terminal colors via tput (auto-disabled on non-TTY)
  - Unicode symbols: ✓ ● ℹ ⚠ ✗ ◇
  - Section headers with ━━━ separators
  - Colored verify results (green OK, red FAIL, yellow SKIP)
  - Cleaner interactive menus with colored prompts

Progress indicators (no dependencies):
  - tar --checkpoint shows live byte progress during backup/restore
  - Graceful fallback when checkpoint unavailable or non-TTY
This commit is contained in:
2026-07-17 06:13:49 -04:00
parent 63609830af
commit f2b17647f4
2 changed files with 326 additions and 88 deletions
+59 -2
View File
@@ -60,7 +60,9 @@ The script finds stacks in three ways:
|------|---------------| |------|---------------|
| `--all` | Every subdirectory of the **script's directory** containing a compose file | | `--all` | Every subdirectory of the **script's directory** containing a compose file |
| `--running` | All running compose projects **anywhere on the filesystem** by reading `ConfigFiles` from `docker compose ls --format json` | | `--running` | All running compose projects **anywhere on the filesystem** by reading `ConfigFiles` from `docker compose ls --format json` |
| `--stack <spec>` | A specific stack by name (relative to the script's directory) or by absolute path | | `--stack <spec>` | A specific stack by name (relative to the **current directory**) or by absolute path |
Add `--dir <path>` to any command to override the default scan directory (script dir for backup/restore, current dir for init).
### External Stacks ### External Stacks
@@ -76,6 +78,23 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
## Usage ## Usage
### Init
```bash
# Inject default x-backup config into all stacks in the current directory
./docker-stack-backup.sh init
# Or specify a directory to scan (no cd needed)
./docker-stack-backup.sh init --dir /mnt/data/stacks
# Or target specific stacks
./docker-stack-backup.sh init --running
./docker-stack-backup.sh init --stack random_stack
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
./docker-stack-backup.sh init -s stack_a -s stack_b
./docker-stack-backup.sh init --dry-run --verbose # preview changes
```
### Backup ### Backup
```bash ```bash
@@ -94,6 +113,9 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
./docker-stack-backup.sh backup -s stack_a -s stack_b ./docker-stack-backup.sh backup -s stack_a -s stack_b
./docker-stack-backup.sh backup --stack stack_a,stack_b ./docker-stack-backup.sh backup --stack stack_a,stack_b
# Scan a specific directory for stacks instead of the script's directory
./docker-stack-backup.sh backup --dir /srv/docker --all
# With a custom backup destination # With a custom backup destination
./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups ./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups
@@ -128,6 +150,9 @@ After selecting stacks, the script re-executes via `sudo` with your selections a
# Restore an external stack by path # Restore an external stack by path
./docker-stack-backup.sh restore --stack /srv/docker/nextcloud ./docker-stack-backup.sh restore --stack /srv/docker/nextcloud
# Restore to a different location (overrides the original path)
./docker-stack-backup.sh restore --stack random_stack --to /new/location/random_stack
# Restore from a specific backup directory # Restore from a specific backup directory
./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups ./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups
``` ```
@@ -149,6 +174,38 @@ After selecting backups, the script re-executes via `sudo` with the resolved sta
When restoring over an existing stack directory, the current directory is briefly renamed to `<stack>.pre-restore-<timestamp>` as a safety net during extraction. Once the restore completes successfully, the pre-restore copy is removed — the backup archive is the authoritative restore point. Any stale pre-restore directories from previous failed restores are cleaned up automatically. When restoring over an existing stack directory, the current directory is briefly renamed to `<stack>.pre-restore-<timestamp>` as a safety net during extraction. Once the restore completes successfully, the pre-restore copy is removed — the backup archive is the authoritative restore point. Any stale pre-restore directories from previous failed restores are cleaned up automatically.
### Init (Inject Default Config)
```bash
# Inject default x-backup config into all stacks in the current directory
./docker-stack-backup.sh init
# Or target running/external stacks
./docker-stack-backup.sh init --running
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
```
Scans each compose file (relative to the **current directory** by default, or use `--dir` to point elsewhere) and injects a default `x-backup` block at the top:
```yaml
x-backup:
stop: true
backup-volumes: true
# pre-hook: ""
# post-hook: ""
# pre-restore-hook: ""
# post-restore-hook: ""
# exclude:
# - path/to/exclude
# retention: 7
```
Active defaults (`stop: true`, `backup-volumes: true`) are uncommented. Optional fields are commented out as a reference — uncomment and adjust as needed.
If the compose file already has an `x-backup:` block, it is removed first so the result is always a clean standard config. This makes `init` idempotent and safe to re-run at any time.
Does not require root by default — reads and writes compose files as the current user. If a compose file is owned by another user (e.g. root), the script automatically escalates with `sudo` for the write.
### Verify ### Verify
```bash ```bash
@@ -383,7 +440,7 @@ Or as a cron job on the host (in root's crontab, or use `sudo`):
## Requirements ## Requirements
- **Root access** — backup and restore require root because `tar --same-permissions --preserve-permissions` only preserves UID/GID ownership when running as root. The script automatically re-executes via `sudo` when needed. Read-only commands (`list`, `verify`, and the interactive menus) run without elevation. - **Root access** — backup and restore require root because `tar --same-permissions --preserve-permissions` only preserves UID/GID ownership when running as root. The script automatically re-executes via `sudo` when needed. Read-only commands (`list`, `verify`, and the interactive menus) run without elevation. The `init` command also runs without root by default, but auto-escalates with `sudo` if the compose file isn't writable by the current user.
When run via `sudo`, the backup archives (`.tar.gz` and `.meta` files) are automatically `chown`ed back to the original user so you can list, copy, or delete them without root. When run via `sudo`, the backup archives (`.tar.gz` and `.meta` files) are automatically `chown`ed back to the original user so you can list, copy, or delete them without root.
+267 -86
View File
@@ -13,13 +13,29 @@ TARGET_STACKS=()
MODE="all" # all|running MODE="all" # all|running
HAS_FLAGS=false HAS_FLAGS=false
ORIG_ARGS=() ORIG_ARGS=()
TARGET_DIR=""
RESTORE_TARGET=""
# --- Helper Functions -------------------------------------------------------- # --- Output Formatting --------------------------------------------------------
log() { if $VERBOSE; then echo "[INFO] $*"; fi; } # Terminal colors (auto-disabled when not a TTY or tput unavailable)
warn() { echo "[WARN] $*" >&2; } if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
err() { echo "[ERROR] $*" >&2; exit 1; } BOLD=$(tput bold 2>/dev/null || true)
dry() { if $DRY_RUN; then echo "[DRY-RUN] $*"; else "$@"; fi; } RED=$(tput setaf 1 2>/dev/null || true)
GREEN=$(tput setaf 2 2>/dev/null || true)
YELLOW=$(tput setaf 3 2>/dev/null || true)
BLUE=$(tput setaf 4 2>/dev/null || true)
MAGENTA=$(tput setaf 5 2>/dev/null || true)
CYAN=$(tput setaf 6 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
fi
log() { $VERBOSE && echo " ${BLUE}${RESET} $*" || true; }
warn() { echo " ${YELLOW}${RESET} $*" >&2; }
err() { echo " ${RED}${RESET} $*" >&2; exit 1; }
dry() { if $DRY_RUN; then echo " ${MAGENTA}${RESET} $*"; else "$@"; fi; }
success() { echo " ${GREEN}${RESET} $*"; }
header() { echo ""; echo " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
# When run via sudo, chown files back to the original user # When run via sudo, chown files back to the original user
fix_owner() { fix_owner() {
@@ -55,21 +71,25 @@ Commands:
With no flags, shows an interactive menu. With no flags, shows an interactive menu.
restore Restore one or more stacks from a backup. restore Restore one or more stacks from a backup.
With no --stack flag, shows an interactive menu. With no --stack flag, shows an interactive menu.
init Inject default x-backup config into compose files
that don't already have one.
list List available stacks and backups list List available stacks and backups
verify Verify backup integrity (checks sha256 hash) verify Verify backup integrity (checks sha256 hash)
Options: Options:
-s, --stack <name> Target a specific stack (repeatable, or comma-separated). -s, --stack <name> Target a specific stack (repeatable, or comma-separated).
Can be a name (relative to stacks dir) or absolute path. Can be a name (relative to current directory) or absolute path.
-a, --all Target all stacks found in stacks directory -a, --all Target all stacks found in stacks directory
-r, --running Target only currently running compose projects wherever -r, --running Target only currently running compose projects wherever
they live (discovers compose file paths automatically) they live (discovers compose file paths automatically)
-d, --backup-dir <dir> Backup directory (default: ${_DEFAULT_BACKUP_DIR}) -d, --backup-dir <dir> Backup directory (default: ${_DEFAULT_BACKUP_DIR})
--dir <dir> Scan this directory for stacks (overrides default discovery dir)
--to <path> Override restore target path (use with restore --stack)
-n, --dry-run Show what would be done without making changes -n, --dry-run Show what would be done without making changes
-v, --verbose Verbose output -v, --verbose Verbose output
-h, --help Show this help message -h, --help Show this help message
Must be run as root (preserves file ownership in backups and restores). Must be run as root for backup/restore (preserves file ownership). The init and list commands do not require root.
Compose x-backup extensions (add to compose.yaml): Compose x-backup extensions (add to compose.yaml):
x-backup: x-backup:
@@ -89,6 +109,9 @@ Examples:
$(basename "$0") backup --stack random_stack --stack another_stack $(basename "$0") backup --stack random_stack --stack another_stack
$(basename "$0") backup -s random_stack -v $(basename "$0") backup -s random_stack -v
$(basename "$0") restore --stack random_stack -d /path/to/backups $(basename "$0") restore --stack random_stack -d /path/to/backups
$(basename "$0") restore --stack random_stack --to /new/location/random_stack
$(basename "$0") init
$(basename "$0") init --running
$(basename "$0") list $(basename "$0") list
EOF EOF
exit 0 exit 0
@@ -106,7 +129,7 @@ require_root() {
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
return return
fi fi
echo "Elevating to root via sudo..." echo " ${MAGENTA}${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
exec sudo "$0" "${ORIG_ARGS[@]}" exec sudo "$0" "${ORIG_ARGS[@]}"
} }
@@ -183,7 +206,7 @@ resolve_target_stacks() {
if [ ${#explicit[@]} -gt 0 ]; then if [ ${#explicit[@]} -gt 0 ]; then
for spec in "${explicit[@]}"; do for spec in "${explicit[@]}"; do
stack_spec_to_path "$stacks_dir" "$spec" stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec"
done done
return return
fi fi
@@ -328,9 +351,9 @@ apply_retention() {
if [ "$count" -gt "$retention" ]; then if [ "$count" -gt "$retention" ]; then
local to_delete local to_delete
to_delete="$((count - retention))" to_delete="$((count - retention))"
log "Retention=$retention, pruning $to_delete old backup(s) for '$stack_name'" log "Retention=${retention}, pruning ${to_delete} old backup(s) for '${stack_name}'"
find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | head -n "$to_delete" | while IFS= read -r f; do find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | head -n "$to_delete" | while IFS= read -r f; do
log " Pruning: $(basename "$f")" echo " ${MAGENTA}${RESET} Pruning: $(basename "$f")"
dry rm -f "$f" "${f}.meta" dry rm -f "$f" "${f}.meta"
done done
fi fi
@@ -394,12 +417,12 @@ warn_named_volumes() {
vols="$(detect_named_volumes "$compose_file")" vols="$(detect_named_volumes "$compose_file")"
[ -z "$vols" ] && return [ -z "$vols" ] && return
echo " [!] Named volumes detected (not backed up by default):" echo " ${YELLOW}${RESET} Named volumes detected but ${BOLD}backup-volumes is false${RESET}, skipping:"
for v in $vols; do for v in $vols; do
echo " - $v" echo " ${YELLOW}${RESET} ${CYAN}${RESET} $v"
done done
echo " These store data inside Docker's storage area, not on the host filesystem." echo " ${YELLOW}${RESET} These store data inside Docker's storage area, not on the host filesystem."
echo " Set 'x-backup.backup-volumes: false' to skip them." echo " ${YELLOW}${RESET} Set ${BOLD}x-backup.backup-volumes: true${RESET} to include them."
} }
# Resolve the actual Docker volume name for a compose volume reference. # Resolve the actual Docker volume name for a compose volume reference.
@@ -407,7 +430,11 @@ warn_named_volumes() {
resolve_docker_volume() { resolve_docker_volume() {
local stack_name="$1" local stack_name="$1"
local vol_name="$2" local vol_name="$2"
echo "${stack_name}_${vol_name}" if [[ "$vol_name" == "${stack_name}_"* ]]; then
echo "$vol_name"
else
echo "${stack_name}_${vol_name}"
fi
} }
# Back up named Docker volumes into a temporary directory. # Back up named Docker volumes into a temporary directory.
@@ -438,7 +465,7 @@ backup_named_volumes() {
if docker volume inspect "$docker_vol" >/dev/null 2>&1; then if docker volume inspect "$docker_vol" >/dev/null 2>&1; then
log "Dumping named volume '$docker_vol'..." log "Dumping named volume '$docker_vol'..."
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz" echo " ${MAGENTA}${RESET} docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz"
else else
docker run --rm -v "${docker_vol}:/_src" alpine tar -czf - -C /_src . > "$volumes_dir/${vol}.tar.gz" 2>/dev/null || \ docker run --rm -v "${docker_vol}:/_src" alpine tar -czf - -C /_src . > "$volumes_dir/${vol}.tar.gz" 2>/dev/null || \
warn "Failed to dump named volume '$docker_vol'" warn "Failed to dump named volume '$docker_vol'"
@@ -467,8 +494,8 @@ restore_named_volumes() {
log "Restoring named volume '$docker_vol'..." log "Restoring named volume '$docker_vol'..."
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] docker volume create $docker_vol" echo " ${MAGENTA}${RESET} docker volume create $docker_vol"
echo "[DRY-RUN] cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst" echo " ${MAGENTA}${RESET} cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst"
else else
docker volume rm "$docker_vol" 2>/dev/null || true docker volume rm "$docker_vol" 2>/dev/null || true
docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; } docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; }
@@ -483,7 +510,7 @@ restore_named_volumes() {
rm -rf "$volumes_dir" rm -rf "$volumes_dir"
if $any_restored; then if $any_restored; then
echo " -> Named volumes restored" success "Named volumes restored"
fi fi
} }
@@ -544,7 +571,7 @@ start_stack() {
compose_file="$(resolve_compose_file "$stack_path")" compose_file="$(resolve_compose_file "$stack_path")"
if [ -n "$compose_file" ]; then if [ -n "$compose_file" ]; then
log "Starting stack '$stack_name' ($stack_path)..." log "Starting stack '$stack_name'..."
dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \ dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \
warn "Failed to start stack '$stack_name'" warn "Failed to start stack '$stack_name'"
fi fi
@@ -552,6 +579,12 @@ start_stack() {
# --- Backup ------------------------------------------------------------------ # --- Backup ------------------------------------------------------------------
# Resolve the real path if the directory involves symlinks
_resolve_dir() {
local dir="$1"
(cd -P "$dir" 2>/dev/null && pwd) || echo "$dir"
}
backup_stack() { backup_stack() {
require_root require_root
local TIMESTAMP local TIMESTAMP
@@ -564,10 +597,15 @@ backup_stack() {
local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz" local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz"
local archive_root local archive_root
archive_root="$(dirname "$stack_path")" archive_root="$(dirname "$stack_path")"
archive_root="$(_resolve_dir "$archive_root")"
local archive_basename local archive_basename
archive_basename="$(basename "$stack_path")" archive_basename="$(basename "$stack_path")"
echo "Backing up stack '$stack_name' ($stack_path) -> $backup_file" header "Backup: ${stack_name}"
echo " ${CYAN}${RESET} Source: ${BOLD}${stack_path}${RESET}"
echo " ${CYAN}${RESET} Archive: ${backup_file##*/}"
echo ""
# Load x-backup config # Load x-backup config
load_x_backup_config "$stack_path" load_x_backup_config "$stack_path"
@@ -581,13 +619,11 @@ backup_stack() {
# Dump named Docker volumes (opt-out via x-backup.backup-volumes: false) # Dump named Docker volumes (opt-out via x-backup.backup-volumes: false)
local compose_file local compose_file
compose_file="$(resolve_compose_file "$stack_path")" compose_file="$(resolve_compose_file "$stack_path")"
local volumes_dumped=false
if [ "$XB_BACKUP_VOLUMES" = "false" ]; then if [ "$XB_BACKUP_VOLUMES" = "false" ]; then
warn_named_volumes "$compose_file" "$stack_name" warn_named_volumes "$compose_file" "$stack_name"
else else
log "Dumping named volumes..." log "Dumping named volumes..."
backup_named_volumes "$stack_path" "$stack_name" "$compose_file" backup_named_volumes "$stack_path" "$stack_name" "$compose_file"
volumes_dumped=true
fi fi
# Pre-backup hook # Pre-backup hook
@@ -600,30 +636,48 @@ backup_stack() {
dry mkdir -p "$backup_dir" dry mkdir -p "$backup_dir"
fix_owner "$backup_dir" 2>/dev/null || true fix_owner "$backup_dir" 2>/dev/null || true
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename" echo " ${MAGENTA}${RESET} tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename"
else else
# shellcheck disable=SC2086 # shellcheck disable=SC2086
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename" if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
local cr=$'\r'
local clr=$'\033[K'
echo -n " ${CYAN}${RESET} Compressing..."
tar -czf "$backup_file" \
--checkpoint=500 \
--checkpoint-action="ttyout=${cr} ${CYAN}${RESET} %T${clr}" \
--same-permissions --preserve-permissions \
$XB_EXCLUDE -C "$archive_root" "$archive_basename"
echo -ne "\r\033[K\n" 2>/dev/null || true
else
echo " ${CYAN}${RESET} Compressing..."
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename"
fi
fix_owner "$backup_file" fix_owner "$backup_file"
echo " -> Backup complete: $(du -h "$backup_file" | cut -f1)" local size
size="$(du -h "$backup_file" | cut -f1)"
success "Backup complete: ${BOLD}${size}${RESET}"
fi fi
# Capture volume state before cleanup
local has_volumes="false"
[ -d "$stack_path/_volumes" ] && has_volumes="true"
# Clean up temporary volume dumps # Clean up temporary volume dumps
if $volumes_dumped && [ -d "$stack_path/_volumes" ]; then if [ "$has_volumes" = "true" ]; then
dry rm -rf "$stack_path/_volumes" dry rm -rf "$stack_path/_volumes"
fi fi
# Write metadata — used by the interactive restore menu and for # Write metadata — used by the interactive restore menu and for
# identifying backups without extracting the archive. # identifying backups without extracting the archive.
if ! $DRY_RUN; then if ! $DRY_RUN; then
local has_volumes="false"
[ -d "$stack_path/_volumes" ] && has_volumes="true"
local archive_sha256 archive_size local archive_sha256 archive_size
archive_sha256="$(sha256sum "$backup_file" | cut -d' ' -f1)" archive_sha256="$(sha256sum "$backup_file" | cut -d' ' -f1)"
archive_size="$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")" archive_size="$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")"
local resolved_path="${archive_root}/${archive_basename}"
cat > "${backup_file}.meta" <<EOF cat > "${backup_file}.meta" <<EOF
stack=${stack_name} stack=${stack_name}
path=${stack_path} path=${resolved_path}
date=${TIMESTAMP} date=${TIMESTAMP}
compose=$(basename "${compose_file:-compose.yaml}") compose=$(basename "${compose_file:-compose.yaml}")
volumes=${has_volumes} volumes=${has_volumes}
@@ -704,7 +758,7 @@ interactive_restore_menu() {
done done
if [ ${#names[@]} -eq 0 ]; then if [ ${#names[@]} -eq 0 ]; then
echo "No backups found in $backup_dir" echo " ${YELLOW}${RESET} No backups found in ${backup_dir}"
exit 0 exit 0
fi fi
@@ -712,20 +766,19 @@ interactive_restore_menu() {
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
echo "" echo ""
echo "Available backups:" echo " ${BOLD}${CYAN}━━━ Available Backups ━━━${RESET}"
echo "------------------"
local i local i
for i in "${!names[@]}"; do for i in "${!names[@]}"; do
local idx=$((i + 1)) local idx=$((i + 1))
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}" printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}"
done done
echo " all) Restore all stacks" echo " ${CYAN}all)${RESET} Restore all stacks"
echo " q) Cancel" echo " ${YELLOW}q)${RESET} Cancel"
echo "" echo ""
read -r -p "Select stacks to restore (numbers, 'all', or 'q'): " selection read -r -p " ${CYAN}${RESET} Select stacks to restore (numbers, 'all', or 'q'): " selection
[ -z "$selection" ] && echo "Cancelled." && exit 0 [ -z "$selection" ] && echo " ${YELLOW}${RESET} Cancelled." && exit 0
# Normalise: handle "all" # Normalise: handle "all"
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
@@ -736,7 +789,7 @@ interactive_restore_menu() {
fi fi
if [ "$selection" = "q" ]; then if [ "$selection" = "q" ]; then
echo "Cancelled." echo " ${YELLOW}${RESET} Cancelled."
exit 0 exit 0
fi fi
@@ -756,13 +809,13 @@ interactive_restore_menu() {
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
selected_stacks+=("${names[$((seen_idx - 1))]}") selected_stacks+=("${names[$((seen_idx - 1))]}")
else else
echo "Invalid selection: $seen_idx (out of range)" echo " ${YELLOW}${RESET} Invalid selection: ${seen_idx} (out of range)"
fi fi
fi fi
done done
if [ ${#selected_stacks[@]} -eq 0 ]; then if [ ${#selected_stacks[@]} -eq 0 ]; then
echo "No valid stacks selected." echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0 exit 0
fi fi
@@ -786,13 +839,13 @@ interactive_restore_menu() {
done done
if [ ${#stack_flags[@]} -eq 0 ]; then if [ ${#stack_flags[@]} -eq 0 ]; then
echo "No valid stacks selected." echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0 exit 0
fi fi
# Elevate and re-exec with the selected stacks # Elevate and re-exec with the selected stacks
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Elevating to root via sudo..." echo " ${MAGENTA}${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}" exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}"
fi fi
@@ -818,7 +871,7 @@ verify_backup() {
fi fi
if [ -z "$files" ]; then if [ -z "$files" ]; then
echo "No backups found." echo " ${YELLOW}${RESET} No backups found."
return return
fi fi
@@ -829,7 +882,7 @@ verify_backup() {
local meta_file="${f}.meta" local meta_file="${f}.meta"
if [ ! -f "$meta_file" ]; then if [ ! -f "$meta_file" ]; then
echo " SKIP $basename_f (no .meta file)" echo " ${YELLOW}${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no .meta file)"
continue continue
fi fi
@@ -838,13 +891,13 @@ verify_backup() {
stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')" stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')"
if [ -z "$stored_hash" ]; then if [ -z "$stored_hash" ]; then
echo " SKIP $basename_f (no sha256 in .meta — legacy backup)" echo " ${YELLOW}${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no sha256 in .meta — legacy backup)"
continue continue
fi fi
# Check file still exists # Check file still exists
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo " FAIL $basename_f (file missing)" echo " ${RED}${RESET} ${RED}FAIL${RESET} ${basename_f} (file missing)"
exit_code=1 exit_code=1
continue continue
fi fi
@@ -853,7 +906,7 @@ verify_backup() {
local actual_size local actual_size
actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")" actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")"
if [ "$actual_size" != "$stored_size" ]; then if [ "$actual_size" != "$stored_size" ]; then
echo " FAIL $basename_f (size mismatch: $actual_size vs $stored_size)" echo " ${RED}${RESET} ${RED}FAIL${RESET} ${basename_f} (size mismatch: ${actual_size} vs ${stored_size})"
exit_code=1 exit_code=1
continue continue
fi fi
@@ -862,12 +915,12 @@ verify_backup() {
local actual_hash local actual_hash
actual_hash="$(sha256sum "$f" | cut -d' ' -f1)" actual_hash="$(sha256sum "$f" | cut -d' ' -f1)"
if [ "$actual_hash" != "$stored_hash" ]; then if [ "$actual_hash" != "$stored_hash" ]; then
echo " FAIL $basename_f (sha256 mismatch)" echo " ${RED}${RESET} ${RED}FAIL${RESET} ${basename_f} (sha256 mismatch)"
exit_code=1 exit_code=1
else else
local human_size local human_size
human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")" human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")"
echo " OK $basename_f ($human_size)" echo " ${GREEN}${RESET} ${GREEN}OK${RESET} ${basename_f} (${human_size})"
fi fi
done done
@@ -929,25 +982,24 @@ interactive_backup_menu() {
done done
if [ ${#names[@]} -eq 0 ]; then if [ ${#names[@]} -eq 0 ]; then
echo "No stacks found." echo " ${YELLOW}${RESET} No stacks found."
exit 0 exit 0
fi fi
echo "" echo ""
echo "Available stacks:" echo " ${BOLD}${CYAN}━━━ Available Stacks ━━━${RESET}"
echo "-----------------"
local i local i
for i in "${!names[@]}"; do for i in "${!names[@]}"; do
local idx=$((i + 1)) local idx=$((i + 1))
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${labels[$i]}" printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${labels[$i]}"
done done
echo " all) Back up all stacks" echo " ${CYAN}all)${RESET} Back up all stacks"
echo " q) Cancel" echo " ${YELLOW}q)${RESET} Cancel"
echo "" echo ""
read -r -p "Select stacks to back up (numbers, 'all', or 'q'): " selection read -r -p " ${CYAN}${RESET} Select stacks to back up (numbers, 'all', or 'q'): " selection
[ -z "$selection" ] && echo "Cancelled." && exit 0 [ -z "$selection" ] && echo " ${YELLOW}${RESET} Cancelled." && exit 0
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
selection="" selection=""
@@ -957,7 +1009,7 @@ interactive_backup_menu() {
fi fi
if [ "$selection" = "q" ]; then if [ "$selection" = "q" ]; then
echo "Cancelled." echo " ${YELLOW}${RESET} Cancelled."
exit 0 exit 0
fi fi
@@ -975,13 +1027,13 @@ interactive_backup_menu() {
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
selected_paths+=("${paths[$((seen_idx - 1))]}") selected_paths+=("${paths[$((seen_idx - 1))]}")
else else
echo "Invalid selection: $seen_idx (out of range)" echo " ${YELLOW}${RESET} Invalid selection: ${seen_idx} (out of range)"
fi fi
fi fi
done done
if [ ${#selected_paths[@]} -eq 0 ]; then if [ ${#selected_paths[@]} -eq 0 ]; then
echo "No valid stacks selected." echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0 exit 0
fi fi
@@ -991,7 +1043,7 @@ interactive_backup_menu() {
for spath in "${selected_paths[@]}"; do for spath in "${selected_paths[@]}"; do
stack_flags+=("--stack" "$spath") stack_flags+=("--stack" "$spath")
done done
echo "Elevating to root via sudo..." echo " ${MAGENTA}${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}" exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}"
fi fi
@@ -1010,12 +1062,14 @@ restore_stack() {
local stacks_dir="$1" local stacks_dir="$1"
local backup_dir="$2" local backup_dir="$2"
local stack_path="$3" local stack_path="$3"
local restore_path="${4:-$stack_path}"
local stack_name local stack_name
stack_name="$(stack_spec_to_name "$stack_path")" stack_name="$(stack_spec_to_name "$stack_path")"
local archive_root local archive_root
archive_root="$(dirname "$stack_path")" archive_root="$(dirname "$restore_path")"
archive_root="$(_resolve_dir "$archive_root")"
local archive_basename local archive_basename
archive_basename="$(basename "$stack_path")" archive_basename="$(basename "$restore_path")"
# Find the latest backup for this stack # Find the latest backup for this stack
local backup_file local backup_file
@@ -1025,7 +1079,11 @@ restore_stack() {
err "No backup found for stack '$stack_name' in $backup_dir" err "No backup found for stack '$stack_name' in $backup_dir"
fi fi
echo "Restoring stack '$stack_name' ($stack_path) from $backup_file" header "Restore: ${stack_name}"
echo " ${CYAN}${RESET} Target: ${BOLD}${restore_path}${RESET}"
echo " ${CYAN}${RESET} Backup: ${backup_file##*/}"
echo ""
# Get list of files in backup to confirm it's the right one # Get list of files in backup to confirm it's the right one
if $VERBOSE; then if $VERBOSE; then
@@ -1070,20 +1128,32 @@ restore_stack() {
# Restore # Restore
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root" echo " ${MAGENTA}${RESET} tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root"
else else
if [ -d "$stack_path" ]; then if [ -d "$stack_path" ]; then
local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}" local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}"
log "Moving existing '$stack_name' to $existing_backup" log "Moving existing '$stack_name' to $existing_backup"
mv "$stack_path" "$existing_backup" mv "$stack_path" "$existing_backup"
fi fi
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root" if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
local cr=$'\r'
local clr=$'\033[K'
echo -n " ${CYAN}${RESET} Extracting..."
tar -xzf "$backup_file" \
--checkpoint=500 \
--checkpoint-action="ttyout=${cr} ${CYAN}${RESET} %T${clr}" \
--same-permissions --preserve-permissions -C "$archive_root"
echo -ne "\r\033[K\n" 2>/dev/null || true
else
echo " ${CYAN}${RESET} Extracting..."
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root"
fi
# Restore succeeded — remove the pre-restore safety copy since the # Restore succeeded — remove the pre-restore safety copy since the
# backup archive is the authoritative restore point. # backup archive is the authoritative restore point.
if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then
dry rm -rf "$existing_backup" dry rm -rf "$existing_backup"
fi fi
echo " -> Restore complete" success "Restore complete"
# Restore named Docker volumes from the _volumes/ dir inside the archive # Restore named Docker volumes from the _volumes/ dir inside the archive
restore_named_volumes "$stack_path" "$stack_name" restore_named_volumes "$stack_path" "$stack_name"
fi fi
@@ -1102,14 +1172,88 @@ restore_stack() {
fi fi
} }
# --- Init (inject default x-backup config) -----------------------------------
_remove_x_backup() {
awk '
/^x-backup:/ { skip=1; next }
skip && /^[a-zA-Z_-][a-zA-Z0-9_-]*:/ { skip=0 }
skip { next }
{ print }
' "$1"
}
_init_inject_header() {
local header="$1"
local file="$2"
local cp_cmd="${3:-cp}"
local tmp
tmp="$(mktemp)"
if grep -q '^---' "$file" 2>/dev/null; then
awk -v h="$header" '!done && /^---/ { print; print ""; print h; done=1; next } 1' "$file" > "$tmp"
else
{ echo "$header"; echo ""; cat "$file"; } > "$tmp"
fi
$cp_cmd "$tmp" "$file" && rm -f "$tmp"
}
init_stack() {
local stack_path="$1"
local compose_file
compose_file="$(resolve_compose_file "$stack_path")"
local stack_name
stack_name="$(basename "$stack_path")"
[ -z "$compose_file" ] && { warn "No compose file in '$stack_name'"; return; }
local has_xb=false
grep -q '^x-backup:' "$compose_file" 2>/dev/null && has_xb=true
local sudo_write=""
[ -f "$compose_file" ] && [ ! -w "$compose_file" ] && sudo_write="sudo"
local cp_cmd="${sudo_write:+sudo }cp"
local header
header='x-backup:
stop: true
backup-volumes: true
# pre-hook: ""
# post-hook: ""
# pre-restore-hook: ""
# post-restore-hook: ""
# exclude:
# - path/to/exclude
# retention: 7'
if $DRY_RUN; then
local verb="inject"
$has_xb && verb="replace"
local maybe_sudo=""
[ -n "$sudo_write" ] && maybe_sudo=" (with sudo)"
echo " ${MAGENTA}${RESET} Init: would ${verb} x-backup config in ${BOLD}${compose_file}${RESET}${maybe_sudo}"
return
fi
if $has_xb; then
local tmp
tmp="$(mktemp)"
_remove_x_backup "$compose_file" > "$tmp"
_init_inject_header "$header" "$tmp"
$cp_cmd "$tmp" "$compose_file" && rm -f "$tmp"
echo " ${GREEN}${RESET} ${BOLD}${stack_name}${RESET} x-backup config replaced"
else
_init_inject_header "$header" "$compose_file" "$cp_cmd"
echo " ${GREEN}${RESET} ${BOLD}${stack_name}${RESET} x-backup config injected"
fi
}
# --- List -------------------------------------------------------------------- # --- List --------------------------------------------------------------------
list_stacks_and_backups() { list_stacks_and_backups() {
local stacks_dir="$1" local stacks_dir="$1"
local backup_dir="$2" local backup_dir="$2"
echo "=== Stacks ===" echo " ${BOLD}${CYAN}━━━ Stacks ━━━${RESET}"
# Collect all seen stack dirs to avoid duplicates
local -A seen local -A seen
# 1) Running stacks (discovered wherever they live) # 1) Running stacks (discovered wherever they live)
@@ -1132,9 +1276,9 @@ list_stacks_and_backups() {
local config_tags local config_tags
config_tags="$(format_xb_tags)" config_tags="$(format_xb_tags)"
if [ "$canon" != "$stacks_dir/$run_name" ]; then if [ "$canon" != "$stacks_dir/$run_name" ]; then
echo " - $run_name ($run_path) $label$config_tags" echo " ${CYAN}${RESET} ${BOLD}${run_name}${RESET} ${BLUE}(${run_path})${RESET} ${label}${config_tags}"
else else
echo " - $run_name $label$config_tags" echo " ${CYAN}${RESET} ${BOLD}${run_name}${RESET} ${label}${config_tags}"
fi fi
has_running=true has_running=true
done <<< "$running" done <<< "$running"
@@ -1156,18 +1300,18 @@ list_stacks_and_backups() {
load_x_backup_config "$dir" load_x_backup_config "$dir"
local config_tags local config_tags
config_tags="$(format_xb_tags)" config_tags="$(format_xb_tags)"
echo " - $stack$label$config_tags" echo " ${CYAN}${RESET} ${BOLD}${stack}${RESET}${label}${config_tags}"
has_local=true has_local=true
fi fi
fi fi
done done
if ! $has_running && ! $has_local; then if ! $has_running && ! $has_local; then
echo " No stacks found." echo " ${YELLOW}${RESET} No stacks found."
fi fi
echo "" echo ""
echo "=== Backups ===" echo " ${BOLD}${CYAN}━━━ Backups ━━━${RESET}"
if [ -d "$backup_dir" ]; then if [ -d "$backup_dir" ]; then
local backup_count=0 local backup_count=0
for f in "$backup_dir"/*.tar.gz; do for f in "$backup_dir"/*.tar.gz; do
@@ -1180,18 +1324,20 @@ list_stacks_and_backups() {
mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')" mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')"
mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')" mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')"
mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')" mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')"
[ -n "$mhash" ] && meta_info=" sha256:${mhash:0:12}.." [ -n "$mhash" ] && meta_info="${meta_info} ${BLUE}sha256:${mhash:0:12}..${RESET}"
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="$meta_info ($mpath)" [ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="${meta_info} ${CYAN}(${mpath})${RESET}"
[ "$mvols" = "true" ] && meta_info="$meta_info [volumes]" [ "$mvols" = "true" ] && meta_info="${meta_info} ${GREEN}[volumes]${RESET}"
fi fi
echo " $(basename "$f") ($(du -h "$f" | cut -f1)) -> $sname$meta_info" local fsize
fsize="$(du -h "$f" | cut -f1)"
echo " ${CYAN}${RESET} ${BOLD}$(basename "$f")${RESET} ${BLUE}${fsize}${RESET}${sname}${meta_info}"
backup_count=$((backup_count + 1)) backup_count=$((backup_count + 1))
done done
if [ "$backup_count" -eq 0 ]; then if [ "$backup_count" -eq 0 ]; then
echo " No backups found in $backup_dir" echo " ${YELLOW}${RESET} No backups found in ${backup_dir}"
fi fi
else else
echo " No backups directory found at $backup_dir" echo " ${YELLOW}${RESET} No backups directory found at ${backup_dir}"
fi fi
} }
@@ -1211,7 +1357,7 @@ main() {
# Parse arguments # Parse arguments
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
backup|restore|list|verify) backup|restore|init|list|verify)
ACTION="$1" ACTION="$1"
shift shift
;; ;;
@@ -1234,6 +1380,18 @@ main() {
HAS_FLAGS=true HAS_FLAGS=true
shift shift
;; ;;
--dir)
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
TARGET_DIR="$2"
HAS_FLAGS=true
shift 2
;;
--to)
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
RESTORE_TARGET="$2"
HAS_FLAGS=true
shift 2
;;
-d|--backup-dir) -d|--backup-dir)
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
backup_dir="$2" backup_dir="$2"
@@ -1260,11 +1418,32 @@ main() {
usage usage
fi fi
if [ -n "$TARGET_DIR" ]; then
stacks_dir="$TARGET_DIR"
fi
if [ ! -d "$stacks_dir" ]; then if [ ! -d "$stacks_dir" ]; then
err "Stacks directory not found: $stacks_dir" err "Stacks directory not found: $stacks_dir"
fi fi
case "$ACTION" in case "$ACTION" in
init)
local scan_dir="${TARGET_DIR:-$INITIAL_DIR}"
if ! $HAS_FLAGS; then
MODE="all"
fi
local stacks
stacks="$(resolve_target_stacks "$scan_dir" "$MODE" "${TARGET_STACKS[@]}")"
if [ -z "$stacks" ]; then
echo " ${YELLOW}${RESET} No stacks found."
exit 0
fi
for stack in $stacks; do
local spath
spath="$(stack_spec_to_path "$scan_dir" "$stack")"
init_stack "$spath"
done
;;
list) list)
list_stacks_and_backups "$stacks_dir" "$backup_dir" list_stacks_and_backups "$stacks_dir" "$backup_dir"
;; ;;
@@ -1278,7 +1457,8 @@ main() {
local stacks local stacks
stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")" stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")"
if [ -z "$stacks" ]; then if [ -z "$stacks" ]; then
echo "No stacks found in '$stacks_dir'. Either cd there, pass --stack /path, or use -d to set the backup directory." echo " ${YELLOW}${RESET} No stacks found in ${BOLD}${stacks_dir}${RESET}."
echo " ${CYAN}${RESET} Either cd there, pass ${BOLD}--stack /path${RESET}, or use ${BOLD}-d${RESET} to set the backup directory."
exit 0 exit 0
fi fi
local stack local stack
@@ -1299,8 +1479,9 @@ main() {
fi fi
for spec in "${TARGET_STACKS[@]}"; do for spec in "${TARGET_STACKS[@]}"; do
local spath local spath
spath="$(stack_spec_to_path "$stacks_dir" "$spec")" spath="$(stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec")"
restore_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Restore failed for '$spath'" local restore_path="${RESTORE_TARGET:-$spath}"
restore_stack "$stacks_dir" "$backup_dir" "$spath" "$restore_path" || warn "Restore failed for '$spath'"
done done
;; ;;
verify) verify)