#!/usr/bin/env bash set -euo pipefail # --- Configuration ----------------------------------------------------------- # NOTE: INITIAL_DIR is re-set inside main() after exec sudo, since sudo may # change the working directory. The top-level value here is a fallback for # the usage text. _DEFAULT_BACKUP_DIR="${PWD}/backups" VERBOSE=false DRY_RUN=false ACTION="" TARGET_STACKS=() MODE="all" # all|running HAS_FLAGS=false ORIG_ARGS=() TARGET_DIR="" RESTORE_TARGET="" # --- Output Formatting -------------------------------------------------------- # 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() { [ -n "${SUDO_USER:-}" ] && chown "$SUDO_USER:" "$@" 2>/dev/null || true } # --- Stack Spec Resolution ---------------------------------------------------- # A stack spec can be: # name – resolved as / # /absolute/path – used directly, name = basename stack_spec_to_path() { local stacks_dir="$1" local spec="$2" if [[ "$spec" == /* ]]; then echo "$spec" else echo "$stacks_dir/$spec" fi } stack_spec_to_name() { local spec="$1" basename "$spec" } usage() { cat < [options] Commands: backup Backup one or more docker compose stacks. 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 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 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: stop: false # Skip container stop/start exclude: - path/to/exclude # Paths to exclude from the archive pre-hook: "command" # Run before backup post-hook: "command" # Run after backup pre-restore-hook: "cmd" # Run before restore post-restore-hook: "cmd" # Run after restore retention: 7 # Keep only N latest backups backup-volumes: false # Skip named Docker volume dumps Examples: $(basename "$0") backup --all $(basename "$0") backup --running $(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 } # --- Dependency Checks ------------------------------------------------------- check_deps() { for cmd in docker tar; do command -v "$cmd" >/dev/null 2>&1 || err "Required command '$cmd' is not installed" done } require_root() { if [ "$(id -u)" -eq 0 ]; then return fi echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..." exec sudo "$0" "${ORIG_ARGS[@]}" } # --- Stack Discovery --------------------------------------------------------- # Get all directories in stacks dir that contain a compose file discover_all_stacks() { local stacks_dir="$1" local stack local result=() for dir in "$stacks_dir"/*/; do stack="$(basename "$dir")" if [ -f "$dir/compose.yaml" ] || [ -f "$dir/compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; then result+=("$stack") fi done echo "${result[@]}" } _RUNNING_CACHE="" # Get currently running compose projects — returns absolute directory paths # Results are cached for the lifetime of the process since running compose # projects don't change during a single backup/restore/list invocation. discover_running_stacks() { [ -n "$_RUNNING_CACHE" ] && echo "$_RUNNING_CACHE" && return local result if result="$(docker compose ls --format json 2>/dev/null | python3 -c " import sys, json, os raw = sys.stdin.read().strip() if not raw: sys.exit(0) try: data = json.loads(raw) except json.JSONDecodeError: sys.exit(0) if isinstance(data, list): items = data else: items = [data] for obj in items: if not isinstance(obj, dict): continue name = obj.get('Name', '') or '' config_files = obj.get('ConfigFiles', '') or '' if config_files: path = config_files.split(',')[0].strip() path = os.path.dirname(path) print(path) else: wd = obj.get('WorkingDirectory', '') or '' if wd: print(os.path.join(wd, name)) else: print(name) " 2>/dev/null)"; then _RUNNING_CACHE="$result" else result="$(docker compose ls 2>/dev/null | tail -n +2 | awk '{print $1}' || true)" _RUNNING_CACHE="$result" fi echo "$_RUNNING_CACHE" } resolve_target_stacks() { local stacks_dir="$1" local mode="$2" shift 2 local explicit=("$@") if [ ${#explicit[@]} -gt 0 ]; then for spec in "${explicit[@]}"; do stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec" done return fi case "$mode" in running) discover_running_stacks ;; all|*) discover_all_stacks "$stacks_dir" ;; esac } # --- Compose File Helpers ---------------------------------------------------- # Resolve the compose file path for a stack resolve_compose_file() { local stack_path="$1" for name in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do [ -f "$stack_path/$name" ] && echo "$stack_path/$name" && return done echo "" } # Extract the x-backup block (lines between "x-backup:" and the next top-level key) extract_x_backup_block() { local compose_file="$1" [ -z "$compose_file" ] && return 1 awk ' /^x-backup:/ { found=1; next } found && /^[a-zA-Z_-][a-zA-Z0-9_-]*:/ { found=0; next } found { print } ' "$compose_file" 2>/dev/null || true } # Get a boolean x-backup setting (default: "true") x_backup_bool() { local block="$1" local key="$2" local default="${3:-true}" local val val="$(echo "$block" | grep -E "^\s+${key}:" | sed "s/.*${key}:[[:space:]]*//" | head -1 | tr -d ' \t' | tr '[:upper:]' '[:lower:]')" # Strip inline YAML comments val="${val%%#*}" [ -z "$val" ] && echo "$default" && return [ "$val" = "false" ] || [ "$val" = "0" ] || [ "$val" = "no" ] && echo "false" && return echo "true" } # Get a string x-backup setting x_backup_str() { local block="$1" local key="$2" local default="${3:-}" local val val="$(echo "$block" | grep -E "^\s+${key}:" | sed "s/.*${key}:[[:space:]]*//" | head -1)" # Strip inline YAML comments and trailing whitespace val="${val%%#*}" val="${val%% }" # Strip YAML quotes only when the entire value is wrapped if [[ "$val" =~ ^\"(.*)\"$ ]] || [[ "$val" =~ ^\'(.*)\'$ ]]; then val="${BASH_REMATCH[1]}" fi echo "${val:-$default}" } # Get a list x-backup setting (items starting with "- ") x_backup_list() { local block="$1" local key="$2" local indent indent="$(echo "$block" | grep -n "^\s\+${key}:" | head -1 | sed 's/\([0-9]*\):.*/\1/')" if [ -n "$indent" ]; then echo "$block" | awk -v start="$indent" 'NR > start && /^\s+- / { gsub(/^\s+-[[:space:]]*/, ""); gsub(/"/, ""); gsub(/#.*/, ""); gsub(/[[:space:]]+$/, ""); print }' fi } # Load x-backup config for a stack into global vars load_x_backup_config() { local stack_path="$1" local compose_file compose_file="$(resolve_compose_file "$stack_path")" XB_STOP=true XB_EXCLUDE="" XB_PRE_HOOK="" XB_POST_HOOK="" XB_PRE_RESTORE_HOOK="" XB_POST_RESTORE_HOOK="" XB_RETENTION="" XB_BACKUP_VOLUMES="true" [ -z "$compose_file" ] && return local block block="$(extract_x_backup_block "$compose_file")" [ -z "$block" ] && return XB_STOP="$(x_backup_bool "$block" "stop" "true")" XB_PRE_HOOK="$(x_backup_str "$block" "pre-hook")" XB_POST_HOOK="$(x_backup_str "$block" "post-hook")" XB_PRE_RESTORE_HOOK="$(x_backup_str "$block" "pre-restore-hook")" XB_POST_RESTORE_HOOK="$(x_backup_str "$block" "post-restore-hook")" XB_RETENTION="$(x_backup_str "$block" "retention")" local excludes excludes="$(x_backup_list "$block" "exclude")" if [ -n "$excludes" ]; then XB_EXCLUDE="" while IFS= read -r item; do [ -n "$item" ] && XB_EXCLUDE="$XB_EXCLUDE --exclude=$(basename "$stack_path")/$item" done <<< "$excludes" fi XB_BACKUP_VOLUMES="$(x_backup_bool "$block" "backup-volumes" "true")" } # Build the x-backup config tag string for list display (reads global XB_ vars) format_xb_tags() { local tags="" [ "$XB_STOP" = "false" ] && tags="$tags no-stop" [ -n "$XB_PRE_HOOK" ] && tags="$tags pre-hook" [ -n "$XB_POST_HOOK" ] && tags="$tags post-hook" [ -n "$XB_EXCLUDE" ] && tags="$tags exclude" [ -n "$XB_RETENTION" ] && tags="$tags retention=$XB_RETENTION" [ -n "$tags" ] && tags=" [x-backup:$tags]" echo "$tags" } # Apply retention: keep only the N most recent backups apply_retention() { local backup_dir="$1" local stack_name="$2" local retention="$3" [ -z "$retention" ] && return [ "$retention" -le 0 ] 2>/dev/null && return local count count="$(find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | wc -l)" if [ "$count" -gt "$retention" ]; then local to_delete to_delete="$((count - retention))" 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 echo " ${MAGENTA}◇${RESET} Pruning: $(basename "$f")" dry rm -f "$f" "${f}.meta" done fi } # --- Named Volume Detection & Backup ----------------------------------------- # Parse the compose file and return named (non-bind-mount) volume names. # These are listed under a top-level "volumes:" key or referenced under services. detect_named_volumes() { local compose_file="$1" [ -z "$compose_file" ] && return python3 -c ' import sys, re with open(sys.argv[1]) as f: content = f.read() service_vols = set() in_vol_list = False for line in content.split("\n"): s = line.strip() if re.match(r"^volumes:", s): in_vol_list = True continue if in_vol_list: if not s or not line[0].isspace(): in_vol_list = False continue # Short syntax: - source:target if s.startswith("- ") and ":" in s: parts = s.split(":", 1) src = parts[0].replace("- ", "", 1).strip() # Skip long-syntax keys (type, source, target, content) if src in ("type", "source", "target", "content", "consistency"): continue if src and not src.startswith("/") and not src.startswith("./") and not src.startswith("../") and not src.startswith("${"): service_vols.add(src) continue # Long syntax: source: value m = re.search(r"source:\s*(\S+)", s) if m: src = m.group(1) if src and not src.startswith("/") and not src.startswith("./") and not src.startswith("../") and not src.startswith("${"): service_vols.add(src) for v in sorted(service_vols): print(v) ' "$compose_file" 2>/dev/null || true } # Warn about named volumes that won't be included in the file backup warn_named_volumes() { local compose_file="$1" local stack_name="$2" [ -z "$compose_file" ] && return local vols vols="$(detect_named_volumes "$compose_file")" [ -z "$vols" ] && return echo " ${YELLOW}┃${RESET} Named volumes detected but ${BOLD}backup-volumes is false${RESET}, skipping:" for v in $vols; do echo " ${YELLOW}┃${RESET} ${CYAN}●${RESET} $v" done 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. # Compose v2 names volumes as _ where project = stack dir name. resolve_docker_volume() { local stack_name="$1" local vol_name="$2" 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. # Each volume is dumped as /_volumes/.tar.gz backup_named_volumes() { local stack_path="$1" local stack_name="$2" local compose_file="$3" [ -z "$compose_file" ] && return local vols vols="$(detect_named_volumes "$compose_file")" [ -z "$vols" ] && return local volumes_dir="$stack_path/_volumes" mkdir -p "$volumes_dir" # shellcheck disable=SC2066 while IFS= read -r vol; do [ -z "$vol" ] && continue # Skip anything that looks like a shell variable — it's a parametrised # bind-mount path, not a named Docker volume. [[ "$vol" == *'$'* ]] && continue local docker_vol docker_vol="$(resolve_docker_volume "$stack_name" "$vol")" if docker volume inspect "$docker_vol" >/dev/null 2>&1; then log "Dumping named volume '$docker_vol'..." if $DRY_RUN; then 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'" fi else warn "Named volume '$docker_vol' not found, skipping" fi done <<< "$vols" } # Restore named Docker volumes from a backup archive (the _volumes/ dir) restore_named_volumes() { local stack_path="$1" local stack_name="$2" local volumes_dir="$stack_path/_volumes" [ ! -d "$volumes_dir" ] && return local any_restored=false for dump in "$volumes_dir"/*.tar.gz; do [ -f "$dump" ] || continue local vol_name vol_name="$(basename "$dump" .tar.gz)" local docker_vol docker_vol="$(resolve_docker_volume "$stack_name" "$vol_name")" log "Restoring named volume '$docker_vol'..." if $DRY_RUN; then 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; } # Pipe via cat + -i flag to ensure stdin works inside the container cat "$dump" | docker run -i --rm -v "${docker_vol}:/_dst" alpine tar -xzf - -C /_dst 2>/dev/null || \ warn "Failed to restore named volume '$docker_vol'" fi any_restored=true done # Clean up the extracted volume dumps rm -rf "$volumes_dir" if $any_restored; then success "Named volumes restored" fi } run_hook() { local stage="$1" local stack_name="$2" local command="$3" [ -z "$command" ] && return log "Running $stage hook for '$stack_name': $command" if $DRY_RUN; then echo "[DRY-RUN] $command" else eval "$command" || warn "Hook '$stage' for '$stack_name' exited with status $?" fi } # --- Docker Operations ------------------------------------------------------- stack_is_running() { local stack_path="$1" local running running="$(discover_running_stacks)" # Match against either the full path or the basename (name-only fallback) echo "$running" | grep -qx "$stack_path" || echo "$running" | grep -qx "$(basename "$stack_path")" } stop_stack() { local stack_path="$1" local stack_name stack_name="$(basename "$stack_path")" # Find compose file in the stack directory local compose_file compose_file="$(resolve_compose_file "$stack_path")" if stack_is_running "$stack_path"; then echo " ${CYAN}●${RESET} Stopping containers..." if [ -n "$compose_file" ]; then dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \ dry docker compose -p "$stack_name" down 2>/dev/null || \ warn "Failed to stop stack '$stack_name'" else dry docker compose -p "$stack_name" down 2>/dev/null || \ warn "Failed to stop stack '$stack_name'" fi else log "Stack '$stack_name' is not running, skipping stop" fi } start_stack() { local stack_path="$1" local stack_name stack_name="$(basename "$stack_path")" local compose_file compose_file="$(resolve_compose_file "$stack_path")" if [ -n "$compose_file" ]; then echo " ${CYAN}●${RESET} Starting containers..." dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \ warn "Failed to start stack '$stack_name'" fi } # --- 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 TIMESTAMP="$(date +%Y%m%d_%H%M%S)" local stacks_dir="$1" local backup_dir="$2" local stack_path="$3" local stack_name stack_name="$(stack_spec_to_name "$stack_path")" 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")" local _backup_size="" 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" if [ "$XB_STOP" = "false" ]; then log "x-backup.stop=false, skipping container stop" else stop_stack "$stack_path" fi # Dump named Docker volumes (opt-out via x-backup.backup-volumes: false) local compose_file compose_file="$(resolve_compose_file "$stack_path")" if [ "$XB_BACKUP_VOLUMES" = "false" ]; then warn_named_volumes "$compose_file" "$stack_name" else echo " ${CYAN}●${RESET} Dumping named volumes..." backup_named_volumes "$stack_path" "$stack_name" "$compose_file" fi # Pre-backup hook run_hook "pre-backup" "$stack_name" "$XB_PRE_HOOK" # Create the backup: tar from the parent directory so the stack dir # appears as itself in the archive, preserving relative exclude paths. # If named volumes were dumped, they live under _volumes/ inside stack_path # and are included in the archive automatically. dry mkdir -p "$backup_dir" fix_owner "$backup_dir" 2>/dev/null || true if $DRY_RUN; then echo " ${MAGENTA}◇${RESET} tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename" else # 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" fi fix_owner "$backup_file" _backup_size="$(du -h "$backup_file" | cut -f1)" fi # Capture volume state before cleanup local has_volumes="false" [ -d "$stack_path/_volumes" ] && has_volumes="true" # Clean up temporary volume dumps 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 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" < "$(basename "${stack_latest[$sname]}")" ]; then stack_latest["$sname"]="$f" stack_date["$sname"]="$(format_backup_date "$basename_f")" local sname_meta="${f}.meta" if [ -f "$sname_meta" ]; then local sv sv="$(grep '^volumes=' "$sname_meta" | head -1 | sed 's/^volumes=//')" [ "$sv" = "true" ] && stack_date["$sname"]="${stack_date[$sname]} [volumes]" || true fi fi done local names=() for key in "${!stack_latest[@]}"; do names+=("$key") done if [ ${#names[@]} -eq 0 ]; then echo " ${YELLOW}┃${RESET} No backups found in ${backup_dir}" exit 0 fi # Sort alphabetically IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS echo "" echo " ${BOLD}${CYAN}━━━ Available Backups ━━━${RESET}" local i for i in "${!names[@]}"; do local idx=$((i + 1)) printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}" done echo " ${CYAN}all)${RESET} Restore all stacks" echo " ${YELLOW}q)${RESET} Cancel" echo "" read -r -p " ${CYAN}›${RESET} Select stacks to restore (numbers, 'all', or 'q'): " selection [ -z "$selection" ] && echo " ${YELLOW}┃${RESET} Cancelled." && exit 0 # Normalise: handle "all" if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then selection="" for i in "${!names[@]}"; do selection="$selection $((i + 1))" done fi if [ "$selection" = "q" ]; then echo " ${YELLOW}┃${RESET} Cancelled." exit 0 fi # Convert space-separated indices to stack names local selected_stacks=() local seen_idx for seen_idx in $selection; do seen_idx="$(echo "$seen_idx" | xargs)" # trim # Allow ranges like 1-3 if [[ "$seen_idx" =~ ^([0-9]+)-([0-9]+)$ ]]; then for ((j = ${BASH_REMATCH[1]}; j <= ${BASH_REMATCH[2]}; j++)); do if [ "$j" -ge 1 ] && [ "$j" -le "${#names[@]}" ]; then selected_stacks+=("${names[$((j - 1))]}") fi done elif [[ "$seen_idx" =~ ^[0-9]+$ ]]; then if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then selected_stacks+=("${names[$((seen_idx - 1))]}") else echo " ${YELLOW}┃${RESET} Invalid selection: ${seen_idx} (out of range)" fi fi done if [ ${#selected_stacks[@]} -eq 0 ]; then echo " ${YELLOW}┃${RESET} No valid stacks selected." exit 0 fi # Resolve paths and re-exec via sudo with explicit --stack flags local -a stack_flags=() for s in "${selected_stacks[@]}"; do local spath # Read original path from .meta file if available local meta_file meta_file="$(find "$backup_dir" -maxdepth 1 -name "${s}_*.tar.gz.meta" -type f 2>/dev/null | sort | tail -1)" if [ -n "$meta_file" ]; then spath="$(grep '^path=' "$meta_file" | head -1 | sed 's/^path=//')" [ -z "$spath" ] && spath="$(cat "$meta_file" | head -1)" # legacy format elif [ -d "$stacks_dir/$s" ]; then spath="$stacks_dir/$s" else warn "Cannot determine where to restore '$s': no metadata file and no directory under '$stacks_dir'. Use '--stack /original/path'." continue fi stack_flags+=("--stack" "$spath") done if [ ${#stack_flags[@]} -eq 0 ]; then 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 " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..." exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}" fi echo "" for ((i = 0; i < ${#stack_flags[@]}; i += 2)); do local spath="${stack_flags[$((i + 1))]}" restore_stack "$stacks_dir" "$backup_dir" "$spath" done } # --- Verify ------------------------------------------------------------------- verify_backup() { local backup_dir="$1" local stack_name="$2" if [ -n "$stack_name" ]; then local files files="$(find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort)" else local files files="$(find "$backup_dir" -maxdepth 1 -name '*.tar.gz' -type f 2>/dev/null | sort)" fi if [ -z "$files" ]; then echo " ${YELLOW}┃${RESET} No backups found." return fi local exit_code=0 for f in $files; do local basename_f basename_f="$(basename "$f")" local meta_file="${f}.meta" if [ ! -f "$meta_file" ]; then echo " ${YELLOW}⚠${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no .meta file)" continue fi local stored_hash stored_size stored_hash="$(grep '^sha256=' "$meta_file" | head -1 | sed 's/^sha256=//')" stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')" if [ -z "$stored_hash" ]; then 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 " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (file missing)" exit_code=1 continue fi # Check size 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 " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (size mismatch: ${actual_size} vs ${stored_size})" exit_code=1 continue fi # Check hash local actual_hash actual_hash="$(sha256sum "$f" | cut -d' ' -f1)" if [ "$actual_hash" != "$stored_hash" ]; then 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 " ${GREEN}✓${RESET} ${GREEN}OK${RESET} ${basename_f} (${human_size})" fi done return $exit_code } # --- Interactive Backup Menu ------------------------------------------------ interactive_backup_menu() { local stacks_dir="$1" local backup_dir="$2" # Collect stacks: local + running, deduped by canonical path local -A seen local -a names local -a paths local -a labels # Running stacks (absolute paths) local running running="$(discover_running_stacks 2>/dev/null || true)" if [ -n "$running" ]; then while IFS= read -r run_path; do [ -z "$run_path" ] && continue [[ "$run_path" != /* ]] && continue local canon canon="$(cd "$run_path" 2>/dev/null && pwd || echo "$run_path")" [ -n "${seen[$canon]:-}" ] && continue seen["$canon"]=1 local run_name run_name="$(basename "$run_path")" names+=("$run_name") if [ "$canon" != "$stacks_dir/$run_name" ]; then paths+=("$canon") labels+=("(running, $canon)") else paths+=("$canon") labels+=("(running)") fi done <<< "$running" fi # Local stacks (not already seen) for dir in "$stacks_dir"/*/; do [ -d "$dir" ] || continue if [ -f "$dir/compose.yaml" ] || [ -f "$dir/compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; then local canon canon="$(cd "$dir" 2>/dev/null && pwd || echo "$dir")" [ -n "${seen[$canon]:-}" ] && continue seen["$canon"]=1 local stack stack="$(basename "$dir")" names+=("$stack") paths+=("$canon") local running_label="" stack_is_running "$canon" 2>/dev/null && running_label=" (running)" labels+=("$running_label") fi done if [ ${#names[@]} -eq 0 ]; then echo " ${YELLOW}┃${RESET} No stacks found." exit 0 fi echo "" echo " ${BOLD}${CYAN}━━━ Available Stacks ━━━${RESET}" local i for i in "${!names[@]}"; do local idx=$((i + 1)) printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${labels[$i]}" done echo " ${CYAN}all)${RESET} Back up all stacks" echo " ${YELLOW}q)${RESET} Cancel" echo "" read -r -p " ${CYAN}›${RESET} Select stacks to back up (numbers, 'all', or 'q'): " selection [ -z "$selection" ] && echo " ${YELLOW}┃${RESET} Cancelled." && exit 0 if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then selection="" for i in "${!names[@]}"; do selection="$selection $((i + 1))" done fi if [ "$selection" = "q" ]; then echo " ${YELLOW}┃${RESET} Cancelled." exit 0 fi local selected_paths=() local seen_idx for seen_idx in $selection; do seen_idx="$(echo "$seen_idx" | xargs)" if [[ "$seen_idx" =~ ^([0-9]+)-([0-9]+)$ ]]; then for ((j = ${BASH_REMATCH[1]}; j <= ${BASH_REMATCH[2]}; j++)); do if [ "$j" -ge 1 ] && [ "$j" -le "${#names[@]}" ]; then selected_paths+=("${paths[$((j - 1))]}") fi done elif [[ "$seen_idx" =~ ^[0-9]+$ ]]; then if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then selected_paths+=("${paths[$((seen_idx - 1))]}") else echo " ${YELLOW}┃${RESET} Invalid selection: ${seen_idx} (out of range)" fi fi done if [ ${#selected_paths[@]} -eq 0 ]; then echo " ${YELLOW}┃${RESET} No valid stacks selected." exit 0 fi # Elevate and re-exec with the selected stacks if [ "$(id -u)" -ne 0 ]; then local -a stack_flags=() for spath in "${selected_paths[@]}"; do stack_flags+=("--stack" "$spath") done echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..." exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}" fi echo "" for spath in "${selected_paths[@]}"; do backup_stack "$stacks_dir" "$backup_dir" "$spath" done } # --- Restore ----------------------------------------------------------------- restore_stack() { require_root local TIMESTAMP TIMESTAMP="$(date +%Y%m%d_%H%M%S)" 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 "$restore_path")" archive_root="$(_resolve_dir "$archive_root")" local archive_basename archive_basename="$(basename "$restore_path")" # Find the latest backup for this stack local backup_file backup_file="$(find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | tail -1)" if [ -z "$backup_file" ]; then err "No backup found for stack '$stack_name' in $backup_dir" fi 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 tar -tzf "$backup_file" | head -20 fi # Load x-backup config from within the backup (extract to temp) local temp_dir temp_dir="$(mktemp -d)" local parsed_compose="" tar -xzf "$backup_file" -C "$temp_dir" --wildcards "*/compose.y*" "*/compose.yml*" "*/docker-compose.y*" "*/docker-compose.yml*" 2>/dev/null || true local stack_dir_in_backup stack_dir_in_backup="$(ls "$temp_dir" 2>/dev/null | head -1)" [ -n "$stack_dir_in_backup" ] && parsed_compose="$temp_dir/$stack_dir_in_backup" if [ -n "$parsed_compose" ]; then load_x_backup_config "$parsed_compose" else # Fallback: load from the live dir if it still exists load_x_backup_config "$stack_path" fi # Track whether the stack was running before we stop it, so we can # restart it afterwards only if it was originally running. local was_running=false stack_is_running "$stack_path" && was_running=true # Always stop during restore — we're about to rename/move the entire # stack directory out from under any running containers. The # x-backup.stop flag only applies to backup, where data is read-only. stop_stack "$stack_path" # Pre-restore hook run_hook "pre-restore" "$stack_name" "$XB_PRE_RESTORE_HOOK" # Clean up any stale pre-restore dirs from previous failed restores for old in "$stack_path".pre-restore-*; do [ -d "$old" ] || continue log "Removing stale pre-restore backup: $(basename "$old")" dry rm -rf "$old" done # Restore if $DRY_RUN; then 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 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 success "Restore complete" # Restore named Docker volumes from the _volumes/ dir inside the archive restore_named_volumes "$stack_path" "$stack_name" fi # Post-restore hook run_hook "post-restore" "$stack_name" "$XB_POST_RESTORE_HOOK" # Cleanup temp rm -rf "$temp_dir" # Only restart if it was running before we stopped it if $was_running; then start_stack "$stack_path" else log "Stack was not running before restore, skipping start" 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 " ${BOLD}${CYAN}━━━ Stacks ━━━${RESET}" local -A seen # 1) Running stacks (discovered wherever they live) local running running="$(discover_running_stacks 2>/dev/null || true)" local has_running=false if [ -n "$running" ]; then while IFS= read -r run_path; do [ -z "$run_path" ] && continue # skip bare names (fallback from no-Docker mode — they'll be in local) [[ "$run_path" != /* ]] && continue # canonical key: realpath or full path local canon canon="$(cd "$run_path" 2>/dev/null && pwd || echo "$run_path")" seen["$canon"]=1 local run_name run_name="$(basename "$run_path")" local label="(running)" load_x_backup_config "$run_path" local config_tags config_tags="$(format_xb_tags)" if [ "$canon" != "$stacks_dir/$run_name" ]; then echo " ${CYAN}●${RESET} ${BOLD}${run_name}${RESET} ${BLUE}(${run_path})${RESET} ${label}${config_tags}" else echo " ${CYAN}●${RESET} ${BOLD}${run_name}${RESET} ${label}${config_tags}" fi has_running=true done <<< "$running" fi # 2) Local stacks (not already listed as running) local has_local=false for dir in "$stacks_dir"/*/; do [ -d "$dir" ] || continue local stack stack="$(basename "$dir")" if [ -f "$dir/compose.yaml" ] || [ -f "$dir/compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; then local canon canon="$(cd "$dir" 2>/dev/null && pwd || echo "$dir")" if [ -z "${seen["$canon"]:-}" ]; then seen["$canon"]=1 local label="" stack_is_running "$dir" 2>/dev/null && label=" (running)" load_x_backup_config "$dir" local config_tags config_tags="$(format_xb_tags)" echo " ${CYAN}●${RESET} ${BOLD}${stack}${RESET}${label}${config_tags}" has_local=true fi fi done if ! $has_running && ! $has_local; then echo " ${YELLOW}┃${RESET} No stacks found." fi echo "" echo " ${BOLD}${CYAN}━━━ Backups ━━━${RESET}" if [ -d "$backup_dir" ]; then local backup_count=0 for f in "$backup_dir"/*.tar.gz; do [ -f "$f" ] || continue local sname sname="$(basename "$f" | sed 's/_[0-9]\{8\}_[0-9]\{6\}\.tar\.gz$//')" local meta_info="" if [ -f "${f}.meta" ]; then local mpath mvols mhash 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="${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 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 " ${YELLOW}┃${RESET} No backups found in ${backup_dir}" fi else echo " ${YELLOW}┃${RESET} No backups directory found at ${backup_dir}" fi } # --- Main -------------------------------------------------------------------- main() { check_deps ORIG_ARGS=("$@") local SCRIPT_DIR SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" local INITIAL_DIR INITIAL_DIR="$(pwd -P)" local stacks_dir="$SCRIPT_DIR" local backup_dir="${INITIAL_DIR}/backups" # Parse arguments while [ $# -gt 0 ]; do case "$1" in backup|restore|init|list|verify) ACTION="$1" shift ;; -s|--stack) if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi IFS=',' read -ra ADDR <<< "$2" for s in "${ADDR[@]}"; do TARGET_STACKS+=("$(echo "$s" | xargs)") done HAS_FLAGS=true shift 2 ;; -a|--all) MODE="all" HAS_FLAGS=true shift ;; -r|--running) MODE="running" 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" shift 2 ;; -n|--dry-run) DRY_RUN=true shift ;; -v|--verbose) VERBOSE=true shift ;; -h|--help) usage ;; *) err "Unknown option: $1. Use -h for help." ;; esac done if [ -z "$ACTION" ]; then 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" ;; backup) mkdir -p "$backup_dir" if ! $HAS_FLAGS; then # No flags at all — show interactive menu (display doesn't need root) interactive_backup_menu "$stacks_dir" "$backup_dir" exit 0 fi local stacks stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")" if [ -z "$stacks" ]; then 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 for stack in $stacks; do local spath spath="$(stack_spec_to_path "$stacks_dir" "$stack")" backup_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Backup failed for '$spath'" done ;; restore) if [ ! -d "$backup_dir" ]; then err "Backup directory not found: $backup_dir" fi if [ ${#TARGET_STACKS[@]} -eq 0 ]; then # No --stack flag — show interactive menu (display doesn't need root) interactive_restore_menu "$stacks_dir" "$backup_dir" exit 0 fi for spec in "${TARGET_STACKS[@]}"; do local 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) if [ ! -d "$backup_dir" ]; then err "Backup directory not found: $backup_dir" fi local vtarget="" [ ${#TARGET_STACKS[@]} -gt 0 ] && vtarget="${TARGET_STACKS[0]}" verify_backup "$backup_dir" "$vtarget" ;; esac } main "$@"