diff --git a/Master.conf b/Master.conf index 858ebef..fc49620 100644 --- a/Master.conf +++ b/Master.conf @@ -33,13 +33,13 @@ SSH_PORT=221 # Max number of concurrently running rsync processes MAX_RSYNC_PROCS=1 # Container start/stop/restart before and after rsync - CRITICAL_CONTAINER_NAMES=("") + CRITICAL_CONTAINER_NAMES=() # Containers that need delayed before starting - DELAYED_CONTAINERS=("") + DELAYED_CONTAINERS=() # Delay in seconds between starting containers, useful for things like Authelia CONTAINER_DELAY=5 # Not include logs during transfer - EXCLUDE_DIRS=("") + EXCLUDE_DIRS=() # Default global rsync options DEFAULT_RSYNC_OPTS=(-av --info=progress2 --human-readable --bwlimit="$BW_LIMIT" --delete --inplace --no-whole-file) @@ -58,7 +58,6 @@ SSH_PORT=221 # --------------------- Profile System ------------------------- # Profiles are inferred from the directory basename (lowercased) # Profile-specific rsync options (override global) must list all options as they do not inherit from global -# For example, look up at global rsync options above # SPACE-SEPARATED STRINGS declare -A PROFILE_RSYNC_OPTS=( @@ -110,6 +109,7 @@ declare -A PROFILE_CRITICAL_CONTAINER_NAMES=( [emby]="" ) +# SPACE-SEPARATED STRINGS — containers that need a delay before starting declare -A PROFILE_DELAYED_CONTAINERS=( [arrs_stack]="" [critical-data]="Authelia" @@ -125,6 +125,7 @@ declare -A PROFILE_CONTAINER_DELAY=( [important-data]=10 [emby]=5 ) + # SPACE-SEPARATED STRINGS declare -A PROFILE_EXCLUDE_DIRS=( [arrs_stack]="logs *.tmp" diff --git a/Rsync/rsync.sh b/Rsync/rsync.sh index 0e8e6a8..2a5a9fa 100644 --- a/Rsync/rsync.sh +++ b/Rsync/rsync.sh @@ -1,5 +1,4 @@ #!/bin/bash -set -e # ---------------------------------------------------------------------------------------------- # --------------------------------- Rsync Core Script ------------------------------------------ # ---------------------------------------------------------------------------------------------- @@ -11,6 +10,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../Master.conf" source "$SCRIPT_DIR/../common.sh" +# ----------------------------------------------------------------------------------------------- +# Separate the positional directory argument from flag/key=value args +# ----------------------------------------------------------------------------------------------- DIRECTORY="" RAW_ARGS=() @@ -23,29 +25,40 @@ done parse_args "${RAW_ARGS[@]}" -[[ -z "$DIRECTORY" ]] && error "Missing directory" && exit 1 +[[ -z "$DIRECTORY" ]] && error "No directory specified. Usage: rsync.sh [--dry-run] [--log]" && exit 1 detect_hosts resolve_remote_ip +# ----------------------------------------------------------------------------------------------- +# Profile resolution — inferred from the directory basename +# ----------------------------------------------------------------------------------------------- PROFILE_NAME=$(basename "$DIRECTORY" | tr '[:upper:]' '[:lower:]') +info "Profile: $PROFILE_NAME" +# Scalar overrides from profile MAX_RSYNC_PROCS=${PROFILE_MAX_PROCS[$PROFILE_NAME]:-$MAX_RSYNC_PROCS} BW_LIMIT=${PROFILE_BW_LIMIT[$PROFILE_NAME]:-$BW_LIMIT} RETRY_COUNT=${PROFILE_RETRY_COUNT[$PROFILE_NAME]:-$RETRY_COUNT} SLEEP=${PROFILE_SLEEP[$PROFILE_NAME]:-$SLEEP} - -CRITICAL_CONTAINER_NAMES="${PROFILE_CRITICAL_CONTAINER_NAMES[$PROFILE_NAME]}" -DELAYED_CONTAINERS="${PROFILE_DELAYED_CONTAINERS[$PROFILE_NAME]}" CONTAINER_DELAY=${PROFILE_CONTAINER_DELAY[$PROFILE_NAME]:-$CONTAINER_DELAY} -EXCLUDE_DIRS="${PROFILE_EXCLUDE_DIRS[$PROFILE_NAME]}" + +# Array overrides from profile — convert space-separated strings to proper bash arrays +read -r -a CRITICAL_CONTAINER_NAMES <<< "${PROFILE_CRITICAL_CONTAINER_NAMES[$PROFILE_NAME]:-}" +read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[$PROFILE_NAME]:-}" +read -r -a EXCLUDE_DIRS <<< "${PROFILE_EXCLUDE_DIRS[$PROFILE_NAME]:-}" [[ "$SHOW_STATUS" == true ]] && show_status && exit 0 +# ----------------------------------------------------------------------------------------------- +# Run +# ----------------------------------------------------------------------------------------------- +echo "" echo "$ICON_RUN Sync Starting" -echo "Source: $DIRECTORY" -echo "Remote: $REMOTE_SERVER:$DIRECTORY" +echo "Source: $DIRECTORY" +echo "Remote: $REMOTE_SERVER:$DIRECTORY" echo "Profile: $PROFILE_NAME" +echo "" wait_for_rsync @@ -53,23 +66,26 @@ stop_containers get_rsync_opts -for ex in $EXCLUDE_DIRS; do - RSYNC_OPTS+=(--exclude="$ex") +# Append excludes +for ex in "${EXCLUDE_DIRS[@]}"; do + [[ -n "$ex" ]] && RSYNC_OPTS+=(--exclude="$ex") done -[[ "$DRY_RUN" == true ]] && RSYNC_OPTS+=("--dry-run") +[[ "$DRY_RUN" == true ]] && RSYNC_OPTS+=("--dry-run") && warn "DRY RUN — no changes will be made" START=$(date +%s) +RSYNC_SUCCESS=false for i in $(seq 1 "$RETRY_COUNT"); do if rsync "${RSYNC_OPTS[@]}" \ -e "ssh -i $SSH_KEY -T -o Compression=no -o IPQoS=throughput" \ - "$DIRECTORY" "root@${REMOTE_SERVER}:$DIRECTORY"; then + "$DIRECTORY" "root@${REMOTE_SERVER}:$(dirname "$DIRECTORY")/"; then success "Rsync complete" + RSYNC_SUCCESS=true break else - warn "Rsync failed ($i/$RETRY_COUNT)" - sleep "$SLEEP" + warn "$ICON_RETRY Rsync failed (attempt $i/$RETRY_COUNT)" + [[ "$i" -lt "$RETRY_COUNT" ]] && info "Retrying in ${SLEEP}s..." && sleep "$SLEEP" fi done @@ -77,9 +93,17 @@ start_containers END=$(date +%s) +echo "" echo "===== SUMMARY =====" echo "Directory: $DIRECTORY" echo "Profile: $PROFILE_NAME" -echo "Duration: $((END-START))s" -echo "Status: DONE" -echo "===================" \ No newline at end of file +echo "Duration: $((END - START))s" +if [[ "$RSYNC_SUCCESS" == true ]]; then + echo "Status: $ICON_SUCCESS DONE" +else + echo "Status: $ICON_ERROR FAILED after $RETRY_COUNT attempts" +fi +echo "===================" + +[[ "$RSYNC_SUCCESS" == false ]] && exit 1 +exit 0 diff --git a/common.sh b/common.sh index 94542f9..c4cb81c 100644 --- a/common.sh +++ b/common.sh @@ -1,9 +1,9 @@ #!/bin/bash # ----------------------------------------------------------------------------------------------- -# ----------------- UNRAID OPS COMMON LIBRARY (STABLE FRAMEWORK v1) ----------------------- +# ----------------- UNRAID OPS COMMON LIBRARY (STABLE FRAMEWORK v1) ---------------------------- # ----------------------------------------------------------------------------------------------- -# ICONS Profiles +# ICONS ICON_INFO="ℹ️" ICON_WARN="⚠️" ICON_ERROR="❌" @@ -15,16 +15,18 @@ ICON_SYNC="🔄" ICON_GEAR="⚙️" # OUTPUT -info() { echo "$ICON_INFO [INFO] $*"; } -warn() { echo "$ICON_WARN [WARN] $*"; } -error() { echo "$ICON_ERROR [ERROR] $*"; } -success() { echo "$ICON_SUCCESS [OK] $*"; } +info() { echo "$ICON_INFO [INFO] $*"; } +warn() { echo "$ICON_WARN [WARN] $*"; } +error() { echo "$ICON_ERROR [ERROR] $*"; } +success() { echo "$ICON_SUCCESS [OK] $*"; } log() { [[ "${ENABLE_LOGGING:-false}" == true ]] && echo "[LOG] $*" } +# ----------------------------------------------------------------------------------------------- # ARG PARSER +# ----------------------------------------------------------------------------------------------- parse_args() { ENABLE_LOGGING=${ENABLE_LOGGING:-false} DRY_RUN=${DRY_RUN:-false} @@ -39,7 +41,7 @@ parse_args() { case "$VAR" in LOG) - [[ "$VAL" == "true" ]] && ENABLE_LOGGING=true + [[ "$VAL" == "true" ]] && ENABLE_LOGGING=true [[ "$VAL" == "false" ]] && ENABLE_LOGGING=false ;; *) @@ -53,12 +55,12 @@ parse_args() { esac else case "$ARG" in - --dry-run|-n) DRY_RUN=true ;; - --log) ENABLE_LOGGING=true ;; - --no-log) ENABLE_LOGGING=false ;; + --dry-run|-n) DRY_RUN=true ;; + --log) ENABLE_LOGGING=true ;; + --no-log) ENABLE_LOGGING=false ;; --status|--summary) SHOW_STATUS=true ;; --help|-h) - echo "Usage: script [--dry-run] [--log]" + echo "Usage: script [--dry-run] [--log] [--status]" exit 0 ;; *) CLEAN_ARGS+=("$ARG") ;; @@ -69,12 +71,16 @@ parse_args() { PARSED_ARGS=("${CLEAN_ARGS[@]}") } +# ----------------------------------------------------------------------------------------------- # VALIDATION +# ----------------------------------------------------------------------------------------------- require_var() { [[ -z "${!1:-}" ]] && error "Missing required: $1" && exit 1 } +# ----------------------------------------------------------------------------------------------- # HOST DETECTION +# ----------------------------------------------------------------------------------------------- detect_hosts() { LOCAL_HOSTNAME="$(hostname)" @@ -100,33 +106,45 @@ detect_hosts() { info "Host: $LOCAL_SERVER_NAME → $REMOTE_SERVER_NAME" } -# REMOTE +# ----------------------------------------------------------------------------------------------- +# REMOTE IP +# ----------------------------------------------------------------------------------------------- resolve_remote_ip() { log "Resolving remote IP..." REMOTE_SERVER=$(tailscale ip -4 "$REMOTE_SERVER_NAME" 2>/dev/null) - [[ -z "$REMOTE_SERVER" ]] && error "Failed to resolve remote IP" && exit 1 + [[ -z "$REMOTE_SERVER" ]] && error "Failed to resolve remote IP for $REMOTE_SERVER_NAME" && exit 1 info "Remote IP: $REMOTE_SERVER" } +# ----------------------------------------------------------------------------------------------- # CONTAINERS +# CRITICAL_CONTAINER_NAMES and DELAYED_CONTAINERS must be bash arrays before calling these. +# rsync.sh handles the read -r -a conversion from profile strings. +# ----------------------------------------------------------------------------------------------- RUNNING_CONTAINERS=() stop_containers() { - [[ ${#CRITICAL_CONTAINER_NAMES[@]} -eq 0 ]] && return + if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -eq 0 ]] || \ + [[ "${CRITICAL_CONTAINER_NAMES[*]}" == "" ]]; then + log "No containers configured for this profile, skipping stop." + return + fi info "Stopping containers..." RUNNING_CONTAINERS=() for c in "${CRITICAL_CONTAINER_NAMES[@]}"; do - info "Checking container: $c" + [[ -z "$c" ]] && continue + + log "Checking container: $c" STATUS=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \ - "docker inspect -f '{{.State.Running}}' $c 2>/dev/null" || echo "false") + "docker inspect -f '{{.State.Running}}' $c 2>/dev/null" 2>/dev/null || echo "false") if [[ "$STATUS" == "true" ]]; then - warn "Stopping $c" + warn "$ICON_STOP Stopping $c" RUNNING_CONTAINERS+=("$c") if ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" "docker stop $c" >/dev/null; then @@ -135,19 +153,37 @@ stop_containers() { error "Failed to stop $c" fi else - log "$c already stopped" + log "$c already stopped or not found" fi done } start_containers() { - [[ ${#RUNNING_CONTAINERS[@]} -eq 0 ]] && return + if [[ ${#RUNNING_CONTAINERS[@]} -eq 0 ]]; then + log "No containers to restart." + return + fi info "Starting containers..." for c in "${RUNNING_CONTAINERS[@]}"; do - info "Starting $c" + [[ -z "$c" ]] && continue + # Check if this container needs a delay before starting + local needs_delay=false + for d in "${DELAYED_CONTAINERS[@]}"; do + if [[ "$c" == "$d" ]]; then + needs_delay=true + break + fi + done + + if [[ "$needs_delay" == true ]]; then + info "Delaying start of $c by ${CONTAINER_DELAY}s..." + sleep "$CONTAINER_DELAY" + fi + + info "Starting $c" if ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" "docker start $c" >/dev/null; then success "$c started" else @@ -156,49 +192,56 @@ start_containers() { done } -# RSYNC LIMITER +# ----------------------------------------------------------------------------------------------- +# RSYNC SLOT LIMITER +# ----------------------------------------------------------------------------------------------- wait_for_rsync() { : "${RETRY_COUNT:=5}" : "${MAX_RSYNC_PROCS:=2}" : "${SLEEP:=2}" for i in $(seq 1 "$RETRY_COUNT"); do - - COUNT=$(ps -eo pid,cmd | grep "[r]sync .*${REMOTE_SERVER:-}" | wc -l || true) - - if [[ -z "$REMOTE_SERVER" ]]; then - COUNT=$(pgrep -x rsync | wc -l || true) - fi + COUNT=$(ps -eo cmd | grep "[r]sync .*${REMOTE_SERVER}" | wc -l || true) if [[ "$COUNT" -ge "$MAX_RSYNC_PROCS" ]]; then - warn "Waiting rsync slot ($COUNT/$MAX_RSYNC_PROCS)" + warn "$ICON_RETRY Waiting for rsync slot ($COUNT/$MAX_RSYNC_PROCS) — attempt $i/$RETRY_COUNT" sleep "$SLEEP" else + log "Rsync slot available ($COUNT/$MAX_RSYNC_PROCS)" return 0 fi done - error "Too many rsync processes" + error "Too many rsync processes after $RETRY_COUNT attempts, aborting." exit 1 } +# ----------------------------------------------------------------------------------------------- # RSYNC OPTIONS +# ----------------------------------------------------------------------------------------------- get_rsync_opts() { if [[ -n "${PROFILE_RSYNC_OPTS[$PROFILE_NAME]:-}" ]]; then read -r -a RSYNC_OPTS <<< "${PROFILE_RSYNC_OPTS[$PROFILE_NAME]}" + log "Using profile rsync opts for $PROFILE_NAME: ${RSYNC_OPTS[*]}" else RSYNC_OPTS=("${DEFAULT_RSYNC_OPTS[@]}") + log "Using default rsync opts: ${RSYNC_OPTS[*]}" fi } +# ----------------------------------------------------------------------------------------------- # STATUS +# ----------------------------------------------------------------------------------------------- show_status() { echo "===== STATUS =====" - echo "Local: $LOCAL_SERVER_NAME" - echo "Remote: $REMOTE_SERVER_NAME" - echo "IP: $REMOTE_SERVER" - echo "Profile: $PROFILE_NAME" - echo "DryRun: $DRY_RUN" - echo "Logging: $ENABLE_LOGGING" + echo "Local: $LOCAL_SERVER_NAME" + echo "Remote: $REMOTE_SERVER_NAME" + echo "IP: $REMOTE_SERVER" + echo "Profile: $PROFILE_NAME" + echo "DryRun: $DRY_RUN" + echo "Logging: $ENABLE_LOGGING" + echo "Containers: ${CRITICAL_CONTAINER_NAMES[*]}" + echo "Delayed: ${DELAYED_CONTAINERS[*]}" + echo "Excludes: ${EXCLUDE_DIRS[*]}" echo "==================" } \ No newline at end of file