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

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
}