713 lines
23 KiB
Bash
Executable File
713 lines
23 KiB
Bash
Executable File
#!/bin/bash
|
|
# DocWell Shared Library
|
|
# Common functions used across all docker-tools bash scripts
|
|
# Source this file: source "$(dirname "$0")/lib/common.sh"
|
|
|
|
# Version
|
|
LIB_VERSION="2.6.2"
|
|
|
|
# ─── Colors (with NO_COLOR support) ──────────────────────────────────────────
|
|
if [[ -n "${NO_COLOR:-}" ]] || [[ ! -t 1 ]]; then
|
|
RED='' GREEN='' YELLOW='' BLUE='' GRAY='' NC='' BOLD='' CYAN=''
|
|
else
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
GRAY='\033[0;90m'
|
|
NC='\033[0m'
|
|
BOLD='\033[1m'
|
|
CYAN='\033[0;36m'
|
|
fi
|
|
|
|
# ─── Common defaults ─────────────────────────────────────────────────────────
|
|
STACKS_DIR="${STACKS_DIR:-/opt/stacks}"
|
|
QUIET="${QUIET:-false}"
|
|
YES="${YES:-false}"
|
|
DRY_RUN="${DRY_RUN:-false}"
|
|
AUTO_INSTALL="${AUTO_INSTALL:-false}"
|
|
LOG_FILE="${LOG_FILE:-/tmp/docwell/docwell.log}"
|
|
DEBUG="${DEBUG:-false}"
|
|
VERBOSE="${VERBOSE:-false}"
|
|
|
|
# Concurrency limit (bounded by nproc)
|
|
MAX_PARALLEL="${MAX_PARALLEL:-$(nproc 2>/dev/null || echo 4)}"
|
|
|
|
# ─── Debug and Tracing ──────────────────────────────────────────────────────
|
|
# Enable xtrace if DEBUG is set (but preserve existing set options)
|
|
# Note: This should be called after scripts set their own 'set' options
|
|
enable_debug_trace() {
|
|
if [[ "${DEBUG}" == "true" ]]; then
|
|
# Check if we're already in a script context with set -euo pipefail
|
|
# If so, preserve those flags when enabling -x
|
|
if [[ "${-}" == *e* ]] && [[ "${-}" == *u* ]] && [[ "${-}" == *o* ]]; then
|
|
set -euxo pipefail
|
|
else
|
|
set -x
|
|
fi
|
|
export PS4='+ [${BASH_SOURCE##*/}:${LINENO}] ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
|
|
log_debug "Debug trace enabled (xtrace)"
|
|
fi
|
|
}
|
|
|
|
# Function call tracing (when DEBUG or VERBOSE is enabled)
|
|
debug_trace() {
|
|
[[ "$DEBUG" == "true" || "$VERBOSE" == "true" ]] || return 0
|
|
local func="${FUNCNAME[1]:-main}"
|
|
local line="${BASH_LINENO[0]:-?}"
|
|
local file="${BASH_SOURCE[1]##*/}"
|
|
echo -e "${GRAY}[DEBUG]${NC} ${file}:${line} ${func}()" >&2
|
|
}
|
|
|
|
# Debug logging
|
|
log_debug() {
|
|
[[ "$DEBUG" == "true" || "$VERBOSE" == "true" ]] || return 0
|
|
local msg="$*"
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
write_log "$timestamp [DEBUG] $msg"
|
|
echo -e "${GRAY}[DEBUG]${NC} $msg" >&2
|
|
}
|
|
|
|
# Verbose logging
|
|
log_verbose() {
|
|
[[ "$VERBOSE" == "true" ]] || return 0
|
|
local msg="$*"
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
write_log "$timestamp [VERBOSE] $msg"
|
|
[[ "$QUIET" == "false" ]] && echo -e "${GRAY}[VERBOSE]${NC} $msg"
|
|
}
|
|
|
|
# ─── Spinner ──────────────────────────────────────────────────────────────────
|
|
_SPINNER_PID=""
|
|
_SPINNER_FRAMES=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
|
|
|
show_spinner() {
|
|
local msg="${1:-Working...}"
|
|
[[ "$QUIET" == "true" ]] && return
|
|
[[ "$DEBUG" == "true" ]] && return # Don't show spinner in debug mode
|
|
|
|
(
|
|
local i=0
|
|
while true; do
|
|
printf "\r%b%s%b %s" "$CYAN" "${_SPINNER_FRAMES[$((i % ${#_SPINNER_FRAMES[@]}))]}" "$NC" "$msg"
|
|
((i++))
|
|
sleep 0.08
|
|
done
|
|
) &
|
|
_SPINNER_PID=$!
|
|
disown "$_SPINNER_PID" 2>/dev/null || true
|
|
log_debug "Spinner started (PID: $_SPINNER_PID)"
|
|
}
|
|
|
|
hide_spinner() {
|
|
if [[ -n "$_SPINNER_PID" ]]; then
|
|
log_debug "Stopping spinner (PID: $_SPINNER_PID)"
|
|
kill "$_SPINNER_PID" 2>/dev/null || true
|
|
wait "$_SPINNER_PID" 2>/dev/null || true
|
|
_SPINNER_PID=""
|
|
printf "\r\033[K" # Clear the spinner line
|
|
fi
|
|
}
|
|
|
|
# ─── Logging ──────────────────────────────────────────────────────────────────
|
|
write_log() {
|
|
local msg="$1"
|
|
local log_dir
|
|
log_dir=$(dirname "$LOG_FILE")
|
|
|
|
# Try to create log directory if it doesn't exist
|
|
if [[ ! -d "$log_dir" ]]; then
|
|
mkdir -p "$log_dir" 2>/dev/null || {
|
|
# If we can't create the directory, try /tmp as fallback
|
|
if [[ "$log_dir" != "/tmp"* ]]; then
|
|
log_dir="/tmp/docwell"
|
|
mkdir -p "$log_dir" 2>/dev/null || return 0
|
|
else
|
|
return 0
|
|
fi
|
|
}
|
|
fi
|
|
|
|
# Check if we can write to the log file
|
|
if [[ -w "$log_dir" ]] 2>/dev/null || [[ -w "$(dirname "$log_dir")" ]] 2>/dev/null; then
|
|
echo "$msg" >> "$LOG_FILE" 2>/dev/null || {
|
|
# Fallback to /tmp if original location fails
|
|
if [[ "$LOG_FILE" != "/tmp/"* ]]; then
|
|
echo "$msg" >> "/tmp/docwell/fallback.log" 2>/dev/null || true
|
|
fi
|
|
}
|
|
fi
|
|
}
|
|
|
|
log_info() {
|
|
local msg="$*"
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
write_log "$timestamp [INFO] $msg"
|
|
[[ "$QUIET" == "false" ]] && echo -e "${GREEN}[INFO]${NC} $msg"
|
|
debug_trace
|
|
}
|
|
|
|
log_warn() {
|
|
local msg="$*"
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
write_log "$timestamp [WARN] $msg"
|
|
[[ "$QUIET" == "false" ]] && echo -e "${YELLOW}[WARN]${NC} $msg" >&2
|
|
}
|
|
|
|
log_error() {
|
|
local msg="$*"
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
write_log "$timestamp [ERROR] $msg"
|
|
echo -e "${RED}[ERROR]${NC} $msg" >&2
|
|
debug_trace
|
|
# In debug mode, show stack trace
|
|
if [[ "$DEBUG" == "true" ]]; then
|
|
local i=0
|
|
while caller $i >/dev/null 2>&1; do
|
|
local frame
|
|
frame=$(caller $i)
|
|
echo -e "${GRAY} ->${NC} $frame" >&2
|
|
((i++))
|
|
done
|
|
fi
|
|
}
|
|
|
|
log_success() {
|
|
local msg="$*"
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
write_log "$timestamp [OK] $msg"
|
|
[[ "$QUIET" == "false" ]] && echo -e "${GREEN}[✓]${NC} $msg"
|
|
}
|
|
|
|
log_header() {
|
|
local msg="$1"
|
|
write_log "=== $msg ==="
|
|
echo -e "${BLUE}${BOLD}═══════════════════════════════════════════════${NC}"
|
|
echo -e "${BLUE}${BOLD} $msg${NC}"
|
|
echo -e "${BLUE}${BOLD}═══════════════════════════════════════════════${NC}"
|
|
}
|
|
|
|
log_separator() {
|
|
echo -e "${GRAY}───────────────────────────────────────────────${NC}"
|
|
}
|
|
|
|
# ─── Prompts ──────────────────────────────────────────────────────────────────
|
|
confirm() {
|
|
local prompt="$1"
|
|
if [[ "$YES" == "true" ]]; then
|
|
return 0
|
|
fi
|
|
read -p "$(echo -e "${YELLOW}?${NC} $prompt (y/N): ")" -n 1 -r
|
|
echo
|
|
[[ $REPLY =~ ^[Yy]$ ]]
|
|
}
|
|
|
|
# ─── Argument validation ──────────────────────────────────────────────────────
|
|
# Call from parse_args for options that require a value. Returns 1 if missing/invalid.
|
|
require_arg() {
|
|
local opt="${1:-}"
|
|
local val="${2:-}"
|
|
if [[ -z "$val" ]] || [[ "$val" == -* ]]; then
|
|
log_error "Option $opt requires a value"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ─── Validation ───────────────────────────────────────────────────────────────
|
|
validate_stack_name() {
|
|
local name="$1"
|
|
debug_trace
|
|
log_debug "Validating stack name: '$name'"
|
|
|
|
if [[ -z "$name" ]] || [[ "$name" == "." ]] || [[ "$name" == ".." ]]; then
|
|
log_error "Invalid stack name: cannot be empty, '.', or '..'"
|
|
return 1
|
|
fi
|
|
if [[ "$name" =~ [^a-zA-Z0-9._-] ]]; then
|
|
log_error "Invalid stack name: contains invalid characters (allowed: a-z, A-Z, 0-9, ., _, -)"
|
|
return 1
|
|
fi
|
|
if [[ ${#name} -gt 64 ]]; then
|
|
log_error "Invalid stack name: too long (max 64 characters, got ${#name})"
|
|
return 1
|
|
fi
|
|
log_debug "Stack name validation passed"
|
|
return 0
|
|
}
|
|
|
|
validate_port() {
|
|
local port="$1"
|
|
debug_trace
|
|
log_debug "Validating port: '$port'"
|
|
|
|
if ! [[ "$port" =~ ^[0-9]+$ ]] || [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then
|
|
log_error "Invalid port: $port (must be 1-65535)"
|
|
return 1
|
|
fi
|
|
log_debug "Port validation passed"
|
|
return 0
|
|
}
|
|
|
|
# ─── Config file support ─────────────────────────────────────────────────────
|
|
DOCWELL_CONFIG_FILE="${HOME}/.config/docwell/config"
|
|
|
|
load_config() {
|
|
[[ -f "$DOCWELL_CONFIG_FILE" ]] || return 0
|
|
|
|
while IFS= read -r line; do
|
|
# Skip comments and empty lines
|
|
line=$(echo "$line" | xargs)
|
|
[[ -z "$line" || "$line" == \#* ]] && continue
|
|
|
|
# Split on first '=' so values can contain '='
|
|
key="${line%%=*}"
|
|
key=$(echo "$key" | xargs)
|
|
value="${line#*=}"
|
|
value=$(echo "$value" | xargs | sed 's/^"//;s/"$//')
|
|
|
|
[[ -z "$key" ]] && continue
|
|
|
|
case "$key" in
|
|
StacksDir) STACKS_DIR="$value" ;;
|
|
BackupBase) BACKUP_BASE="$value" ;;
|
|
LogFile) LOG_FILE="$value" ;;
|
|
OldHost) OLD_HOST="$value" ;;
|
|
OldPort) OLD_PORT="$value" ;;
|
|
OldUser) OLD_USER="$value" ;;
|
|
NewHost) NEW_HOST="$value" ;;
|
|
NewPort) NEW_PORT="$value" ;;
|
|
NewUser) NEW_USER="$value" ;;
|
|
BandwidthMB) BANDWIDTH_MB="$value" ;;
|
|
TransferRetries) TRANSFER_RETRIES="$value" ;;
|
|
esac
|
|
done < "$DOCWELL_CONFIG_FILE"
|
|
}
|
|
|
|
# ─── Compose file detection ──────────────────────────────────────────────────
|
|
get_compose_file() {
|
|
local stack_path="$1"
|
|
debug_trace
|
|
log_debug "Looking for compose file in: $stack_path"
|
|
|
|
if [[ ! -d "$stack_path" ]]; then
|
|
log_debug "Stack path does not exist: $stack_path"
|
|
return 1
|
|
fi
|
|
|
|
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
|
if [[ -f "$stack_path/$f" ]]; then
|
|
log_debug "Found compose file: $f"
|
|
echo "$f"
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
log_debug "No compose file found in: $stack_path"
|
|
return 1
|
|
}
|
|
|
|
has_compose_file() {
|
|
local stack_path="$1"
|
|
get_compose_file "$stack_path" >/dev/null 2>&1
|
|
}
|
|
|
|
# ─── Stack operations ────────────────────────────────────────────────────────
|
|
get_stacks() {
|
|
debug_trace
|
|
log_debug "Discovering stacks in: $STACKS_DIR"
|
|
|
|
if [[ ! -d "$STACKS_DIR" ]]; then
|
|
log_debug "Stacks directory does not exist: $STACKS_DIR"
|
|
return 1
|
|
fi
|
|
|
|
local stacks=()
|
|
local dir_count=0
|
|
|
|
while IFS= read -r -d '' dir; do
|
|
((dir_count++))
|
|
local name
|
|
name=$(basename "$dir")
|
|
log_debug "Found directory: $name"
|
|
if validate_stack_name "$name" >/dev/null 2>&1; then
|
|
stacks+=("$name")
|
|
log_debug "Added valid stack: $name"
|
|
else
|
|
log_debug "Skipped invalid stack name: $name"
|
|
fi
|
|
done < <(find "$STACKS_DIR" -maxdepth 1 -mindepth 1 -type d -print0 2>/dev/null | sort -z)
|
|
|
|
log_debug "Found $dir_count directories, ${#stacks[@]} valid stacks"
|
|
|
|
# Fallback: also discover stacks from docker ps
|
|
log_debug "Checking running containers for additional stacks..."
|
|
local docker_stacks
|
|
docker_stacks=$(docker ps --format '{{.Labels}}' 2>/dev/null | \
|
|
grep -oP 'com\.docker\.compose\.project=\K[^,]+' | sort -u || true)
|
|
|
|
local added_from_docker=0
|
|
for ds in $docker_stacks; do
|
|
[[ -z "$ds" ]] && continue
|
|
local found=false
|
|
for s in "${stacks[@]}"; do
|
|
[[ "$s" == "$ds" ]] && { found=true; break; }
|
|
done
|
|
if [[ "$found" == "false" ]] && validate_stack_name "$ds" >/dev/null 2>&1; then
|
|
stacks+=("$ds")
|
|
((added_from_docker++))
|
|
log_debug "Added stack from docker ps: $ds"
|
|
fi
|
|
done
|
|
|
|
log_debug "Total stacks discovered: ${#stacks[@]} (${added_from_docker} from docker ps)"
|
|
printf '%s\n' "${stacks[@]}"
|
|
}
|
|
|
|
is_stack_running() {
|
|
local stack="$1"
|
|
local stack_path="${2:-$STACKS_DIR/$stack}"
|
|
local compose_file
|
|
|
|
debug_trace
|
|
log_debug "Checking if stack '$stack' is running (path: $stack_path)"
|
|
|
|
compose_file=$(get_compose_file "$stack_path") || {
|
|
log_debug "No compose file found for stack: $stack"
|
|
return 1
|
|
}
|
|
|
|
local running
|
|
running=$(docker compose -f "$stack_path/$compose_file" ps -q 2>/dev/null | grep -c . || echo "0")
|
|
|
|
if [[ "$running" -gt 0 ]]; then
|
|
log_debug "Stack '$stack' is running ($running container(s))"
|
|
return 0
|
|
else
|
|
log_debug "Stack '$stack' is not running"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
get_stack_size() {
|
|
local stack="$1"
|
|
local stack_path="$STACKS_DIR/$stack"
|
|
debug_trace
|
|
|
|
if [[ ! -d "$stack_path" ]]; then
|
|
log_debug "Stack path does not exist: $stack_path"
|
|
echo "?"
|
|
return 0
|
|
fi
|
|
|
|
local size
|
|
size=$(du -sh "$stack_path" 2>/dev/null | awk '{print $1}' || echo "?")
|
|
log_debug "Stack size for $stack: $size"
|
|
echo "$size"
|
|
}
|
|
|
|
get_service_volumes() {
|
|
local service="$1"
|
|
local stack_path="${2:-$STACKS_DIR/$service}"
|
|
local compose_file
|
|
debug_trace
|
|
log_debug "Getting volumes for service: $service (path: $stack_path)"
|
|
|
|
compose_file=$(get_compose_file "$stack_path") || {
|
|
log_debug "No compose file found, cannot get volumes"
|
|
return 1
|
|
}
|
|
|
|
local volumes
|
|
volumes=$(docker compose -f "$stack_path/$compose_file" config --volumes 2>/dev/null || true)
|
|
|
|
if [[ -n "$volumes" ]]; then
|
|
local vol_count
|
|
vol_count=$(echo "$volumes" | grep -c . || echo "0")
|
|
log_debug "Found $vol_count volume(s) for service: $service"
|
|
else
|
|
log_debug "No volumes found for service: $service"
|
|
fi
|
|
|
|
echo "$volumes"
|
|
}
|
|
|
|
# ─── Dependency management ───────────────────────────────────────────────────
|
|
install_deps_debian() {
|
|
local deps=("$@")
|
|
local apt_pkgs=()
|
|
for dep in "${deps[@]}"; do
|
|
case "$dep" in
|
|
docker) apt_pkgs+=("docker.io") ;;
|
|
zstd) apt_pkgs+=("zstd") ;;
|
|
rsync) apt_pkgs+=("rsync") ;;
|
|
rclone) apt_pkgs+=("rclone") ;;
|
|
*) apt_pkgs+=("$dep") ;;
|
|
esac
|
|
done
|
|
if [[ ${#apt_pkgs[@]} -gt 0 ]]; then
|
|
sudo apt update && sudo apt install -y "${apt_pkgs[@]}"
|
|
fi
|
|
}
|
|
|
|
install_deps_arch() {
|
|
local deps=("$@")
|
|
local pacman_pkgs=()
|
|
for dep in "${deps[@]}"; do
|
|
case "$dep" in
|
|
docker) pacman_pkgs+=("docker") ;;
|
|
zstd) pacman_pkgs+=("zstd") ;;
|
|
rsync) pacman_pkgs+=("rsync") ;;
|
|
rclone) pacman_pkgs+=("rclone") ;;
|
|
*) pacman_pkgs+=("$dep") ;;
|
|
esac
|
|
done
|
|
if [[ ${#pacman_pkgs[@]} -gt 0 ]]; then
|
|
sudo pacman -Sy --noconfirm "${pacman_pkgs[@]}"
|
|
fi
|
|
}
|
|
|
|
install_dependencies() {
|
|
local deps=("$@")
|
|
if command -v apt &>/dev/null; then
|
|
install_deps_debian "${deps[@]}"
|
|
elif command -v pacman &>/dev/null; then
|
|
install_deps_arch "${deps[@]}"
|
|
else
|
|
log_error "No supported package manager found (apt/pacman)"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
check_docker() {
|
|
debug_trace
|
|
log_debug "Checking Docker installation and daemon status"
|
|
|
|
if ! command -v docker &>/dev/null; then
|
|
log_error "Docker not found. Please install Docker first."
|
|
echo -e "${BLUE}Install commands:${NC}"
|
|
command -v apt &>/dev/null && echo -e " ${GREEN}Debian/Ubuntu:${NC} sudo apt install docker.io"
|
|
command -v pacman &>/dev/null && echo -e " ${GREEN}Arch Linux:${NC} sudo pacman -S docker"
|
|
|
|
if [[ "$AUTO_INSTALL" == "true" ]]; then
|
|
log_info "Auto-installing dependencies..."
|
|
install_dependencies "docker"
|
|
return $?
|
|
fi
|
|
return 1
|
|
fi
|
|
|
|
log_debug "Docker command found: $(command -v docker)"
|
|
log_debug "Docker version: $(docker --version 2>&1 || echo 'unknown')"
|
|
|
|
if ! docker info &>/dev/null; then
|
|
log_error "Docker daemon not running or not accessible."
|
|
log_debug "Attempting to get more details..."
|
|
docker info 2>&1 | head -5 | while IFS= read -r line; do
|
|
log_debug " $line"
|
|
done || true
|
|
return 1
|
|
fi
|
|
|
|
log_debug "Docker daemon is accessible"
|
|
return 0
|
|
}
|
|
|
|
check_root() {
|
|
if [[ $EUID -ne 0 ]]; then
|
|
if ! sudo -n true 2>/dev/null; then
|
|
log_error "Root/sudo privileges required"
|
|
return 1
|
|
fi
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ─── Screen control ───────────────────────────────────────────────────────────
|
|
clear_screen() {
|
|
printf '\033[H\033[2J'
|
|
}
|
|
|
|
# ─── Elapsed time formatting ─────────────────────────────────────────────────
|
|
format_elapsed() {
|
|
local seconds="$1"
|
|
# Handle invalid input
|
|
if ! [[ "$seconds" =~ ^[0-9]+$ ]]; then
|
|
echo "0s"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$seconds" -lt 60 ]]; then
|
|
echo "${seconds}s"
|
|
elif [[ "$seconds" -lt 3600 ]]; then
|
|
local mins=$((seconds / 60))
|
|
local secs=$((seconds % 60))
|
|
echo "${mins}m ${secs}s"
|
|
else
|
|
local hours=$((seconds / 3600))
|
|
local mins=$((seconds % 3600 / 60))
|
|
local secs=$((seconds % 60))
|
|
echo "${hours}h ${mins}m ${secs}s"
|
|
fi
|
|
}
|
|
|
|
# ─── Compression helper ──────────────────────────────────────────────────────
|
|
get_compressor() {
|
|
local method="${1:-zstd}"
|
|
case "$method" in
|
|
gzip)
|
|
echo "gzip:.tar.gz"
|
|
;;
|
|
zstd|*)
|
|
if command -v zstd &>/dev/null; then
|
|
echo "zstd -T0:.tar.zst"
|
|
else
|
|
log_warn "zstd not found, falling back to gzip"
|
|
echo "gzip:.tar.gz"
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ─── Cleanup trap helper ─────────────────────────────────────────────────────
|
|
# Usage: register_cleanup "command to run"
|
|
_CLEANUP_CMDS=()
|
|
|
|
register_cleanup() {
|
|
local cmd="$1"
|
|
debug_trace
|
|
log_debug "Registering cleanup command: $cmd"
|
|
_CLEANUP_CMDS+=("$cmd")
|
|
}
|
|
|
|
_run_cleanups() {
|
|
hide_spinner
|
|
if [[ ${#_CLEANUP_CMDS[@]} -gt 0 ]]; then
|
|
log_debug "Running ${#_CLEANUP_CMDS[@]} cleanup command(s)"
|
|
for cmd in "${_CLEANUP_CMDS[@]}"; do
|
|
log_debug "Executing cleanup: $cmd"
|
|
eval "$cmd" 2>/dev/null || {
|
|
log_warn "Cleanup command failed: $cmd"
|
|
}
|
|
done
|
|
fi
|
|
}
|
|
|
|
trap _run_cleanups EXIT INT TERM
|
|
|
|
# ─── Dry-run wrapper ─────────────────────────────────────────────────────────
|
|
# Usage: run_or_dry "description" command arg1 arg2 ...
|
|
run_or_dry() {
|
|
local desc="$1"
|
|
shift
|
|
debug_trace
|
|
log_debug "run_or_dry: $desc"
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
log_info "[DRY-RUN] Would: $desc"
|
|
log_verbose "[DRY-RUN] Command: $*"
|
|
return 0
|
|
fi
|
|
|
|
log_verbose "Executing: $*"
|
|
"$@"
|
|
local rc=$?
|
|
log_debug "Command exit code: $rc"
|
|
return $rc
|
|
}
|
|
|
|
# ─── Parallel execution with bounded concurrency ─────────────────────────────
|
|
# Usage: parallel_run max_jobs command arg1 arg2 ...
|
|
# Each arg is processed by spawning: command arg &
|
|
_parallel_pids=()
|
|
|
|
parallel_wait() {
|
|
debug_trace
|
|
if [[ ${#_parallel_pids[@]} -gt 0 ]]; then
|
|
log_debug "Waiting for ${#_parallel_pids[@]} parallel jobs to complete"
|
|
local failed_count=0
|
|
for pid in "${_parallel_pids[@]}"; do
|
|
local exit_code=0
|
|
if ! wait "$pid" 2>/dev/null; then
|
|
exit_code=$?
|
|
log_debug "Job $pid failed with exit code: $exit_code"
|
|
((failed_count++)) || true
|
|
else
|
|
log_debug "Job $pid completed successfully"
|
|
fi
|
|
done
|
|
if [[ $failed_count -gt 0 ]]; then
|
|
log_warn "$failed_count parallel job(s) failed"
|
|
fi
|
|
fi
|
|
_parallel_pids=()
|
|
}
|
|
|
|
parallel_throttle() {
|
|
local max_jobs="${1:-$MAX_PARALLEL}"
|
|
debug_trace
|
|
log_debug "Throttling parallel jobs: ${#_parallel_pids[@]}/$max_jobs"
|
|
|
|
while [[ ${#_parallel_pids[@]} -ge "$max_jobs" ]]; do
|
|
local new_pids=()
|
|
for pid in "${_parallel_pids[@]}"; do
|
|
if kill -0 "$pid" 2>/dev/null; then
|
|
new_pids+=("$pid")
|
|
else
|
|
log_debug "Job $pid completed, removing from throttle list"
|
|
fi
|
|
done
|
|
_parallel_pids=("${new_pids[@]+"${new_pids[@]}"}")
|
|
[[ ${#_parallel_pids[@]} -ge "$max_jobs" ]] && sleep 0.1
|
|
done
|
|
log_debug "Throttle check passed: ${#_parallel_pids[@]}/$max_jobs"
|
|
}
|
|
|
|
# ─── SSH helpers ──────────────────────────────────────────────────────────────
|
|
ssh_cmd() {
|
|
local host="$1" port="$2" user="$3"
|
|
shift 3
|
|
|
|
debug_trace
|
|
log_debug "Executing SSH command: host=$host, port=$port, user=$user"
|
|
log_verbose "Command: $*"
|
|
|
|
if [[ "$host" == "local" ]]; then
|
|
log_debug "Local execution (no SSH)"
|
|
"$@"
|
|
local rc=$?
|
|
log_debug "Local command exit code: $rc"
|
|
return $rc
|
|
fi
|
|
|
|
validate_port "$port" || return 1
|
|
|
|
log_debug "SSH connection: $user@$host:$port"
|
|
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -p "$port" "$user@$host" "$@"
|
|
local rc=$?
|
|
log_debug "SSH command exit code: $rc"
|
|
return $rc
|
|
}
|
|
|
|
test_ssh() {
|
|
local host="$1" port="$2" user="$3"
|
|
|
|
[[ "$host" == "local" ]] && return 0
|
|
|
|
validate_port "$port" || return 1
|
|
|
|
if ssh_cmd "$host" "$port" "$user" 'echo OK' >/dev/null 2>&1; then
|
|
log_success "SSH connection to $user@$host:$port successful"
|
|
return 0
|
|
else
|
|
log_error "SSH connection failed to $user@$host:$port"
|
|
return 1
|
|
fi
|
|
}
|