Files
docker-tools/bash/docker-auto-migrate.sh
2026-03-22 00:54:34 -07:00

367 lines
10 KiB
Bash
Executable File

#!/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 "$@"