603 lines
17 KiB
Bash
Executable File
603 lines
17 KiB
Bash
Executable File
#!/bin/bash
|
|
# Docker Stack Backup Script
|
|
# Enhanced version matching Go docwell functionality
|
|
|
|
set -euo pipefail
|
|
|
|
# Version
|
|
VERSION="2.6.2"
|
|
|
|
# Resolve script directory and source shared library
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck source=lib/common.sh
|
|
source "$SCRIPT_DIR/lib/common.sh"
|
|
|
|
# Enable debug trace after sourcing common.sh (so DEBUG variable is available)
|
|
enable_debug_trace
|
|
|
|
# Default configuration
|
|
HOSTNAME=$(hostname 2>/dev/null | tr -cd 'a-zA-Z0-9._-' || echo "unknown")
|
|
DATE=$(date +%Y-%m-%d)
|
|
BACKUP_BASE="${BACKUP_BASE:-/storage/backups/docker-$HOSTNAME}"
|
|
BACKUP_DIR="$BACKUP_BASE/$DATE"
|
|
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-backup.log}"
|
|
|
|
# CLI flags
|
|
BACKUP_ALL=false
|
|
BACKUP_STACK=""
|
|
LIST_ONLY=false
|
|
AUTO_MODE=false
|
|
COMPRESSION="zstd"
|
|
|
|
# Parser configuration
|
|
MAX_STACK_NAME_LEN=30
|
|
|
|
# Load config file overrides
|
|
load_config
|
|
|
|
# Parse command line arguments
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--version)
|
|
echo "docker-backup.sh v$VERSION"
|
|
exit 0
|
|
;;
|
|
--quiet|-q)
|
|
QUIET=true
|
|
shift
|
|
;;
|
|
--yes|-y)
|
|
YES=true
|
|
shift
|
|
;;
|
|
--all|-a)
|
|
BACKUP_ALL=true
|
|
shift
|
|
;;
|
|
--stack|-s)
|
|
require_arg "$1" "${2:-}" || exit 1
|
|
BACKUP_STACK="$2"
|
|
shift 2
|
|
;;
|
|
--list|-l)
|
|
LIST_ONLY=true
|
|
shift
|
|
;;
|
|
--stacks-dir)
|
|
STACKS_DIR="$2"
|
|
shift 2
|
|
;;
|
|
--backup-base)
|
|
BACKUP_BASE="$2"
|
|
BACKUP_DIR="$BACKUP_BASE/$DATE"
|
|
shift 2
|
|
;;
|
|
--auto)
|
|
AUTO_MODE=true
|
|
QUIET=true
|
|
YES=true
|
|
shift
|
|
;;
|
|
--install-deps)
|
|
AUTO_INSTALL=true
|
|
shift
|
|
;;
|
|
--log)
|
|
LOG_FILE="$2"
|
|
shift 2
|
|
;;
|
|
--compression|-c)
|
|
COMPRESSION="$2"
|
|
shift 2
|
|
;;
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
--debug|-d)
|
|
DEBUG=true
|
|
shift
|
|
;;
|
|
--verbose|-v)
|
|
VERBOSE=true
|
|
shift
|
|
;;
|
|
--help|-h)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo -e "${RED}Unknown option: $1${NC}" >&2
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
show_help() {
|
|
cat << EOF
|
|
Docker Stack Backup Script v$VERSION
|
|
|
|
Usage: $0 [OPTIONS]
|
|
|
|
Options:
|
|
--all, -a Backup all stacks
|
|
--stack STACK, -s Backup specific stack by name
|
|
--list, -l List available stacks for backup
|
|
--stacks-dir DIR Stacks directory (default: /opt/stacks)
|
|
--backup-base DIR Backup base directory (default: /storage/backups/docker-\$HOSTNAME)
|
|
--log FILE Log file path (default: /tmp/docwell/docker-backup.log)
|
|
--quiet, -q Suppress non-error output
|
|
--yes, -y Auto-confirm all prompts
|
|
--auto Auto mode: backup all stacks & clean resources (for cron)
|
|
--compression METHOD Compression method: zstd or gzip (default: zstd)
|
|
--dry-run Show what would be done without actually doing it
|
|
--debug, -d Enable debug mode (verbose output + xtrace)
|
|
--verbose, -v Enable verbose logging
|
|
--install-deps Auto-install missing dependencies (requires root)
|
|
--version Show version information
|
|
--help, -h Show this help message
|
|
|
|
Examples:
|
|
$0 --list # List all stacks
|
|
$0 --all # Backup all stacks
|
|
$0 --stack myapp # Backup specific stack
|
|
$0 --all --quiet --yes # Backup all stacks non-interactively
|
|
$0 --all --dry-run # Preview backup without executing
|
|
EOF
|
|
}
|
|
|
|
# Check for required dependencies
|
|
check_dependencies() {
|
|
local required_cmds=("docker")
|
|
local optional_cmds=("zstd")
|
|
local missing=()
|
|
local missing_optional=()
|
|
|
|
for cmd in "${required_cmds[@]}"; do
|
|
if ! command -v "$cmd" &>/dev/null; then
|
|
missing+=("$cmd")
|
|
fi
|
|
done
|
|
|
|
for cmd in "${optional_cmds[@]}"; do
|
|
if ! command -v "$cmd" &>/dev/null; then
|
|
missing_optional+=("$cmd")
|
|
fi
|
|
done
|
|
|
|
if [[ ${#missing_optional[@]} -gt 0 ]]; then
|
|
log_warn "Optional dependencies missing: ${missing_optional[*]} (will use fallback)"
|
|
fi
|
|
|
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
|
log_error "Missing required dependencies: ${missing[*]}"
|
|
echo -e "${BLUE}Install commands:${NC}"
|
|
command -v apt &>/dev/null && echo -e " ${GREEN}Debian/Ubuntu:${NC} sudo apt install docker.io ${missing_optional[*]}"
|
|
command -v pacman &>/dev/null && echo -e " ${GREEN}Arch Linux:${NC} sudo pacman -S docker ${missing_optional[*]}"
|
|
|
|
if [[ "$AUTO_INSTALL" == "true" ]]; then
|
|
log_info "Auto-installing dependencies..."
|
|
install_dependencies "${missing[@]}" "${missing_optional[@]}"
|
|
return $?
|
|
fi
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
find_compose_file() {
|
|
local stack="$1"
|
|
get_compose_file "$STACKS_DIR/$stack"
|
|
}
|
|
|
|
stop_stack() {
|
|
local stack="$1"
|
|
local stack_path="$STACKS_DIR/$stack"
|
|
local compose_file
|
|
|
|
log_debug "Attempting to stop stack: $stack"
|
|
compose_file=$(find_compose_file "$stack") || {
|
|
log_debug "No compose file found for stack: $stack"
|
|
return 1
|
|
}
|
|
|
|
if is_stack_running "$stack"; then
|
|
log_info "Stopping $stack..."
|
|
if run_or_dry "stop $stack" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1; then
|
|
log_debug "Stack stopped successfully: $stack"
|
|
return 0
|
|
else
|
|
log_error "Failed to stop stack: $stack"
|
|
return 1
|
|
fi
|
|
fi
|
|
log_debug "Stack was not running: $stack"
|
|
return 1
|
|
}
|
|
|
|
start_stack() {
|
|
local stack="$1"
|
|
local stack_path="$STACKS_DIR/$stack"
|
|
local compose_file
|
|
|
|
log_debug "Attempting to start stack: $stack"
|
|
compose_file=$(find_compose_file "$stack") || {
|
|
log_error "No compose file found for stack: $stack"
|
|
return 1
|
|
}
|
|
|
|
log_info "Starting $stack..."
|
|
if run_or_dry "start $stack" docker compose -f "$stack_path/$compose_file" up -d >/dev/null 2>&1; then
|
|
log_debug "Stack started successfully: $stack"
|
|
return 0
|
|
else
|
|
log_error "Failed to start stack: $stack"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
create_backup_archive() {
|
|
local stack="$1"
|
|
log_debug "Creating backup archive for: $stack"
|
|
|
|
# Temp files in BACKUP_DIR, cleaned up on failure
|
|
local temp_tar
|
|
temp_tar=$(mktemp "${BACKUP_DIR}/.${stack}.tar.XXXXXX" 2>/dev/null) || {
|
|
log_error "Failed to create temporary file in $BACKUP_DIR"
|
|
return 1
|
|
}
|
|
register_cleanup "rm -f '$temp_tar'"
|
|
log_debug "Temporary tar file: $temp_tar"
|
|
|
|
log_info "Archiving $stack..."
|
|
|
|
local comp_info
|
|
comp_info=$(get_compressor "$COMPRESSION")
|
|
local compressor="${comp_info%%:*}"
|
|
local ext="${comp_info#*:}"
|
|
log_debug "Using compressor: $compressor, extension: $ext"
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
log_info "[DRY-RUN] Would create: $BACKUP_DIR/docker-$stack$ext"
|
|
rm -f "$temp_tar"
|
|
return 0
|
|
fi
|
|
|
|
log_debug "Creating tar archive..."
|
|
if ! tar -cf "$temp_tar" -C "$STACKS_DIR" "$stack" 2>/dev/null; then
|
|
log_error "Failed to create tar archive for $stack"
|
|
rm -f "$temp_tar"
|
|
return 1
|
|
fi
|
|
|
|
local tar_size
|
|
tar_size=$(stat -f%z "$temp_tar" 2>/dev/null || stat -c%s "$temp_tar" 2>/dev/null || echo "unknown")
|
|
log_debug "Tar archive size: $tar_size bytes"
|
|
|
|
log_debug "Compressing archive with $compressor..."
|
|
if ! $compressor < "$temp_tar" > "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null; then
|
|
log_error "Failed to compress archive for $stack"
|
|
rm -f "$temp_tar" "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null
|
|
return 1
|
|
fi
|
|
|
|
local final_size
|
|
final_size=$(stat -f%z "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null || stat -c%s "$BACKUP_DIR/docker-$stack$ext" 2>/dev/null || echo "unknown")
|
|
log_debug "Final backup size: $final_size bytes"
|
|
log_debug "Backup archive created successfully: $BACKUP_DIR/docker-$stack$ext"
|
|
|
|
rm -f "$temp_tar"
|
|
return 0
|
|
}
|
|
|
|
backup_stack() {
|
|
local stack="$1"
|
|
log_debug "Starting backup for stack: $stack"
|
|
|
|
if ! validate_stack_name "$stack"; then
|
|
log_error "Invalid stack name: $stack"
|
|
return 1
|
|
fi
|
|
|
|
local stack_path="$STACKS_DIR/$stack"
|
|
if [[ ! -d "$stack_path" ]]; then
|
|
log_error "Stack directory not found: $stack_path"
|
|
return 1
|
|
fi
|
|
|
|
log_debug "Stack path verified: $stack_path"
|
|
|
|
# Create backup directory
|
|
if ! mkdir -p "$BACKUP_DIR"; then
|
|
log_error "Failed to create backup directory: $BACKUP_DIR"
|
|
return 1
|
|
fi
|
|
log_debug "Backup directory ready: $BACKUP_DIR"
|
|
|
|
local was_running=false
|
|
|
|
if stop_stack "$stack"; then
|
|
was_running=true
|
|
log_debug "Stack was running, stopped for backup"
|
|
else
|
|
log_debug "Stack was not running"
|
|
fi
|
|
|
|
if create_backup_archive "$stack"; then
|
|
log_success "Backup complete: $stack"
|
|
if [[ "$was_running" == "true" ]]; then
|
|
log_debug "Restarting stack: $stack"
|
|
start_stack "$stack" || log_warn "Failed to restart $stack"
|
|
fi
|
|
return 0
|
|
else
|
|
log_error "Backup failed: $stack"
|
|
if [[ "$was_running" == "true" ]]; then
|
|
log_debug "Attempting to restart stack after failed backup: $stack"
|
|
start_stack "$stack" || log_warn "Failed to restart $stack"
|
|
fi
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
list_stacks() {
|
|
show_spinner "Loading stacks..."
|
|
local stacks
|
|
mapfile -t stacks < <(get_stacks)
|
|
hide_spinner
|
|
|
|
if [[ ${#stacks[@]} -eq 0 ]]; then
|
|
log_info "No stacks found in $STACKS_DIR"
|
|
return 1
|
|
fi
|
|
|
|
for stack in "${stacks[@]}"; do
|
|
local status="stopped"
|
|
local size
|
|
size=$(get_stack_size "$stack")
|
|
if is_stack_running "$stack"; then
|
|
status="running"
|
|
fi
|
|
echo -e "$stack\t$status\t$size"
|
|
done
|
|
}
|
|
|
|
interactive_backup() {
|
|
local stacks
|
|
show_spinner "Loading stacks..."
|
|
mapfile -t stacks < <(get_stacks)
|
|
hide_spinner
|
|
|
|
if [[ ${#stacks[@]} -eq 0 ]]; then
|
|
log_error "No stacks found in $STACKS_DIR"
|
|
exit 1
|
|
fi
|
|
|
|
log_header "Backup Docker Stacks"
|
|
echo -e "${GRAY}DocWell Backup v$VERSION${NC}"
|
|
echo
|
|
|
|
echo "Available stacks:"
|
|
echo -e " ${BOLD}0${NC}) All stacks ${GRAY}[Backup everything]${NC}"
|
|
for i in "${!stacks[@]}"; do
|
|
local stack="${stacks[$i]}"
|
|
local status="●"
|
|
local color="$GRAY"
|
|
if is_stack_running "$stack"; then
|
|
color="$GREEN"
|
|
fi
|
|
local size
|
|
size=$(get_stack_size "$stack")
|
|
echo -e " ${BOLD}$((i+1))${NC}) ${color}${status}${NC} ${stack:0:$MAX_STACK_NAME_LEN} ${GRAY}[${size}]${NC}"
|
|
done
|
|
echo
|
|
|
|
read -rp "Enter selection (0 for all, comma-separated numbers): " selection
|
|
|
|
log_info "Starting backup on $HOSTNAME"
|
|
local start_time=$SECONDS
|
|
|
|
if [[ "$selection" == "0" ]]; then
|
|
_backup_stacks_parallel "${stacks[@]}"
|
|
else
|
|
IFS=',' read -ra SELECTIONS <<< "$selection"
|
|
for sel in "${SELECTIONS[@]}"; do
|
|
sel=$(echo "$sel" | xargs)
|
|
|
|
if [[ ! "$sel" =~ ^[0-9]+$ ]]; then
|
|
log_warn "Invalid selection: $sel"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$sel" -ge 1 ]] && [[ "$sel" -le "${#stacks[@]}" ]]; then
|
|
local idx=$((sel-1))
|
|
backup_stack "${stacks[$idx]}" || true
|
|
else
|
|
log_warn "Selection out of range: $sel"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
local elapsed=$(( SECONDS - start_time ))
|
|
log_info "Backup completed in $(format_elapsed "$elapsed")"
|
|
}
|
|
|
|
_backup_stacks_parallel() {
|
|
local stacks=("$@")
|
|
local total=${#stacks[@]}
|
|
local pids=()
|
|
local fail_dir
|
|
fail_dir=$(mktemp -d /tmp/backup_fail.XXXXXX)
|
|
register_cleanup "rm -rf '$fail_dir'"
|
|
|
|
log_info "Starting backups for $total stack(s)..."
|
|
|
|
local idx=0
|
|
for stack in "${stacks[@]}"; do
|
|
((idx++))
|
|
echo -e "${CYAN}[$idx/$total]${NC} Backing up $stack..."
|
|
parallel_throttle "$MAX_PARALLEL"
|
|
(
|
|
if ! backup_stack "$stack"; then
|
|
touch "$fail_dir/$stack"
|
|
fi
|
|
) &
|
|
_parallel_pids+=($!)
|
|
done
|
|
|
|
parallel_wait
|
|
|
|
# Collect failures
|
|
local failed_stacks=()
|
|
for stack in "${stacks[@]}"; do
|
|
if [[ -f "$fail_dir/$stack" ]]; then
|
|
failed_stacks+=("$stack")
|
|
fi
|
|
done
|
|
|
|
if [[ ${#failed_stacks[@]} -gt 0 ]]; then
|
|
log_warn "Failed to backup ${#failed_stacks[@]} stack(s): ${failed_stacks[*]}"
|
|
else
|
|
log_success "All stacks backed up successfully"
|
|
fi
|
|
}
|
|
|
|
auto_mode() {
|
|
log_info "Starting auto mode on $HOSTNAME"
|
|
local start_time=$SECONDS
|
|
|
|
# Backup all stacks
|
|
local stacks
|
|
mapfile -t stacks < <(get_stacks)
|
|
|
|
if [[ ${#stacks[@]} -eq 0 ]]; then
|
|
log_warn "No stacks found for backup"
|
|
else
|
|
log_info "Backing up ${#stacks[@]} stack(s)..."
|
|
local failed_count=0
|
|
local idx=0
|
|
for stack in "${stacks[@]}"; do
|
|
((idx++))
|
|
[[ "$QUIET" == "false" ]] && echo -e "${CYAN}[$idx/${#stacks[@]}]${NC} $stack"
|
|
if ! backup_stack "$stack"; then
|
|
((failed_count++))
|
|
fi
|
|
done
|
|
|
|
if [[ $failed_count -gt 0 ]]; then
|
|
log_warn "Failed to backup $failed_count stack(s)"
|
|
else
|
|
log_success "All stacks backed up successfully"
|
|
fi
|
|
fi
|
|
|
|
# Cleanup: stopped containers
|
|
log_info "Cleaning up stopped containers..."
|
|
if run_or_dry "prune containers" docker container prune -f > /dev/null 2>&1; then
|
|
log_success "Stopped containers cleaned"
|
|
else
|
|
log_warn "Failed to clean stopped containers"
|
|
fi
|
|
|
|
# Cleanup: dangling images
|
|
log_info "Cleaning up dangling images..."
|
|
if run_or_dry "prune images" docker image prune -f > /dev/null 2>&1; then
|
|
log_success "Dangling images cleaned"
|
|
else
|
|
log_warn "Failed to clean dangling images"
|
|
fi
|
|
|
|
local elapsed=$(( SECONDS - start_time ))
|
|
log_info "Auto mode completed in $(format_elapsed "$elapsed")"
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
|
|
log_debug "Starting docker-backup.sh v$VERSION"
|
|
log_debug "Configuration: STACKS_DIR=$STACKS_DIR, BACKUP_BASE=$BACKUP_BASE"
|
|
log_debug "Flags: QUIET=$QUIET, YES=$YES, DRY_RUN=$DRY_RUN, DEBUG=$DEBUG, VERBOSE=$VERBOSE"
|
|
|
|
# Check dependencies first
|
|
if ! check_dependencies; then
|
|
log_error "Dependency check failed"
|
|
exit 1
|
|
fi
|
|
|
|
# Validation of critical dirs
|
|
if [[ ! -d "$STACKS_DIR" ]]; then
|
|
log_error "Stacks directory does not exist: $STACKS_DIR"
|
|
log_debug "Attempting to create stacks directory..."
|
|
if mkdir -p "$STACKS_DIR" 2>/dev/null; then
|
|
log_info "Created stacks directory: $STACKS_DIR"
|
|
else
|
|
log_error "Cannot create stacks directory: $STACKS_DIR"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Check docker connectivity
|
|
if ! docker info >/dev/null 2>&1; then
|
|
log_debug "Docker info check failed, checking permissions..."
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
if ! confirm "Docker socket not accessible. Run with sudo?"; then
|
|
exit 1
|
|
fi
|
|
log_debug "Re-executing with sudo..."
|
|
exec sudo "$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")" "$@"
|
|
else
|
|
log_error "Cannot connect to Docker daemon even as root."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Handle auto mode
|
|
if [[ "$AUTO_MODE" == "true" ]]; then
|
|
auto_mode
|
|
exit 0
|
|
fi
|
|
|
|
# Handle list only
|
|
if [[ "$LIST_ONLY" == "true" ]]; then
|
|
list_stacks
|
|
exit 0
|
|
fi
|
|
|
|
# Handle specific stack backup
|
|
if [[ -n "$BACKUP_STACK" ]]; then
|
|
if ! validate_stack_name "$BACKUP_STACK"; then
|
|
exit 1
|
|
fi
|
|
local start_time=$SECONDS
|
|
backup_stack "$BACKUP_STACK"
|
|
local rc=$?
|
|
local elapsed=$(( SECONDS - start_time ))
|
|
log_info "Completed in $(format_elapsed "$elapsed")"
|
|
exit $rc
|
|
fi
|
|
|
|
# Handle backup all
|
|
if [[ "$BACKUP_ALL" == "true" ]]; then
|
|
local stacks
|
|
mapfile -t stacks < <(get_stacks)
|
|
if [[ ${#stacks[@]} -eq 0 ]]; then
|
|
log_error "No stacks found"
|
|
exit 1
|
|
fi
|
|
|
|
local start_time=$SECONDS
|
|
_backup_stacks_parallel "${stacks[@]}"
|
|
local elapsed=$(( SECONDS - start_time ))
|
|
log_info "Completed in $(format_elapsed "$elapsed")"
|
|
exit 0
|
|
fi
|
|
|
|
# Interactive mode
|
|
interactive_backup
|
|
}
|
|
|
|
main "$@"
|