Files
Varaverk/Rsync/rsync.sh
T
Gmer4Lfe 3f5ad22632 Only apply --delete on merge-run when the pull pass completed
A capped or failed pull leaves the remote holding content the local never received, so
deleting against it destroys the only copy.
2026-08-01 20:37:58 -04:00

656 lines
31 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ============================= Rsync Core Script ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Core rsync engine for the two-server ecosystem. Called per share or per
# appdata profile by orchestrators (daily_sync_maintenance, weekly_sync_maintenance,
# critical_sync_maintenance) and directly for manual or scheduled dirty syncs.
#
# Profile is inferred from the directory basename (lowercased). Override with
# --profile=name for explicit selection. If no profile matches, global defaults
# from master.conf apply and no containers are stopped.
#
# After each sync, logs transfer data to bandwidth_monitor.sh for the weekly
# bandwidth report. Silent on success — only failures produce visible output.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Profiles define per-share behavior:
# PROFILE_CRITICAL_CONTAINER_NAMES — containers stopped on both servers before sync
# PROFILE_DELAYED_CONTAINERS — containers with a delay before restart after sync
# PROFILE_CONTAINER_DELAY — seconds before delayed containers start
# PROFILE_RSYNC_OPTS — rsync flags (does not inherit DEFAULT_RSYNC_OPTS)
# PROFILE_BW_LIMIT — bandwidth limit in KB/s
# PROFILE_RETRY_COUNT — retry attempts on failure
# PROFILE_SLEEP — seconds between retry attempts
# PROFILE_EXCLUDE_DIRS — paths excluded from transfer
# PROFILE_REMOTE_RESTART_CONTAINERS — containers restarted on remote after dirty sync
# Was running → restart. Was stopped → leave stopped.
#
# Two-tier rsync enable/disable:
# Tier 1: RSYNC_ENABLED=false → all rsync stops immediately (checked by this script)
# Tier 2: per-orchestrator flag (DAILY_RSYNC_ENABLED etc.) → checked by caller
#
# Bandwidth logging: after each sync, logs profile/duration/status/bytes to
# bandwidth_monitor.sh --log-transfer. Bytes captured from rsync --stats via awk
# using version-stable field names.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Deletion Is Opt-In, Never Inherited
# DEFAULT_RSYNC_OPTS deliberately omits --delete. Media shares only ever gain files
# here; the arr cleanup scripts own deletion and are the only things that understand
# whether a file is genuinely orphaned. --delete appears in exactly two places: a
# profile that sets it explicitly, and the push pass of --merge-run.
#
# Profiles Replace, Not Extend
# PROFILE_RSYNC_OPTS does not inherit DEFAULT_RSYNC_OPTS. A profile states its full
# flag set, so reading one profile tells you exactly what will run — no tracing
# through a base list to discover an inherited --delete.
#
# Local Is Authoritative
# In --merge-run the remote may contribute content the local lacks, but never a
# competing version. Pass 1 pulls only with --ignore-existing, so the push in pass 2
# can safely be authoritative. Reversing that order would let the remote overwrite
# local files before the delete pass.
#
# Pre-flight Before Payload
# Connectivity, version parity, remote rootfs, remote share, remote disks and drive
# temperature are all checked before a byte moves. A transfer aborted halfway is more
# expensive to reason about than one that never started.
#
# Bounded, Resumable Transfers
# No single attempt may exceed RSYNC_MAX_RUNTIME_HOURS. This is safe only because
# --partial is in the default opts: a terminated transfer resumes rather than
# restarting, so bounding it costs nothing and prevents one huge or stuck transfer
# from holding its lock indefinitely and starving every other profile of a turn.
#
# Silent on Success
# Only failures produce visible output. A quiet run is a successful one.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# rsync over SSH as root and container stop/start both require it.
#
# Docker Presence Check
# Verified before any profile container operations.
#
# Source Path Depth Guard
# The source must be an absolute path at least three levels deep. It is pushed to
# root@remote at the same absolute path and --merge-run adds --delete, so a truncated
# argument is a remote-side hazard: /mnt/user would sync every share at once.
#
# Source Existence Check
# The local directory must exist. Without this, --merge-run's pull pass would create
# a mistyped directory, populate it from the remote, then push it back with --delete.
#
# Global Rsync Gate
# check_rsync_enabled() — RSYNC_ENABLED=false exits cleanly before any operation.
#
# Partnership Blocklist
# Refuses to sync if REMOTE_SERVER_NAME appears in the partnership blocklist.
# Written at offboard — prevents stale access after a partnership ends.
#
# Version Parity
# check_os_version_parity — refuses sync if servers on incompatible unRAID versions.
#
# Remote Health Pre-flights
# check_connectivity() — Tailscale IP reachable before any SSH
# check_remote_rootfs() — aborts if remote rootfs exceeds ROOTFS_WARN
# check_remote_share() — aborts if target directory missing or empty on remote
# check_remote_disks() — verifies all backing disks online on remote
#
# Drive Temperature Check
# check_local_disk_temps() — runs before any transfer. Exit 1 = skip this profile,
# exit 2 = abort all remaining syncs (CRITICAL temperature).
#
# Remote Docker Daemon Check
# check_remote_docker_daemon — verified before any container stop/start operations.
# If daemon unresponsive: container operations skipped, rsync proceeds without stopping.
#
# Per-Profile Concurrency Lock
# acquire_rsync_lock() — per-profile lock prevents parallel runs of the same profile.
# Global concurrent limit prevents too many simultaneous rsync processes.
#
# Runtime Ceiling
# RSYNC_MAX_RUNTIME_HOURS terminates a single attempt that overruns, releasing the
# per-profile lock for the next scheduled run. --partial in the default opts means the
# paused transfer resumes rather than restarting from scratch.
#
# Merge-Run Ordering
# Pass 1 pulls remote-unique content with --ignore-existing before pass 2 pushes with
# --delete. Content the remote had and the local did not is preserved locally before
# anything is deleted remotely.
#
# Merge-Run Delete Interlock
# --delete is applied only when pass 1 completed. Its entire justification is that the
# local is now the authoritative superset, and a capped or failed pull means it is not:
# the remote still holds content the local never received. On an incomplete pull the
# push proceeds without --delete and notifies, so local content still propagates while
# nothing remote-unique is destroyed. The delete happens on a later run whose pull
# succeeded.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# RSYNC_ENABLED
# Global on/off toggle for all rsync operations. (default: true)
#
# DEFAULT_RSYNC_OPTS
# Base rsync flags for unproiled shares. Note: --delete is intentionally absent —
# media shares spread files only, arr cleanup scripts own deletions. Profile-specific
# opts set --delete explicitly where needed.
#
# BW_LIMIT
# Default bandwidth cap in KB/s when no PROFILE_BW_LIMIT is set. (default: 0 = unlimited)
#
# RETRY_COUNT
# Default retry attempts on rsync failure. (default: 3)
#
# SLEEP
# Default seconds between retry attempts. (default: 60)
#
# RSYNC_MAX_RUNTIME_HOURS
# Max hours a single transfer attempt may run before it's terminated and paused
# for the next scheduled run. Protects the per-profile lock from being held
# indefinitely by one huge/stuck transfer, starving other profiles of a turn.
# Safe because DEFAULT_RSYNC_OPTS includes --partial — a paused transfer resumes
# from where it left off, not from scratch. (default: 23)
#
# ROOTFS_WARN
# Abort threshold for remote rootfs percentage full. (default: 75)
#
# PROFILES["profile_KEY"]
# Profile definitions — one entry per PROFILE_* key per profile name.
# See OPERATIONAL MODEL above for all supported keys.
#
# BANDWIDTH_LOG / BANDWIDTH_WARN_GB
# Shared with bandwidth_monitor.sh — set once, used by both.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# rsync.sh /path/to/share
# Sync the given path to the remote. Profile inferred from directory basename.
#
# rsync.sh /path/to/share --profile=name
# Sync with explicit profile override — bypasses basename inference.
#
# rsync.sh /path/to/share --dry-run
# Run all pre-flight checks and show what rsync would transfer. No transfer,
# no container stops.
#
# rsync.sh /path/to/share --status
# Show resolved profile, remote identity, and configuration. Then exit.
#
# rsync.sh /path/to/share --log
# Verbose output throughout — every decision logged.
#
# rsync.sh /path/to/share --seed
# Skip the empty-remote-share guard. Use for first-time seeding of a new share.
#
# rsync.sh /path/to/share --merge-run
# Bidirectional merge: pull remote-unique content to local first (--ignore-existing),
# then push local → remote with --delete so the remote matches local exactly.
# Local is always authoritative — remote loses divergent file versions but keeps
# any content the local did not have (pulled in pass 1 before the delete push).
# Also auto-triggered when pre-scan detects ≥75% directory overlap with remote.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Separate positional directory arg from flags ───────────────────────────────────────────────
DIRECTORY=""
PROFILE_OVERRIDE=""
SEED=false
MERGE_RUN=false
RAW_ARGS=()
for ARG in "$@"; do
case "$ARG" in
--profile=*) PROFILE_OVERRIDE="${ARG#--profile=}" ;;
--seed) SEED=true ;;
--merge-run) MERGE_RUN=true ;;
--*|*=*) RAW_ARGS+=("$ARG") ;;
*) [[ -z "$DIRECTORY" ]] && DIRECTORY="$ARG" || RAW_ARGS+=("$ARG") ;;
esac
done
parse_args "${RAW_ARGS[@]}"
[[ -z "$DIRECTORY" ]] && {
error "No directory specified"
error "Usage: rsync.sh <dir> [--dry-run] [--log] [--profile=name]"
exit 1
}
# ── Source path guards ────────────────────────────────────────────────────────────────────────
# This script pushes to root@remote at the same absolute path, and --merge-run adds --delete on
# the push pass. A truncated or mistyped source is therefore a remote-side data hazard, not just
# a local no-op: /mnt/user would sync every share at once, and / would target the filesystem
# root. Every real job path (see HOST*_*_SYNC_SHARES) is at least three levels deep.
_rsync_depth="${DIRECTORY//[^\/]/}"
if [[ "$DIRECTORY" != /* || "${#_rsync_depth}" -lt 3 ]]; then
error "Refusing unsafe source path: '$DIRECTORY' — expected an absolute path at least 3 levels deep"
exit 1
fi
unset _rsync_depth
# Without this, --merge-run's pull pass would create a mistyped local directory, fill it with
# remote content, then push it back with --delete.
if [[ ! -d "$DIRECTORY" ]]; then
error "Source directory does not exist locally: $DIRECTORY"
exit 1
fi
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
require_partnership
# Tier 1 global gate — Tier 2 (per-orchestrator) checked by caller
if ! check_rsync_enabled; then
warn "RSYNC_ENABLED=false — exiting cleanly"
exit 0
fi
# Blocklist gate — refuse to sync with a partner blocked after offboard
BLOCKLIST_FILE="${PARTNERSHIP_BLOCKLIST_FILE:-${STATE_DIR}/partnership_blocklist.db}"
if [[ -f "$BLOCKLIST_FILE" ]] && grep -q "^${REMOTE_SERVER_NAME}|" "$BLOCKLIST_FILE" 2>/dev/null; then
error "Rsync blocked — $REMOTE_SERVER_NAME is on the partnership blocklist"
error "Re-onboard the partnership to restore access: partnership_manager.sh --onboard"
exit 1
fi
resolve_remote_ip
# ── Profile inference ─────────────────────────────────────────────────────────────────────────
if [[ -n "$PROFILE_OVERRIDE" ]]; then
PROFILE_NAME="$PROFILE_OVERRIDE"
log "Profile override: $PROFILE_NAME"
else
PROFILE_NAME=$(basename "$DIRECTORY" | tr '[:upper:]' '[:lower:]')
log "Profile inferred: $PROFILE_NAME"
fi
# Acquire per-profile lock and check global concurrent limit
acquire_rsync_lock "$PROFILE_NAME"
# Tee all output to a live log file for the Varaverk UI
VV_LIVE_LOG="/tmp/unraid_locks/rsync_${PROFILE_NAME}.log"
VV_LAST_LOG="/tmp/unraid_locks/rsync_${PROFILE_NAME}.last.log"
: > "$VV_LIVE_LOG"
exec 1> >(tee -a "$VV_LIVE_LOG") 2>&1
# ── Load profile settings ─────────────────────────────────────────────────────────────────────
# NOTE: DEFAULT_RSYNC_OPTS and PROFILE_RSYNC_OPTS are bash arrays defined in master.conf —
# any "$BW_LIMIT" inside them is expanded once, when master.conf is sourced, before this
# override runs. So this recomputed $BW_LIMIT only affects the log line below and --status;
# it does NOT change the actual rsync --bwlimit unless a profile also bakes its own
# --bwlimit="${PROFILE_BW_LIMIT[name]}" directly into that profile's PROFILE_RSYNC_OPTS entry.
BW_LIMIT=${PROFILE_BW_LIMIT[$PROFILE_NAME]:-$BW_LIMIT}
RETRY_COUNT=${PROFILE_RETRY_COUNT[$PROFILE_NAME]:-$RETRY_COUNT}
SLEEP=${PROFILE_SLEEP[$PROFILE_NAME]:-$SLEEP}
CONTAINER_DELAY=${PROFILE_CONTAINER_DELAY[$PROFILE_NAME]:-$CONTAINER_DELAY}
RSYNC_MAX_RUNTIME_HOURS=${RSYNC_MAX_RUNTIME_HOURS:-23}
RSYNC_MAX_RUNTIME_SECONDS=$(( RSYNC_MAX_RUNTIME_HOURS * 3600 ))
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]:-}"
read -r -a REMOTE_RESTART_CONTAINERS <<< "${PROFILE_REMOTE_RESTART_CONTAINERS[$PROFILE_NAME]:-}"
# Local containers use same names as remote (mirrored naming scheme)
LOCAL_CRITICAL_CONTAINER_NAMES=("${CRITICAL_CONTAINER_NAMES[@]}")
log "$ICON_GEAR Config: profile=${PROFILE_NAME} bw-limit=${BW_LIMIT}KB/s retry=${RETRY_COUNT} sleep=${SLEEP}s container-delay=${CONTAINER_DELAY}s"
log "$ICON_GEAR Containers: critical=${CRITICAL_CONTAINER_NAMES[*]:-none} delayed=${DELAYED_CONTAINERS[*]:-none} remote-restart=${REMOTE_RESTART_CONTAINERS[*]:-none}"
[[ "$SHOW_STATUS" == true ]] && show_status && exit 0
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Pre-flight Checks ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
# Disk temp — before touching remote or moving data
# Exit 1 = skip this profile | Exit 2 = abort all remaining profiles
check_local_disk_temps
TEMP_RESULT=$?
if [[ "$TEMP_RESULT" -eq 2 ]]; then
error "Drive temps CRITICAL — aborting all remaining syncs"
exit 2
elif [[ "$TEMP_RESULT" -eq 1 ]]; then
warn "Drive temps high — skipping profile [$PROFILE_NAME]"
exit 1
else
log "Drive temps OK — $TEMP_CHECK_RESULT"
fi
# Version parity — refuse if servers on incompatible unRAID versions
check_os_version_parity || exit 1
check_connectivity
check_remote_rootfs
[[ "$SEED" == false ]] && check_remote_share "$DIRECTORY"
check_remote_disks "$DIRECTORY"
# ── Merge-run pre-scan ────────────────────────────────────────────────────────────────────────
# Auto-promote to merge mode when ≥75% of remote's top-level entries exist locally.
# Skipped when: --merge-run is already set, --seed is active, a named profile is resolved,
# or RSYNC_MERGE_ENABLED=false in master.conf.
if [[ "$MERGE_RUN" == false && "$SEED" == false \
&& "${RSYNC_MERGE_ENABLED:-true}" != "false" \
&& -z "${PROFILE_RSYNC_OPTS[$PROFILE_NAME]+x}" ]]; then
_remote_top=$(ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
root@"$REMOTE_SERVER" "ls -1A '$DIRECTORY' 2>/dev/null | sort" 2>/dev/null)
_remote_count=$(echo "$_remote_top" | grep -c .)
_remote_count=${_remote_count:-0}
if [[ "$_remote_count" -gt 0 ]]; then
_local_top=$(ls -1A "$DIRECTORY" 2>/dev/null | sort)
_overlap=$(comm -12 \
<(echo "$_local_top") \
<(echo "$_remote_top") | grep -c .)
_overlap=${_overlap:-0}
_overlap_pct=$(( _overlap * 100 / _remote_count ))
if [[ "$_overlap_pct" -ge 75 ]]; then
info "Overlap ${_overlap_pct}% (${_overlap}/${_remote_count} entries) — auto-promoting to merge mode"
MERGE_RUN=true
else
log "Overlap ${_overlap_pct}% (${_overlap}/${_remote_count} entries) — normal sync"
fi
fi
unset _remote_top _remote_count _local_top _overlap _overlap_pct
fi
# Remote Docker daemon — check before attempting container operations
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -gt 0 ]] || [[ ${#REMOTE_RESTART_CONTAINERS[@]} -gt 0 ]]; then
check_remote_docker_daemon || {
warn "Remote Docker daemon unresponsive — skipping container operations"
warn "Proceeding with rsync only — containers will not be stopped or restarted"
CRITICAL_CONTAINER_NAMES=()
LOCAL_CRITICAL_CONTAINER_NAMES=()
REMOTE_RESTART_CONTAINERS=()
}
fi
# ==============================================================================================
# ━━━ Stop Containers ━━━
# ==============================================================================================
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -gt 0 || ${#LOCAL_CRITICAL_CONTAINER_NAMES[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_STOP $ICON_CONTAINERS Stop Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
for c in "${LOCAL_CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would stop local: $c"
done
for c in "${CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would stop remote: $c"
done
else
# Local first — flush local databases before pushing
stop_local_containers
# Remote next — prevent writes while receiving
stop_containers
fi
fi
# ==============================================================================================
# ━━━ Transfer ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Transfer ━━━"
echo "$ICON_RUN Source: $DIRECTORY"
echo "$ICON_NET Remote: $REMOTE_SERVER:$DIRECTORY"
echo "$ICON_GEAR Profile: $PROFILE_NAME"
echo "$ICON_HOST Identity: $MY_ID$REMOTE_ID"
echo ""
get_rsync_opts
# Append profile excludes
for ex in "${EXCLUDE_DIRS[@]:-}"; do
[[ -n "$ex" ]] && RSYNC_OPTS+=(--exclude="$ex")
done
# Add --stats to capture bytes transferred for bandwidth logging
RSYNC_OPTS+=(--stats)
[[ "$DRY_RUN" == true ]] && RSYNC_OPTS+=("--dry-run")
# ── Merge pass 1: pull remote-unique content to local (--ignore-existing) ────────────────────
# Runs before the push so anything the remote has that we don't is preserved locally.
# After this pass, local is the superset — the delete push in pass 2 is then safe.
if [[ "$MERGE_RUN" == true ]]; then
echo "$ICON_SYNC Merge pass 1 — pulling ${REMOTE_SERVER_NAME}-unique content to local (capped at ${RSYNC_MAX_RUNTIME_HOURS}h)..."
_pull_opts=(-av --ignore-existing --stats)
[[ "$DRY_RUN" == true ]] && _pull_opts+=(--dry-run)
_pull_output=$(timeout "${RSYNC_MAX_RUNTIME_SECONDS}s" rsync "${_pull_opts[@]}" \
-e "ssh -i $SSH_KEY -T -o Compression=no -o IPQoS=throughput" \
"root@${REMOTE_SERVER}:${DIRECTORY}/" "${DIRECTORY}/" 2>&1)
_pull_exit=$?
_pull_bytes=$(echo "$_pull_output" | \
awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}')
_pull_complete=false
if [[ "$_pull_exit" -eq 0 ]]; then
echo "$ICON_DONE Merge pass 1 complete — ${_pull_bytes:-0} bytes pulled"
_pull_complete=true
elif [[ "$_pull_exit" -eq 124 ]]; then
# Same cap as the main push — never let one direction of the merge run unbounded.
# Files already completed before the timeout stay pulled; next scheduled run
# picks up whatever's still remote-unique (no --partial here, so an in-flight
# file at the moment of the kill is discarded, not corrupted).
warn "Merge pass 1 exceeded ${RSYNC_MAX_RUNTIME_HOURS}h cap — pull incomplete"
else
warn "Merge pass 1 failed (exit $_pull_exit)"
echo "$_pull_output" | grep -iE "error|rsync:|permission denied" | while read -r _line; do
warn " $_line"
done
fi
# Pass 2: push with --delete — but ONLY if pass 1 actually finished.
# --delete is justified solely by "local is now the authoritative superset", and that
# premise holds only on a complete pull. If pass 1 was capped or failed, the remote still
# holds content the local never received; deleting it here would destroy it permanently
# and make the "resume the pull next run" promise impossible to keep. Push without
# --delete instead — local content still propagates, nothing remote-unique is lost, and
# the delete happens on a later run whose pull completed.
if [[ "$_pull_complete" == true ]]; then
RSYNC_OPTS+=(--delete)
else
warn "Skipping --delete this run — pass 1 did not complete, remote may hold content the local lacks"
warn "Local content still pushes; the delete pass runs once a full pull succeeds"
notify "Merge run on $(hostname) pushed without --delete — pass 1 incomplete for $PROFILE_NAME" \
"Rsync" "warning"
fi
unset _pull_opts _pull_output _pull_exit _pull_bytes _pull_complete
fi
START=$(date +%s)
RSYNC_SUCCESS=false
RSYNC_TIMED_OUT=false
BYTES_TRANSFERRED=0
ATTEMPT=0
for (( ATTEMPT=1; ATTEMPT<=RETRY_COUNT; ATTEMPT++ )); do
log "$ICON_RETRY Attempt $ATTEMPT of $RETRY_COUNT..."
echo "$ICON_SYNC Rsync running — this may take a while (capped at ${RSYNC_MAX_RUNTIME_HOURS}h)..."
RSYNC_OUTPUT=$(timeout "${RSYNC_MAX_RUNTIME_SECONDS}s" rsync "${RSYNC_OPTS[@]}" \
-e "ssh -i $SSH_KEY -T -o Compression=no -o IPQoS=throughput" \
"$DIRECTORY" "root@${REMOTE_SERVER}:$(dirname "$DIRECTORY")/" 2>&1)
RSYNC_EXIT=$?
if [[ "$RSYNC_EXIT" -eq 0 ]]; then
# Parse bytes transferred from --stats output
BYTES_TRANSFERRED=$(echo "$RSYNC_OUTPUT" | \
awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}')
BYTES_TRANSFERRED="${BYTES_TRANSFERRED:-0}"
echo "$ICON_DONE Rsync complete — $BYTES_TRANSFERRED bytes transferred"
RSYNC_SUCCESS=true
break
elif [[ "$RSYNC_EXIT" -eq 124 ]]; then
# Hit the runtime cap, not a failure — --partial means next run resumes from here.
# No retry: retrying an already-huge transfer 2 more times just wastes the window.
warn "$ICON_RETRY Rsync exceeded ${RSYNC_MAX_RUNTIME_HOURS}h cap — pausing, will resume next scheduled run"
RSYNC_TIMED_OUT=true
break
else
warn "$ICON_RETRY Rsync failed (attempt $ATTEMPT/$RETRY_COUNT)"
log "Exit code: $RSYNC_EXIT"
if [[ "$ATTEMPT" -lt "$RETRY_COUNT" ]]; then
log "Retrying in ${SLEEP}s..."
sleep "$SLEEP"
fi
fi
done
# ==============================================================================================
# ━━━ Start Containers ━━━
# ==============================================================================================
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -gt 0 || ${#LOCAL_CRITICAL_CONTAINER_NAMES[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_START $ICON_CONTAINERS Start Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
for c in "${CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would start remote: $c"
done
for c in "${LOCAL_CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would start local: $c"
done
else
# Remote first — can be coming up while local restarts
start_containers
# Local next
start_local_containers
fi
fi
# ==============================================================================================
# ━━━ Remote Restart (dirty sync profiles) ━━━
# ==============================================================================================
# For dirty sync profiles (critical-fallback) — restart containers on remote
# that were running before sync so they pick up config changes from the dirty sync window.
# Was running → restart. Was stopped → leave stopped.
if [[ ${#REMOTE_RESTART_CONTAINERS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_START $ICON_CONTAINERS Remote Restart (post dirty sync) ━━━"
log "Restarting configured containers on $REMOTE_SERVER_NAME..."
for container in "${REMOTE_RESTART_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
# Check if container was running before sync (still tracked via RUNNING_CONTAINERS)
WAS_RUNNING=false
for prev in "${RUNNING_CONTAINERS[@]:-}"; do
[[ "$prev" == "$container" ]] && WAS_RUNNING=true && break
done
if [[ "$WAS_RUNNING" == false ]]; then
# Not in stop list — check current remote state
REMOTE_STATUS=$(timeout 15 ssh -i "$SSH_KEY" \
-o ConnectTimeout=10 root@"$REMOTE_SERVER" \
"docker inspect -f '{{.State.Running}}' $container 2>/dev/null" 2>/dev/null)
[[ "$REMOTE_STATUS" != "true" ]] && \
log "$container not running on $REMOTE_SERVER_NAME — skipping remote restart" && \
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart $container on $REMOTE_SERVER_NAME"
continue
fi
timeout 15 ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \
"docker restart $container" >/dev/null 2>&1 && \
echo "$ICON_STARTED $container restarted on $REMOTE_SERVER_NAME ✅" || \
warn "Failed to restart $container on $REMOTE_SERVER_NAME"
done
fi
END=$(date +%s)
DURATION=$(( END - START ))
# ==============================================================================================
# ━━━ Bandwidth Logging ━━━
# ==============================================================================================
# Logs to bandwidth_monitor.sh — new format includes bytes transferred and warn flag.
# Only logs on actual runs (not dry-run) and only when bandwidth_monitor.sh exists.
BANDWIDTH_MONITOR="$SCRIPT_DIR/../Monitors/bandwidth_monitor.sh"
if [[ "$DRY_RUN" == false ]] && [[ -f "$BANDWIDTH_MONITOR" ]]; then
STATUS="success"
[[ "$RSYNC_SUCCESS" == false ]] && STATUS="failed"
[[ "$RSYNC_TIMED_OUT" == true ]] && STATUS="timeout"
bash "$BANDWIDTH_MONITOR" --log-transfer \
"$PROFILE_NAME" "$DURATION" "$STATUS" "$BYTES_TRANSFERRED"
log "$ICON_BANDWIDTH Transfer logged to bandwidth monitor ($BYTES_TRANSFERRED bytes)"
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RSYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RUN Directory: $DIRECTORY"
echo "$ICON_GEAR Profile: $PROFILE_NAME"
echo "$ICON_TIME Duration: $(format_duration $DURATION)"
[[ "$BYTES_TRANSFERRED" -gt 0 ]] && \
echo "$ICON_BANDWIDTH Transferred: $(awk "BEGIN {printf \"%.2fGB\", $BYTES_TRANSFERRED / 1073741824}")"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$RSYNC_SUCCESS" == true ]]; then
echo "$ICON_DONE Status: $ICON_SUCCESS DONE"
elif [[ "$RSYNC_TIMED_OUT" == true ]]; then
echo "$ICON_DONE Status: PAUSED — exceeded ${RSYNC_MAX_RUNTIME_HOURS}h cap, resumes next scheduled run"
else
echo "$ICON_ERROR Status: FAILED after $RETRY_COUNT attempts"
notify "Rsync FAILED — $DIRECTORY ($PROFILE_NAME) after $RETRY_COUNT attempts on $(hostname)" \
"Rsync" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
# Flush tee and preserve log for UI — close both ends of the pipe so tee gets EOF
exec 1>&- 2>&-; wait
cp "$VV_LIVE_LOG" "$VV_LAST_LOG" 2>/dev/null
rm -f "$VV_LIVE_LOG"
[[ "$RSYNC_SUCCESS" == false ]] && [[ "$RSYNC_TIMED_OUT" == false ]] && [[ "$DRY_RUN" == false ]] && exit 1
exit 0