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

759 lines
24 KiB
Bash
Executable File

#!/bin/bash
# Docker Service Migration 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
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-migrate.log}"
HOSTNAME=$(hostname 2>/dev/null | tr -cd 'a-zA-Z0-9._-' || echo "unknown")
# Source/destination configuration
OLD_HOST="${OLD_HOST:-local}"
OLD_PORT="${OLD_PORT:-22}"
OLD_USER="${OLD_USER:-$(whoami)}"
NEW_HOST="${NEW_HOST:-}"
NEW_PORT="${NEW_PORT:-22}"
NEW_USER="${NEW_USER:-$(whoami)}"
# Transfer options
TRANSFER_METHOD="rsync"
TRANSFER_MODE="clone" # clone (non-disruptive) or transfer (stop source)
COMPRESSION="zstd"
BANDWIDTH_MB="${BANDWIDTH_MB:-0}"
TRANSFER_RETRIES="${TRANSFER_RETRIES:-3}"
SERVICE_NAME=""
# Load config file overrides
load_config
parse_args() {
# Support legacy positional argument: first arg as service name
if [[ $# -gt 0 ]] && [[ ! "$1" =~ ^-- ]]; then
SERVICE_NAME="$1"
shift
fi
while [[ $# -gt 0 ]]; do
case $1 in
--version)
echo "docker-migrate.sh v$VERSION"
exit 0
;;
--quiet|-q)
QUIET=true
shift
;;
--yes|-y)
YES=true
shift
;;
--service)
require_arg "$1" "${2:-}" || exit 1
SERVICE_NAME="$2"
shift 2
;;
--source)
require_arg "$1" "${2:-}" || exit 1
OLD_HOST="$2"
shift 2
;;
--dest)
require_arg "$1" "${2:-}" || exit 1
NEW_HOST="$2"
shift 2
;;
--method)
TRANSFER_METHOD="$2"
shift 2
;;
--transfer)
TRANSFER_MODE="transfer"
shift
;;
--compression)
COMPRESSION="$2"
shift 2
;;
--bwlimit)
BANDWIDTH_MB="$2"
shift 2
;;
--retries)
TRANSFER_RETRIES="$2"
shift 2
;;
--stacks-dir)
STACKS_DIR="$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 Service Migration Script v$VERSION
Usage: $0 [SERVICE] [OPTIONS]
Options:
--service SERVICE Service name to migrate
--source HOST Source host (default: local)
--dest HOST Destination host/user@host
--method METHOD Transfer method: rsync, tar, rclone (default: rsync)
--transfer Transfer mode (stop source), default is clone mode
--compression METHOD Compression: zstd or gzip (default: zstd)
--bwlimit MB Bandwidth limit in MB/s for rsync (0 = unlimited)
--retries N Number of retries for failed transfers (default: 3)
--stacks-dir DIR Stacks directory (default: /opt/stacks)
--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
Examples:
$0 myapp --dest user@remote-host
$0 --service myapp --source local --dest user@remote --method rsync
$0 --service myapp --source old-server --dest new-server --transfer
$0 --service myapp --dest user@remote --bwlimit 50 --retries 5
$0 --service myapp --dest user@remote --dry-run
EOF
}
# ─── Dependency checks ───────────────────────────────────────────────────────
check_dependencies() {
local required_cmds=("docker" "ssh")
local missing=()
case "$TRANSFER_METHOD" in
rsync) required_cmds+=("rsync") ;;
rclone) required_cmds+=("rclone") ;;
esac
for cmd in "${required_cmds[@]}"; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
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
}
# ─── Parse user@host:port ─────────────────────────────────────────────────────
parse_host_string() {
local input="$1"
local -n _host="$2"
local -n _user="$3"
local -n _port="$4"
if [[ "$input" == "local" ]]; then
_host="local"
return 0
fi
# Parse user@host:port
if [[ "$input" =~ ^(.+)@(.+):([0-9]+)$ ]]; then
_user="${BASH_REMATCH[1]}"
_host="${BASH_REMATCH[2]}"
_port="${BASH_REMATCH[3]}"
elif [[ "$input" =~ ^(.+)@(.+)$ ]]; then
_user="${BASH_REMATCH[1]}"
_host="${BASH_REMATCH[2]}"
elif [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
_host="${BASH_REMATCH[1]}"
_port="${BASH_REMATCH[2]}"
else
_host="$input"
fi
}
# ─── Pre-flight checks ───────────────────────────────────────────────────────
preflight_checks() {
local service="$1"
log_info "Running pre-flight checks..."
# Validate service name
if ! validate_stack_name "$service"; then
return 1
fi
# Check source
if [[ "$OLD_HOST" == "local" ]]; then
if [[ ! -d "$STACKS_DIR/$service" ]]; then
log_error "Service not found locally: $STACKS_DIR/$service"
return 1
fi
log_success "Source: local ($STACKS_DIR/$service)"
else
log_info "Source: $OLD_USER@$OLD_HOST:$OLD_PORT"
if ! test_ssh "$OLD_HOST" "$OLD_PORT" "$OLD_USER"; then
return 1
fi
# Verify service exists on source
if ! ssh_cmd "$OLD_HOST" "$OLD_PORT" "$OLD_USER" "test -d '$STACKS_DIR/$service'" 2>/dev/null; then
log_error "Service not found on source: $STACKS_DIR/$service"
return 1
fi
log_success "Service found on source"
fi
# Check destination
if [[ -z "$NEW_HOST" ]]; then
log_error "Destination host not specified"
return 1
fi
log_info "Destination: $NEW_USER@$NEW_HOST:$NEW_PORT"
if ! test_ssh "$NEW_HOST" "$NEW_PORT" "$NEW_USER"; then
return 1
fi
# Service conflict check: verify service doesn't already exist on destination
if ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "test -d '$STACKS_DIR/$service'" 2>/dev/null; then
log_warn "Service already exists on destination: $STACKS_DIR/$service"
if ! confirm "Overwrite existing service on destination?"; then
return 1
fi
fi
log_success "Pre-flight checks passed"
return 0
}
# ─── Transfer functions ──────────────────────────────────────────────────────
rsync_transfer() {
local src="$1" dst_host="$2" dst_port="$3" dst_user="$4" dst_path="$5"
local attempt=0
local max_retries="$TRANSFER_RETRIES"
local rsync_opts=(-avz --progress --delete)
# Bandwidth limiting
if [[ "$BANDWIDTH_MB" -gt 0 ]]; then
local bw_kbps=$((BANDWIDTH_MB * 1024))
rsync_opts+=(--bwlimit="$bw_kbps")
log_info "Bandwidth limited to ${BANDWIDTH_MB}MB/s"
fi
# Compression
local comp_info
comp_info=$(get_compressor "$COMPRESSION")
local compressor="${comp_info%%:*}"
if [[ "$compressor" == "zstd" ]]; then
rsync_opts+=(--compress-choice=zstd)
fi
rsync_opts+=(-e "ssh -p $dst_port -o StrictHostKeyChecking=no -o ConnectTimeout=10")
while [[ $attempt -lt $max_retries ]]; do
((attempt++))
if [[ $attempt -gt 1 ]]; then
local backoff=$((attempt * 5))
log_warn "Retry $attempt/$max_retries in ${backoff}s..."
sleep "$backoff"
fi
if run_or_dry "rsync $src to $dst_user@$dst_host:$dst_path" \
rsync "${rsync_opts[@]}" "$src" "$dst_user@$dst_host:$dst_path"; then
return 0
fi
log_warn "Transfer attempt $attempt failed"
done
log_error "Transfer failed after $max_retries attempts"
return 1
}
tar_transfer() {
local src="$1" dst_host="$2" dst_port="$3" dst_user="$4" dst_path="$5"
local comp_info
comp_info=$(get_compressor "$COMPRESSION")
local compressor="${comp_info%%:*}"
log_info "Transferring via tar+$compressor..."
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would tar $src | $compressor | ssh -> $dst_path"
return 0
fi
ssh_cmd "$dst_host" "$dst_port" "$dst_user" "mkdir -p '$dst_path'" || return 1
tar -cf - -C "$(dirname "$src")" "$(basename "$src")" | \
$compressor | \
ssh -p "$dst_port" -o StrictHostKeyChecking=no "$dst_user@$dst_host" \
"cd '$dst_path' && $compressor -d | tar -xf -"
}
rclone_transfer() {
local src="$1" dst_host="$2" dst_port="$3" dst_user="$4" dst_path="$5"
if ! command -v rclone &>/dev/null; then
log_error "rclone not installed, falling back to rsync"
rsync_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path"
return $?
fi
log_info "Transferring via rclone SFTP..."
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would rclone sync $src to :sftp:$dst_host:$dst_path"
return 0
fi
rclone sync "$src" ":sftp,host=$dst_host,user=$dst_user,port=$dst_port:$dst_path" \
--transfers "$((MAX_PARALLEL > 16 ? 16 : MAX_PARALLEL))" \
--progress \
--sftp-ask-password \
2>&1 || {
log_error "rclone transfer failed"
return 1
}
}
file_transfer() {
local method="$1" src="$2" dst_host="$3" dst_port="$4" dst_user="$5" dst_path="$6"
case "$method" in
rsync) rsync_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path" ;;
tar) tar_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path" ;;
rclone) rclone_transfer "$src" "$dst_host" "$dst_port" "$dst_user" "$dst_path" ;;
*)
log_error "Unknown transfer method: $method"
return 1
;;
esac
}
# ─── Volume operations ────────────────────────────────────────────────────────
backup_volume() {
local vol="$1"
local backup_dir="$2"
local comp_info
comp_info=$(get_compressor "$COMPRESSION")
local compressor="${comp_info%%:*}"
local ext="${comp_info#*:}"
local archive="$backup_dir/${vol}${ext}"
log_info "Backing up volume: $vol"
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would backup volume $vol to $archive"
return 0
fi
docker run --rm \
-v "${vol}:/data:ro" \
-v "$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
}
log_success "Volume $vol backed up"
}
restore_volume() {
local vol="$1"
local backup_dir="$2"
local host="$3" port="$4" user="$5"
# Detect archive format
local archive=""
local decompressor=""
if [[ -f "$backup_dir/${vol}.tar.zst" ]]; then
archive="$backup_dir/${vol}.tar.zst"
decompressor="zstd -d"
elif [[ -f "$backup_dir/${vol}.tar.gz" ]]; then
archive="$backup_dir/${vol}.tar.gz"
decompressor="gzip -d"
else
log_error "No backup found for volume: $vol"
return 1
fi
log_info "Restoring volume: $vol"
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would restore volume $vol from $archive"
return 0
fi
if [[ "$host" == "local" ]]; then
docker run --rm \
-v "${vol}:/data" \
-v "$backup_dir:/backup:ro" \
alpine \
sh -c "cd /data && cat /backup/$(basename "$archive") | $decompressor | tar -xf -" 2>/dev/null || {
log_error "Failed to restore volume: $vol"
return 1
}
else
ssh_cmd "$host" "$port" "$user" \
"docker run --rm -v '${vol}:/data' -v '$backup_dir:/backup:ro' alpine sh -c 'cd /data && cat /backup/$(basename "$archive") | $decompressor | tar -xf -'" 2>/dev/null || {
log_error "Failed to restore volume on remote: $vol"
return 1
}
fi
log_success "Volume $vol restored"
}
# ─── Post-migration verification ─────────────────────────────────────────────
verify_migration() {
local service="$1"
local host="$2" port="$3" user="$4"
log_info "Verifying migration..."
# Check service directory exists on destination
if ! ssh_cmd "$host" "$port" "$user" "test -d '$STACKS_DIR/$service'" 2>/dev/null; then
log_error "Service directory not found on destination"
return 1
fi
log_success "Service directory present"
# Check compose file exists
local compose_found=false
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
if ssh_cmd "$host" "$port" "$user" "test -f '$STACKS_DIR/$service/$f'" 2>/dev/null; then
compose_found=true
break
fi
done
if [[ "$compose_found" == "false" ]]; then
log_error "No compose file found on destination"
return 1
fi
log_success "Compose file present"
# Wait and check if service started
log_info "Waiting for service health check (5s)..."
sleep 5
if ssh_cmd "$host" "$port" "$user" \
"cd '$STACKS_DIR/$service' && docker compose ps -q 2>/dev/null | head -1" 2>/dev/null | grep -q .; then
log_success "Service is running on destination"
else
log_warn "Service may not be running on destination yet"
fi
log_success "Migration verification passed"
return 0
}
# ─── Main migration logic ────────────────────────────────────────────────────
migrate_service() {
local service="$1"
local start_time=$SECONDS
log_header "Migrating Service: $service"
log_info "Mode: $TRANSFER_MODE | Method: $TRANSFER_METHOD | Compression: $COMPRESSION"
write_log "Starting migration of $service from $OLD_HOST to $NEW_HOST"
# Create temp dir for volume backups
local vol_backup_dir
vol_backup_dir=$(mktemp -d /tmp/docwell_migrate.XXXXXX)
register_cleanup "rm -rf '$vol_backup_dir'"
# Step 1: Transfer configuration
log_info "[Step 1/5] Transferring configuration..."
local stack_path="$STACKS_DIR/$service"
if [[ "$OLD_HOST" == "local" ]]; then
file_transfer "$TRANSFER_METHOD" "$stack_path/" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "$STACKS_DIR/$service/"
else
# Remote to remote: pull from source then push to dest
local local_temp
local_temp=$(mktemp -d /tmp/docwell_config.XXXXXX)
register_cleanup "rm -rf '$local_temp'"
rsync -avz -e "ssh -p $OLD_PORT -o StrictHostKeyChecking=no" \
"$OLD_USER@$OLD_HOST:$stack_path/" "$local_temp/$service/" >/dev/null 2>&1 || {
log_error "Failed to pull config from source"
return 1
}
file_transfer "$TRANSFER_METHOD" "$local_temp/$service/" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "$STACKS_DIR/$service/"
fi
log_success "Configuration transferred"
# Step 2: Get volumes
log_info "[Step 2/5] Discovering volumes..."
local volumes
mapfile -t volumes < <(get_service_volumes "$service")
if [[ ${#volumes[@]} -eq 0 ]] || [[ -z "${volumes[0]}" ]]; then
log_info "No volumes to migrate"
else
log_info "Found ${#volumes[@]} volume(s)"
# Step 3: Backup volumes
log_info "[Step 3/5] Backing up volumes..."
local vol_idx=0
for vol in "${volumes[@]}"; do
[[ -z "$vol" ]] && continue
((vol_idx++))
echo -e "${CYAN}[$vol_idx/${#volumes[@]}]${NC} Volume: $vol"
backup_volume "$vol" "$vol_backup_dir" || {
log_error "Failed to backup volume: $vol"
return 1
}
done
# Step 4: Transfer and restore volumes
log_info "[Step 4/5] Transferring volumes..."
file_transfer "$TRANSFER_METHOD" "$vol_backup_dir/" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "$vol_backup_dir/"
log_info "Restoring volumes on destination..."
vol_idx=0
for vol in "${volumes[@]}"; do
[[ -z "$vol" ]] && continue
((vol_idx++))
# Create volume on destination
ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "docker volume create '$vol'" >/dev/null 2>&1 || true
echo -e "${CYAN}[$vol_idx/${#volumes[@]}]${NC} Restoring: $vol"
restore_volume "$vol" "$vol_backup_dir" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" || {
log_warn "Failed to restore volume: $vol"
}
done
fi
# Step 5: Start on destination and optionally stop source
log_info "[Step 5/5] Starting service on destination..."
local remote_compose
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
if ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" "test -f '$STACKS_DIR/$service/$f'" 2>/dev/null; then
remote_compose="$f"
break
fi
done
if [[ -n "${remote_compose:-}" ]]; then
if run_or_dry "start service on destination" \
ssh_cmd "$NEW_HOST" "$NEW_PORT" "$NEW_USER" \
"cd '$STACKS_DIR/$service' && docker compose -f '$remote_compose' up -d" >/dev/null 2>&1; then
log_success "Service started on destination"
else
log_error "Failed to start service on destination"
fi
fi
# Transfer mode: stop source
if [[ "$TRANSFER_MODE" == "transfer" ]]; then
log_info "Transfer mode: stopping source service..."
if [[ "$OLD_HOST" == "local" ]]; then
local compose_file
compose_file=$(get_compose_file "$stack_path") && \
run_or_dry "stop local source" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1
else
run_or_dry "stop remote source" ssh_cmd "$OLD_HOST" "$OLD_PORT" "$OLD_USER" \
"cd '$STACKS_DIR/$service' && docker compose down" >/dev/null 2>&1 || {
log_warn "Could not stop source service"
}
fi
log_success "Source service stopped"
fi
# Post-migration verification
verify_migration "$service" "$NEW_HOST" "$NEW_PORT" "$NEW_USER" || {
log_warn "Post-migration verification had warnings"
}
local elapsed=$(( SECONDS - start_time ))
log_success "Migration completed in $(format_elapsed "$elapsed")"
}
# ─── Interactive mode ─────────────────────────────────────────────────────────
interactive_migrate() {
clear_screen
log_header "Docker Service Migration"
echo -e "${GRAY}DocWell Migration v$VERSION${NC}"
echo
# Service selection
show_spinner "Loading stacks..."
local stacks
mapfile -t stacks < <(get_stacks)
hide_spinner
if [[ ${#stacks[@]} -eq 0 ]]; then
log_error "No stacks found"
exit 1
fi
echo "Available services:"
for i in "${!stacks[@]}"; do
local status="${GRAY}${NC}"
is_stack_running "${stacks[$i]}" && status="${GREEN}${NC}"
echo -e " ${BOLD}$((i+1))${NC}) $status ${stacks[$i]}"
done
echo
read -rp "Select service to migrate: " choice
if [[ ! "$choice" =~ ^[0-9]+$ ]] || [[ "$choice" -lt 1 ]] || [[ "$choice" -gt "${#stacks[@]}" ]]; then
log_error "Invalid selection"
exit 1
fi
SERVICE_NAME="${stacks[$((choice-1))]}"
# Source
read -rp "Source host [${OLD_HOST}]: " input_host
[[ -n "$input_host" ]] && OLD_HOST="$input_host"
# Destination
read -rp "Destination host: " NEW_HOST
if [[ -z "$NEW_HOST" ]]; then
log_error "Destination required"
exit 1
fi
# Parse user@host for destination
parse_host_string "$NEW_HOST" NEW_HOST NEW_USER NEW_PORT
# Transfer method
echo
echo "Transfer methods:"
echo " 1) rsync (recommended)"
echo " 2) tar over SSH"
echo " 3) rclone"
read -rp "Select method [1]: " method_choice
case "${method_choice:-1}" in
2) TRANSFER_METHOD="tar" ;;
3) TRANSFER_METHOD="rclone" ;;
*) TRANSFER_METHOD="rsync" ;;
esac
# Transfer mode
echo
echo "Transfer modes:"
echo " 1) Clone (keep source running)"
echo " 2) Transfer (stop source after)"
read -rp "Select mode [1]: " mode_choice
case "${mode_choice:-1}" in
2) TRANSFER_MODE="transfer" ;;
*) TRANSFER_MODE="clone" ;;
esac
echo
log_separator
echo -e " Service: ${BOLD}$SERVICE_NAME${NC}"
echo -e " Source: $OLD_USER@$OLD_HOST:$OLD_PORT"
echo -e " Destination: $NEW_USER@$NEW_HOST:$NEW_PORT"
echo -e " Method: $TRANSFER_METHOD"
echo -e " Mode: $TRANSFER_MODE"
log_separator
echo
if ! confirm "Proceed with migration?"; then
log_info "Migration cancelled"
exit 0
fi
if ! preflight_checks "$SERVICE_NAME"; then
log_error "Pre-flight checks failed"
exit 1
fi
migrate_service "$SERVICE_NAME"
}
# ─── Main ────────────────────────────────────────────────────────────────────
main() {
parse_args "$@"
if ! check_dependencies; then
exit 1
fi
# Parse destination user@host if provided
if [[ -n "$NEW_HOST" ]]; then
parse_host_string "$NEW_HOST" NEW_HOST NEW_USER NEW_PORT
fi
# CLI mode
if [[ -n "$SERVICE_NAME" ]] && [[ -n "$NEW_HOST" ]]; then
if ! preflight_checks "$SERVICE_NAME"; then
exit 1
fi
migrate_service "$SERVICE_NAME"
exit $?
fi
# Interactive mode
interactive_migrate
}
main "$@"