759 lines
24 KiB
Bash
Executable File
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 "$@"
|