#!/bin/bash # Docker Auto-Migration Script # Migrates ALL stacks to a specified destination host 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 LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-auto-migrate.log}" DEST_HOST="${DEST_HOST:-}" DEST_USER="${DEST_USER:-$(whoami)}" DEST_PORT="${DEST_PORT:-22}" REMOTE_STACKS_DIR="${REMOTE_STACKS_DIR:-/opt/stacks}" TRANSFER_METHOD="rclone" COMPRESSION="${COMPRESSION:-zstd}" # Load config file overrides load_config parse_args() { while [[ $# -gt 0 ]]; do case $1 in --version) echo "docker-auto-migrate.sh v$VERSION" exit 0 ;; --quiet|-q) QUIET=true shift ;; --yes|-y) YES=true shift ;; --dest) DEST_HOST="$2" shift 2 ;; --dest-user) DEST_USER="$2" shift 2 ;; --dest-port) DEST_PORT="$2" shift 2 ;; --stacks-dir) STACKS_DIR="$2" shift 2 ;; --remote-stacks-dir) REMOTE_STACKS_DIR="$2" shift 2 ;; --method) TRANSFER_METHOD="$2" shift 2 ;; --install-deps) AUTO_INSTALL=true shift ;; --log) LOG_FILE="$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 Auto-Migration Script v$VERSION Migrates ALL Docker stacks to a specified destination host. Requires root privileges and SSH key-based authentication. Usage: $0 [OPTIONS] Options: --dest HOST Destination host (required) --dest-user USER Destination user (default: current user) --dest-port PORT Destination SSH port (default: 22) --stacks-dir DIR Local stacks directory (default: /opt/stacks) --remote-stacks-dir Remote stacks directory (default: /opt/stacks) --method METHOD Transfer method: rclone, rsync (default: rclone) --quiet, -q Suppress non-error output --yes, -y Auto-confirm all prompts --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) --log FILE Log file path --version Show version information --help, -h Show this help message Environment Variables: DEST_HOST Destination host DEST_USER Destination user DEST_PORT Destination SSH port STACKS_DIR Local stacks directory REMOTE_STACKS_DIR Remote stacks directory Examples: $0 --dest 10.0.0.2 --dest-user admin $0 --dest 10.0.0.2 --method rsync --dry-run $0 --dest remote-host --yes EOF } check_dependencies() { local required=("docker" "ssh") case "$TRANSFER_METHOD" in rclone) required+=("rclone") ;; rsync) required+=("rsync") ;; esac local missing=() for cmd in "${required[@]}"; do command -v "$cmd" &>/dev/null || missing+=("$cmd") done if [[ ${#missing[@]} -gt 0 ]]; then log_error "Missing dependencies: ${missing[*]}" if [[ "$AUTO_INSTALL" == "true" ]]; then install_dependencies "${missing[@]}" return $? fi return 1 fi return 0 } stop_local_stack() { local stack="$1" local stack_path="$STACKS_DIR/$stack" local compose_file compose_file=$(get_compose_file "$stack_path") || return 1 run_or_dry "stop $stack" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1 } start_remote_stack() { local stack="$1" local remote_path="$REMOTE_STACKS_DIR/$stack" for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do if ssh -p "$DEST_PORT" -o StrictHostKeyChecking=no "$DEST_USER@$DEST_HOST" \ "test -f '$remote_path/$f'" 2>/dev/null; then run_or_dry "start $stack on remote" \ ssh -p "$DEST_PORT" -o StrictHostKeyChecking=no "$DEST_USER@$DEST_HOST" \ "cd '$remote_path' && docker compose -f '$f' up -d" >/dev/null 2>&1 return $? fi done log_error "No compose file found on destination for $stack" return 1 } transfer_files() { local src="$1" dst="$2" case "$TRANSFER_METHOD" in rclone) run_or_dry "rclone sync $src to $dst" \ rclone sync "$src" ":sftp,host=$DEST_HOST,user=$DEST_USER,port=$DEST_PORT:$dst" \ --transfers 16 --progress --sftp-ask-password 2>&1 ;; rsync) run_or_dry "rsync $src to $dst" \ rsync -avz --progress --delete \ -e "ssh -p $DEST_PORT -o StrictHostKeyChecking=no" \ "$src/" "$DEST_USER@$DEST_HOST:$dst/" ;; esac } migrate_volume() { local vol="$1" local vol_backup_dir="$2" local comp_info comp_info=$(get_compressor "$COMPRESSION") local compressor="${comp_info%%:*}" local ext="${comp_info#*:}" log_info "Backing up volume: $vol" if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY-RUN] Would migrate volume $vol" return 0 fi # Backup locally docker run --rm \ -v "${vol}:/data:ro" \ -v "$vol_backup_dir:/backup" \ alpine \ sh -c "tar -cf - -C /data . | $compressor > /backup/${vol}${ext}" 2>/dev/null || { log_error "Failed to backup volume: $vol" return 1 } # Transfer to remote transfer_files "$vol_backup_dir/${vol}${ext}" "/tmp/docwell_vol_migrate/" # Restore on remote local decompressor case "$compressor" in zstd) decompressor="zstd -d" ;; gzip) decompressor="gzip -d" ;; *) decompressor="cat" ;; esac ssh -p "$DEST_PORT" -o StrictHostKeyChecking=no "$DEST_USER@$DEST_HOST" \ "docker volume create '$vol' 2>/dev/null; \ docker run --rm -v '${vol}:/data' -v '/tmp/docwell_vol_migrate:/backup:ro' alpine \ sh -c 'cd /data && cat /backup/${vol}${ext} | ${decompressor} | tar -xf -'" 2>/dev/null || { log_warn "Failed to restore volume on remote: $vol" return 1 } log_success "Volume $vol migrated" } main() { parse_args "$@" # Must be root if [[ $EUID -ne 0 ]]; then log_error "This script requires root privileges" exit 1 fi if [[ -z "$DEST_HOST" ]]; then log_error "Destination host required (--dest HOST)" show_help exit 1 fi if ! check_dependencies; then exit 1 fi # Test SSH if ! test_ssh "$DEST_HOST" "$DEST_PORT" "$DEST_USER"; then exit 1 fi local stacks mapfile -t stacks < <(get_stacks) if [[ ${#stacks[@]} -eq 0 ]]; then log_error "No stacks found in $STACKS_DIR" exit 1 fi log_header "Auto-Migrate All Stacks" echo -e "${GRAY}DocWell Auto-Migration v$VERSION${NC}" echo echo -e " Source: local ($STACKS_DIR)" echo -e " Destination: $DEST_USER@$DEST_HOST:$DEST_PORT ($REMOTE_STACKS_DIR)" echo -e " Method: $TRANSFER_METHOD" echo -e " Stacks: ${#stacks[@]}" echo if ! confirm "Proceed with migrating ALL stacks?"; then exit 0 fi local start_time=$SECONDS local total=${#stacks[@]} local failed=() local vol_backup_dir vol_backup_dir=$(mktemp -d /tmp/docwell_auto_migrate.XXXXXX) register_cleanup "rm -rf '$vol_backup_dir'" local idx=0 for stack in "${stacks[@]}"; do ((idx++)) echo log_separator echo -e "${CYAN}[$idx/$total]${NC} ${BOLD}$stack${NC}" # Step 1: Stop local stack log_info "Stopping local stack..." if is_stack_running "$stack"; then stop_local_stack "$stack" || { log_warn "Failed to stop $stack, continuing..." } fi # Step 2: Transfer files log_info "Transferring files..." if ! transfer_files "$STACKS_DIR/$stack" "$REMOTE_STACKS_DIR/$stack"; then log_error "Failed to transfer files for $stack" failed+=("$stack") continue fi # Step 3: Migrate volumes local volumes mapfile -t volumes < <(get_service_volumes "$stack" 2>/dev/null || true) for vol in "${volumes[@]}"; do [[ -z "$vol" ]] && continue migrate_volume "$vol" "$vol_backup_dir" || { log_warn "Volume migration failed for $vol" } done # Step 4: Start on remote log_info "Starting on destination..." if ! start_remote_stack "$stack"; then log_warn "Failed to start $stack on destination" failed+=("$stack") else log_success "$stack migrated" fi done echo log_separator local elapsed=$(( SECONDS - start_time )) if [[ ${#failed[@]} -gt 0 ]]; then log_warn "Failed stacks (${#failed[@]}): ${failed[*]}" else log_success "All stacks migrated successfully" fi log_info "Auto-migration completed in $(format_elapsed "$elapsed")" } main "$@"