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