diff --git a/docker-stacks-backup/README.md b/docker-stacks-backup/README.md index 32715b7..3d3cac8 100644 --- a/docker-stacks-backup/README.md +++ b/docker-stacks-backup/README.md @@ -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 ` | A specific stack by name (relative to the script's directory) or by absolute path | +| `--stack ` | A specific stack by name (relative to the **current directory**) or by absolute path | + +Add `--dir ` 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 `.pre-restore-` 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. diff --git a/docker-stacks-backup/docker-stacks-backup.sh b/docker-stacks-backup/docker-stacks-backup.sh index 6c39ef5..c51f285 100755 --- a/docker-stacks-backup/docker-stacks-backup.sh +++ b/docker-stacks-backup/docker-stacks-backup.sh @@ -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 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 Backup directory (default: ${_DEFAULT_BACKUP_DIR}) + --dir Scan this directory for stacks (overrides default discovery dir) + --to 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" </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)