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 |
| `--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
@@ -76,6 +78,23 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
## 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
```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 --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
./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
./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
./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.
### 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
```bash
@@ -383,7 +440,7 @@ Or as a cron job on the host (in root's crontab, or use `sudo`):
## 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.
+267 -86
View File
@@ -13,13 +13,29 @@ TARGET_STACKS=()
MODE="all" # all|running
HAS_FLAGS=false
ORIG_ARGS=()
TARGET_DIR=""
RESTORE_TARGET=""
# --- Helper Functions --------------------------------------------------------
# --- Output Formatting --------------------------------------------------------
log() { if $VERBOSE; then echo "[INFO] $*"; fi; }
warn() { echo "[WARN] $*" >&2; }
err() { echo "[ERROR] $*" >&2; exit 1; }
dry() { if $DRY_RUN; then echo "[DRY-RUN] $*"; else "$@"; fi; }
# Terminal colors (auto-disabled when not a TTY or tput unavailable)
if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
BOLD=$(tput bold 2>/dev/null || true)
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
fix_owner() {
@@ -55,21 +71,25 @@ Commands:
With no flags, shows an interactive menu.
restore Restore one or more stacks from a backup.
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
verify Verify backup integrity (checks sha256 hash)
Options:
-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
-r, --running Target only currently running compose projects wherever
they live (discovers compose file paths automatically)
-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
-v, --verbose Verbose output
-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):
x-backup:
@@ -89,6 +109,9 @@ Examples:
$(basename "$0") backup --stack random_stack --stack another_stack
$(basename "$0") backup -s random_stack -v
$(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
EOF
exit 0
@@ -106,7 +129,7 @@ require_root() {
if [ "$(id -u)" -eq 0 ]; then
return
fi
echo "Elevating to root via sudo..."
echo " ${MAGENTA}${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
exec sudo "$0" "${ORIG_ARGS[@]}"
}
@@ -183,7 +206,7 @@ resolve_target_stacks() {
if [ ${#explicit[@]} -gt 0 ]; then
for spec in "${explicit[@]}"; do
stack_spec_to_path "$stacks_dir" "$spec"
stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec"
done
return
fi
@@ -328,9 +351,9 @@ apply_retention() {
if [ "$count" -gt "$retention" ]; then
local to_delete
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
log " Pruning: $(basename "$f")"
echo " ${MAGENTA}${RESET} Pruning: $(basename "$f")"
dry rm -f "$f" "${f}.meta"
done
fi
@@ -394,12 +417,12 @@ warn_named_volumes() {
vols="$(detect_named_volumes "$compose_file")"
[ -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
echo " - $v"
echo " ${YELLOW}${RESET} ${CYAN}${RESET} $v"
done
echo " 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} These store data inside Docker's storage area, not on the host filesystem."
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.
@@ -407,7 +430,11 @@ warn_named_volumes() {
resolve_docker_volume() {
local stack_name="$1"
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.
@@ -438,7 +465,7 @@ backup_named_volumes() {
if docker volume inspect "$docker_vol" >/dev/null 2>&1; then
log "Dumping named volume '$docker_vol'..."
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
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'"
@@ -467,8 +494,8 @@ restore_named_volumes() {
log "Restoring named volume '$docker_vol'..."
if $DRY_RUN; then
echo "[DRY-RUN] 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} docker volume create $docker_vol"
echo " ${MAGENTA}${RESET} cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst"
else
docker volume rm "$docker_vol" 2>/dev/null || true
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"
if $any_restored; then
echo " -> Named volumes restored"
success "Named volumes restored"
fi
}
@@ -544,7 +571,7 @@ start_stack() {
compose_file="$(resolve_compose_file "$stack_path")"
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 || \
warn "Failed to start stack '$stack_name'"
fi
@@ -552,6 +579,12 @@ start_stack() {
# --- 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() {
require_root
local TIMESTAMP
@@ -564,10 +597,15 @@ backup_stack() {
local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz"
local archive_root
archive_root="$(dirname "$stack_path")"
archive_root="$(_resolve_dir "$archive_root")"
local archive_basename
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 "$stack_path"
@@ -581,13 +619,11 @@ backup_stack() {
# Dump named Docker volumes (opt-out via x-backup.backup-volumes: false)
local compose_file
compose_file="$(resolve_compose_file "$stack_path")"
local volumes_dumped=false
if [ "$XB_BACKUP_VOLUMES" = "false" ]; then
warn_named_volumes "$compose_file" "$stack_name"
else
log "Dumping named volumes..."
backup_named_volumes "$stack_path" "$stack_name" "$compose_file"
volumes_dumped=true
fi
# Pre-backup hook
@@ -600,30 +636,48 @@ backup_stack() {
dry mkdir -p "$backup_dir"
fix_owner "$backup_dir" 2>/dev/null || true
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
# 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"
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
# Capture volume state before cleanup
local has_volumes="false"
[ -d "$stack_path/_volumes" ] && has_volumes="true"
# Clean up temporary volume dumps
if $volumes_dumped && [ -d "$stack_path/_volumes" ]; then
if [ "$has_volumes" = "true" ]; then
dry rm -rf "$stack_path/_volumes"
fi
# Write metadata — used by the interactive restore menu and for
# identifying backups without extracting the archive.
if ! $DRY_RUN; then
local has_volumes="false"
[ -d "$stack_path/_volumes" ] && has_volumes="true"
local archive_sha256 archive_size
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")"
local resolved_path="${archive_root}/${archive_basename}"
cat > "${backup_file}.meta" <<EOF
stack=${stack_name}
path=${stack_path}
path=${resolved_path}
date=${TIMESTAMP}
compose=$(basename "${compose_file:-compose.yaml}")
volumes=${has_volumes}
@@ -704,7 +758,7 @@ interactive_restore_menu() {
done
if [ ${#names[@]} -eq 0 ]; then
echo "No backups found in $backup_dir"
echo " ${YELLOW}${RESET} No backups found in ${backup_dir}"
exit 0
fi
@@ -712,20 +766,19 @@ interactive_restore_menu() {
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
echo ""
echo "Available backups:"
echo "------------------"
echo " ${BOLD}${CYAN}━━━ Available Backups ━━━${RESET}"
local i
for i in "${!names[@]}"; do
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
echo " all) Restore all stacks"
echo " q) Cancel"
echo " ${CYAN}all)${RESET} Restore all stacks"
echo " ${YELLOW}q)${RESET} Cancel"
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"
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
@@ -736,7 +789,7 @@ interactive_restore_menu() {
fi
if [ "$selection" = "q" ]; then
echo "Cancelled."
echo " ${YELLOW}${RESET} Cancelled."
exit 0
fi
@@ -756,13 +809,13 @@ interactive_restore_menu() {
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
selected_stacks+=("${names[$((seen_idx - 1))]}")
else
echo "Invalid selection: $seen_idx (out of range)"
echo " ${YELLOW}${RESET} Invalid selection: ${seen_idx} (out of range)"
fi
fi
done
if [ ${#selected_stacks[@]} -eq 0 ]; then
echo "No valid stacks selected."
echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0
fi
@@ -786,13 +839,13 @@ interactive_restore_menu() {
done
if [ ${#stack_flags[@]} -eq 0 ]; then
echo "No valid stacks selected."
echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0
fi
# Elevate and re-exec with the selected stacks
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[@]}"
fi
@@ -818,7 +871,7 @@ verify_backup() {
fi
if [ -z "$files" ]; then
echo "No backups found."
echo " ${YELLOW}${RESET} No backups found."
return
fi
@@ -829,7 +882,7 @@ verify_backup() {
local meta_file="${f}.meta"
if [ ! -f "$meta_file" ]; then
echo " SKIP $basename_f (no .meta file)"
echo " ${YELLOW}${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no .meta file)"
continue
fi
@@ -838,13 +891,13 @@ verify_backup() {
stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')"
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
fi
# Check file still exists
if [ ! -f "$f" ]; then
echo " FAIL $basename_f (file missing)"
echo " ${RED}${RESET} ${RED}FAIL${RESET} ${basename_f} (file missing)"
exit_code=1
continue
fi
@@ -853,7 +906,7 @@ verify_backup() {
local actual_size
actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")"
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
continue
fi
@@ -862,12 +915,12 @@ verify_backup() {
local actual_hash
actual_hash="$(sha256sum "$f" | cut -d' ' -f1)"
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
else
local human_size
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
done
@@ -929,25 +982,24 @@ interactive_backup_menu() {
done
if [ ${#names[@]} -eq 0 ]; then
echo "No stacks found."
echo " ${YELLOW}${RESET} No stacks found."
exit 0
fi
echo ""
echo "Available stacks:"
echo "-----------------"
echo " ${BOLD}${CYAN}━━━ Available Stacks ━━━${RESET}"
local i
for i in "${!names[@]}"; do
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
echo " all) Back up all stacks"
echo " q) Cancel"
echo " ${CYAN}all)${RESET} Back up all stacks"
echo " ${YELLOW}q)${RESET} Cancel"
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
selection=""
@@ -957,7 +1009,7 @@ interactive_backup_menu() {
fi
if [ "$selection" = "q" ]; then
echo "Cancelled."
echo " ${YELLOW}${RESET} Cancelled."
exit 0
fi
@@ -975,13 +1027,13 @@ interactive_backup_menu() {
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
selected_paths+=("${paths[$((seen_idx - 1))]}")
else
echo "Invalid selection: $seen_idx (out of range)"
echo " ${YELLOW}${RESET} Invalid selection: ${seen_idx} (out of range)"
fi
fi
done
if [ ${#selected_paths[@]} -eq 0 ]; then
echo "No valid stacks selected."
echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0
fi
@@ -991,7 +1043,7 @@ interactive_backup_menu() {
for spath in "${selected_paths[@]}"; do
stack_flags+=("--stack" "$spath")
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[@]}"
fi
@@ -1010,12 +1062,14 @@ restore_stack() {
local stacks_dir="$1"
local backup_dir="$2"
local stack_path="$3"
local restore_path="${4:-$stack_path}"
local stack_name
stack_name="$(stack_spec_to_name "$stack_path")"
local archive_root
archive_root="$(dirname "$stack_path")"
archive_root="$(dirname "$restore_path")"
archive_root="$(_resolve_dir "$archive_root")"
local archive_basename
archive_basename="$(basename "$stack_path")"
archive_basename="$(basename "$restore_path")"
# Find the latest backup for this stack
local backup_file
@@ -1025,7 +1079,11 @@ restore_stack() {
err "No backup found for stack '$stack_name' in $backup_dir"
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
if $VERBOSE; then
@@ -1070,20 +1128,32 @@ restore_stack() {
# Restore
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
if [ -d "$stack_path" ]; then
local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}"
log "Moving existing '$stack_name' to $existing_backup"
mv "$stack_path" "$existing_backup"
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
# backup archive is the authoritative restore point.
if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then
dry rm -rf "$existing_backup"
fi
echo " -> Restore complete"
success "Restore complete"
# Restore named Docker volumes from the _volumes/ dir inside the archive
restore_named_volumes "$stack_path" "$stack_name"
fi
@@ -1102,14 +1172,88 @@ restore_stack() {
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_stacks_and_backups() {
local stacks_dir="$1"
local backup_dir="$2"
echo "=== Stacks ==="
# Collect all seen stack dirs to avoid duplicates
echo " ${BOLD}${CYAN}━━━ Stacks ━━━${RESET}"
local -A seen
# 1) Running stacks (discovered wherever they live)
@@ -1132,9 +1276,9 @@ list_stacks_and_backups() {
local config_tags
config_tags="$(format_xb_tags)"
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
echo " - $run_name $label$config_tags"
echo " ${CYAN}${RESET} ${BOLD}${run_name}${RESET} ${label}${config_tags}"
fi
has_running=true
done <<< "$running"
@@ -1156,18 +1300,18 @@ list_stacks_and_backups() {
load_x_backup_config "$dir"
local config_tags
config_tags="$(format_xb_tags)"
echo " - $stack$label$config_tags"
echo " ${CYAN}${RESET} ${BOLD}${stack}${RESET}${label}${config_tags}"
has_local=true
fi
fi
done
if ! $has_running && ! $has_local; then
echo " No stacks found."
echo " ${YELLOW}${RESET} No stacks found."
fi
echo ""
echo "=== Backups ==="
echo " ${BOLD}${CYAN}━━━ Backups ━━━${RESET}"
if [ -d "$backup_dir" ]; then
local backup_count=0
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=//')"
mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')"
mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')"
[ -n "$mhash" ] && meta_info=" sha256:${mhash:0:12}.."
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="$meta_info ($mpath)"
[ "$mvols" = "true" ] && meta_info="$meta_info [volumes]"
[ -n "$mhash" ] && meta_info="${meta_info} ${BLUE}sha256:${mhash:0:12}..${RESET}"
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="${meta_info} ${CYAN}(${mpath})${RESET}"
[ "$mvols" = "true" ] && meta_info="${meta_info} ${GREEN}[volumes]${RESET}"
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))
done
if [ "$backup_count" -eq 0 ]; then
echo " No backups found in $backup_dir"
echo " ${YELLOW}${RESET} No backups found in ${backup_dir}"
fi
else
echo " No backups directory found at $backup_dir"
echo " ${YELLOW}${RESET} No backups directory found at ${backup_dir}"
fi
}
@@ -1211,7 +1357,7 @@ main() {
# Parse arguments
while [ $# -gt 0 ]; do
case "$1" in
backup|restore|list|verify)
backup|restore|init|list|verify)
ACTION="$1"
shift
;;
@@ -1234,6 +1380,18 @@ main() {
HAS_FLAGS=true
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)
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
backup_dir="$2"
@@ -1260,11 +1418,32 @@ main() {
usage
fi
if [ -n "$TARGET_DIR" ]; then
stacks_dir="$TARGET_DIR"
fi
if [ ! -d "$stacks_dir" ]; then
err "Stacks directory not found: $stacks_dir"
fi
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_stacks_and_backups "$stacks_dir" "$backup_dir"
;;
@@ -1278,7 +1457,8 @@ main() {
local stacks
stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")"
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
fi
local stack
@@ -1299,8 +1479,9 @@ main() {
fi
for spec in "${TARGET_STACKS[@]}"; do
local spath
spath="$(stack_spec_to_path "$stacks_dir" "$spec")"
restore_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Restore failed for '$spath'"
spath="$(stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec")"
local restore_path="${RESTORE_TARGET:-$spath}"
restore_stack "$stacks_dir" "$backup_dir" "$spath" "$restore_path" || warn "Restore failed for '$spath'"
done
;;
verify)