Compare commits

...
5 Commits
Author SHA1 Message Date
jeremy e54d77893a docker-stacks-update: fix --list freeze + unbound variable
- Add missing 'shift' in --list case (caused infinite loop)
- Initialize color vars to empty strings before tput block
  (set -u errored when tput unavailable or non-TTY)
2026-07-17 06:32:12 -04:00
jeremy 8617450eb9 docker-stacks-update: add -s/--stack to target specific stacks
Usage:  docker-stacks-update -s stack_a -s stack_b

Only matching running stacks are processed. Shows a helpful message
with available stacks if no match is found.
2026-07-17 06:26:29 -04:00
jeremy 0afc798ee6 docker-stacks-update: add --help, fix over-reporting updates
- Add -h/--help usage with options
- Add argument parsing with unknown-option error
- Fix update detection: 'Pulled' appears for all services even when
  up-to-date; only 'Downloaded newer image for' is reliable
2026-07-17 06:25:39 -04:00
jeremy 84e7dfb2b7 docker-stacks-backup: move success msg after restart, add visible status for each step
- Show 'Stopping containers...' before docker compose down
- Show 'Starting containers...' before docker compose up
- Show 'Dumping named volumes...' during volume backup
- Move 'Backup complete' message to after stack restart
2026-07-17 06:18:08 -04:00
jeremy f2b17647f4 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
2026-07-17 06:13:49 -04:00
3 changed files with 495 additions and 159 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.
+269 -85
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"
if [[ "$vol_name" == "${stack_name}_"* ]]; then
echo "$vol_name"
else
echo "${stack_name}_${vol_name}" 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
} }
@@ -521,7 +548,7 @@ stop_stack() {
compose_file="$(resolve_compose_file "$stack_path")" compose_file="$(resolve_compose_file "$stack_path")"
if stack_is_running "$stack_path"; then if stack_is_running "$stack_path"; then
log "Stopping stack '$stack_name' ($stack_path)..." echo " ${CYAN}${RESET} Stopping containers..."
if [ -n "$compose_file" ]; then if [ -n "$compose_file" ]; then
dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \ dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \
dry docker compose -p "$stack_name" down 2>/dev/null || \ dry docker compose -p "$stack_name" down 2>/dev/null || \
@@ -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)..." echo " ${CYAN}${RESET} Starting containers..."
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,16 @@ 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")"
local _backup_size=""
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 +620,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..." echo " ${CYAN}${RESET} 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 +637,46 @@ 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
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" 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)" _backup_size="$(du -h "$backup_file" | cut -f1)"
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}
@@ -645,6 +698,10 @@ EOF
else else
start_stack "$stack_path" start_stack "$stack_path"
fi fi
if ! $DRY_RUN && [ -n "$_backup_size" ]; then
success "Backup complete: ${BOLD}${_backup_size}${RESET}"
fi
} }
# --- Interactive Restore Menu ------------------------------------------------ # --- Interactive Restore Menu ------------------------------------------------
@@ -704,7 +761,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 +769,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 +792,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 +812,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 +842,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 +874,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 +885,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 +894,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 +909,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 +918,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 +985,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 +1012,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 +1030,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 +1046,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 +1065,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 +1082,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 +1131,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
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" 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 +1175,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 +1279,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 +1303,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 +1327,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 +1360,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 +1383,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 +1421,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 +1460,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 +1482,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)
+163 -68
View File
@@ -9,15 +9,21 @@
set -euo pipefail set -euo pipefail
# ── Terminal colours (disabled when not on a terminal) ────────────── # ── Terminal colours ──────────────────────────────────────────────
if [[ -t 1 ]]; then BOLD=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET=""
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m' if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' BOLD=$(tput bold 2>/dev/null || true)
else RED=$(tput setaf 1 2>/dev/null || true)
GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC='' GREEN=$(tput setaf 2 2>/dev/null || true)
YELLOW=$(tput setaf 3 2>/dev/null || true)
CYAN=$(tput setaf 6 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
fi fi
# ── State ─────────────────────────────────────────────────────────── # ── State ───────────────────────────────────────────────────────────
TARGET_STACKS=() # user-specified stacks (empty = all)
LIST_ONLY=false # --list flag
all_names=() # pre-filter stack names (for error messages)
updated_names=() # stack names that were updated updated_names=() # stack names that were updated
updated_detail=() # parallel: "stack: service1, service2" updated_detail=() # parallel: "stack: service1, service2"
unchanged_names=() # stack names already up to date unchanged_names=() # stack names already up to date
@@ -25,24 +31,58 @@ orphaned_names=() # stacks whose compose files are gone removed
failed_names=() # stack names that errored failed_names=() # stack names that errored
# ── Helpers ───────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────
info() { echo -e "${CYAN}::${NC} $*"; } log() { echo -e " ${CYAN}${RESET} $*"; }
ok() { echo -e " ${GREEN}${NC} $*"; } ok() { echo -e " ${GREEN}${RESET} $*"; }
warn() { echo -e " ${YELLOW}~${NC} $*"; } warn() { echo -e " ${YELLOW}${RESET} $*"; }
fail() { echo -e " ${RED}${NC} $*"; } fail() { echo -e " ${RED}${RESET} $*"; }
header() { echo ""; echo -e " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
# ── Usage ─────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Pull latest images and restart all running Docker Compose stacks
that have updates.
Options:
-h, --help Show this help message
-l, --list List running stacks and exit
-s, --stack <name> Target a specific stack (repeatable)
No options = update all running stacks.
EOF
exit 0
}
# ── Parse arguments ──────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
-l|--list) LIST_ONLY=true; shift ;;
-s|--stack)
shift
[[ $# -eq 0 ]] && { echo -e " ${RED}${RESET} Option --stack requires a name." >&2; exit 1; }
TARGET_STACKS+=("$1")
shift
;;
-*)
echo -e " ${RED}${RESET} Unknown option: $1" >&2; usage ;;
*)
echo -e " ${RED}${RESET} Unexpected argument: $1" >&2; usage ;;
esac
done
# ── Pre-flight ────────────────────────────────────────────────────── # ── Pre-flight ──────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then if ! command -v docker &>/dev/null; then
echo -e "${RED}Error: 'docker' not found in PATH.${NC}" >&2 echo -e "${RED}${RESET} Error: 'docker' not found in PATH." >&2
exit 1 exit 1
fi fi
echo -e "${BOLD}Docker Compose Stack Updater${NC}" header "Docker Compose Stack Updater"
echo -e "${BOLD}────────────────────────────${NC}"
echo
# ── Discover stacks ───────────────────────────────────────────────── # ── Discover stacks ─────────────────────────────────────────────────
info "Scanning for running Docker Compose stacks ..." log "Scanning for running Docker Compose stacks ..."
echo
stack_json=$(docker compose ls --format json 2>/dev/null || true) stack_json=$(docker compose ls --format json 2>/dev/null || true)
@@ -53,7 +93,7 @@ fi
# Parse JSON with jq (required). # Parse JSON with jq (required).
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
echo -e "${RED}Error: 'jq' is required. Install it first.${NC}" >&2 echo -e "${RED}${RESET} Error: 'jq' is required. Install it first." >&2
exit 1 exit 1
fi fi
@@ -61,16 +101,73 @@ readarray -t names < <(echo "$stack_json" | jq -r '.[].Name')
readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles') readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles')
total=${#names[@]} total=${#names[@]}
echo " Found ${total} running stack(s):" echo " ${CYAN}${RESET} Found ${BOLD}${total}${RESET} running stack(s):"
printf " • %s\n" "${names[@]}" for name in "${names[@]}"; do
echo echo " ${CYAN}·${RESET} ${name}"
done
echo ""
if $LIST_ONLY; then
exit 0
fi
# ── Spinner for long operations ─────────────────────────────────────
_spin_kill=""
_spin_start() {
local msg="$1"
local -a ch=('-' '\' '|' '/')
local i=0
(
while true; do
echo -ne "\r ${CYAN}${ch[i]}${RESET} ${msg} "
i=$(( (i+1) % 4 ))
sleep 0.15
done
) &
_spin_kill=$!
disown 2>/dev/null || true
}
_spin_stop() {
[[ -z "$_spin_kill" ]] && return
kill "$_spin_kill" 2>/dev/null || true
wait "$_spin_kill" 2>/dev/null || true
_spin_kill=""
echo -ne "\r\033[K"
}
# ── Filter stacks if --stack was given ───────────────────────────────
if [[ ${#TARGET_STACKS[@]} -gt 0 ]]; then
all_names=("${names[@]}")
filtered_names=()
filtered_configs=()
for i in "${!names[@]}"; do
for t in "${TARGET_STACKS[@]}"; do
if [[ "${names[$i]}" == "$t" ]]; then
filtered_names+=("${names[$i]}")
filtered_configs+=("${configs[$i]}")
break
fi
done
done
names=("${filtered_names[@]}")
configs=("${filtered_configs[@]}")
if [[ ${#names[@]} -eq 0 ]]; then
warn "No running stacks match the requested name(s)."
echo " ${CYAN}${RESET} Running stacks: ${all_names[*]}"
exit 0
fi
echo " ${CYAN}${RESET} Targeting ${BOLD}${#names[@]}${RESET} of ${BOLD}${total}${RESET} running stack(s)"
echo ""
fi
# ── Process each stack ────────────────────────────────────────────── # ── Process each stack ──────────────────────────────────────────────
for i in "${!names[@]}"; do for i in "${!names[@]}"; do
stack="${names[$i]}" stack="${names[$i]}"
cfg_str="${configs[$i]}" cfg_str="${configs[$i]}"
echo -e "${BOLD}── ${stack}${NC}" echo -e " ${BOLD}${CYAN}──── ${stack}${RESET}"
# Support multi-file projects (ConfigFiles is comma-separated). # Support multi-file projects (ConfigFiles is comma-separated).
IFS=',' read -ra cfg_files <<< "$cfg_str" IFS=',' read -ra cfg_files <<< "$cfg_str"
@@ -97,12 +194,13 @@ for i in "${!names[@]}"; do
for m in "${missing_files[@]}"; do for m in "${missing_files[@]}"; do
echo " ${m}" echo " ${m}"
done done
info "Tearing down orphaned stack ..." _spin_start "Tearing down orphaned stack..."
if docker compose -p "$stack" down 2>/dev/null; then if docker compose -p "$stack" down 2>/dev/null; then
ok "${stack} — orphaned stack removed" _spin_stop; ok "${stack} — orphaned stack removed"
else else
_spin_stop
# Fall back to direct container / network cleanup by project label. # Fall back to direct container / network cleanup by project label.
info "Fallback: removing containers by project label" log "Fallback: removing containers by project label..."
containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true
if [[ -n "$containers" ]]; then if [[ -n "$containers" ]]; then
docker container rm -f $containers 2>/dev/null || true docker container rm -f $containers 2>/dev/null || true
@@ -114,49 +212,43 @@ for i in "${!names[@]}"; do
ok "${stack} — orphaned containers removed" ok "${stack} — orphaned containers removed"
fi fi
orphaned_names+=("$stack") orphaned_names+=("$stack")
echo; continue echo ""; continue
fi fi
info "Pulling latest images ..." # Pull with spinner (capture output for parsing later).
_spin_start "Pulling latest images..."
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || { pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
_spin_stop
fail "Image pull failed for ${stack}" fail "Image pull failed for ${stack}"
echo "$pull_out" | head -5 echo "$pull_out" | head -5 | while IFS= read -r line; do echo " ${line}"; done
failed_names+=("$stack") failed_names+=("$stack")
echo; continue echo ""; continue
} }
_spin_stop
# Always run up -d after a successful pull (idempotent when nothing # Recreate containers with spinner.
# changed). _spin_start "Recreating containers..."
info "Recreating containers …"
docker compose "${compose_args[@]}" up -d 2>&1 || { docker compose "${compose_args[@]}" up -d 2>&1 || {
_spin_stop
fail "Container recreation failed for ${stack}" fail "Container recreation failed for ${stack}"
failed_names+=("$stack"); echo; continue failed_names+=("$stack"); echo ""; continue
} }
_spin_stop
# Determine which services (if any) received new images by parsing the # Determine which services (if any) received new images by parsing the
# pull output. Only the pull output is authoritative — up -d restarts # pull output. Only the pull output is authoritative — up -d restarts
# are idempotent and tell us nothing about whether images changed. # are idempotent and tell us nothing about whether images changed.
# #
# Patterns matched (TTY and non-TTY alike): # The only reliable indicator of an actual image update is the
# " ✔ svc Pulled" / "svc Pulled" # "Downloaded newer image for <service>" string. The "Pulled" status
# "Downloaded newer image for svc" # appears even when the image is already up to date (Docker shows
# "Pulled" for the manifest check), so we ignore it.
updated_svc=() updated_svc=()
while IFS= read -r line; do while IFS= read -r line; do
# "Downloaded newer image for svc" — unambiguous. if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\ \"]+) ]]; then
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\"]+) ]]; then updated_svc+=("${BASH_REMATCH[1]}")
updated_svc+=("${BASH_REMATCH[1]}"); continue
fi fi
# "Pulled" not "Already up to date"
[[ "$line" =~ Already\ up\ to\ date ]] && continue
[[ ! "$line" =~ Pulled ]] && continue
# Extract the word before "Pulled". Strip leading " ✔ " if
# present, then read the first field.
cleaned="${line#*✔ }"
read -r svc _ <<< "$cleaned"
[[ -n "$svc" && "$svc" != "Pulled" ]] && updated_svc+=("$svc")
done <<< "$pull_out" done <<< "$pull_out"
# Deduplicate while preserving order. # Deduplicate while preserving order.
@@ -170,49 +262,52 @@ for i in "${!names[@]}"; do
done done
if [[ ${#deduped[@]} -gt 0 ]]; then if [[ ${#deduped[@]} -gt 0 ]]; then
ok "${stack} — updated: ${deduped[*]}" ok "${stack} — updated: ${BOLD}${deduped[*]}${RESET}"
updated_names+=("$stack") updated_names+=("$stack")
updated_detail+=("${stack}: ${deduped[*]}") updated_detail+=("${stack}: ${deduped[*]}")
else else
info "${stack} — already up to date" log "${stack} — already up to date"
unchanged_names+=("$stack") unchanged_names+=("$stack")
fi fi
echo echo ""
done done
# ── Summary ───────────────────────────────────────────────────────── # ── Summary ─────────────────────────────────────────────────────────
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}" header "Summary"
echo -e "${BOLD} Summary${NC}"
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
echo
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} )) total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
echo " Total stacks found: ${total_found}" echo " ${CYAN}${RESET} Stacks processed: ${BOLD}${total_found}${RESET}"
echo echo ""
if [[ ${#updated_names[@]} -gt 0 ]]; then if [[ ${#updated_names[@]} -gt 0 ]]; then
echo -e " ${GREEN}${NC} ${BOLD}Updated (${#updated_names[@]})${NC}" echo -e " ${GREEN}${RESET} ${BOLD}Updated (${#updated_names[@]})${RESET}"
for d in "${updated_detail[@]}"; do for d in "${updated_detail[@]}"; do
echo " ${d}" echo " ${d}"
done done
echo echo ""
fi fi
if [[ ${#unchanged_names[@]} -gt 0 ]]; then if [[ ${#unchanged_names[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${NC} ${BOLD}Unchanged (${#unchanged_names[@]})${NC}" echo -e " ${CYAN}${RESET} ${BOLD}Already up to date (${#unchanged_names[@]})${RESET}"
printf " %s\n" "${unchanged_names[@]}" for s in "${unchanged_names[@]}"; do
echo echo " ${CYAN}·${RESET} ${s}"
done
echo ""
fi fi
if [[ ${#orphaned_names[@]} -gt 0 ]]; then if [[ ${#orphaned_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${NC}" echo -e " ${YELLOW}${RESET} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${RESET}"
printf " %s\n" "${orphaned_names[@]}" for s in "${orphaned_names[@]}"; do
echo echo " ${YELLOW}·${RESET} ${s}"
done
echo ""
fi fi
if [[ ${#failed_names[@]} -gt 0 ]]; then if [[ ${#failed_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Failed (${#failed_names[@]})${NC}" echo -e " ${RED}${RESET} ${BOLD}Failed (${#failed_names[@]})${RESET}"
printf " %s\n" "${failed_names[@]}" for s in "${failed_names[@]}"; do
echo echo " ${RED}·${RESET} ${s}"
done
echo ""
fi fi