Finishes the pass: every script now documents its safeguards, and the deliberate absences in the sourced libraries are recorded so they are not "corrected" later.
3135 lines
147 KiB
Bash
Executable File
3135 lines
147 KiB
Bash
Executable File
#!/bin/bash
|
||
# ==============================================================================================
|
||
# ================================= COMMON LIBRARY =============================================
|
||
# ==============================================================================================
|
||
#
|
||
# PURPOSE
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Shared functions used by every script in the ecosystem.
|
||
# Sourced automatically by load_config.sh — do not source directly.
|
||
#
|
||
# ==============================================================================================
|
||
# OPERATIONAL MODEL
|
||
# ==============================================================================================
|
||
#
|
||
# A pure function library. It defines and returns; it starts nothing on its own.
|
||
#
|
||
# Load order matters and is owned by load_config.sh:
|
||
# varaverk.cfg → master.conf → host*.conf → common.sh → Plugin/$PLATFORM/adapter.sh
|
||
#
|
||
# common.sh is sourced AFTER the conf files because detect_hosts() and the health checks
|
||
# need HOST* and the thresholds to already exist. It is sourced BEFORE the adapter so the
|
||
# adapter can rely on log()/warn()/error() being defined.
|
||
#
|
||
# Note it defines detect_hosts() but never calls it. MY_ID and REMOTE_ID stay unset until a
|
||
# script calls it itself — anything building HOST*-prefixed variable names must do so after
|
||
# that call, not before.
|
||
#
|
||
# ==============================================================================================
|
||
# DESIGN PRINCIPLES
|
||
# ==============================================================================================
|
||
#
|
||
# Define, Never Act
|
||
# Sourcing this file changes no system state: no containers touched, no files written, no
|
||
# locks taken, no host detected. Every script in the ecosystem sources it, including
|
||
# read-only reporting ones, so anything that acted here would act everywhere.
|
||
#
|
||
# One Implementation of Each Shared Behaviour
|
||
# Locking, host detection, notification, retry and timeout wrapping live here once. When
|
||
# a safeguard needs strengthening it is strengthened for every caller at the same moment —
|
||
# which is why individual scripts call acquire_lock() rather than rolling their own flock.
|
||
#
|
||
# Platform-Agnostic
|
||
# No OS-specific paths or commands. Anything Unraid-specific belongs in the adapter, which
|
||
# is why this file calls platform_*() rather than touching /etc/rc.d or /boot directly.
|
||
#
|
||
# Callers Own Policy
|
||
# Helpers return status; they do not decide what failure means. retry_docker() reports
|
||
# that a command failed after N attempts — whether that aborts a run, skips an item, or
|
||
# raises a notification is the caller's call, and only the caller has the context.
|
||
#
|
||
# ==============================================================================================
|
||
# OPERATIONAL SAFEGUARDS
|
||
# ==============================================================================================
|
||
#
|
||
# No Root, No Lock, No detect_hosts on Load — Deliberate
|
||
# This is a sourced library and must stay inert. A root check here would fire for every
|
||
# script including ones that legitimately do not need it; a lock would be taken on every
|
||
# source; calling detect_hosts() automatically would exit the caller on an unknown
|
||
# hostname before it had a chance to handle that itself. Do not add them.
|
||
#
|
||
# Safe Defaults on Every Threshold
|
||
# Helpers apply :- defaults (DOCKER_TIMEOUT, RESTART_VERIFY_WAIT, RETRY_COUNT and friends)
|
||
# so a conf missing a key degrades to a sane value rather than an empty string that would
|
||
# silently disable a timeout or a retry ceiling.
|
||
#
|
||
# Timeout Wrapping Provided Centrally
|
||
# docker_cmd() and the remote helpers wrap calls in timeout, so no caller has to remember
|
||
# to. A hung daemon or unreachable partner cannot stall a scheduled window.
|
||
#
|
||
# Locking Provided Centrally
|
||
# acquire_lock() implements strict, wait and continuous modes in one place, so every
|
||
# script gets identical semantics and a fix applies everywhere at once.
|
||
#
|
||
# Host Identity Is Exact-Match Only
|
||
# detect_hosts() matches hostname exactly, with a single explicit NetBIOS-truncation
|
||
# fallback that requires an unambiguous candidate. No fuzzy or similarity matching — a
|
||
# wrong host identity would alias the wrong credentials and paths into every script.
|
||
#
|
||
# ==============================================================================================
|
||
# CONFIGURATION
|
||
# ==============================================================================================
|
||
#
|
||
# Defines no config of its own. It consumes what load_config.sh has already sourced from
|
||
# master.conf and host*.conf, and applies defaults where a key may be absent.
|
||
#
|
||
# Consumed broadly: DOCKER_TIMEOUT, RESTART_VERIFY_WAIT, RETRY_COUNT, SLEEP, SSH_KEY,
|
||
# SSH_TIMEOUT, STATE_DIR, DATA_DIR, LOCK_DIR, NOTIFY_UNRAID, HOST*, and the RSYNC_*/
|
||
# PARTNERSHIP_* gates.
|
||
#
|
||
# ==============================================================================================
|
||
# RUNTIME MODES
|
||
# ==============================================================================================
|
||
#
|
||
# None — sourced, never executed:
|
||
#
|
||
# source "$SCRIPT_DIR/../load_config.sh" # which sources this file
|
||
#
|
||
# It parses no arguments of its own. parse_args() is defined here for callers to invoke,
|
||
# and --dry-run/--status/--log are honoured by the calling script, not by this file.
|
||
#
|
||
# ==============================================================================================
|
||
# ── WHAT THIS FILE PROVIDES ───────────────────────────────────────────────────────────────────
|
||
# Icons — consistent visual language across all script output
|
||
# Output helpers — info, warn, error, success, log, notify
|
||
# Arg parser — unified --dry-run, --log, --status, KEY=VALUE handling
|
||
# Validation — require_var, validate_int
|
||
# Host detection — detect_hosts() identifies local/remote server, sets all aliases
|
||
# Connectivity — resolve_remote_ip, check_connectivity, ping_remote, ping_internet
|
||
# Health checks — local/remote array, docker daemon, rootfs, share, disk temps
|
||
# Disk verification — check_remote_disks() — XFS, ZFS, cache pools, nested paths
|
||
# Container mgmt — stop/start local and remote containers with state tracking
|
||
# Rsync helpers — get_rsync_opts, check_rsync_enabled
|
||
# Locking — acquire_lock, acquire_rsync_lock — concurrent execution protection
|
||
# Path translation — translate_path() — container → host path for arr cleanup scripts
|
||
# Arr utilities — check_arr_version() — API version safety gate
|
||
# Status display — show_status() — runtime config dump on --status flag
|
||
#
|
||
# ── CHANGELOG ─────────────────────────────────────────────────────────────────────────────────
|
||
#
|
||
# Version What changed
|
||
# ───────────────────────────────────────────────────────────────────────────────────────────
|
||
# v1.0 Initial stable framework — output helpers, arg parser, host detection
|
||
#
|
||
# v1.1 format_duration() moved here from daily_sync_maintenance.sh for shared use
|
||
# SSH_KEY naming collision resolved — gitea key renamed GITEA_SSH_KEY in master.conf
|
||
#
|
||
# v1.2 check_connectivity() added — fatal ping with Tailscale hint on failure
|
||
# Function header comment blocks standardised across all functions
|
||
#
|
||
# v1.3 check_remote_rootfs() — aborts rsync if remote rootfs exceeds ROOTFS_WARN
|
||
# check_remote_share() — aborts if target directory missing or empty on remote
|
||
# Both protect against rsync running when remote array is down
|
||
#
|
||
# v1.4 check_remote_disks() — verifies all physical disks backing a share are mounted
|
||
# Auto-discovers disk layout at runtime from disks.ini — no config required
|
||
# Aborts if any disk backing the share is offline or unmounted
|
||
#
|
||
# v1.5-2.9 Icon set expanded progressively — each operation has its own distinct icon
|
||
# All function output updated to use correct icon per context
|
||
# notify() added — unRAID native + Discord webhook notifications
|
||
# Script locking system added — acquire_lock(), acquire_rsync_lock()
|
||
# translate_path() added — container → host path for arr cleanup scripts
|
||
# check_arr_version() added — API version safety gate before arr operations
|
||
# check_api() added — pre-flight API reachability check
|
||
# ping_remote(), ping_internet() — non-fatal ping for fallback use
|
||
# check_remote_array() — array health check
|
||
# get_unraid_temp_thresholds() — reads thresholds from dynamix.cfg
|
||
# check_local_disk_temps() — pre-rsync temp check with exit codes 0/1/2
|
||
# stop/start local containers added alongside existing remote variants
|
||
#
|
||
# v3.1 Three-file config split — master.conf + host1.conf + host2.conf
|
||
# load_config.sh introduced — auto-discovers all host*.conf files
|
||
# detect_hosts() rewritten — sets MY_ID/REMOTE_ID and aliases all HOST* vars
|
||
# All scripts now source load_config.sh instead of conf files directly
|
||
# Adding a new server = add host*.conf, zero script changes required
|
||
#
|
||
# v3.2 check_remote_disks() rewritten — three-tier detection:
|
||
# Tier 1: array disk paths (/mnt/disk*/sharename)
|
||
# Tier 2: ZFS pools with find -maxdepth 2 (catches nested cache paths)
|
||
# Tier 3: shfs fallback — verifies /mnt/user is mounted
|
||
# Fixes: nested paths like /mnt/cache/appdata-Fallback/Critical-Data
|
||
# Fixes: ZFS cache pools not detected when share is not at pool root
|
||
#
|
||
# v3.3 PROFILE_REMOTE_RESTART_CONTAINERS support added
|
||
# Dirty sync profiles (critical-fallback) restart remote
|
||
# containers after sync if they were running before — picks up config changes
|
||
# Was stopped → stays stopped. Was running → gets restarted. ✅
|
||
#
|
||
# v3.5 notify_emby_scan() added — triggers Emby "Clean Missing Files" task
|
||
# after arr cleanup scripts delete orphaned files
|
||
# Emby immediately removes ghost entries — no user-facing file-not-found errors
|
||
# Called by lidarr/sonarr/radarr_cleanup.sh when files are deleted
|
||
#
|
||
#
|
||
# Two new safety functions added:
|
||
# check_os_version_parity() — refuses remote ops on version mismatch
|
||
# reads OS version via platform_get_os_version() / platform_os_version_probe_cmd()
|
||
# major mismatch → abort | minor mismatch → configurable warn/abort
|
||
# check_remote_docker_daemon() — verifies remote Docker daemon before
|
||
# issuing any remote container commands — strike system → skip/retry/exit
|
||
#
|
||
# ── VERSION ───────────────────────────────────────────────────────────────────────────────────
|
||
# Current: v3.5
|
||
# ==============================================================================================
|
||
|
||
# Cron runs with a minimal PATH (/usr/bin:/bin) that omits /usr/local/sbin where tailscale,
|
||
# and other Unraid tools live. Prepend it here so every sourcing script finds them.
|
||
export PATH="/usr/local/sbin:/usr/local/bin:$PATH"
|
||
|
||
# ==============================================================================================
|
||
# ── ICONS ─────────────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Each icon has one job — do not reuse across different contexts.
|
||
# Adding a new icon: add to the appropriate group below with a comment describing its job.
|
||
|
||
# System / Host
|
||
ICON_HOST="🖥️" # host detection and identity
|
||
ICON_NET="🌐" # network / IP resolution
|
||
ICON_PING="📡" # connectivity check
|
||
ICON_GEAR="⚙️" # setup section header / profile load
|
||
|
||
# Health Checks
|
||
ICON_DISK="💾" # disk checks
|
||
ICON_HEALTH="🩺" # rootfs / share health checks
|
||
ICON_SHIELD="🛡️" # pre-flight section header
|
||
|
||
# Containers
|
||
ICON_CONTAINERS="📦" # container section anchor
|
||
ICON_STOP="⛔" # stop command being issued
|
||
ICON_STOPPED="🔴" # container confirmed stopped
|
||
ICON_START="▶️" # start command being issued
|
||
ICON_STARTED="💚" # container confirmed started
|
||
ICON_RUNNING="🟢" # container already running when checked
|
||
ICON_SKIP="⏭️" # skipping — already running healthy instance
|
||
ICON_PROTECTED="🔰" # file/item is protected — never delete
|
||
ICON_NOT_RUNNING="⭕" # container already stopped when checked
|
||
|
||
# Transfer
|
||
ICON_SYNC="🔄" # transfer section header
|
||
ICON_RUN="🚀" # sync starting / rsync attempt
|
||
ICON_RETRY="🔁" # retry attempt
|
||
ICON_DONE="🏁" # transfer complete
|
||
ICON_GIT="🌱" # git pull/push section header
|
||
|
||
# Summary
|
||
ICON_SUMMARY="📋" # summary section header
|
||
ICON_TIME="⏱️" # duration line
|
||
|
||
# System Operations
|
||
ICON_MOVER="🔃" # mover operations
|
||
ICON_REBOOT="⚡" # scheduled server reboot
|
||
ICON_REBOOT_SMART="🚨" # smart conditional reboot triggered
|
||
ICON_PLUGIN="🧩" # user scripts plugin operations
|
||
ICON_PHP="👥" # PHP-FPM operations
|
||
ICON_WEBGUI="💻" # WebGUI / nginx / emhttp operations
|
||
|
||
# Diagnostics
|
||
ICON_ZFS="📊" # ZFS ARC statistics
|
||
ICON_MEM="🧠" # memory status
|
||
ICON_WATCHDOG="🐾" # docker watchdog monitoring operations
|
||
|
||
# Media Operations
|
||
ICON_CLEAN="🧹" # media cleaner operations
|
||
ICON_TRASH="🗑️" # files being deleted
|
||
ICON_PERMS="🔐" # permissions operation / section header
|
||
ICON_UNLOCKED="🔓" # permissions successfully applied to a share
|
||
ICON_LOCK="🔏" # script instance lock — acquired/released
|
||
|
||
# Transcode Operations
|
||
ICON_RAM="💨" # ramdisk operations — fast ephemeral storage
|
||
ICON_LINK="🔗" # symlink state and management
|
||
|
||
# fallback Operations
|
||
ICON_FALLBACK="🔀" # fallback state changes and operations
|
||
|
||
# Docker Network Operations
|
||
ICON_DOCKER_NET="🔌" # Docker network connect operations
|
||
|
||
# Security / Certificate Operations
|
||
ICON_CERT="🔒" # SSL certificate monitoring
|
||
|
||
# Monitor Operations
|
||
ICON_MONITOR="📈" # monitoring section headers and general monitoring
|
||
ICON_SMART="🔧" # drive SMART health attribute monitoring
|
||
ICON_BANDWIDTH="📶" # bandwidth usage tracking and reporting
|
||
ICON_DIGEST="📰" # health digest — aggregated system summary
|
||
ICON_EMBY="🎬" # Emby media server session reporting
|
||
ICON_VERIFY="✔️" # backup verification — checksum comparison
|
||
|
||
# Notifications
|
||
ICON_NOTIFY="🔔" # notification operations
|
||
|
||
# Output
|
||
ICON_INFO="ℹ️"
|
||
ICON_WARN="⚠️"
|
||
ICON_ERROR="❌"
|
||
ICON_SUCCESS="✅"
|
||
|
||
# ==============================================================================================
|
||
# ── OUTPUT HELPERS ────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Standardised output functions used across all scripts.
|
||
#
|
||
# Output tiers:
|
||
# echo — always visible — summaries, status conclusions, section headers
|
||
# info() — always visible — status confirmations, reachability, check pass
|
||
# warn() — always visible — state transitions, warnings, important events
|
||
# error() — always visible — something broke
|
||
# success() — always visible — operation completed successfully
|
||
# log() — only with --log flag — per-item detail, internal checks
|
||
#
|
||
# All output goes to stdout — callers can redirect as needed.
|
||
|
||
info() { echo "$ICON_INFO [INFO] $*"; }
|
||
warn() { echo "$ICON_WARN [WARN] $*"; }
|
||
error() { echo "$ICON_ERROR [ERROR] $*"; }
|
||
success() { echo "$ICON_DONE [OK] $*"; }
|
||
|
||
log() {
|
||
[[ "${ENABLE_LOGGING:-false}" == true ]] && echo "[LOG] $*"
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── NOTIFICATION ──────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Sends a notification via unRAID native system and/or Discord webhook.
|
||
# Both channels are optional and independently controlled:
|
||
# NOTIFY_UNRAID — shared toggle in master.conf
|
||
# MY_DISCORD_WEBHOOK — per-host in host*.conf, set by detect_hosts()
|
||
#
|
||
# Severity levels: normal, warning, alert
|
||
# Usage: notify "message" "subject" "severity"
|
||
|
||
notify() {
|
||
local message="$1"
|
||
local subject="${2:-unRAID Notification}"
|
||
local severity="${3:-normal}"
|
||
|
||
log "$ICON_NOTIFY Sending notification: $subject — $message"
|
||
|
||
if [[ "${NOTIFY_UNRAID:-false}" == true ]]; then
|
||
if platform_send_os_notification "$message" "$subject" "$severity"; then
|
||
log "$ICON_NOTIFY OS notification sent"
|
||
else
|
||
log "$ICON_NOTIFY OS notify not available — skipping"
|
||
fi
|
||
fi
|
||
|
||
# Uses MY_DISCORD_WEBHOOK — set by detect_hosts() from HOST*_DISCORD_WEBHOOK
|
||
if [[ -n "${MY_DISCORD_WEBHOOK:-}" ]]; then
|
||
local payload
|
||
payload=$(printf '{"content": "%s — **%s**\\n%s"}' \
|
||
"$ICON_NOTIFY" "$subject" "$message")
|
||
if curl -s -H "Content-Type: application/json" \
|
||
-d "$payload" "$MY_DISCORD_WEBHOOK" >/dev/null 2>&1; then
|
||
log "$ICON_NOTIFY Discord notification sent"
|
||
else
|
||
warn "Discord notification failed — check HOST*_DISCORD_WEBHOOK in host*.conf"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── DURATION FORMATTER ────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Converts raw seconds into a human readable string.
|
||
# Examples: 47 → "47s" | 653 → "10m53s"
|
||
# Usage: format_duration $SECONDS
|
||
|
||
format_duration() {
|
||
local secs=$1
|
||
local days=$(( secs / 86400 ))
|
||
local hrs=$(( (secs % 86400) / 3600 ))
|
||
local mins=$(( (secs % 3600) / 60 ))
|
||
local rem=$(( secs % 60 ))
|
||
if [[ $days -gt 0 ]]; then echo "${days}d ${hrs}h ${mins}m"
|
||
elif [[ $hrs -gt 0 ]]; then echo "${hrs}h ${mins}m"
|
||
elif [[ $mins -gt 0 ]]; then echo "${mins}m${rem}s"
|
||
else echo "${rem}s"; fi
|
||
}
|
||
|
||
# Tiered human-readable size — picks B/MB/GB based on magnitude.
|
||
# Usage: format_bytes 5368709120 → 5.0GB
|
||
format_bytes() {
|
||
local bytes=${1:-0}
|
||
if (( bytes > 1073741824 )); then
|
||
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
|
||
elif (( bytes > 1048576 )); then
|
||
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
|
||
else
|
||
echo "${bytes}B"
|
||
fi
|
||
}
|
||
|
||
# Fixed-precision bytes → GB (no tiering) — for values always compared against a GB threshold.
|
||
# Usage: bytes_to_gb 5368709120 [decimals=2] → 5.00
|
||
bytes_to_gb() {
|
||
local bytes=${1:-0} decimals=${2:-2}
|
||
awk "BEGIN {printf \"%.${decimals}f\", $bytes / 1073741824}"
|
||
}
|
||
|
||
# Fixed-precision KB → GB (no tiering) — df --output=used/avail reports in KB.
|
||
# Usage: kb_to_gb 5242880 [decimals=2] → 5.00
|
||
kb_to_gb() {
|
||
local kb=${1:-0} decimals=${2:-2}
|
||
awk "BEGIN {printf \"%.${decimals}f\", $kb / 1048576}"
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── ARG PARSER ────────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Processes all flags and key=value pairs passed to any script.
|
||
# Call early in every script: parse_args "$@"
|
||
#
|
||
# Supported flags:
|
||
# --dry-run | -n → DRY_RUN=true
|
||
# --log → ENABLE_LOGGING=true
|
||
# --no-log → ENABLE_LOGGING=false
|
||
# --status → SHOW_STATUS=true
|
||
# --help | -h → print usage and exit
|
||
#
|
||
# Supported key=value:
|
||
# LOG=true/false → toggle logging
|
||
# BW_LIMIT=5000 → override any declared master.conf variable for this run
|
||
#
|
||
# Unparsed positional args returned in PARSED_ARGS array.
|
||
|
||
parse_args() {
|
||
ENABLE_LOGGING=${ENABLE_LOGGING:-false}
|
||
DRY_RUN=${DRY_RUN:-false}
|
||
SHOW_STATUS=${SHOW_STATUS:-false}
|
||
CLEAN_ARGS=()
|
||
|
||
for ARG in "$@"; do
|
||
if [[ "$ARG" == *=* ]]; then
|
||
VAR="${ARG%%=*}"
|
||
VAL="${ARG#*=}"
|
||
case "$VAR" in
|
||
LOG)
|
||
[[ "$VAL" == "true" ]] && ENABLE_LOGGING=true
|
||
[[ "$VAL" == "false" ]] && ENABLE_LOGGING=false
|
||
;;
|
||
*)
|
||
if declare -p "$VAR" &>/dev/null; then
|
||
printf -v "$VAR" '%s' "$VAL"
|
||
log "Set $VAR=$VAL"
|
||
else
|
||
warn "Unknown variable: $VAR"
|
||
fi
|
||
;;
|
||
esac
|
||
else
|
||
case "$ARG" in
|
||
--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] [--status]"
|
||
exit 0
|
||
;;
|
||
*) CLEAN_ARGS+=("$ARG") ;;
|
||
esac
|
||
fi
|
||
done
|
||
|
||
PARSED_ARGS=("${CLEAN_ARGS[@]}")
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── VALIDATION HELPERS ────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
# Exits with error if a required variable is empty or unset.
|
||
# Usage: require_var VAR_NAME
|
||
require_var() {
|
||
[[ -z "${!1:-}" ]] && error "Missing required: $1" && exit 1
|
||
}
|
||
|
||
# Exits with error if a variable is not a valid positive integer.
|
||
# Usage: validate_int VAR_NAME "$VAR_VALUE"
|
||
validate_int() {
|
||
local name="$1" value="$2"
|
||
if [[ -z "$value" ]]; then
|
||
error "$name is not set — check master.conf"
|
||
exit 1
|
||
fi
|
||
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
|
||
error "$name must be a positive integer — got: '$value'"
|
||
exit 1
|
||
fi
|
||
log "$name validated: $value"
|
||
}
|
||
|
||
# Membership check against a list of values (e.g. an ignore list).
|
||
# Usage: is_in_list "$needle" "${HAYSTACK_ARRAY[@]}"
|
||
is_in_list() {
|
||
local needle="$1"; shift
|
||
local item
|
||
for item in "$@"; do
|
||
[[ "$needle" == "$item" ]] && return 0
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── HOST DETECTION ────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Determines which server is local and which is remote by comparing hostname against
|
||
# all HOST* values discovered from host*.conf files.
|
||
#
|
||
# Sets:
|
||
# MY_ID — "HOST1" or "HOST2" (the role key, not the hostname)
|
||
# REMOTE_ID — the peer's role key
|
||
# LOCAL_SERVER_NAME — local hostname string (e.g. "unRAID-Gmer4Lfe")
|
||
# REMOTE_SERVER_NAME — remote hostname string
|
||
# SSH_KEY — SSH key for server-to-server operations
|
||
# REMOTE_STORAGE_PATH — remote's storage root from HOST*_STORAGE_PATH in host*.conf;
|
||
# falls back to platform_storage_path() for unconfigured pairs
|
||
#
|
||
# Also sets aliases for all host-specific arrays so scripts use unprefixed names:
|
||
# DAILY_SYNC_SHARES ← HOST*_DAILY_SYNC_SHARES
|
||
# INTERMEDIATE_SYNC_SHARES ← HOST*_INTERMEDIATE_SYNC_SHARES
|
||
# WEEKLY_SYNC_SHARES ← HOST*_WEEKLY_SYNC_SHARES
|
||
# CRITICAL_SYNC_SHARES ← HOST*_CRITICAL_SYNC_SHARES
|
||
# DAILY_RESTART_CONTAINERS ← HOST*_DAILY_RESTART_CONTAINERS
|
||
# WEEKLY_RESTART_CONTAINERS ← HOST*_WEEKLY_RESTART_CONTAINERS
|
||
# WATCHDOG_CONTAINERS ← HOST*_WATCHDOG_CONTAINERS (associative)
|
||
# WATCHDOG_CONTAINER_URLS ← HOST*_WATCHDOG_CONTAINER_URLS (associative)
|
||
# WATCHDOG_REQUIRED_CONTAINERS ← HOST*_WATCHDOG_REQUIRED_CONTAINERS
|
||
# WATCHDOG_SCAN_IGNORE ← HOST*_WATCHDOG_SCAN_IGNORE
|
||
# WATCHDOG_DEPENDENCIES ← HOST*_WATCHDOG_DEPENDENCIES (associative)
|
||
# WATCHDOG_APPDATA_SIZES ← HOST*_WATCHDOG_APPDATA_SIZES (associative)
|
||
# NETWORK_CONNECT_CONTAINERS ← HOST*_NETWORK_CONNECT_CONTAINERS
|
||
# NETWORK_CONNECT_NETWORKS ← HOST*_NETWORK_CONNECT_NETWORKS
|
||
# MEDIA_PERMISSION_SHARES ← HOST*_MEDIA_PERMISSION_SHARES
|
||
# ANIME_CLEAN_FOLDERS ← HOST*_ANIME_CLEAN_FOLDERS
|
||
# MEDIA_CLEAN_FOLDERS ← HOST*_MEDIA_CLEAN_FOLDERS
|
||
# CERT_MONITOR_DOMAINS ← HOST*_CERT_MONITOR_DOMAINS
|
||
# SMART_IGNORE_DRIVES ← HOST*_SMART_IGNORE_DRIVES
|
||
# ZFS_REPORT_IGNORE_POOLS ← HOST*_ZFS_REPORT_IGNORE_POOLS
|
||
# TRANSCODE_SSD ← HOST*_TRANSCODE_SSD
|
||
# TRANSCODE_SERVERS ← HOST*_TRANSCODE_SERVERS
|
||
# RAMDISK_SIZE ← HOST*_RAMDISK_SIZE
|
||
# RAMDISK_WARN_GB ← HOST*_RAMDISK_WARN_GB
|
||
# RAMDISK_LOW_GB ← HOST*_RAMDISK_LOW_GB
|
||
# BACKUP_VERIFY_SHARES ← HOST*_BACKUP_VERIFY_SHARES
|
||
# DDNS_CONTAINERS ← HOST*_DDNS_CONTAINERS
|
||
# PARTNERSHIP_AUTH_WEBUIS ← HOST*_PARTNERSHIP_AUTH_WEBUIS
|
||
# PARTNERSHIP_MIRROR_BACKUPS ← HOST*_PARTNERSHIP_MIRROR_BACKUPS
|
||
# MY_DISCORD_WEBHOOK ← HOST*_DISCORD_WEBHOOK
|
||
# EMBY_CONTAINER ← HOST*_EMBY_CONTAINER
|
||
# EMBY_URL ← HOST*_EMBY_URL
|
||
# EMBY_API_KEY ← HOST*_EMBY_API_KEY
|
||
# JELLYFIN_CONTAINER ← HOST*_JELLYFIN_CONTAINER
|
||
# JELLYFIN_URL ← HOST*_JELLYFIN_URL
|
||
# JELLYFIN_API_KEY ← HOST*_JELLYFIN_API_KEY
|
||
# GITEA_API_TOKEN ← HOST*_GITEA_API_TOKEN
|
||
#
|
||
# Arr-specific vars set by detect_hosts() when needed by arr scripts:
|
||
# LIDARR_URL / LIDARR_API_KEY / LIDARR_MUSIC_ROOT (MY_ID only — Lidarr is HOST1 only)
|
||
# SONARR_URL / SONARR_API_KEY / SONARR_TV_ROOT
|
||
# RADARR_URL / RADARR_API_KEY / RADARR_MOVIES_ROOT
|
||
# SLSKD_URL / SLSKD_API_KEY / SLSKD_FAILED_IMPORTS_DIR (MY_ID only)
|
||
# SABNZBD_URL / SABNZBD_API_KEY (MY_ID only)
|
||
# QBIT_URL / QBIT_USERNAME / QBIT_PASSWORD (MY_ID only)
|
||
|
||
detect_hosts() {
|
||
local local_hostname
|
||
local_hostname="$(hostname)"
|
||
|
||
# ── Find MY_ID by matching hostname against all HOST* vars ────────────────
|
||
MY_ID=""
|
||
local host_var host_val
|
||
|
||
# Scan all HOST* vars that hold a hostname value
|
||
# HOST1, HOST2, HOST3 etc. — all defined in host*.conf files
|
||
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
|
||
host_val="${!host_var:-}"
|
||
[[ -z "$host_val" ]] && continue
|
||
if [[ "${local_hostname,,}" == "${host_val,,}" ]]; then
|
||
MY_ID="$host_var"
|
||
break
|
||
fi
|
||
done
|
||
|
||
# Unraid truncates the Server Name to 15 chars (NetBIOS limit). If the live hostname
|
||
# is at that exact limit, a configured HOST* value may be a longer, untruncated version
|
||
# (matching what Tailscale shows for this peer — resolve_tailscale_ip() keys off the same
|
||
# value). Only accept the match if it's unambiguous — exactly one HOST* may qualify.
|
||
if [[ -z "$MY_ID" && ${#local_hostname} -eq 15 ]]; then
|
||
local candidate="" candidate_count=0
|
||
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
|
||
host_val="${!host_var:-}"
|
||
[[ -z "$host_val" ]] && continue
|
||
if [[ ${#host_val} -gt 15 && "${host_val,,}" == "${local_hostname,,}"* ]]; then
|
||
candidate="$host_var"
|
||
(( candidate_count++ ))
|
||
fi
|
||
done
|
||
[[ "$candidate_count" -eq 1 ]] && MY_ID="$candidate"
|
||
fi
|
||
|
||
if [[ -z "$MY_ID" ]]; then
|
||
error "Unknown host: $local_hostname"
|
||
error "Hostname must match a HOST* value in host*.conf"
|
||
error "Available: $(for h in HOST1 HOST2 HOST3 HOST4; do
|
||
[[ -n "${!h:-}" ]] && echo -n "${!h} "; done)"
|
||
exit 1
|
||
fi
|
||
|
||
# ── Find REMOTE_ID — first HOST* that isn't MY_ID ────────────────────────
|
||
REMOTE_ID=""
|
||
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
|
||
[[ "$host_var" == "$MY_ID" ]] && continue
|
||
host_val="${!host_var:-}"
|
||
[[ -z "$host_val" ]] && continue
|
||
REMOTE_ID="$host_var"
|
||
break
|
||
done
|
||
|
||
# ── Set host name strings ─────────────────────────────────────────────────
|
||
LOCAL_SERVER_NAME="${!MY_ID}"
|
||
[[ -n "$REMOTE_ID" ]] && REMOTE_SERVER_NAME="${!REMOTE_ID}" || REMOTE_SERVER_NAME="unknown"
|
||
|
||
# ── Set SSH key ───────────────────────────────────────────────────────────
|
||
local ssh_key_var="${MY_ID}_SSH_KEY"
|
||
SSH_KEY="${!ssh_key_var:-}"
|
||
if [[ -z "$SSH_KEY" ]]; then
|
||
error "Missing SSH key: ${ssh_key_var} not set in host*.conf"
|
||
exit 1
|
||
fi
|
||
|
||
# ── Set scalar aliases ────────────────────────────────────────────────────
|
||
MY_DISCORD_WEBHOOK_VAR="${MY_ID}_DISCORD_WEBHOOK"
|
||
MY_DISCORD_WEBHOOK="${!MY_DISCORD_WEBHOOK_VAR:-}"
|
||
|
||
OWNER_NAME_VAR="${MY_ID}_OWNER"
|
||
OWNER_NAME="${!OWNER_NAME_VAR:-}"
|
||
OWNER_EMAIL_VAR="${MY_ID}_OWNER_EMAIL"
|
||
OWNER_EMAIL="${!OWNER_EMAIL_VAR:-}"
|
||
|
||
EMBY_CONTAINER_VAR="${MY_ID}_EMBY_CONTAINER"
|
||
EMBY_CONTAINER="${!EMBY_CONTAINER_VAR:-}"
|
||
EMBY_URL_VAR="${MY_ID}_EMBY_URL"
|
||
EMBY_URL="${!EMBY_URL_VAR:-}"
|
||
EMBY_API_KEY_VAR="${MY_ID}_EMBY_API_KEY"
|
||
EMBY_API_KEY="${!EMBY_API_KEY_VAR:-}"
|
||
|
||
JELLYFIN_CONTAINER_VAR="${MY_ID}_JELLYFIN_CONTAINER"
|
||
JELLYFIN_CONTAINER="${!JELLYFIN_CONTAINER_VAR:-}"
|
||
JELLYFIN_URL_VAR="${MY_ID}_JELLYFIN_URL"
|
||
JELLYFIN_URL="${!JELLYFIN_URL_VAR:-}"
|
||
JELLYFIN_API_KEY_VAR="${MY_ID}_JELLYFIN_API_KEY"
|
||
JELLYFIN_API_KEY="${!JELLYFIN_API_KEY_VAR:-}"
|
||
|
||
GITEA_API_TOKEN_VAR="${MY_ID}_GITEA_API_TOKEN"
|
||
GITEA_API_TOKEN="${!GITEA_API_TOKEN_VAR:-}"
|
||
|
||
local _prov_var
|
||
for _prov_var in \
|
||
PARTNERSHIP_PROVISION_EMBY_ADMIN \
|
||
PARTNERSHIP_EMBY_ADMIN_USER \
|
||
PARTNERSHIP_EMBY_ADMIN_PASS \
|
||
PARTNERSHIP_EMBY_PORT; do
|
||
local _src="${MY_ID}_${_prov_var}"
|
||
printf -v "$_prov_var" '%s' "${!_src:-}"
|
||
done
|
||
|
||
# ── System watchdog check toggles — per-host ─────────────────────────────
|
||
# Aliased as unprefixed SYS_WATCHDOG_CHECK_* for use in system_watchdog.sh
|
||
local _wd_checks=(
|
||
SYS_WATCHDOG_NIC
|
||
SYS_WATCHDOG_CHECK_DOCKER_DAEMON SYS_WATCHDOG_CHECK_ROOTFS
|
||
SYS_WATCHDOG_CHECK_KERNEL_OOPS SYS_WATCHDOG_CHECK_FD
|
||
SYS_WATCHDOG_CHECK_BOOT SYS_WATCHDOG_CHECK_OOM
|
||
SYS_WATCHDOG_CHECK_RAM SYS_WATCHDOG_CHECK_LOG
|
||
SYS_WATCHDOG_CHECK_ARC SYS_WATCHDOG_CHECK_CPU_TEMP
|
||
SYS_WATCHDOG_CHECK_LOAD SYS_WATCHDOG_CHECK_ZOMBIES
|
||
SYS_WATCHDOG_CHECK_CONTAINERS SYS_WATCHDOG_CHECK_TMP
|
||
SYS_WATCHDOG_CHECK_MDSTAT SYS_WATCHDOG_CHECK_NETWORK
|
||
SYS_WATCHDOG_CHECK_SSHD SYS_WATCHDOG_CHECK_RUNAWAY
|
||
)
|
||
local _wd_var
|
||
for _wd_var in "${_wd_checks[@]}"; do
|
||
local _wd_src="${MY_ID}_${_wd_var}"
|
||
# Only alias if the host-specific var is set — preserves master.conf defaults
|
||
[[ -n "${!_wd_src+x}" ]] && eval "${_wd_var}=\"\${${_wd_src}}\""
|
||
done
|
||
|
||
TRANSCODE_SSD_VAR="${MY_ID}_TRANSCODE_SSD"
|
||
TRANSCODE_SSD="${!TRANSCODE_SSD_VAR:-}"
|
||
RAMDISK_SIZE_VAR="${MY_ID}_RAMDISK_SIZE"
|
||
RAMDISK_SIZE="${!RAMDISK_SIZE_VAR:-8G}"
|
||
RAMDISK_WARN_GB_VAR="${MY_ID}_RAMDISK_WARN_GB"
|
||
RAMDISK_WARN_GB="${!RAMDISK_WARN_GB_VAR:-6.8}"
|
||
RAMDISK_LOW_GB_VAR="${MY_ID}_RAMDISK_LOW_GB"
|
||
RAMDISK_LOW_GB="${!RAMDISK_LOW_GB_VAR:-5.5}"
|
||
|
||
# Arr credentials — MY_ID only (remote arr accessed via its own host)
|
||
LIDARR_URL_VAR="${MY_ID}_LIDARR_URL"; LIDARR_URL="${!LIDARR_URL_VAR:-}"
|
||
LIDARR_API_KEY_VAR="${MY_ID}_LIDARR_API_KEY"; LIDARR_API_KEY="${!LIDARR_API_KEY_VAR:-}"
|
||
LIDARR_MUSIC_ROOT_VAR="${MY_ID}_LIDARR_MUSIC_ROOT"; LIDARR_MUSIC_ROOT="${!LIDARR_MUSIC_ROOT_VAR:-}"
|
||
LIDARR_RECOVERY_VAR="${MY_ID}_LIDARR_RECOVERY"; LIDARR_RECOVERY="${!LIDARR_RECOVERY_VAR:-false}"
|
||
FANART_API_KEY_VAR="${MY_ID}_FANART_API_KEY"; FANART_API_KEY="${!FANART_API_KEY_VAR:-}"
|
||
LASTFM_API_KEY_VAR="${MY_ID}_LASTFM_API_KEY"; LASTFM_API_KEY="${!LASTFM_API_KEY_VAR:-}"
|
||
TMDB_API_KEY_VAR="${MY_ID}_TMDB_API_KEY"; TMDB_API_KEY="${!TMDB_API_KEY_VAR:-}"
|
||
|
||
SONARR_URL_VAR="${MY_ID}_SONARR_URL"; SONARR_URL="${!SONARR_URL_VAR:-}"
|
||
SONARR_API_KEY_VAR="${MY_ID}_SONARR_API_KEY"; SONARR_API_KEY="${!SONARR_API_KEY_VAR:-}"
|
||
SONARR_TV_ROOT_VAR="${MY_ID}_SONARR_TV_ROOT"; SONARR_TV_ROOT="${!SONARR_TV_ROOT_VAR:-}"
|
||
SONARR_GENERAL_ROOT_VAR="${MY_ID}_SONARR_GENERAL_ROOT"; SONARR_GENERAL_ROOT="${!SONARR_GENERAL_ROOT_VAR:-}"
|
||
SONARR_KIDS_ROOT_VAR="${MY_ID}_SONARR_KIDS_ROOT"; SONARR_KIDS_ROOT="${!SONARR_KIDS_ROOT_VAR:-}"
|
||
SONARR_ANIME_ROOT_VAR="${MY_ID}_SONARR_ANIME_ROOT"; SONARR_ANIME_ROOT="${!SONARR_ANIME_ROOT_VAR:-}"
|
||
SONARR_RECOVERY_VAR="${MY_ID}_SONARR_RECOVERY"; SONARR_RECOVERY="${!SONARR_RECOVERY_VAR:-true}"
|
||
|
||
RADARR_URL_VAR="${MY_ID}_RADARR_URL"; RADARR_URL="${!RADARR_URL_VAR:-}"
|
||
RADARR_API_KEY_VAR="${MY_ID}_RADARR_API_KEY"; RADARR_API_KEY="${!RADARR_API_KEY_VAR:-}"
|
||
RADARR_MOVIES_ROOT_VAR="${MY_ID}_RADARR_MOVIES_ROOT"; RADARR_MOVIES_ROOT="${!RADARR_MOVIES_ROOT_VAR:-}"
|
||
RADARR_GENERAL_ROOT_VAR="${MY_ID}_RADARR_GENERAL_ROOT"; RADARR_GENERAL_ROOT="${!RADARR_GENERAL_ROOT_VAR:-}"
|
||
RADARR_KIDS_ROOT_VAR="${MY_ID}_RADARR_KIDS_ROOT"; RADARR_KIDS_ROOT="${!RADARR_KIDS_ROOT_VAR:-}"
|
||
RADARR_ANIME_ROOT_VAR="${MY_ID}_RADARR_ANIME_ROOT"; RADARR_ANIME_ROOT="${!RADARR_ANIME_ROOT_VAR:-}"
|
||
RADARR_RECOVERY_VAR="${MY_ID}_RADARR_RECOVERY"; RADARR_RECOVERY="${!RADARR_RECOVERY_VAR:-true}"
|
||
|
||
SLSKD_URL_VAR="${MY_ID}_SLSKD_URL"; SLSKD_URL="${!SLSKD_URL_VAR:-}"
|
||
SLSKD_API_KEY_VAR="${MY_ID}_SLSKD_API_KEY"; SLSKD_API_KEY="${!SLSKD_API_KEY_VAR:-}"
|
||
SLSKD_FAILED_IMPORTS_DIR_VAR="${MY_ID}_SLSKD_FAILED_IMPORTS_DIR"
|
||
SLSKD_FAILED_IMPORTS_DIR="${!SLSKD_FAILED_IMPORTS_DIR_VAR:-}"
|
||
|
||
SABNZBD_URL_VAR="${MY_ID}_SABNZBD_URL"; SABNZBD_URL="${!SABNZBD_URL_VAR:-}"
|
||
SABNZBD_API_KEY_VAR="${MY_ID}_SABNZBD_API_KEY"; SABNZBD_API_KEY="${!SABNZBD_API_KEY_VAR:-}"
|
||
|
||
QBIT_URL_VAR="${MY_ID}_QBIT_URL"; QBIT_URL="${!QBIT_URL_VAR:-}"
|
||
QBIT_USERNAME_VAR="${MY_ID}_QBIT_USERNAME"; QBIT_USERNAME="${!QBIT_USERNAME_VAR:-}"
|
||
QBIT_PASSWORD_VAR="${MY_ID}_QBIT_PASSWORD"; QBIT_PASSWORD="${!QBIT_PASSWORD_VAR:-}"
|
||
|
||
# ── Set array aliases — indexed arrays ────────────────────────────────────
|
||
# Each eval copies the host-specific array into the unprefixed name scripts use
|
||
|
||
_alias_array() {
|
||
local alias_name="$1"
|
||
local source_var="${MY_ID}_${alias_name}"
|
||
eval "${alias_name}=(\"\${${source_var}[@]}\")"
|
||
}
|
||
|
||
_alias_array "DAILY_SYNC_SHARES"
|
||
_alias_array "PERSONAL_SHARES"
|
||
_alias_array "INTERMEDIATE_SYNC_SHARES"
|
||
_alias_array "WEEKLY_SYNC_SHARES"
|
||
_alias_array "CRITICAL_SYNC_SHARES"
|
||
_alias_array "BACKUP_VERIFY_SHARES"
|
||
_alias_array "DAILY_RESTART_CONTAINERS"
|
||
_alias_array "WEEKLY_RESTART_CONTAINERS"
|
||
_alias_array "WATCHDOG_REQUIRED_CONTAINERS"
|
||
_alias_array "WATCHDOG_SCAN_IGNORE"
|
||
_alias_array "NETWORK_CONNECT_CONTAINERS"
|
||
_alias_array "NETWORK_CONNECT_NETWORKS"
|
||
_alias_array "MEDIA_PERMISSION_SHARES"
|
||
_alias_array "ANIME_CLEAN_FOLDERS"
|
||
_alias_array "MEDIA_CLEAN_FOLDERS"
|
||
_alias_array "CERT_MONITOR_DOMAINS"
|
||
_alias_array "SMART_IGNORE_DRIVES"
|
||
_alias_array "ZFS_REPORT_IGNORE_POOLS"
|
||
_alias_array "TRANSCODE_SERVERS"
|
||
_alias_array "DDNS_CONTAINERS"
|
||
_alias_array "PARTNERSHIP_AUTH_WEBUIS"
|
||
_alias_array "PARTNERSHIP_MIRROR_BACKUPS"
|
||
_alias_array "PARTNERSHIP_OWN_CONTAINERS"
|
||
_alias_array "PARTNERSHIP_AUTH_STACK"
|
||
_alias_array "PARTNERSHIP_REPLACE_CONTAINERS"
|
||
_alias_array "PARTNERSHIP_ARR_STACK"
|
||
_alias_array "PARTNERSHIP_ARR_REPLACE_CONTAINERS"
|
||
_alias_array "PARTNERSHIP_SERVICES_STACK"
|
||
_alias_array "PARTNERSHIP_SERVICES_REPLACE_CONTAINERS"
|
||
_alias_array "RW_PAUSE_CONTAINERS"
|
||
_alias_array "RW_STOP_CONTAINERS"
|
||
|
||
# ── Set array aliases — associative arrays ────────────────────────────────
|
||
# Associative arrays cannot be copied with eval — must be rebuilt key by key
|
||
|
||
_alias_assoc() {
|
||
local alias_name="$1"
|
||
local source_name="${MY_ID}_${alias_name}"
|
||
eval "declare -gA ${alias_name}"
|
||
local -n _assoc_src="${source_name}" 2>/dev/null || return 0
|
||
local -n _assoc_dst="${alias_name}"
|
||
local _assoc_key
|
||
for _assoc_key in "${!_assoc_src[@]}"; do
|
||
_assoc_dst["$_assoc_key"]="${_assoc_src[$_assoc_key]}"
|
||
done
|
||
}
|
||
|
||
_alias_assoc "WATCHDOG_CONTAINERS"
|
||
_alias_assoc "WATCHDOG_CONTAINER_URLS"
|
||
_alias_assoc "WATCHDOG_CONTAINER_API_CHECKS"
|
||
_alias_assoc "WATCHDOG_DEPENDENCIES"
|
||
_alias_assoc "WATCHDOG_APPDATA_SIZES"
|
||
|
||
# ── Remote storage path ───────────────────────────────────────────────────
|
||
# Resolved from REMOTE_ID's host*.conf so SSH commands use the correct path
|
||
# even when the remote is a different platform. Falls back to the local
|
||
# platform_storage_path() for unconfigured same-platform pairs.
|
||
local _rsp_var="${REMOTE_ID}_STORAGE_PATH"
|
||
REMOTE_STORAGE_PATH="${!_rsp_var:-$(platform_storage_path)}"
|
||
|
||
# ── Output ────────────────────────────────────────────────────────────────
|
||
log "$ICON_HOST Host: $LOCAL_SERVER_NAME → $REMOTE_SERVER_NAME"
|
||
}
|
||
|
||
# Derives owner/mirror roles from PARTNERSHIP_OWNER_HOST — used by every Partnership/*.sh
|
||
# script. Requires detect_hosts() to have already run (needs MY_ID, SSH_KEY).
|
||
# Sets: OWNER_ID MIRROR_ID OWNER MIRROR MIRROR_SSH_KEY OWNER_SSH_KEY AM_OWNER AM_MIRROR
|
||
# Does NOT set any *_STATE_FILE vars — callers that need those set them after calling this,
|
||
# since not every Partnership script needs the same set (transfer/onboard need fewer than
|
||
# manager/offboard).
|
||
partnership_resolve_roles() {
|
||
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
|
||
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
|
||
OWNER="${!OWNER_ID}"
|
||
MIRROR="${!MIRROR_ID}"
|
||
# SSH_KEY (set by detect_hosts) is this server's own private key. The remote accepts it
|
||
# because this server's PUBLIC key was installed there via ssh_setup.sh. With sparse
|
||
# checkout, each server only has its own host{N}.conf — the other server's key path is
|
||
# never available here. Use SSH_KEY for all outbound SSH regardless of mode.
|
||
MIRROR_SSH_KEY="$SSH_KEY"
|
||
OWNER_SSH_KEY="$SSH_KEY"
|
||
|
||
AM_OWNER=false
|
||
AM_MIRROR=false
|
||
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
|
||
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
|
||
}
|
||
|
||
# Read a scalar/array var from a partner's own config via SSH — the partner sources its
|
||
# own load_config.sh + detect_hosts() remotely so the value reflects ITS host*.conf, not
|
||
# ours. Requires $SCRIPT_DIR to be set by the caller (one level above the repo root, same
|
||
# convention every Partnership/*.sh script already uses for its own SCRIPT_DIR).
|
||
# Usage: read_remote_conf_var "$mirror_ip" "VAR_NAME"
|
||
# read_remote_conf_array "$mirror_ip" "ARRAY_NAME" # one element per line
|
||
read_remote_conf_var() {
|
||
local mirror_ip="$1" var_name="$2"
|
||
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
|
||
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
|
||
detect_hosts 2>/dev/null
|
||
printf '%s' \"\${${var_name}:-}\"" 2>/dev/null
|
||
}
|
||
|
||
read_remote_conf_array() {
|
||
local mirror_ip="$1" var_name="$2"
|
||
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
|
||
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
|
||
detect_hosts 2>/dev/null
|
||
printf '%s\n' \"\${${var_name}[@]:-}\"" 2>/dev/null
|
||
}
|
||
|
||
# Probes a remote host's SCRIPTS_DIR (in case it differs from ours — e.g. one host runs
|
||
# internal storage mode, the other flash), falling back to our own $SCRIPTS_DIR if the
|
||
# probe fails or the remote isn't reachable. strict_host_key defaults to "yes" (matches
|
||
# existing behavior everywhere except partnership_transfer.sh, which passes "no" since
|
||
# it may be contacting a mirror for the first time during an ownership transfer).
|
||
# Usage: resolve_remote_scripts_dir "$ip" ["$ssh_key"] ["no"|"yes"]
|
||
resolve_remote_scripts_dir() {
|
||
local ip="$1" ssh_key="${2:-$SSH_KEY}" strict_host_key="${3:-yes}"
|
||
local probe_cmd result
|
||
local -a opts=(-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes)
|
||
[[ "$strict_host_key" == "no" ]] && opts+=(-o StrictHostKeyChecking=no)
|
||
probe_cmd=$(platform_scripts_dir_probe_cmd)
|
||
result=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" "${opts[@]}" root@"$ip" "$probe_cmd" \
|
||
2>/dev/null | tr -d '[:space:]')
|
||
echo "${result:-$SCRIPTS_DIR}"
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── MULTI-NODE DISCOVERY ──────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Scans HOST1..HOST8 for every configured node other than this one. Requires detect_hosts()
|
||
# to have already run (needs MY_ID). Sets the global REMOTE_NODES array — empty if none found.
|
||
discover_remote_nodes() {
|
||
REMOTE_NODES=()
|
||
local _hv
|
||
for _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
|
||
[[ "$_hv" == "$MY_ID" ]] && continue
|
||
[[ -z "${!_hv:-}" ]] && continue
|
||
REMOTE_NODES+=("$_hv")
|
||
done
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── REMOTE IP RESOLUTION ──────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Resolves the Tailscale IPv4 address of the remote server.
|
||
# Sets REMOTE_SERVER used by all subsequent SSH and rsync calls.
|
||
# Exits if resolution fails — Tailscale may be down or peer offline.
|
||
|
||
resolve_remote_ip() {
|
||
log "Resolving remote IP for $REMOTE_SERVER_NAME..."
|
||
local ts_name="${REMOTE_SERVER_NAME,,}"
|
||
local attempts=3
|
||
|
||
for ((i=1; i<=attempts; i++)); do
|
||
# Direct lookup — works when MagicDNS short-name resolution is active
|
||
REMOTE_SERVER=$(tailscale ip -4 "$ts_name" 2>/dev/null)
|
||
|
||
# Fallback — parse tailscale status for FQDN entries (e.g. hostname.tailXXXX.ts.net)
|
||
if [[ -z "$REMOTE_SERVER" ]]; then
|
||
REMOTE_SERVER=$(tailscale status 2>/dev/null | awk -v name="$ts_name" '$2 ~ "^" name { print $1; exit }')
|
||
fi
|
||
|
||
[[ -n "$REMOTE_SERVER" ]] && break
|
||
[[ $i -lt $attempts ]] && { warn "Tailscale resolution attempt $i/$attempts failed — retrying in 5s..."; sleep 5; }
|
||
done
|
||
|
||
if [[ -z "$REMOTE_SERVER" ]]; then
|
||
error "Failed to resolve Tailscale IP for $REMOTE_SERVER_NAME after $attempts attempts"
|
||
error "Check: tailscale status | grep $ts_name"
|
||
exit 1
|
||
fi
|
||
info "$ICON_NET Remote IP: $REMOTE_SERVER"
|
||
}
|
||
|
||
# Resolve any hostname to a Tailscale IPv4 — tries direct lookup, falls back to status parse.
|
||
# Usage: ip=$(resolve_tailscale_ip "hostname") — returns empty string on failure.
|
||
resolve_tailscale_ip() {
|
||
local hostname="${1,,}"
|
||
local ip
|
||
ip=$(tailscale ip -4 "$hostname" 2>/dev/null)
|
||
if [[ -n "$ip" ]]; then
|
||
echo "$ip"
|
||
return
|
||
fi
|
||
# Fallback: unambiguous prefix match against tailscale status (either direction) — handles
|
||
# Unraid's 15-char NetBIOS hostname truncation vs. a longer name recorded in master.conf.
|
||
# Only accept the match when exactly one peer could qualify; never guess between multiple
|
||
# candidates that happen to share a prefix (e.g. server1/server10).
|
||
local matches count
|
||
matches=$(tailscale status 2>/dev/null | awk -v name="$hostname" '
|
||
{ split(tolower($2), parts, "."); host = parts[1];
|
||
if (index(host, name) == 1 || index(name, host) == 1) print $1 }')
|
||
count=$(echo "$matches" | grep -c .)
|
||
[[ "$count" -eq 1 ]] && echo "$matches"
|
||
}
|
||
|
||
# Strips the "unraid-" prefix (case-insensitive) and title-cases what remains.
|
||
# Usage: derive_short_name "unRAID-Gmer4Lfe" → "Gmer4lfe"
|
||
derive_short_name() {
|
||
local hostname="$1"
|
||
local short="${hostname,,}"
|
||
[[ "$short" == unraid-* ]] && short="${short:7}"
|
||
echo "${short^}"
|
||
}
|
||
|
||
# True if the given host*.conf basename belongs to this host (case-insensitive).
|
||
# Usage: is_own_conf_file "$(basename "$conf")"
|
||
is_own_conf_file() {
|
||
[[ "${1,,}" == "${MY_ID,,}.conf" ]]
|
||
}
|
||
|
||
# Sets key=value in a flat state file (setup.db style) — updates in place if the
|
||
# key exists, appends if not. No indentation handling — for master.conf use
|
||
# partnership_manager.sh's update_master_conf() instead.
|
||
# Usage: set_state_var "$state_file" "$key" "$value"
|
||
set_state_var() {
|
||
local file="$1" key="$2" value="$3"
|
||
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
||
sed -i "s|^${key}=.*|${key}=${value}|" "$file"
|
||
else
|
||
echo "${key}=${value}" >> "$file"
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── CONNECTIVITY CHECKS ───────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
# Fatal connectivity check — used by rsync and other scripts that must abort if unreachable.
|
||
# For fallback use ping_remote() which returns status without exiting.
|
||
check_connectivity() {
|
||
log "Checking connectivity to $REMOTE_SERVER..."
|
||
if ! ping -c1 -W3 "$REMOTE_SERVER" &>/dev/null; then
|
||
error "$ICON_PING Remote $REMOTE_SERVER ($REMOTE_SERVER_NAME) is unreachable"
|
||
info "Hint: tailscale status | grep $REMOTE_SERVER_NAME"
|
||
exit 1
|
||
fi
|
||
info "$ICON_PING $REMOTE_SERVER_NAME is reachable"
|
||
}
|
||
|
||
# Non-fatal ping — used by fallback.sh which handles its own state machine.
|
||
# Returns 0 if reachable, 1 if not — does NOT exit.
|
||
ping_remote() {
|
||
ping -c2 -W3 "$REMOTE_SERVER" &>/dev/null
|
||
}
|
||
|
||
# Non-fatal external connectivity check — used by fallback.sh.
|
||
# Returns 0 if internet reachable, 1 if not — does NOT exit.
|
||
ping_internet() {
|
||
ping -c2 -W3 "${EXTERNAL_IP:-8.8.8.8}" &>/dev/null
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── RSYNC GATE CHECK ──────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Two-tier rsync enable/disable system.
|
||
# Tier 1: RSYNC_ENABLED — global gate, overrides everything
|
||
# Tier 2: orchestrator-specific flag — fine-grained control per window
|
||
#
|
||
# Usage:
|
||
# check_rsync_enabled "CRITICAL" ← checks RSYNC_ENABLED + CRITICAL_RSYNC_ENABLED
|
||
# check_rsync_enabled "INTERMEDIATE" ← checks RSYNC_ENABLED + INTERMEDIATE_RSYNC_ENABLED
|
||
# check_rsync_enabled "DAILY" ← checks RSYNC_ENABLED + DAILY_RSYNC_ENABLED
|
||
# check_rsync_enabled "WEEKLY" ← checks RSYNC_ENABLED + WEEKLY_RSYNC_ENABLED
|
||
# check_rsync_enabled "FALLBACK" ← checks RSYNC_ENABLED + FALLBACK_RSYNC_ENABLED
|
||
# check_rsync_enabled ← checks RSYNC_ENABLED only (direct rsync.sh call)
|
||
#
|
||
# Returns: 0 = enabled, proceed | 1 = disabled, skip cleanly
|
||
|
||
check_rsync_enabled() {
|
||
local orchestrator="${1:-}"
|
||
|
||
# Tier 1 — global gate
|
||
if [[ "${RSYNC_ENABLED:-true}" == false ]]; then
|
||
warn "RSYNC_ENABLED=false — rsync globally disabled, skipping all syncs"
|
||
return 1
|
||
fi
|
||
|
||
# Tier 2 — per-orchestrator
|
||
if [[ -n "$orchestrator" ]]; then
|
||
local var_name="${orchestrator}_RSYNC_ENABLED"
|
||
local var_value="${!var_name:-true}"
|
||
if [[ "$var_value" == false ]]; then
|
||
info "${var_name}=false — rsync disabled for $orchestrator orchestrator"
|
||
info "All other $orchestrator jobs will still run normally"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── PLATFORM SERVICE STATE ────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Check whether Docker and VM Manager are enabled on this host.
|
||
# Delegates to platform_is_service_enabled() from the platform adapter.
|
||
|
||
is_docker_enabled() {
|
||
platform_is_service_enabled docker
|
||
}
|
||
|
||
is_vm_manager_enabled() {
|
||
platform_is_service_enabled libvirt
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── LOCAL HEALTH CHECKS ───────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
# ==============================================================================================
|
||
# ── REMOTE HEALTH CHECKS ─────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
# Verifies remote storage is mounted via SSH.
|
||
# Non-fatal — returns status. Used before handback rsync.
|
||
# Syncing to remote with no array fills rootfs rapidly.
|
||
check_remote_array() {
|
||
log "Checking remote array on $REMOTE_SERVER_NAME..."
|
||
local _spath result
|
||
_spath="$REMOTE_STORAGE_PATH"
|
||
result=$(ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \
|
||
"mountpoint -q '$_spath' && echo yes || echo no" 2>/dev/null)
|
||
if [[ "$result" != "yes" ]]; then
|
||
error "$ICON_DISK Remote array not started on $REMOTE_SERVER_NAME"
|
||
return 1
|
||
fi
|
||
log "Remote array is healthy"
|
||
return 0
|
||
}
|
||
|
||
# Aborts if remote rootfs (/) usage is at or above ROOTFS_WARN threshold.
|
||
# Fatal — exits the calling script.
|
||
# When remote array is down rsync writes land on rootfs and fill it rapidly.
|
||
check_remote_rootfs() {
|
||
log "Checking remote rootfs usage..."
|
||
local remote_usage
|
||
remote_usage=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null)
|
||
if [[ -z "$remote_usage" ]]; then
|
||
error "Could not retrieve rootfs usage from $REMOTE_SERVER_NAME"
|
||
exit 1
|
||
fi
|
||
if [[ "$remote_usage" -ge "${ROOTFS_WARN:-75}" ]]; then
|
||
error "$ICON_HEALTH Remote rootfs ${remote_usage}% — threshold ${ROOTFS_WARN:-75}%"
|
||
exit 1
|
||
fi
|
||
info "$ICON_HEALTH Remote rootfs: ${remote_usage}% (threshold: ${ROOTFS_WARN:-75}%)"
|
||
}
|
||
|
||
# Verifies target directory exists and is not empty on remote.
|
||
# Fatal — exits the calling script.
|
||
# Usage: check_remote_share "/mnt/user/Movies"
|
||
check_remote_share() {
|
||
local dir="$1"
|
||
log "Checking remote share: $dir..."
|
||
|
||
local share_exists
|
||
share_exists=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"[[ -d '$dir' ]] && echo yes || echo no" 2>/dev/null)
|
||
if [[ "$share_exists" != "yes" ]]; then
|
||
error "$ICON_HEALTH Remote share does not exist: $dir"
|
||
exit 1
|
||
fi
|
||
|
||
local share_empty
|
||
share_empty=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"[[ -z \"\$(ls -A '$dir' 2>/dev/null)\" ]] && echo yes || echo no" 2>/dev/null)
|
||
if [[ "$share_empty" == "yes" ]]; then
|
||
warn "$ICON_HEALTH Remote share exists but is empty: $dir — aborting to protect data"
|
||
exit 1
|
||
fi
|
||
|
||
info "$ICON_HEALTH Remote share verified: $dir"
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── DISK TEMPERATURE CHECKS ───────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
# Returns 0 (true) if device is non-rotational (SSD/NVMe), 1 (false) if HDD.
|
||
# Checks /sys/block/<device>/queue/rotational — 0=SSD, 1=HDD.
|
||
is_ssd() {
|
||
local drive="$1"
|
||
local base rotational
|
||
base=$(basename "$drive")
|
||
rotational="/sys/block/$base/queue/rotational"
|
||
[[ -f "$rotational" ]] && [[ "$(cat "$rotational" 2>/dev/null)" == "0" ]] && return 0
|
||
# NVMe is always SSD — some NVMe controllers don't expose rotational correctly
|
||
[[ "$drive" == *nvme* ]] && return 0
|
||
return 1
|
||
}
|
||
|
||
# Sets globals: UNRAID_DISK_HOT UNRAID_DISK_MAX UNRAID_SSD_HOT UNRAID_SSD_MAX
|
||
# Falls back to master.conf values (SMART_TEMP_WARN/CRIT) if platform returns defaults.
|
||
get_unraid_temp_thresholds() {
|
||
read -r UNRAID_DISK_HOT UNRAID_DISK_MAX UNRAID_SSD_HOT UNRAID_SSD_MAX \
|
||
<<< "$(platform_get_temp_thresholds)"
|
||
UNRAID_DISK_HOT="${UNRAID_DISK_HOT:-${SMART_TEMP_WARN:-45}}"
|
||
UNRAID_DISK_MAX="${UNRAID_DISK_MAX:-${SMART_TEMP_CRIT:-55}}"
|
||
UNRAID_SSD_HOT="${UNRAID_SSD_HOT:-60}"
|
||
UNRAID_SSD_MAX="${UNRAID_SSD_MAX:-70}"
|
||
}
|
||
|
||
# Checks local disk temperatures before rsync.
|
||
# Fetches a domain's TLS cert expiry via openssl s_client + x509 and computes days
|
||
# remaining. Pure data — no logging, no thresholds — callers classify warn/crit
|
||
# themselves against their own CERT_WARN_DAYS/CERT_CRIT_DAYS and log however they like.
|
||
#
|
||
# Sets _CERT_DAYS, _CERT_EXPIRY (YYYY-MM-DD) on success. Sets _CERT_EXPIRY_RAW to the
|
||
# unparsed openssl date string only when parsing failed (empty when unreachable) so a
|
||
# caller that wants a more specific error message can distinguish the two failure modes.
|
||
#
|
||
# Usage: check_cert_expiry "$domain" [port=443] [timeout=$CERT_TIMEOUT or 10]
|
||
# Returns: 0 = fetched and parsed | 1 = unreachable or unparseable
|
||
check_cert_expiry() {
|
||
local domain="$1" port="${2:-443}" cert_timeout="${3:-${CERT_TIMEOUT:-10}}"
|
||
_CERT_DAYS=""
|
||
_CERT_EXPIRY=""
|
||
_CERT_EXPIRY_RAW=""
|
||
local expiry_str
|
||
expiry_str=$(echo | timeout "$cert_timeout" openssl s_client \
|
||
-connect "${domain}:${port}" -servername "$domain" \
|
||
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||
[[ -z "$expiry_str" ]] && return 1
|
||
local expiry_epoch
|
||
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
|
||
if [[ -z "$expiry_epoch" ]]; then
|
||
_CERT_EXPIRY_RAW="$expiry_str"
|
||
return 1
|
||
fi
|
||
_CERT_DAYS=$(( (expiry_epoch - $(date +%s)) / 86400 ))
|
||
_CERT_EXPIRY=$(date -d "$expiry_str" '+%Y-%m-%d' 2>/dev/null)
|
||
return 0
|
||
}
|
||
|
||
# Reads temps and rotational flag from /var/local/emhttp/disks.ini.
|
||
# Uses unRAID's own thresholds from dynamix.cfg via get_unraid_temp_thresholds().
|
||
#
|
||
# Returns:
|
||
# 0 = all temps OK — proceed
|
||
# 1 = warn threshold exceeded — skip this profile only
|
||
# 2 = critical threshold exceeded — abort all remaining profiles
|
||
#
|
||
# Sets TEMP_CHECK_RESULT with human readable summary for logging.
|
||
check_local_disk_temps() {
|
||
get_unraid_temp_thresholds
|
||
|
||
local disks_ini_content
|
||
if ! disks_ini_content=$(platform_get_disk_states); then
|
||
warn "disk state unavailable — skipping temp check"
|
||
TEMP_CHECK_RESULT="temp check skipped (disk state unavailable)"
|
||
return 0
|
||
fi
|
||
|
||
local worst_result=0
|
||
local hot_drives=()
|
||
local crit_drives=()
|
||
local current_name=""
|
||
local current_device=""
|
||
local current_rotational=""
|
||
local current_temp=""
|
||
|
||
check_drive() {
|
||
[[ -z "$current_name" ]] || [[ -z "$current_temp" ]] && return
|
||
[[ "$current_temp" -eq 0 ]] && return # spun down — skip
|
||
|
||
local warn_thresh crit_thresh
|
||
if [[ "$current_rotational" == "0" ]]; then
|
||
warn_thresh="$UNRAID_SSD_HOT"
|
||
crit_thresh="$UNRAID_SSD_MAX"
|
||
else
|
||
warn_thresh="$UNRAID_DISK_HOT"
|
||
crit_thresh="$UNRAID_DISK_MAX"
|
||
fi
|
||
|
||
if [[ "$current_temp" -ge "$crit_thresh" ]]; then
|
||
crit_drives+=("${current_name}(${current_device}):${current_temp}°C≥${crit_thresh}°C")
|
||
[[ $worst_result -lt 2 ]] && worst_result=2
|
||
elif [[ "$current_temp" -ge "$warn_thresh" ]]; then
|
||
hot_drives+=("${current_name}(${current_device}):${current_temp}°C≥${warn_thresh}°C")
|
||
[[ $worst_result -lt 1 ]] && worst_result=1
|
||
fi
|
||
}
|
||
|
||
while IFS= read -r ini_line; do
|
||
if echo "$ini_line" | grep -qE '^\["(disk[0-9]+|parity[0-9]?|cache[0-9]?)"\]'; then
|
||
check_drive
|
||
current_name=$(echo "$ini_line" | grep -o '"[^"]*"' | head -1 | tr -d '"')
|
||
current_device=""
|
||
current_rotational="1" # default HDD
|
||
current_temp=""
|
||
elif echo "$ini_line" | grep -q '^device='; then
|
||
current_device=$(echo "$ini_line" | cut -d= -f2 | tr -d '"')
|
||
elif echo "$ini_line" | grep -q '^rotational='; then
|
||
current_rotational=$(echo "$ini_line" | cut -d= -f2 | tr -d '"')
|
||
elif echo "$ini_line" | grep -q '^temp='; then
|
||
current_temp=$(echo "$ini_line" | cut -d= -f2 | tr -d '"')
|
||
current_temp="${current_temp//[^0-9]/}"
|
||
current_temp="${current_temp:-0}"
|
||
fi
|
||
done <<< "$disks_ini_content"
|
||
check_drive # process last drive
|
||
|
||
if [[ ${#crit_drives[@]} -gt 0 ]]; then
|
||
TEMP_CHECK_RESULT="CRITICAL temps: ${crit_drives[*]}"
|
||
error "Drive temp CRITICAL — aborting all remaining syncs: ${crit_drives[*]}"
|
||
notify "Rsync aborted on $(hostname) — drive temp CRITICAL: ${crit_drives[*]}" "Rsync Temp Check" "warning"
|
||
return 2
|
||
elif [[ ${#hot_drives[@]} -gt 0 ]]; then
|
||
TEMP_CHECK_RESULT="HOT drives: ${hot_drives[*]}"
|
||
warn "Drive temp WARNING — skipping this profile: ${hot_drives[*]}"
|
||
notify "Rsync profile skipped on $(hostname) — drive temp WARNING: ${hot_drives[*]}" "Rsync Temp Check" "normal"
|
||
return 1
|
||
else
|
||
TEMP_CHECK_RESULT="all normal (HDD warn:${UNRAID_DISK_HOT}°C crit:${UNRAID_DISK_MAX}°C SSD warn:${UNRAID_SSD_HOT}°C crit:${UNRAID_SSD_MAX}°C)"
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── REMOTE DISK VERIFICATION ──────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Verifies all disks backing a share are healthy on the remote server.
|
||
# Auto-detects filesystem type from disks.ini — handles XFS, ZFS, and cache pools.
|
||
# No configuration required — discovers disk layout at runtime.
|
||
#
|
||
# Three-tier detection handles all share locations:
|
||
# Tier 1: Array disk paths — /mnt/disk*/sharename (XFS or ZFS per-disk)
|
||
# Tier 2: ZFS standalone pools — finds nested paths up to 2 levels deep
|
||
# catches /mnt/cache/appdata-Fallback/Critical-Data correctly
|
||
# Tier 3: shfs fallback — if share exists under /mnt/user, resolve backing pool
|
||
# or verify shfs itself is mounted if pool can't be determined
|
||
#
|
||
# Fatal — exits the calling script if any disk is offline.
|
||
# Usage: check_remote_disks "/mnt/user/Movies"
|
||
|
||
check_remote_disks() {
|
||
local dir="$1"
|
||
local share_name
|
||
share_name=$(basename "$dir")
|
||
|
||
info "$ICON_DISK Checking disks backing $share_name on $REMOTE_SERVER_NAME..."
|
||
|
||
# Read remote disk state for fsType mapping
|
||
local disks_ini_content _disk_states_path
|
||
_disk_states_path=$(platform_disk_states_path)
|
||
disks_ini_content=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"cat '$_disk_states_path' 2>/dev/null" 2>/dev/null)
|
||
|
||
if [[ -z "$disks_ini_content" ]]; then
|
||
error "$ICON_DISK Cannot read disk state from $REMOTE_SERVER_NAME"
|
||
exit 1
|
||
fi
|
||
|
||
# Tier 1 — array disk paths (/mnt/disk*/sharename)
|
||
local backing_disks
|
||
backing_disks=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"ls -d /mnt/disk*/$share_name 2>/dev/null | awk -F/ '{print \$3}'" 2>/dev/null)
|
||
|
||
# Tier 2 — ZFS standalone pools, including nested paths
|
||
local zfs_pool_paths
|
||
zfs_pool_paths=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"zpool list -H -o name 2>/dev/null | while read pool; do
|
||
[[ -d \"/mnt/\${pool}/$share_name\" ]] && echo \"\$pool\" && continue
|
||
find \"/mnt/\${pool}\" -maxdepth 2 -name '$share_name' -type d 2>/dev/null | \
|
||
grep -q . && echo \"\$pool\"
|
||
done | sort -u" 2>/dev/null)
|
||
|
||
# Tier 3 — storage root fallback
|
||
local _spath="$REMOTE_STORAGE_PATH"
|
||
if [[ -z "$backing_disks" ]] && [[ -z "$zfs_pool_paths" ]]; then
|
||
local on_user
|
||
on_user=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"find '$_spath' -maxdepth 3 -name '$share_name' -type d 2>/dev/null | head -1" \
|
||
2>/dev/null)
|
||
if [[ -n "$on_user" ]]; then
|
||
local actual_path
|
||
actual_path=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"readlink -f '$on_user' 2>/dev/null || echo '$on_user'" 2>/dev/null)
|
||
local pool_name
|
||
pool_name=$(echo "$actual_path" | awk -F/ '{print $3}')
|
||
if [[ -n "$pool_name" ]] && [[ "$pool_name" != "user" ]]; then
|
||
zfs_pool_paths="$pool_name"
|
||
info "$ICON_DISK $share_name found at $actual_path on $REMOTE_SERVER_NAME"
|
||
else
|
||
# Can't determine pool — verify storage root itself is mounted
|
||
local shfs_ok
|
||
shfs_ok=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"mountpoint -q '$_spath' && echo yes || echo no" 2>/dev/null)
|
||
if [[ "$shfs_ok" == "yes" ]]; then
|
||
success "All disks backing $share_name are online ✅ (via storage root)"
|
||
return 0
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "$backing_disks" ]] && [[ -z "$zfs_pool_paths" ]]; then
|
||
error "$ICON_DISK No disks found backing $share_name on $REMOTE_SERVER_NAME"
|
||
exit 1
|
||
fi
|
||
|
||
local all_ok=true
|
||
|
||
# Check array disks (XFS or ZFS single-disk-in-array)
|
||
if [[ -n "$backing_disks" ]]; then
|
||
while IFS= read -r disk_name; do
|
||
[[ -z "$disk_name" ]] && continue
|
||
|
||
local fs_type
|
||
fs_type=$(echo "$disks_ini_content" | awk -F= -v disk="$disk_name" '
|
||
/^\["'"'"'?/ { current=substr($0,3,length($0)-4) }
|
||
current==disk && /^fsType=/ { print $2; exit }
|
||
' | tr -d '"')
|
||
fs_type="${fs_type:-xfs}"
|
||
|
||
if [[ "$fs_type" == "zfs" ]]; then
|
||
local pool_health
|
||
pool_health=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"zpool list -H -o health '$disk_name' 2>/dev/null" 2>/dev/null)
|
||
if [[ "$pool_health" == "ONLINE" ]]; then
|
||
info "$ICON_DISK $disk_name (ZFS) $ICON_RUNNING — $share_name ONLINE"
|
||
else
|
||
error "$ICON_DISK $disk_name (ZFS) $ICON_STOPPED — pool ${pool_health:-offline}"
|
||
all_ok=false
|
||
fi
|
||
else
|
||
local mounted
|
||
mounted=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"mountpoint -q '/mnt/$disk_name' && echo yes || echo no" 2>/dev/null)
|
||
if [[ "$mounted" == "yes" ]]; then
|
||
info "$ICON_DISK $disk_name (XFS) $ICON_RUNNING — $share_name present"
|
||
else
|
||
error "$ICON_DISK $disk_name (XFS) $ICON_STOPPED — not mounted"
|
||
all_ok=false
|
||
fi
|
||
fi
|
||
done <<< "$backing_disks"
|
||
fi
|
||
|
||
# Check ZFS standalone pools (cache, gaming, media-servers etc.)
|
||
if [[ -n "$zfs_pool_paths" ]]; then
|
||
while IFS= read -r pool_name; do
|
||
[[ -z "$pool_name" ]] && continue
|
||
local pool_health
|
||
pool_health=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"zpool list -H -o health '$pool_name' 2>/dev/null" 2>/dev/null)
|
||
if [[ "$pool_health" == "ONLINE" ]]; then
|
||
info "$ICON_DISK $pool_name (ZFS pool) $ICON_RUNNING — $share_name ONLINE"
|
||
else
|
||
error "$ICON_DISK $pool_name (ZFS pool) $ICON_STOPPED — pool ${pool_health:-offline}"
|
||
all_ok=false
|
||
fi
|
||
done <<< "$zfs_pool_paths"
|
||
fi
|
||
|
||
if [[ "$all_ok" == false ]]; then
|
||
error "One or more disks backing $share_name are offline on $REMOTE_SERVER_NAME"
|
||
exit 1
|
||
fi
|
||
success "All disks backing $share_name are online ✅"
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── CONTAINER MANAGEMENT ──────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Four functions — stop/start on both local and remote.
|
||
# State tracking: only containers that WERE running get restarted.
|
||
# Was stopped → stays stopped. Was running → gets restarted. ✅
|
||
|
||
# Tracks which remote containers were running before stop — used by start_containers()
|
||
RUNNING_CONTAINERS=()
|
||
|
||
# ── Docker command helpers (used by docker_daily_restart, docker_weekly_restart, docker_update) ──
|
||
|
||
# Default timeouts — scripts can override before calling.
|
||
DOCKER_TIMEOUT=${DOCKER_TIMEOUT:-30} # seconds before docker command is killed
|
||
RESTART_VERIFY_WAIT=${RESTART_VERIFY_WAIT:-5} # seconds to wait before checking state post-restart
|
||
|
||
# Run a docker command with a hard timeout. Returns 124 on timeout, else docker's exit code.
|
||
docker_cmd() {
|
||
timeout "$DOCKER_TIMEOUT" "$@"
|
||
local exit_code=$?
|
||
if [[ "$exit_code" -eq 124 ]]; then
|
||
error "Docker command timed out after ${DOCKER_TIMEOUT}s: $*"
|
||
return 1
|
||
fi
|
||
return "$exit_code"
|
||
}
|
||
|
||
# Verify a container is still running after a restart.
|
||
# Waits RESTART_VERIFY_WAIT seconds to let it settle, then checks State.Running.
|
||
verify_running() {
|
||
local container="$1"
|
||
sleep "$RESTART_VERIFY_WAIT"
|
||
local state
|
||
state=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
|
||
if [[ "$state" != "true" ]]; then
|
||
error "$container failed to stay running after restart — may have crashed"
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# Retry a docker command up to RETRY_COUNT times, sleeping SLEEP seconds between attempts.
|
||
retry_docker() {
|
||
local attempt=1
|
||
while [[ "$attempt" -le "$RETRY_COUNT" ]]; do
|
||
log "$ICON_RETRY Attempt $attempt of $RETRY_COUNT: $*"
|
||
if docker_cmd "$@"; then
|
||
log "Succeeded on attempt $attempt"
|
||
return 0
|
||
else
|
||
warn "Attempt $attempt failed"
|
||
(( attempt++ ))
|
||
[[ "$attempt" -le "$RETRY_COUNT" ]] && sleep "$SLEEP"
|
||
fi
|
||
done
|
||
error "Command failed after $RETRY_COUNT attempts: $*"
|
||
return 1
|
||
}
|
||
|
||
# ── Emby API helper ───────────────────────────────────────────────────────────
|
||
# General Emby REST call. Uses EMBY_URL and EMBY_API_KEY (aliased by detect_hosts).
|
||
# Usage: emby_api <endpoint> [timeout_seconds]
|
||
# Prints response body on 200; returns 1 on HTTP error or curl failure.
|
||
emby_api() {
|
||
local endpoint="$1" max_time="${2:-30}"
|
||
local response http_code body
|
||
response=$(curl -sf --max-time "$max_time" \
|
||
-H "X-Emby-Token: $EMBY_API_KEY" \
|
||
-w "\n%{http_code}" \
|
||
"${EMBY_URL}/${endpoint}" 2>/dev/null)
|
||
http_code=$(echo "$response" | tail -1)
|
||
body=$(echo "$response" | head -n -1)
|
||
[[ "$http_code" != "200" ]] && { error "Emby API HTTP $http_code: $endpoint"; return 1; }
|
||
echo "$body"
|
||
}
|
||
|
||
# ── Media Server Detection ────────────────────────────────────────────────────
|
||
# Picks whichever media server is actually running right now, so callers don't have to
|
||
# hardcode Emby or Jellyfin. Emby is preferred when both are up (it's the primary player on
|
||
# every host; Jellyfin is mainly kept around for its working ffprobe binary — see
|
||
# HOST*_FFPROBE_CONTAINER). Either container being merely *configured* in host*.conf doesn't
|
||
# count — this checks Docker's actual running state, so a host with Jellyfin installed but
|
||
# stopped correctly falls through to Emby (or to nothing, if neither is up).
|
||
#
|
||
# Requires detect_hosts() to have already run (needs EMBY_*/JELLYFIN_* aliases).
|
||
# Sets: MEDIA_SERVER ("Emby"|"Jellyfin"|""), MEDIA_SERVER_URL, MEDIA_SERVER_API_KEY.
|
||
# Returns 1 and leaves MEDIA_SERVER="" if neither configured container is running.
|
||
# Usage: detect_media_server [docker_timeout_seconds]
|
||
detect_media_server() {
|
||
local docker_timeout="${1:-10}"
|
||
MEDIA_SERVER="" MEDIA_SERVER_URL="" MEDIA_SERVER_API_KEY=""
|
||
|
||
local emby_up=false jellyfin_up=false
|
||
|
||
if [[ -n "${EMBY_CONTAINER:-}" ]]; then
|
||
[[ "$(timeout "$docker_timeout" docker inspect -f '{{.State.Running}}' \
|
||
"$EMBY_CONTAINER" 2>/dev/null)" == "true" ]] && emby_up=true
|
||
fi
|
||
if [[ -n "${JELLYFIN_CONTAINER:-}" ]]; then
|
||
[[ "$(timeout "$docker_timeout" docker inspect -f '{{.State.Running}}' \
|
||
"$JELLYFIN_CONTAINER" 2>/dev/null)" == "true" ]] && jellyfin_up=true
|
||
fi
|
||
|
||
if [[ "$emby_up" == true && "$jellyfin_up" == true ]]; then
|
||
log "Both Emby and Jellyfin are running — using Emby (primary)"
|
||
elif [[ "$emby_up" == true ]]; then
|
||
log "Emby is running, Jellyfin is not — using Emby"
|
||
elif [[ "$jellyfin_up" == true ]]; then
|
||
log "Jellyfin is running, Emby is not — using Jellyfin"
|
||
else
|
||
warn "Neither Emby nor Jellyfin is running — no media server available"
|
||
return 1
|
||
fi
|
||
|
||
if [[ "$emby_up" == true ]]; then
|
||
MEDIA_SERVER="Emby"
|
||
MEDIA_SERVER_URL="$EMBY_URL"
|
||
MEDIA_SERVER_API_KEY="$EMBY_API_KEY"
|
||
else
|
||
MEDIA_SERVER="Jellyfin"
|
||
MEDIA_SERVER_URL="$JELLYFIN_URL"
|
||
MEDIA_SERVER_API_KEY="$JELLYFIN_API_KEY"
|
||
fi
|
||
}
|
||
|
||
# General REST call against whichever server detect_media_server() picked. Jellyfin accepts
|
||
# the same X-Emby-Token header Emby uses (inherited from its Emby-fork lineage — confirmed
|
||
# live 2026-07-19), so one call shape covers both backends.
|
||
# Usage: detect_media_server && media_server_api <endpoint> [timeout_seconds]
|
||
# Prints response body on 200; returns 1 on HTTP error, curl failure, or no server detected.
|
||
media_server_api() {
|
||
local endpoint="$1" max_time="${2:-30}"
|
||
if [[ -z "${MEDIA_SERVER:-}" ]]; then
|
||
error "media_server_api called with no MEDIA_SERVER set — call detect_media_server first"
|
||
return 1
|
||
fi
|
||
local response http_code body
|
||
response=$(curl -sf --max-time "$max_time" \
|
||
-H "X-Emby-Token: $MEDIA_SERVER_API_KEY" \
|
||
-w "\n%{http_code}" \
|
||
"${MEDIA_SERVER_URL}/${endpoint}" 2>/dev/null)
|
||
http_code=$(echo "$response" | tail -1)
|
||
body=$(echo "$response" | head -n -1)
|
||
[[ "$http_code" != "200" ]] && { error "$MEDIA_SERVER API HTTP $http_code: $endpoint"; return 1; }
|
||
echo "$body"
|
||
}
|
||
|
||
# Stop containers on the REMOTE server via SSH.
|
||
# Reads CRITICAL_CONTAINER_NAMES — set from profile arrays by rsync.sh.
|
||
# Tracks running state in RUNNING_CONTAINERS for restart after sync.
|
||
stop_containers() {
|
||
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -eq 0 ]] || \
|
||
[[ "${CRITICAL_CONTAINER_NAMES[*]}" == "" ]]; then
|
||
log "No remote containers configured for this profile, skipping remote stop."
|
||
return
|
||
fi
|
||
echo "━━━ $ICON_STOP $ICON_CONTAINERS Containers ━━━"
|
||
info "Stopping remote containers..."
|
||
RUNNING_CONTAINERS=()
|
||
for c in "${CRITICAL_CONTAINER_NAMES[@]}"; do
|
||
[[ -z "$c" ]] && continue
|
||
local status
|
||
status=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||
"docker inspect -f '{{.State.Running}}' $c 2>/dev/null" 2>/dev/null || echo "unknown")
|
||
if [[ "$status" == "true" ]]; then
|
||
echo "$ICON_STOP Stopping remote $c..."
|
||
RUNNING_CONTAINERS+=("$c")
|
||
if ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" "docker stop $c" >/dev/null; then
|
||
echo "$ICON_STOPPED $c stopped"
|
||
else
|
||
error "Failed to stop remote $c"
|
||
fi
|
||
elif [[ "$status" == "false" ]]; then
|
||
echo "$ICON_NOT_RUNNING $c is not running — skipping"
|
||
else
|
||
log "$c not found on remote — skipping"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Tracks which local containers were running before stop — used by start_local_containers()
|
||
LOCAL_RUNNING_CONTAINERS=()
|
||
|
||
# Stop containers on the LOCAL server.
|
||
# Reads LOCAL_CRITICAL_CONTAINER_NAMES — set from profile arrays by rsync.sh.
|
||
# Flushes local databases cleanly before pushing data to remote.
|
||
stop_local_containers() {
|
||
if [[ ${#LOCAL_CRITICAL_CONTAINER_NAMES[@]} -eq 0 ]] || \
|
||
[[ "${LOCAL_CRITICAL_CONTAINER_NAMES[*]}" == "" ]]; then
|
||
log "No local containers configured for this profile, skipping local stop."
|
||
return
|
||
fi
|
||
info "Stopping local containers..."
|
||
LOCAL_RUNNING_CONTAINERS=()
|
||
for c in "${LOCAL_CRITICAL_CONTAINER_NAMES[@]}"; do
|
||
[[ -z "$c" ]] && continue
|
||
local status
|
||
status=$(docker inspect -f '{{.State.Running}}' "$c" 2>/dev/null || echo "unknown")
|
||
if [[ "$status" == "true" ]]; then
|
||
echo "$ICON_STOP Stopping local $c..."
|
||
LOCAL_RUNNING_CONTAINERS+=("$c")
|
||
if docker stop "$c" >/dev/null; then
|
||
echo "$ICON_STOPPED $c stopped"
|
||
else
|
||
error "Failed to stop local $c"
|
||
fi
|
||
elif [[ "$status" == "false" ]]; then
|
||
echo "$ICON_NOT_RUNNING $c is not running — skipping"
|
||
else
|
||
log "$c not found locally — skipping"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Restart containers on the REMOTE server that were running before sync.
|
||
# Reads RUNNING_CONTAINERS set by stop_containers().
|
||
# Respects DELAYED_CONTAINERS — waits CONTAINER_DELAY seconds before starting them.
|
||
start_containers() {
|
||
if [[ ${#RUNNING_CONTAINERS[@]} -eq 0 ]]; then
|
||
log "No remote containers to restart."
|
||
return
|
||
fi
|
||
info "Starting remote containers..."
|
||
for c in "${RUNNING_CONTAINERS[@]}"; do
|
||
[[ -z "$c" ]] && continue
|
||
local needs_delay=false
|
||
for d in "${DELAYED_CONTAINERS[@]}"; do
|
||
[[ "$c" == "$d" ]] && needs_delay=true && break
|
||
done
|
||
if [[ "$needs_delay" == true ]]; then
|
||
info "Waiting ${CONTAINER_DELAY}s before starting $c..."
|
||
sleep "$CONTAINER_DELAY"
|
||
fi
|
||
echo "$ICON_START Starting remote $c..."
|
||
if ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" "docker start $c" >/dev/null 2>&1; then
|
||
echo "$ICON_STARTED $c started"
|
||
else
|
||
error "Failed to start remote $c — start manually if needed"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Restart containers on the LOCAL server that were running before sync.
|
||
# Reads LOCAL_RUNNING_CONTAINERS set by stop_local_containers().
|
||
# Respects DELAYED_CONTAINERS and CONTAINER_DELAY same as remote.
|
||
start_local_containers() {
|
||
if [[ ${#LOCAL_RUNNING_CONTAINERS[@]} -eq 0 ]]; then
|
||
log "No local containers to restart."
|
||
return
|
||
fi
|
||
info "Starting local containers..."
|
||
for c in "${LOCAL_RUNNING_CONTAINERS[@]}"; do
|
||
[[ -z "$c" ]] && continue
|
||
local needs_delay=false
|
||
for d in "${DELAYED_CONTAINERS[@]}"; do
|
||
[[ "$c" == "$d" ]] && needs_delay=true && break
|
||
done
|
||
if [[ "$needs_delay" == true ]]; then
|
||
info "Waiting ${CONTAINER_DELAY}s before starting local $c..."
|
||
sleep "$CONTAINER_DELAY"
|
||
fi
|
||
echo "$ICON_START Starting local $c..."
|
||
if docker start "$c" >/dev/null 2>&1; then
|
||
echo "$ICON_STARTED $c started"
|
||
else
|
||
error "Failed to start local $c — start manually if needed"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Builds a dependency-safe restart order — dependency containers (anything named as a
|
||
# value in WATCHDOG_DEPENDENCIES) restart first, then everything else in original order.
|
||
# Sets ORDERED_RESTART. Usage: build_restart_order CONTAINERS_ARRAY_NAME (bare name, e.g.
|
||
# build_restart_order DAILY_RESTART_CONTAINERS — not "${DAILY_RESTART_CONTAINERS[@]}").
|
||
build_restart_order() {
|
||
local -n _bro_containers="$1"
|
||
ORDERED_RESTART=()
|
||
local remaining=("${_bro_containers[@]}")
|
||
local placed=()
|
||
|
||
# First pass — add dependency containers that appear in our list
|
||
for container in "${remaining[@]}"; do
|
||
[[ -z "$container" ]] && continue
|
||
local is_dependency=false
|
||
for dependent in "${!WATCHDOG_DEPENDENCIES[@]}"; do
|
||
if [[ "${WATCHDOG_DEPENDENCIES[$dependent]}" == *"$container"* ]]; then
|
||
is_dependency=true
|
||
break
|
||
fi
|
||
done
|
||
if [[ "$is_dependency" == true ]]; then
|
||
local already=false
|
||
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
|
||
if [[ "$already" == false ]]; then
|
||
ORDERED_RESTART+=("$container")
|
||
placed+=("$container")
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# Second pass — add remaining containers (dependents and independents)
|
||
for container in "${remaining[@]}"; do
|
||
[[ -z "$container" ]] && continue
|
||
local already=false
|
||
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
|
||
if [[ "$already" == false ]]; then
|
||
ORDERED_RESTART+=("$container")
|
||
placed+=("$container")
|
||
fi
|
||
done
|
||
|
||
log "Restart order: ${ORDERED_RESTART[*]}"
|
||
}
|
||
|
||
# Waits CONTAINER_DELAY if this container depends on the last restarted one.
|
||
# Usage: check_dependency_delay "$container" "$last_restarted"
|
||
check_dependency_delay() {
|
||
local container="$1" last="$2"
|
||
[[ -z "$last" ]] && return
|
||
local deps="${WATCHDOG_DEPENDENCIES[$container]:-}"
|
||
if [[ -n "$deps" ]] && [[ "$deps" == *"$last"* ]]; then
|
||
log "Waiting ${CONTAINER_DELAY}s — $container depends on $last..."
|
||
sleep "$CONTAINER_DELAY"
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── RSYNC OPTIONS ─────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Loads rsync options for the current profile.
|
||
# Falls back to DEFAULT_RSYNC_OPTS from master.conf if no profile match found.
|
||
# Profile opts do NOT inherit from defaults — list all desired flags explicitly in master.conf.
|
||
|
||
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
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── SCRIPT LOCKING ────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Prevents multiple instances of the same script running simultaneously.
|
||
# All lock files live in /tmp/unraid_locks/ — auto-cleared on reboot.
|
||
#
|
||
# Modes:
|
||
# strict — exit immediately if already running (default)
|
||
# wait — wait LOCK_WAIT_TIMEOUT seconds then exit if still locked
|
||
# continuous — for long-running scripts: skip gracefully if healthy instance running
|
||
#
|
||
# Features:
|
||
# Stale lock detection — if lock PID is dead, clears and proceeds
|
||
# PID reuse protection — lock stores PID:scriptname, validates both
|
||
# Age warning — warns if lock older than LOCK_WARN_AGE (skipped for continuous)
|
||
# EXIT trap — lock always released on exit, crash, or kill signal
|
||
|
||
LOCK_DIR="/tmp/unraid_locks"
|
||
|
||
# Ensure DATA_DIR and STATE_DIR exist — created here so every script that sources common.sh
|
||
# can safely write to either without checking first.
|
||
if [[ -n "${DATA_DIR:-}" ]] && [[ ! -d "$DATA_DIR" ]]; then
|
||
mkdir -p "$DATA_DIR" 2>/dev/null || true
|
||
fi
|
||
if [[ -n "${STATE_DIR:-}" ]] && [[ ! -d "$STATE_DIR" ]]; then
|
||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||
fi
|
||
|
||
RSYNC_COUNT_FILE="$LOCK_DIR/rsync_active_count"
|
||
RSYNC_MAX_CONCURRENT=3
|
||
LOCK_WARN_AGE=300 # seconds — warn if lock older than this (5min default)
|
||
LOCK_WAIT_TIMEOUT=30 # seconds — how long "wait" mode waits before giving up
|
||
|
||
# Registry of all lock files acquired by this process — released together on exit.
|
||
# Prevents the single-trap-per-acquire problem: each new acquire_lock/acquire_rsync_lock
|
||
# call used to overwrite the EXIT trap, orphaning the previous lock file.
|
||
declare -ga _LOCK_FILES=()
|
||
_release_all_locks() {
|
||
local lf
|
||
for lf in "${_LOCK_FILES[@]}"; do
|
||
[[ -f "$lf" ]] && rm -f "$lf"
|
||
done
|
||
}
|
||
_register_lock() {
|
||
_LOCK_FILES+=("$1")
|
||
trap '_release_all_locks' EXIT
|
||
}
|
||
|
||
# Internal — script name used as lock identifier
|
||
_lock_name() {
|
||
basename "${BASH_SOURCE[1]:-$0}" .sh
|
||
}
|
||
|
||
# Internal — lock file path for this script
|
||
_lock_file() {
|
||
echo "$LOCK_DIR/${1:-$(_lock_name)}.lock"
|
||
}
|
||
|
||
# Acquire exclusive lock for this script.
|
||
# Usage: acquire_lock [strict|wait|continuous]
|
||
acquire_lock() {
|
||
local mode="${1:-strict}"
|
||
local script_name
|
||
script_name=$(basename "${BASH_SOURCE[1]:-$0}" .sh)
|
||
local lockfile
|
||
lockfile="$(_lock_file "$script_name")"
|
||
|
||
mkdir -p "$LOCK_DIR"
|
||
|
||
if [[ -f "$lockfile" ]]; then
|
||
local lock_content existing_pid locked_name
|
||
lock_content=$(cat "$lockfile" 2>/dev/null)
|
||
existing_pid="${lock_content%%:*}"
|
||
locked_name="${lock_content##*:}"
|
||
|
||
# Stale lock — PID dead
|
||
if [[ -z "$existing_pid" ]] || ! kill -0 "$existing_pid" 2>/dev/null; then
|
||
warn "Stale lock detected for $script_name (PID $existing_pid gone) — clearing"
|
||
rm -f "$lockfile"
|
||
|
||
# PID reuse — PID alive but belongs to different process
|
||
elif [[ "$locked_name" != "$script_name" ]]; then
|
||
warn "Lock PID $existing_pid reused by different process ($locked_name ≠ $script_name) — clearing"
|
||
rm -f "$lockfile"
|
||
|
||
else
|
||
# Lock is genuinely active — check age
|
||
local lock_age
|
||
lock_age=$(( $(date +%s) - $(stat -c %Y "$lockfile" 2>/dev/null || echo 0) ))
|
||
if [[ "$lock_age" -gt "$LOCK_WARN_AGE" ]] && [[ "$mode" != "continuous" ]]; then
|
||
warn "$script_name has been running for ${lock_age}s — may be stuck (PID $existing_pid)"
|
||
fi
|
||
|
||
if [[ "$mode" == "continuous" ]]; then
|
||
log "$ICON_SKIP $script_name already running healthy (PID $existing_pid) — skipping"
|
||
exit 0
|
||
elif [[ "$mode" == "wait" ]]; then
|
||
info "Another instance of $script_name is running — waiting up to ${LOCK_WAIT_TIMEOUT}s"
|
||
local waited=0
|
||
while [[ -f "$lockfile" ]] && [[ "$waited" -lt "$LOCK_WAIT_TIMEOUT" ]]; do
|
||
sleep 1
|
||
((waited++))
|
||
lock_content=$(cat "$lockfile" 2>/dev/null)
|
||
existing_pid="${lock_content%%:*}"
|
||
locked_name="${lock_content##*:}"
|
||
if [[ -z "$existing_pid" ]] || ! kill -0 "$existing_pid" 2>/dev/null; then
|
||
warn "Lock became stale while waiting — clearing"
|
||
rm -f "$lockfile"
|
||
break
|
||
elif [[ "$locked_name" != "$script_name" ]]; then
|
||
warn "Lock PID reused while waiting — clearing"
|
||
rm -f "$lockfile"
|
||
break
|
||
fi
|
||
done
|
||
if [[ -f "$lockfile" ]]; then
|
||
error "$script_name still locked after ${LOCK_WAIT_TIMEOUT}s — exiting"
|
||
exit 1
|
||
fi
|
||
else
|
||
error "Another instance of $script_name is already running (PID $existing_pid) — exiting"
|
||
case "$script_name" in
|
||
fallback|transcode_management|daily_sync_maintenance|stability_watchdog)
|
||
notify "$script_name lock collision on $(hostname) — concurrent instance detected" "$script_name" "warning"
|
||
;;
|
||
esac
|
||
exit 1
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Acquire lock — store PID:scriptname to prevent PID reuse false positives
|
||
echo "$$:$script_name" > "$lockfile"
|
||
_register_lock "$lockfile"
|
||
log "$ICON_LOCK Lock acquired: $script_name (PID $$)"
|
||
}
|
||
|
||
# Acquire per-profile rsync lock + enforce global concurrent limit.
|
||
# Prevents same profile running twice and limits total concurrent rsync instances.
|
||
# Usage: acquire_rsync_lock "$PROFILE_NAME"
|
||
acquire_rsync_lock() {
|
||
local profile="$1"
|
||
local profile_lock
|
||
profile_lock="$(_lock_file "rsync_${profile}")"
|
||
|
||
mkdir -p "$LOCK_DIR"
|
||
|
||
# Per-profile lock — same profile cannot run twice
|
||
if [[ -f "$profile_lock" ]]; then
|
||
local lock_content existing_pid locked_name
|
||
lock_content=$(cat "$profile_lock" 2>/dev/null)
|
||
existing_pid="${lock_content%%:*}"
|
||
locked_name="${lock_content##*:}"
|
||
if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null && \
|
||
[[ "$locked_name" == "rsync_${profile}" ]]; then
|
||
error "rsync profile '$profile' is already running (PID $existing_pid) — exiting"
|
||
exit 1
|
||
else
|
||
warn "Stale rsync lock for profile '$profile' — clearing"
|
||
rm -f "$profile_lock"
|
||
fi
|
||
fi
|
||
|
||
# Global concurrent limit — validate count against actual live locks
|
||
local current_count=0
|
||
if [[ -f "$RSYNC_COUNT_FILE" ]]; then
|
||
current_count=$(cat "$RSYNC_COUNT_FILE" 2>/dev/null || echo 0)
|
||
local actual_count=0
|
||
for lf in "$LOCK_DIR"/rsync_*.lock; do
|
||
[[ -f "$lf" ]] || continue
|
||
local lpid lf_content
|
||
lf_content=$(cat "$lf" 2>/dev/null)
|
||
lpid="${lf_content%%:*}"
|
||
kill -0 "$lpid" 2>/dev/null && ((actual_count++))
|
||
done
|
||
if [[ "$actual_count" -ne "$current_count" ]]; then
|
||
log "rsync count corrected: $current_count → $actual_count"
|
||
current_count=$actual_count
|
||
echo "$current_count" > "$RSYNC_COUNT_FILE"
|
||
fi
|
||
fi
|
||
|
||
if [[ "$current_count" -ge "$RSYNC_MAX_CONCURRENT" ]]; then
|
||
error "Maximum concurrent rsync limit ($RSYNC_MAX_CONCURRENT) reached — exiting"
|
||
info "Active rsync locks: $(ls "$LOCK_DIR"/rsync_*.lock 2>/dev/null | \
|
||
xargs -I{} basename {} .lock | tr '\n' ' ')"
|
||
exit 1
|
||
fi
|
||
|
||
# Acquire profile lock and increment counter
|
||
echo "$$:rsync_${profile}" > "$profile_lock"
|
||
echo $(( current_count + 1 )) > "$RSYNC_COUNT_FILE"
|
||
_register_lock "$profile_lock"
|
||
log "$ICON_LOCK rsync lock acquired: profile '$profile' (PID $$, active: $(( current_count + 1 ))/$RSYNC_MAX_CONCURRENT)"
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── FLAT STATE-FILE KEY/VALUE HELPERS ─────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Generic get/set for flat "key<sep>value" state files (one entry per line) — the pattern
|
||
# every watchdog's strike-tracking and state-file logic was independently reimplementing.
|
||
# grep -v + tempfile-swap on write, not sed -i in place — avoids sed treating a key containing
|
||
# regex metacharacters (container names, etc.) as part of the substitution pattern.
|
||
#
|
||
# Usage: wd_state_get "$key" "$file" [sep=:]
|
||
# wd_state_set "$key" "$value" "$file" [sep=:]
|
||
#
|
||
# Callers with a fixed state file and/or separator (e.g. stability_watchdog.sh's
|
||
# get_strikes/get_state_val, resource_watchdog.sh's rm_state_get/rm_state_get_eq) should
|
||
# keep their own thin same-named wrapper around these rather than changing call sites.
|
||
wd_state_get() {
|
||
local key="$1" file="$2" sep="${3:-:}"
|
||
grep -E "^${key}${sep}" "$file" 2>/dev/null | cut -d"$sep" -f2-
|
||
}
|
||
|
||
wd_state_set() {
|
||
local key="$1" value="$2" file="$3" sep="${4:-:}"
|
||
grep -vE "^${key}${sep}" "$file" 2>/dev/null > "${file}.tmp"
|
||
echo "${key}${sep}${value}" >> "${file}.tmp"
|
||
mv "${file}.tmp" "$file"
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── ORCHESTRATOR CHILD EXECUTION ──────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Shared child-script runner for Orchestrators/*.sh. Replaces the run_job()/run_watchdog()
|
||
# copies that used to be hand-duplicated (with small inconsistencies) into each orchestrator —
|
||
# one implementation now, one place to fix a bug or extend behavior (e.g. per-child logging).
|
||
#
|
||
# Usage — caller declares its own tracking arrays before the loop:
|
||
# JOB_PASS=()
|
||
# JOB_FAIL=()
|
||
# for entry in "${SOME_SCRIPTS[@]}"; do
|
||
# run_orch_child "$entry"
|
||
# done
|
||
#
|
||
# $entry is "relative/path.sh [extra args...]" — the same format every master.conf
|
||
# script-list array already uses. Resolved against $ECOSYSTEM_ROOT, which the caller
|
||
# must set before calling this (the absolute repo root — "$(cd "$SCRIPT_DIR/.." && pwd)").
|
||
#
|
||
# --dry-run / --log are threaded down automatically from $DRY_RUN / $ENABLE_LOGGING —
|
||
# never $VERBOSE, which nothing in this codebase ever assigns.
|
||
|
||
run_orch_child() {
|
||
local entry="$1"
|
||
local script_args script_path script_name label extra_args run_args
|
||
|
||
read -r -a script_args <<< "$entry"
|
||
script_path="$ECOSYSTEM_ROOT/${script_args[0]}"
|
||
script_name=$(basename "${script_args[0]}")
|
||
extra_args=("${script_args[@]:1}")
|
||
label="$script_name"
|
||
[[ -n "${extra_args[*]}" ]] && label="$script_name ${extra_args[*]}"
|
||
|
||
if [[ ! -f "$script_path" ]]; then
|
||
error "$script_name — not found at $script_path"
|
||
JOB_FAIL+=("$label")
|
||
return 1
|
||
fi
|
||
[[ ! -x "$script_path" ]] && chmod +x "$script_path"
|
||
|
||
run_args=("${extra_args[@]}")
|
||
[[ "$DRY_RUN" == true ]] && run_args+=("--dry-run")
|
||
[[ "$ENABLE_LOGGING" == true ]] && run_args+=("--log")
|
||
|
||
local _start _ec
|
||
_start=$(date +%s)
|
||
log "Running: $label"
|
||
if bash "$script_path" "${run_args[@]}"; then
|
||
log "$script_name — done in $(format_duration $(( $(date +%s) - _start )))"
|
||
JOB_PASS+=("$label")
|
||
return 0
|
||
else
|
||
_ec=$?
|
||
error "$label — failed (exit $_ec, $(format_duration $(( $(date +%s) - _start ))))"
|
||
JOB_FAIL+=("$label")
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── PATH TRANSLATION ──────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Translates container-internal file paths to host paths for arr cleanup scripts.
|
||
# Arr APIs return file paths as seen inside the container — scripts need host paths to scan.
|
||
#
|
||
# Uses ARR_PATH_MAP associative array — must be declared before calling.
|
||
# Longest-match wins — prevents partial path collisions.
|
||
#
|
||
# Usage:
|
||
# declare -A ARR_PATH_MAP=(["/ext-music"]="/mnt/user/Music")
|
||
# translate_path "/ext-music/Artist/Album/track.flac"
|
||
# Returns: /mnt/user/Music/Artist/Album/track.flac
|
||
#
|
||
# If no match found — returns path unchanged (safe fallback).
|
||
|
||
translate_path() {
|
||
local api_path="$1"
|
||
local best_match=""
|
||
local best_len=0
|
||
|
||
for container_path in "${!ARR_PATH_MAP[@]}"; do
|
||
if [[ "$api_path" == "$container_path"* ]]; then
|
||
if [[ "${#container_path}" -gt "$best_len" ]]; then
|
||
best_match="$container_path"
|
||
best_len="${#container_path}"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [[ -n "$best_match" ]]; then
|
||
echo "${ARR_PATH_MAP[$best_match]}${api_path#$best_match}"
|
||
else
|
||
echo "$api_path" # no match — return unchanged
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── CONTAINER STOP/RESTART FOR MAINTENANCE ────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# The "stop a container for a maintenance window, guarantee it comes back" shape shared by
|
||
# container_data_export.sh and emby_database_repair.sh. Callers still register their own EXIT
|
||
# trap (they may need extra cleanup, e.g. removing a partial archive) — the trap should call
|
||
# container_force_restart_if_needed() for the actual "still down? bring it back" part.
|
||
|
||
# Usage: container_stop_for_maintenance "$container" was_running_var_name \
|
||
# ["pre-stop reason message"] [log|warn] [post_stop_sleep=0]
|
||
# The reason message (if given) prints via the chosen level (default: log) right before the
|
||
# stop attempt, only when the container was actually running — callers use this for their
|
||
# own context ("stopping for clean export" vs "active sessions will be interrupted").
|
||
# Sets the named var true/false. Returns 1 (caller should exit) if the container doesn't
|
||
# exist or fails to stop; 0 otherwise (including the "wasn't running" case — caller logs
|
||
# its own context-specific message for that, since wording/visibility differs per caller).
|
||
container_stop_for_maintenance() {
|
||
local container="$1"
|
||
local -n _csm_was_running="$2"
|
||
local pre_stop_msg="${3:-}" pre_stop_level="${4:-log}" post_stop_sleep="${5:-0}"
|
||
_csm_was_running=false
|
||
|
||
local status
|
||
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
|
||
case "$status" in
|
||
true)
|
||
_csm_was_running=true
|
||
if [[ -n "$pre_stop_msg" ]]; then
|
||
if [[ "$pre_stop_level" == "warn" ]]; then warn "$pre_stop_msg"; else log "$pre_stop_msg"; fi
|
||
fi
|
||
if [[ "$DRY_RUN" == false ]]; then
|
||
if timeout "$DOCKER_TIMEOUT" docker stop "$container" >/dev/null 2>&1; then
|
||
echo "$container stopped ✅"
|
||
[[ "$post_stop_sleep" -gt 0 ]] && sleep "$post_stop_sleep"
|
||
else
|
||
error "Failed to stop $container — aborting"
|
||
return 1
|
||
fi
|
||
else
|
||
warn "DRY RUN — would stop $container"
|
||
fi
|
||
;;
|
||
false) : ;; # not running — caller logs its own context-specific message
|
||
"")
|
||
error "$container not found — check container name"
|
||
return 1
|
||
;;
|
||
*)
|
||
warn "$container status: $status — proceeding with caution"
|
||
;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# Force-restart from inside a script's own EXIT trap if the container is still down after
|
||
# a crash mid-maintenance. No-op if it wasn't running before, in dry-run, or already up.
|
||
# Usage: container_force_restart_if_needed "$container" "$was_running"
|
||
container_force_restart_if_needed() {
|
||
local container="$1" was_running="$2"
|
||
[[ "$was_running" == true && "$DRY_RUN" == false ]] || return 0
|
||
local status
|
||
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
|
||
if [[ "$status" != "true" ]]; then
|
||
warn "Restarting $container (cleanup)..."
|
||
timeout "$DOCKER_TIMEOUT" docker start "$container" >/dev/null 2>&1 || \
|
||
error "Failed to restart $container — start it manually"
|
||
fi
|
||
}
|
||
|
||
# Normal-path restart after maintenance completes — verifies it stayed up, notifies on failure.
|
||
# Sets RESTART_OK true/false.
|
||
# Usage: container_restart_after_maintenance "$container" "$was_running" [settle_sleep=3] [notify_label]
|
||
container_restart_after_maintenance() {
|
||
local container="$1" was_running="$2" settle_sleep="${3:-3}" notify_label="${4:-Container Maintenance}"
|
||
RESTART_OK=false
|
||
|
||
if [[ "$was_running" != true ]]; then
|
||
echo "$container was not running — leaving stopped (state respected) ✅"
|
||
RESTART_OK=true
|
||
return 0
|
||
fi
|
||
if [[ "$DRY_RUN" == true ]]; then
|
||
warn "DRY RUN — would restart $container"
|
||
RESTART_OK=true
|
||
return 0
|
||
fi
|
||
|
||
log "Restarting $container..."
|
||
if timeout "$DOCKER_TIMEOUT" docker start "$container" >/dev/null 2>&1; then
|
||
sleep "$settle_sleep"
|
||
local post_status
|
||
post_status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
|
||
if [[ "$post_status" == "true" ]]; then
|
||
echo "$container restarted and running ✅"
|
||
RESTART_OK=true
|
||
else
|
||
error "$container started but crashed — check container logs"
|
||
notify "$container failed to stay running after maintenance on $(hostname)" \
|
||
"$notify_label" "warning"
|
||
fi
|
||
else
|
||
error "Failed to restart $container — start it manually"
|
||
notify "$container failed to restart after maintenance on $(hostname)" \
|
||
"$notify_label" "warning"
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── ARR CLEANUP SAFETY GATES ──────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Shared by lidarr/radarr/sonarr_cleanup.sh — these scripts DELETE files, so every function
|
||
# here is a direct byte-for-byte port of what was independently duplicated three times, not
|
||
# a rewrite. Callers should behave identically to before this consolidation.
|
||
|
||
# Safety Layer 1 — container running, not starting/unhealthy. Exits 1 (with notify) on failure.
|
||
# Usage: check_container_health "$LIDARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Lidarr Cleanup"
|
||
check_container_health() {
|
||
local container="$1" docker_timeout="$2" notify_label="$3"
|
||
|
||
local running
|
||
running=$(timeout "$docker_timeout" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
|
||
if [[ "$running" != "true" ]]; then
|
||
error "$container is not running — aborting"
|
||
notify "$notify_label aborted on $(hostname) — container not running" "$notify_label" "warning"
|
||
exit 1
|
||
fi
|
||
|
||
local health
|
||
health=$(timeout "$docker_timeout" docker inspect -f '{{.State.Health.Status}}' "$container" 2>/dev/null)
|
||
case "$health" in
|
||
healthy) info "$container is healthy" ;;
|
||
"") info "$container has no health check — proceeding" ;;
|
||
starting)
|
||
error "$container is still starting — aborting"
|
||
notify "$notify_label aborted on $(hostname) — container still starting" "$notify_label" "warning"
|
||
exit 1 ;;
|
||
unhealthy)
|
||
error "$container is unhealthy — aborting"
|
||
notify "$notify_label aborted on $(hostname) — container unhealthy" "$notify_label" "warning"
|
||
exit 1 ;;
|
||
*) warn "$container health: $health — proceeding with caution" ;;
|
||
esac
|
||
|
||
info "Safety layer 1 passed — container healthy"
|
||
}
|
||
|
||
# Safety Layer 6 — abort if tracked count dropped below min_pct of the last known count
|
||
# (protects against a misconfigured path / partial API response silently wiping the library).
|
||
# Writes the new baseline only if the check passes. Exits 1 (with notify) on failure.
|
||
# Usage: check_tracked_count_floor "$TRACKED_COUNT" "$LIDARR_TRACKED_COUNT_FILE" "$LIDARR_MIN_TRACKED_PCT" "Lidarr Cleanup"
|
||
check_tracked_count_floor() {
|
||
local tracked_count="$1" baseline_file="$2" min_pct="$3" notify_label="$4"
|
||
|
||
if [[ -f "$baseline_file" ]]; then
|
||
local last_count pct
|
||
last_count=$(cat "$baseline_file" 2>/dev/null || echo 0)
|
||
if [[ "$last_count" -gt 0 ]]; then
|
||
pct=$(awk "BEGIN {printf \"%d\", ($tracked_count / $last_count) * 100}")
|
||
if [[ "$pct" -lt "$min_pct" ]]; then
|
||
error "Tracked count dropped to ${pct}% of last run ($tracked_count vs $last_count)"
|
||
error "Suggests API issue — aborting to prevent mass deletion"
|
||
error "If expected (large library removal) delete: $baseline_file"
|
||
notify "$notify_label aborted on $(hostname) — tracked count dropped to ${pct}%" \
|
||
"$notify_label" "warning"
|
||
exit 1
|
||
fi
|
||
info "Tracked count: ${pct}% of last run ($tracked_count vs $last_count) ✅"
|
||
fi
|
||
else
|
||
info "No previous count on record — first run, saving baseline"
|
||
fi
|
||
|
||
echo "$tracked_count" > "$baseline_file"
|
||
}
|
||
|
||
# Filters --i-know-what-im-doing / --skip-age-check out of "$@" before parse_args sees them
|
||
# (both are cleanup-script-specific, not part of the shared arg parser).
|
||
# Sets I_KNOW, SKIP_AGE_CHECK, FILTERED_ARGS — call parse_args "${FILTERED_ARGS[@]}" after.
|
||
parse_destructive_flags() {
|
||
I_KNOW=false
|
||
SKIP_AGE_CHECK=false
|
||
FILTERED_ARGS=()
|
||
local arg
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
--i-know-what-im-doing) I_KNOW=true ;;
|
||
--skip-age-check) SKIP_AGE_CHECK=true ;;
|
||
*) FILTERED_ARGS+=("$arg") ;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
# Prints the nuclear-mode banner and sleeps 10s (Ctrl+C window) when both destructive flags
|
||
# are set and this isn't a dry run. Call after parse_args. No-op otherwise.
|
||
nuclear_mode_warning() {
|
||
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_AGE_CHECK" == true ]] && [[ "$DRY_RUN" != true ]]; then
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " Flags: --i-know-what-im-doing --skip-age-check"
|
||
echo " Age check: BYPASSED — deletes on first pass"
|
||
echo " Size threshold: BYPASSED — no GB limit"
|
||
echo " Data recovery: NOT POSSIBLE after deletion"
|
||
echo ""
|
||
echo " Review --dry-run output before proceeding."
|
||
echo " You have 10 seconds to cancel (Ctrl+C)..."
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
sleep 10
|
||
echo " Proceeding..."
|
||
echo ""
|
||
fi
|
||
}
|
||
|
||
# Safety Layer 7 — abort if total deletion size exceeds max_gb and --i-know-what-im-doing
|
||
# wasn't passed. Sets TOTAL_HUMAN. Exits 1 (with notify) on failure; warns and continues
|
||
# if I_KNOW is true (set by parse_destructive_flags).
|
||
# Usage: check_delete_size_threshold "$TOTAL_DELETE_BYTES" "$LIDARR_MAX_DELETE_GB" "Lidarr Cleanup"
|
||
check_delete_size_threshold() {
|
||
local total_bytes="$1" max_gb="$2" notify_label="$3"
|
||
local max_bytes
|
||
max_bytes=$(awk "BEGIN {printf \"%d\", $max_gb * 1073741824}")
|
||
if [[ "$total_bytes" -gt "$max_bytes" ]]; then
|
||
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $total_bytes / 1073741824}")
|
||
if [[ "$I_KNOW" != true ]]; then
|
||
echo ""
|
||
error "Deletion would exceed ${max_gb}GB — $TOTAL_HUMAN would be deleted"
|
||
error "Review ORPHAN lines above carefully before proceeding"
|
||
error "Rerun with: --i-know-what-im-doing"
|
||
error "To also bypass age check: add --skip-age-check"
|
||
notify "$notify_label halted on $(hostname) — ${TOTAL_HUMAN} requires --i-know-what-im-doing" \
|
||
"$notify_label" "warning"
|
||
exit 1
|
||
else
|
||
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# True if filepath's extension (case-insensitive) matches one of the given extensions.
|
||
# Usage: has_extension "$filepath" "${LIDARR_EXTENSIONS[@]}"
|
||
has_extension() {
|
||
local filepath="$1"; shift
|
||
local ext="${filepath##*.}"
|
||
ext="${ext,,}"
|
||
local valid_ext
|
||
for valid_ext in "$@"; do
|
||
[[ "$ext" == "$valid_ext" ]] && return 0
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# True if filepath's basename matches one of the given glob patterns.
|
||
# Usage: matches_pattern_list "$filepath" "${LIDARR_PROTECTED_PATTERNS[@]}"
|
||
#
|
||
# Parameter expansion instead of external basename (2026-07-17) — called once per
|
||
# non-tracked file in lidarr_cleanup.sh/sonarr_cleanup.sh/radarr_cleanup.sh's classification
|
||
# loop, which is most files in a media library (every protected sidecar: nfo/jpg/srt/etc).
|
||
# Measured: this was the actual dominant cost left in those scripts even after fixing the
|
||
# per-file stat fork and dirname elsewhere — 47.3s vs 1.65s for 10,000 calls (~28.6x),
|
||
# confirmed identical results across a spot check first.
|
||
matches_pattern_list() {
|
||
local filepath="$1"; shift
|
||
local filename="${filepath##*/}"
|
||
local pattern
|
||
for pattern in "$@"; do
|
||
# shellcheck disable=SC2254
|
||
case "$filename" in
|
||
$pattern) return 0 ;;
|
||
esac
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Builds the global ARR_PATH_MAP associative array (container path → host path) from
|
||
# ${MY_ID}_<ARR_TYPE>_PATH_MAP, for translate_path() to use. Usage: build_arr_path_map "LIDARR"
|
||
build_arr_path_map() {
|
||
local arr_type="$1"
|
||
declare -gA ARR_PATH_MAP=()
|
||
local _bapm_var="${MY_ID}_${arr_type}_PATH_MAP"
|
||
eval "for key in \"\${!${_bapm_var}[@]}\"; do
|
||
ARR_PATH_MAP[\"\$key\"]=\"\${${_bapm_var}[\$key]}\"
|
||
done"
|
||
}
|
||
|
||
# Generic arr API GET wrapper with HTTP status check.
|
||
# Usage: arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr"
|
||
arr_api() {
|
||
local base_url="$1" api_key="$2" api_version="$3" endpoint="$4" label="${5:-Arr}"
|
||
local response http_code body
|
||
|
||
response=$(curl -sf \
|
||
--max-time 30 \
|
||
-H "X-Api-Key: $api_key" \
|
||
-w "\n%{http_code}" \
|
||
"${base_url}/api/${api_version}/${endpoint}" 2>/dev/null)
|
||
|
||
http_code=$(echo "$response" | tail -1)
|
||
body=$(echo "$response" | head -n -1)
|
||
|
||
if [[ "$http_code" != "200" ]]; then
|
||
error "$label API HTTP $http_code for: $endpoint"
|
||
return 1
|
||
fi
|
||
echo "$body"
|
||
}
|
||
|
||
# Triggers an arr "command" endpoint with the given JSON payload, then polls every 10s until
|
||
# completed/failed/timeout, logging progress every 60s. Best-effort pre-flight — never treats
|
||
# an unreachable/timed-out scan as fatal, matching original per-script behavior.
|
||
#
|
||
# On a genuine "completed" observation, records the actual elapsed duration keyed by the
|
||
# command's own name via arr_record_rescan_duration() (needs arr_type to know which arr's
|
||
# duration DB to write to) — this is the one place in the codebase that reliably watches a
|
||
# command from trigger to completion, so it's the natural spot to build up real historical
|
||
# duration data for the wait-calibration logic in arr_get_tracked_data(). A timeout doesn't
|
||
# record anything — we only know a lower bound, not the true duration, and recording that
|
||
# would corrupt future wait calculations downward. arr_type is optional — omit it (or pass
|
||
# empty) to skip duration recording entirely, e.g. for one-off commands that aren't one of
|
||
# the three tracked arrs.
|
||
#
|
||
# Usage: trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" \
|
||
# '{"name": "DownloadedAlbumsScan"}' "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr"
|
||
trigger_and_await_command() {
|
||
local base_url="$1" api_key="$2" api_version="$3" payload="$4" poll_timeout="${5:-600}" arr_type="${6:-}"
|
||
|
||
local cmd_name
|
||
cmd_name=$(echo "$payload" | jq -r '.name // empty' 2>/dev/null)
|
||
|
||
local scan_response scan_cmd_id
|
||
scan_response=$(curl -sf --max-time 30 -X POST \
|
||
-H "X-Api-Key: $api_key" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$payload" \
|
||
"${base_url}/api/${api_version}/command" 2>/dev/null)
|
||
|
||
scan_cmd_id=$(echo "$scan_response" | jq -r '.id // empty' 2>/dev/null)
|
||
|
||
if [[ -z "$scan_cmd_id" ]]; then
|
||
warn "Could not trigger import scan — proceeding without pre-flight"
|
||
return 0
|
||
fi
|
||
|
||
info "Import scan queued (command ID: $scan_cmd_id) — waiting for completion..."
|
||
local start_epoch=$(date +%s)
|
||
local polled=0 scan_status
|
||
while [[ "$polled" -lt "$poll_timeout" ]]; do
|
||
scan_status=$(curl -sf --max-time 10 \
|
||
-H "X-Api-Key: $api_key" \
|
||
"${base_url}/api/${api_version}/command/${scan_cmd_id}" 2>/dev/null | \
|
||
jq -r '.status // empty' 2>/dev/null)
|
||
case "$scan_status" in
|
||
completed)
|
||
info "Import scan complete ✅"
|
||
[[ -n "$cmd_name" && -n "$arr_type" ]] && \
|
||
arr_record_rescan_duration "$arr_type" "$cmd_name" "$(( $(date +%s) - start_epoch ))"
|
||
return 0
|
||
;;
|
||
failed)
|
||
warn "Import scan reported failed — proceeding anyway"
|
||
return 0
|
||
;;
|
||
esac
|
||
sleep 10
|
||
(( polled += 10 ))
|
||
[[ $(( polled % 60 )) -eq 0 ]] && log " Still scanning... (${polled}s elapsed)"
|
||
done
|
||
warn "Import scan timed out after ${poll_timeout}s — proceeding anyway"
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── ARR TRACKED-DATA CACHE (Lidarr, Sonarr, Radarr) ──────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Shared cache for each arr's tracked-library data (Lidarr artists, Sonarr series, Radarr
|
||
# movies). Every script across all three arrs that needs "what does this arr think it has
|
||
# tracked" goes through this — 15+ consumers as of 2026-07-17 (all three cleanup scripts, the
|
||
# missing-art/release-fixer/duplicate-cleanup scripts, all three playback-aware discovery
|
||
# scripts, the TVDB/TMDb-removed checks, arr_sync.sh's local side, and the emby_to_*_sync
|
||
# bootstrap tools). Hitting the live API fresh every time is wasteful, and during an active
|
||
# rescan the live number is actively misleading — tracked counts dip and recover as files are
|
||
# detached/re-verified one by one (confirmed 2026-07-16 on Lidarr: a whole-library
|
||
# RescanFolders made trackFileCount read 22% of normal mid-scan, which is exactly the
|
||
# false-alarm lidarr_cleanup.sh's count-drop guard is meant to catch, but a genuine rescan
|
||
# isn't the "something's actually wrong" case that guard exists for — the same risk applies to
|
||
# Sonarr's RescanSeries and Radarr's RescanMovie).
|
||
#
|
||
# Built Lidarr-only first (2026-07-16), generalized the same day to cover all three arrs —
|
||
# identical mechanism, keyed by arr_type ("lidarr"/"sonarr"/"radarr") so each arr's cache and
|
||
# duration history stay separate.
|
||
#
|
||
# Storage (2026-07-17): primary copy lives on tmpfs (ARR_CACHE_DIR) — reads/writes never touch
|
||
# the array disk, and losing it on reboot costs nothing since a full rebuild for all three arrs
|
||
# measures ~12s live. A persistent backup on $DATA_DIR is kept in sync by every write and gets
|
||
# transparently restored into tmpfs by arr_cache_age_seconds() the moment it notices tmpfs is
|
||
# missing — so a cache that was fresh before a reboot reads as fresh after too.
|
||
#
|
||
# Write-through: any script that already does a live library-list fetch for its own purposes
|
||
# writes the result here as a side effect via arr_cache_write() — no dedicated polling timer
|
||
# needed. arr_cache_write() itself refuses to write while a rescan-type command is active for
|
||
# that arr (2026-07-17) — protects every caller uniformly, not just arr_get_tracked_data().
|
||
# Arrs_Stack/arr_cache_prefill.sh keeps the cache warm two ways: once at array start (10min
|
||
# wait ceiling, closes the cold-boot gap) and again every 30min via CRITICAL_MAINTENANCE_SCRIPTS
|
||
# (1min wait ceiling — a live fetch takes seconds, not the boot-time wait).
|
||
#
|
||
# Consumers should call arr_get_tracked_data() — never read a cache file directly. It handles
|
||
# the fresh/stale-no-rescan/stale-rescan-active branching so no script reimplements it.
|
||
#
|
||
# This is the top-level LIBRARY cache only. A separate, shorter-lived cache exists for the
|
||
# much more expensive per-item track/episode data — see "PER-ITEM TRACKED-FILE CACHE" below.
|
||
# ==============================================================================================
|
||
|
||
# Rescan-type command names per arr — operations long/heavy enough that overlapping with one
|
||
# should trigger a wait rather than a live fetch. Verified live against each arr's API
|
||
# 2026-07-16 (not assumed from memory — RescanSeries/RescanMovie confirmed to scan the whole
|
||
# library when given no id, matching Lidarr's RescanFolders).
|
||
declare -gA ARR_RESCAN_COMMANDS=(
|
||
[lidarr]="RescanFolders DownloadedAlbumsScan RefreshArtist"
|
||
[sonarr]="RescanSeries DownloadedEpisodesScan RefreshSeries"
|
||
[radarr]="RescanMovie DownloadedMoviesScan RefreshMovie"
|
||
)
|
||
|
||
# jq expression computing each arr's "total tracked" number from its library-list response —
|
||
# schemas differ: Lidarr/Sonarr have a per-item file count to sum, Radarr is a binary hasFile
|
||
# per movie (one file at most), so the aggregate has to be a count of true values instead.
|
||
declare -gA ARR_TRACKED_COUNT_EXPR=(
|
||
[lidarr]='[.[].statistics.trackFileCount] | add'
|
||
[sonarr]='[.[].statistics.episodeFileCount] | add'
|
||
[radarr]='[.[] | select(.hasFile==true)] | length'
|
||
)
|
||
|
||
# Library-list endpoint name per arr, for arr_api().
|
||
declare -gA ARR_LIBRARY_ENDPOINT=(
|
||
[lidarr]="artist"
|
||
[sonarr]="series"
|
||
[radarr]="movie"
|
||
)
|
||
|
||
# API version per arr — Lidarr is still v1, Sonarr/Radarr are v3.
|
||
declare -gA ARR_API_VERSION=(
|
||
[lidarr]="v1"
|
||
[sonarr]="v3"
|
||
[radarr]="v3"
|
||
)
|
||
|
||
# tmpfs — primary read/write location. Cleared every reboot; that's fine, a full rebuild for
|
||
# all three arrs measures ~12s live, and arr_cache_age_seconds() transparently restores from
|
||
# the persistent backup below the moment it notices this is missing.
|
||
arr_cache_file() { echo "${ARR_CACHE_DIR}/${1}_tracked_cache.json"; }
|
||
|
||
# Persistent backup — survives reboot. Kept in sync by arr_cache_write() dual-writing here on
|
||
# every write, so it's never more than one write-cycle stale (2026-07-17). Exists purely so a
|
||
# reboot doesn't leave the tmpfs cache genuinely empty until the next live fetch completes —
|
||
# not a source of truth in its own right, just what tmpfs gets restored from when missing.
|
||
arr_cache_backup_file() { echo "${DATA_DIR}/${1}_tracked_cache.json"; }
|
||
|
||
arr_rescan_duration_db() { echo "${DATA_DIR}/${1}_rescan_duration.db"; }
|
||
|
||
# Writes the current library-list JSON + computed total tracked count to arr_type's cache.
|
||
# Radarr's movie list alone runs ~16MB — passing that through jq's --argjson as a literal
|
||
# command-line argument blows past the OS's ARG_MAX ("Argument list too long"), the exact
|
||
# same class of bug the queue-pagination fix (2026-07-16, arrs_failed_stalled_recovery.sh)
|
||
# hit before. Fixed the same way: write the payload to a temp file and use --slurpfile,
|
||
# which reads from disk instead of argv.
|
||
#
|
||
# Refuses to write while a rescan-type command is active for arr_type (2026-07-17). Every
|
||
# direct caller here fetches the library list for its own purposes and writes it through as
|
||
# a side effect — none of them go through arr_get_tracked_data()'s fresh/stale/active-rescan
|
||
# branching, so none of them knew to check scan state first. Confirmed live: a caller wrote
|
||
# Lidarr's cache mid-RescanFolders at 22% of the real total, and that partial number then
|
||
# looked exactly like real data loss to every consumer (check_tracked_count_floor et al.)
|
||
# until the scan finished. The guard lives here instead of in each caller so it applies
|
||
# uniformly — last-known-good stays in place until something writes through the real
|
||
# post-scan number (arr_full_rescan.sh does this itself on completion; for a rescan someone
|
||
# else triggered, see arr_rescan_monitor.sh in Tools/).
|
||
# Args: arr_type, items_json (the full library-list response body)
|
||
arr_cache_write() {
|
||
local arr_type="$1" items_json="$2"
|
||
local expr="${ARR_TRACKED_COUNT_EXPR[$arr_type]:-}"
|
||
[[ -z "$expr" ]] && return 1
|
||
|
||
local url_var="${arr_type^^}_URL" key_var="${arr_type^^}_API_KEY"
|
||
local url="${!url_var:-}" api_key="${!key_var:-}" api_version="${ARR_API_VERSION[$arr_type]:-}"
|
||
if [[ -n "$url" && -n "$api_key" && -n "$api_version" ]]; then
|
||
local active_cmd
|
||
active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version")
|
||
[[ -n "$active_cmd" ]] && return 1
|
||
fi
|
||
|
||
local total
|
||
total=$(echo "$items_json" | jq "$expr" 2>/dev/null)
|
||
[[ -z "$total" || "$total" == "null" ]] && return 1
|
||
|
||
local cache_file backup_file items_tmp
|
||
cache_file=$(arr_cache_file "$arr_type")
|
||
backup_file=$(arr_cache_backup_file "$arr_type")
|
||
items_tmp=$(mktemp)
|
||
echo "$items_json" > "$items_tmp"
|
||
mkdir -p "$(dirname "$cache_file")" "$(dirname "$backup_file")" 2>/dev/null
|
||
jq -c -n --slurpfile items "$items_tmp" --argjson total "$total" --argjson ts "$(date +%s)" \
|
||
'{ts:$ts, totalTracked:$total, items:$items[0]}' > "${cache_file}.tmp" 2>/dev/null \
|
||
&& mv "${cache_file}.tmp" "$cache_file" \
|
||
&& cp "$cache_file" "$backup_file" 2>/dev/null
|
||
rm -f "$items_tmp"
|
||
}
|
||
|
||
# Echoes the cached library-list JSON array for arr_type if a cache file exists, regardless
|
||
# of age — staleness is arr_get_tracked_data()'s decision, not this raw reader's.
|
||
arr_cache_read_raw() {
|
||
local arr_type="$1"
|
||
local cache_file
|
||
cache_file=$(arr_cache_file "$arr_type")
|
||
[[ -f "$cache_file" ]] || return 1
|
||
jq -c '.items // empty' "$cache_file" 2>/dev/null
|
||
}
|
||
|
||
# Echoes arr_type's cache age in seconds, or a very large number if no cache and no backup
|
||
# exist (so age comparisons naturally treat that as "very stale"). Transparently restores the
|
||
# tmpfs cache from its persistent backup first if tmpfs is missing (e.g. right after a reboot)
|
||
# — a cache that was fresh before reboot reads as fresh here too, not as if it never existed.
|
||
arr_cache_age_seconds() {
|
||
local arr_type="$1"
|
||
local cache_file
|
||
cache_file=$(arr_cache_file "$arr_type")
|
||
if [[ ! -f "$cache_file" ]]; then
|
||
local backup_file
|
||
backup_file=$(arr_cache_backup_file "$arr_type")
|
||
if [[ -f "$backup_file" ]]; then
|
||
mkdir -p "$(dirname "$cache_file")" 2>/dev/null
|
||
cp "$backup_file" "$cache_file" 2>/dev/null
|
||
fi
|
||
fi
|
||
[[ -f "$cache_file" ]] || { echo 999999999; return; }
|
||
local ts
|
||
ts=$(jq -r '.ts // 0' "$cache_file" 2>/dev/null)
|
||
echo $(( $(date +%s) - ${ts:-0} ))
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── PER-ITEM TRACKED-FILE CACHE (Lidarr trackFile, Sonarr episodefile) ───────────────────────
|
||
# ==============================================================================================
|
||
# Different in kind from the tracked-LIBRARY cache above. That one caches the top-level
|
||
# artist/series/movie list (one call). This caches the much more expensive per-item walk —
|
||
# one live API call per artist/series — that lidarr_cleanup.sh/sonarr_cleanup.sh already have
|
||
# to do for their own cleanup decisions regardless. Write-through only, from a walk that's
|
||
# already happening: no independent live-fetch-and-refresh path here, since the field set
|
||
# each consumer needs differs (lidarr_cleanup.sh only needs .path; lidarr_missing_art.sh also
|
||
# needs .albumId) and there's no single generic "fetch everything" call worth centralizing —
|
||
# each script keeps its own live per-item fallback, this is purely a fast path in front of it.
|
||
#
|
||
# Freshness is short (default 4h, not the library cache's 1 day) because this is meant for a
|
||
# script running shortly after in the same maintenance window (e.g. lidarr_missing_art.sh
|
||
# right after lidarr_cleanup.sh), not held across a whole day. tmpfs only, no persistent
|
||
# backup — unlike the library cache, nothing unique lives only here (every consumer already
|
||
# has its own live fallback), and it's short-lived by design, so surviving a reboot doesn't
|
||
# matter the way it did for the always-wanted library cache. (2026-07-17)
|
||
#
|
||
# Future scripts needing this data: call arr_get_cached_items() first, fall back to your own
|
||
# live per-item fetch on a miss, and write through via arr_item_cache_write() if you're the
|
||
# one doing that fetch anyway — same pattern as lidarr_cleanup.sh/sonarr_cleanup.sh below.
|
||
# ==============================================================================================
|
||
|
||
arr_item_cache_file() { echo "${ARR_CACHE_DIR}/${1}_items_cache.json"; }
|
||
|
||
# Args: arr_type, items_json (array of raw per-item track/episode-file objects)
|
||
arr_item_cache_write() {
|
||
local arr_type="$1" items_json="$2"
|
||
|
||
local url_var="${arr_type^^}_URL" key_var="${arr_type^^}_API_KEY"
|
||
local url="${!url_var:-}" api_key="${!key_var:-}" api_version="${ARR_API_VERSION[$arr_type]:-}"
|
||
if [[ -n "$url" && -n "$api_key" && -n "$api_version" ]]; then
|
||
local active_cmd
|
||
active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version")
|
||
[[ -n "$active_cmd" ]] && return 1
|
||
fi
|
||
|
||
local cache_file items_tmp
|
||
cache_file=$(arr_item_cache_file "$arr_type")
|
||
items_tmp=$(mktemp)
|
||
echo "$items_json" > "$items_tmp"
|
||
mkdir -p "$(dirname "$cache_file")" 2>/dev/null
|
||
jq -c -n --slurpfile items "$items_tmp" --argjson ts "$(date +%s)" \
|
||
'{ts:$ts, items:$items[0]}' > "${cache_file}.tmp" 2>/dev/null \
|
||
&& mv "${cache_file}.tmp" "$cache_file"
|
||
rm -f "$items_tmp"
|
||
}
|
||
|
||
# Echoes the cached per-item array if present and fresher than max_age_seconds, empty/1 on a
|
||
# miss or stale cache — caller falls back to its own live per-item fetch exactly as it already
|
||
# does today.
|
||
# Args: arr_type, max_age_seconds (default 14400 = 4h)
|
||
arr_get_cached_items() {
|
||
local arr_type="$1" max_age="${2:-14400}"
|
||
local cache_file
|
||
cache_file=$(arr_item_cache_file "$arr_type")
|
||
[[ -f "$cache_file" ]] || return 1
|
||
local ts age
|
||
ts=$(jq -r '.ts // 0' "$cache_file" 2>/dev/null)
|
||
age=$(( $(date +%s) - ${ts:-0} ))
|
||
[[ "$age" -ge "$max_age" ]] && return 1
|
||
jq -c '.items // empty' "$cache_file" 2>/dev/null
|
||
}
|
||
|
||
# Records how long a rescan-type command actually took for arr_type, keyed by command name,
|
||
# so future waits can be calibrated per command type instead of guessed or blended across
|
||
# very different operations — a whole-library RescanFolders/RescanSeries/RescanMovie takes
|
||
# vastly longer than a targeted DownloadedXScan, and averaging them would miscalibrate the
|
||
# wait for both.
|
||
# Args: arr_type, command_name, duration_seconds
|
||
arr_record_rescan_duration() {
|
||
local arr_type="$1" cmd_name="$2" duration="$3"
|
||
[[ -z "$cmd_name" || -z "$duration" ]] && return 1
|
||
local db
|
||
db=$(arr_rescan_duration_db "$arr_type")
|
||
mkdir -p "$(dirname "$db")" 2>/dev/null
|
||
local tmp
|
||
tmp=$(mktemp)
|
||
[[ -f "$db" ]] && grep -v "^${cmd_name}|" "$db" > "$tmp" 2>/dev/null
|
||
echo "${cmd_name}|${duration}" >> "$tmp"
|
||
mv "$tmp" "$db"
|
||
}
|
||
|
||
# Echoes the last recorded duration (seconds) for arr_type + command name, or a fallback
|
||
# default if none has ever been recorded.
|
||
# Args: arr_type, command_name, fallback_default_seconds
|
||
arr_get_rescan_duration() {
|
||
local arr_type="$1" cmd_name="$2" fallback="${3:-300}"
|
||
local db
|
||
db=$(arr_rescan_duration_db "$arr_type")
|
||
[[ -f "$db" ]] || { echo "$fallback"; return; }
|
||
local val
|
||
val=$(grep "^${cmd_name}|" "$db" 2>/dev/null | tail -1 | cut -d'|' -f2)
|
||
echo "${val:-$fallback}"
|
||
}
|
||
|
||
# Checks arr_type's command queue for any currently-active (started or queued) rescan-type
|
||
# command (per ARR_RESCAN_COMMANDS). Echoes the command name of the first one found (so the
|
||
# caller can look up its specific historical duration), empty if none active.
|
||
# Args: arr_type, url, api_key, api_version
|
||
arr_active_rescan_command() {
|
||
local arr_type="$1" url="$2" api_key="$3" api_version="$4"
|
||
local names="${ARR_RESCAN_COMMANDS[$arr_type]:-}"
|
||
[[ -z "$names" ]] && return 1
|
||
local jq_names
|
||
jq_names=$(printf '%s\n' $names | jq -R . | jq -sc .)
|
||
curl -sf --max-time 15 -H "X-Api-Key: $api_key" \
|
||
"${url}/api/${api_version}/command" 2>/dev/null | \
|
||
jq -r --argjson names "$jq_names" '[.[] | select(
|
||
(.status=="started" or .status=="queued") and
|
||
(.name as $n | $names | index($n) != null)
|
||
)] | .[0].name // empty' 2>/dev/null
|
||
}
|
||
|
||
# Waits for an already-active rescan-type command for arr_type to finish — unlike
|
||
# trigger_and_await_command(), this never triggers anything, it only watches. For a rescan
|
||
# someone/something else started (manual intervention, another script) where the caller just
|
||
# needs to know when it's safe to fetch+cache the real post-scan numbers. Returns 0 once no
|
||
# active rescan-type command remains (including immediately if none was active to begin
|
||
# with), 1 on timeout.
|
||
# Args: arr_type, url, api_key, api_version, poll_timeout (default 7200s), poll_interval (default 15s)
|
||
arr_wait_for_active_rescan() {
|
||
local arr_type="$1" url="$2" api_key="$3" api_version="$4"
|
||
local poll_timeout="${5:-7200}" poll_interval="${6:-15}"
|
||
local polled=0 active
|
||
active=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version")
|
||
[[ -z "$active" ]] && return 0
|
||
while [[ "$polled" -lt "$poll_timeout" ]]; do
|
||
sleep "$poll_interval"
|
||
(( polled += poll_interval ))
|
||
active=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version")
|
||
[[ -z "$active" ]] && return 0
|
||
[[ $(( polled % 300 )) -lt "$poll_interval" ]] && log " Still waiting on ${arr_type}'s ${active} (${polled}s elapsed)"
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Main entry point — the only function consuming scripts should call for tracked library
|
||
# data. Handles fresh/stale-no-rescan/stale-rescan-active branching and always writes through
|
||
# on any live fetch it performs. Echoes the library-list JSON array on success, returns 1 if
|
||
# no usable data (no cache and live fetch impossible) could be obtained.
|
||
# Args: arr_type, url, api_key, api_version, max_age_days (default 1), max_wait_strikes (default 3)
|
||
arr_get_tracked_data() {
|
||
local arr_type="$1" url="$2" api_key="$3" api_version="$4"
|
||
local max_age_days="${5:-${LIDARR_CACHE_MAX_AGE_DAYS:-1}}" max_strikes="${6:-3}"
|
||
local max_age_seconds=$(( max_age_days * 86400 ))
|
||
local age
|
||
age=$(arr_cache_age_seconds "$arr_type")
|
||
|
||
if [[ "$age" -lt "$max_age_seconds" ]]; then
|
||
arr_cache_read_raw "$arr_type" && return 0
|
||
fi
|
||
|
||
# Cache missing or stale — check for an active rescan before deciding how to proceed.
|
||
local active_cmd
|
||
active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version")
|
||
|
||
if [[ -n "$active_cmd" ]]; then
|
||
local wait_duration strike arr_label
|
||
wait_duration=$(( $(arr_get_rescan_duration "$arr_type" "$active_cmd" 300) / 2 ))
|
||
[[ "$wait_duration" -lt 30 ]] && wait_duration=30
|
||
arr_label="$(tr '[:lower:]' '[:upper:]' <<< "${arr_type:0:1}")${arr_type:1}"
|
||
for (( strike=1; strike<=max_strikes; strike++ )); do
|
||
# Redirected to stderr — this function's stdout is a data channel (callers
|
||
# capture it via command substitution), never mix log/warn output into it.
|
||
warn " $arr_label busy ($active_cmd) — waiting ${wait_duration}s (strike ${strike}/${max_strikes})" >&2
|
||
sleep "$wait_duration"
|
||
active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version")
|
||
[[ -z "$active_cmd" ]] && break
|
||
done
|
||
if [[ -n "$active_cmd" ]]; then
|
||
warn " $arr_label still busy ($active_cmd) after ${max_strikes} strikes — using cache if present, else skipping" >&2
|
||
arr_cache_read_raw "$arr_type" && return 0
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
# No active rescan (or it just finished mid-wait) — safe to fetch live and refresh cache.
|
||
local endpoint fresh arr_label
|
||
endpoint="${ARR_LIBRARY_ENDPOINT[$arr_type]:-}"
|
||
[[ -z "$endpoint" ]] && return 1
|
||
arr_label="$(tr '[:lower:]' '[:upper:]' <<< "${arr_type:0:1}")${arr_type:1}"
|
||
fresh=$(arr_api "$url" "$api_key" "$api_version" "$endpoint" "$arr_label") || {
|
||
arr_cache_read_raw "$arr_type" && return 0
|
||
return 1
|
||
}
|
||
arr_cache_write "$arr_type" "$fresh"
|
||
echo "$fresh"
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── ARR VERSION CHECK ─────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Verifies arr major version matches tested version in master.conf.
|
||
# Exits if version doesn't match — prevents running against untested API structure.
|
||
# Warns and proceeds if version endpoint is unreachable.
|
||
#
|
||
# Usage:
|
||
# check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr"
|
||
#
|
||
# Arguments:
|
||
# $1 = arr base URL
|
||
# $2 = API key
|
||
# $3 = API path prefix (v3 or v1)
|
||
# $4 = expected major version number from master.conf
|
||
# $5 = arr name for error messages
|
||
|
||
check_arr_version() {
|
||
local url="$1"
|
||
local api_key="$2"
|
||
local api_prefix="$3"
|
||
local expected_major="$4"
|
||
local arr_name="${5:-Arr}"
|
||
|
||
local status_response version major_version
|
||
|
||
status_response=$(curl -sf --max-time 10 \
|
||
-H "X-Api-Key: $api_key" \
|
||
"${url}/api/${api_prefix}/system/status" 2>/dev/null)
|
||
|
||
if [[ -z "$status_response" ]]; then
|
||
warn "$arr_name version check failed — could not reach system/status endpoint"
|
||
warn "Proceeding without version verification — monitor for API errors"
|
||
return 0
|
||
fi
|
||
|
||
version=$(echo "$status_response" | \
|
||
grep -o '"version": *"[^"]*"' | \
|
||
grep -o '[0-9][^"]*' | head -1)
|
||
|
||
if [[ -z "$version" ]]; then
|
||
warn "$arr_name version check failed — could not parse version from response"
|
||
warn "Proceeding without version verification — monitor for API errors"
|
||
return 0
|
||
fi
|
||
|
||
major_version="${version%%.*}"
|
||
|
||
if [[ "$major_version" == "$expected_major" ]]; then
|
||
success "$arr_name version: $version (major $major_version — tested ✅)"
|
||
return 0
|
||
else
|
||
error "$arr_name version mismatch — running v${major_version}, tested against v${expected_major}"
|
||
error "The API endpoint structure may have changed — exiting to protect your library"
|
||
error "Update ${arr_name^^}_VERSION_MAJOR in master.conf after verifying the script works with v${major_version}"
|
||
notify "$arr_name version mismatch on $(hostname) — running v${major_version}, script tested against v${expected_major}" "$arr_name Cleanup" "warning"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── TMDB DISCOVERY SCORING ────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Shared by playback_aware_radarr_discovery.sh and playback_aware_sonarr_discovery.sh — stage 2
|
||
# candidate scoring against TMDB rating/vote/seed-breadth data. Byte-for-byte identical between
|
||
# the two before this consolidation.
|
||
|
||
# Formats a TMDB vote_average×10 integer back to one decimal place (e.g. 78 → 7.8).
|
||
_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; }
|
||
|
||
# Stage 2: TMDB vote_average × 10 as integer (0-40)
|
||
_rating_score_s2() {
|
||
local v="$1"
|
||
if (( v >= 80 )); then echo 40
|
||
elif (( v >= 75 )); then echo 32
|
||
elif (( v >= 70 )); then echo 25
|
||
elif (( v >= 65 )); then echo 18
|
||
elif (( v >= 60 )); then echo 12
|
||
else echo 5
|
||
fi
|
||
}
|
||
|
||
# Stage 2: vote count (0-20)
|
||
_votes_score() {
|
||
local c="$1"
|
||
if (( c >= 10000 )); then echo 20
|
||
elif (( c >= 5000 )); then echo 15
|
||
elif (( c >= 1000 )); then echo 10
|
||
elif (( c >= 200 )); then echo 5
|
||
else echo 2
|
||
fi
|
||
}
|
||
|
||
# Stage 2: seed breadth (0-40)
|
||
_breadth_score() {
|
||
local seeds="$1"
|
||
if (( seeds >= 3 )); then echo 40
|
||
elif (( seeds == 2 )); then echo 25
|
||
else echo 10
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── EMBY LIBRARY SCAN ─────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Triggers Emby "Clean Missing Files" scheduled task after arr cleanup scripts delete files.
|
||
# Without this Emby keeps showing deleted files as ghost entries until its next scheduled scan.
|
||
# User clicks a ghost entry → file not found error. This prevents that entirely.
|
||
#
|
||
# Uses EMBY_URL and EMBY_API_KEY — aliased by detect_hosts() from HOST*_EMBY_* vars.
|
||
# Skips cleanly if Emby is not configured on this host (empty URL or API key).
|
||
#
|
||
# Emby task discovery:
|
||
# GET /ScheduledTasks → find task where Name contains "Clean Missing"
|
||
# POST /ScheduledTasks/Running/{taskId} → trigger it
|
||
#
|
||
# Non-fatal — logs warning if task cannot be triggered but does not exit.
|
||
# Clean Missing Files runs across all libraries at once — one call handles
|
||
# music, TV, and movies together regardless of which arr triggered the cleanup.
|
||
#
|
||
# Usage: notify_emby_scan (no args — always triggers Clean Missing Files)
|
||
|
||
notify_emby_scan() {
|
||
# Skip if Emby not configured on this host
|
||
if [[ -z "${EMBY_URL:-}" ]] || [[ -z "${EMBY_API_KEY:-}" ]]; then
|
||
log "Emby not configured on $MY_ID — skipping library scan notification"
|
||
return 0
|
||
fi
|
||
|
||
log "Notifying Emby to clean missing files..."
|
||
|
||
# Get all scheduled tasks
|
||
local tasks_response
|
||
tasks_response=$(curl -sf --max-time 15 \
|
||
-H "X-Emby-Token: $EMBY_API_KEY" \
|
||
"${EMBY_URL}/ScheduledTasks" 2>/dev/null)
|
||
|
||
if [[ -z "$tasks_response" ]]; then
|
||
warn "Could not reach Emby scheduled tasks API at $EMBY_URL — skipping scan"
|
||
return 0
|
||
fi
|
||
|
||
# Find the "Clean Missing Files" task ID
|
||
local task_id
|
||
task_id=$(echo "$tasks_response" | \
|
||
grep -o '"Id":"[^"]*","Name":"[^"]*Clean Missing[^"]*"' | \
|
||
grep -o '"Id":"[^"]*"' | \
|
||
sed 's/"Id":"//;s/"//' | head -1)
|
||
|
||
# Fallback — try "Scan Media Library" if Clean Missing not found
|
||
if [[ -z "$task_id" ]]; then
|
||
task_id=$(echo "$tasks_response" | \
|
||
grep -o '"Id":"[^"]*","Name":"[^"]*Scan Media Library[^"]*"' | \
|
||
grep -o '"Id":"[^"]*"' | \
|
||
sed 's/"Id":"//;s/"//' | head -1)
|
||
[[ -n "$task_id" ]] && log "Clean Missing Files not found — using Scan Media Library"
|
||
fi
|
||
|
||
if [[ -z "$task_id" ]]; then
|
||
warn "Could not find Emby Clean Missing Files or Scan Media Library task"
|
||
warn "Check Emby scheduled tasks — ghost entries will persist until next Emby scan"
|
||
return 0
|
||
fi
|
||
|
||
log "Found Emby task ID: $task_id"
|
||
|
||
# Trigger the task
|
||
local http_code
|
||
http_code=$(curl -sf --max-time 15 \
|
||
-X POST \
|
||
-H "X-Emby-Token: $EMBY_API_KEY" \
|
||
-w "%{http_code}" \
|
||
-o /dev/null \
|
||
"${EMBY_URL}/ScheduledTasks/Running/${task_id}" 2>/dev/null)
|
||
|
||
if [[ "$http_code" == "204" ]] || [[ "$http_code" == "200" ]]; then
|
||
warn "$ICON_EMBY Emby Clean Missing Files triggered — ghost entries will be removed"
|
||
else
|
||
warn "Emby task trigger returned HTTP $http_code — ghost entries may persist"
|
||
warn "Check Emby dashboard — manually run Clean Missing Files if needed"
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── API REACHABILITY CHECK ────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Pre-flight check — verifies an API endpoint is reachable before attempting operations.
|
||
# Non-fatal — returns 0 if reachable, 1 if not. Caller decides whether to exit.
|
||
#
|
||
# Usage: check_api "http://localhost:8989" "Sonarr" || exit 1
|
||
|
||
check_api() {
|
||
local url="$1"
|
||
local service="${2:-API}"
|
||
local timeout="${3:-10}"
|
||
|
||
if curl -sf --max-time "$timeout" "$url" >/dev/null 2>&1; then
|
||
log "$service API reachable: $url"
|
||
return 0
|
||
else
|
||
error "$service API not reachable: $url"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── UNRAID VERSION PARITY CHECK ───────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Verifies local and remote servers are running compatible unRAID versions before any
|
||
# remote operation. Version mismatches can mean changed APIs, commands, or behaviours
|
||
# that silently break remote container operations, rsync, or fallback logic.
|
||
#
|
||
# Reads platform OS version on both sides via adapter.
|
||
#
|
||
# Mismatch behaviour (UNRAID_VERSION_MISMATCH_ACTION in master.conf):
|
||
# "warn" — log warning and continue (default for minor/patch differences)
|
||
# "abort" — exit the calling script (default for major version differences)
|
||
# Major version mismatch always aborts regardless of setting
|
||
#
|
||
# Usage: check_os_version_parity || exit 1
|
||
|
||
check_os_version_parity() {
|
||
local local_version remote_version
|
||
|
||
# Read local version
|
||
local_version=$(platform_get_os_version 2>/dev/null)
|
||
if [[ -z "$local_version" ]]; then
|
||
warn "Cannot read local OS version — skipping version parity check"
|
||
return 0
|
||
fi
|
||
|
||
# Read remote version via SSH
|
||
local _probe_cmd
|
||
_probe_cmd=$(platform_os_version_probe_cmd)
|
||
remote_version=$(ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \
|
||
"$_probe_cmd" 2>/dev/null)
|
||
if [[ -z "$remote_version" ]]; then
|
||
warn "Cannot read remote OS version from $REMOTE_SERVER_NAME — skipping parity check"
|
||
return 0
|
||
fi
|
||
|
||
log "Version parity: local=$local_version remote=$remote_version"
|
||
|
||
if [[ "$local_version" == "$remote_version" ]]; then
|
||
log "OS versions match: $local_version ✅"
|
||
return 0
|
||
fi
|
||
|
||
# Parse major versions
|
||
local local_major remote_major
|
||
local_major="${local_version%%.*}"
|
||
remote_major="${remote_version%%.*}"
|
||
|
||
if [[ "$local_major" != "$remote_major" ]]; then
|
||
error "OS MAJOR version mismatch — local: $local_version remote: $remote_version"
|
||
error "Major version differences may break remote APIs, commands, and behaviours"
|
||
error "Update both servers to the same major version before running remote operations"
|
||
notify "OS major version mismatch on $(hostname) — local: $local_version remote: $remote_version — remote operations aborted" \
|
||
"Version Parity" "warning"
|
||
return 1
|
||
fi
|
||
|
||
# Minor/patch mismatch — action depends on config
|
||
local action="${UNRAID_VERSION_MISMATCH_ACTION:-warn}"
|
||
warn "OS version mismatch — local: $local_version remote: $remote_version"
|
||
|
||
if [[ "$action" == "abort" ]]; then
|
||
error "UNRAID_VERSION_MISMATCH_ACTION=abort — refusing to continue"
|
||
notify "OS version mismatch on $(hostname) — local: $local_version remote: $remote_version — aborted" \
|
||
"Version Parity" "warning"
|
||
return 1
|
||
fi
|
||
|
||
warn "UNRAID_VERSION_MISMATCH_ACTION=warn — continuing despite mismatch (monitor for issues)"
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── REMOTE DOCKER DAEMON CHECK ────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Verifies the remote Docker daemon is responsive before issuing any remote container commands.
|
||
# A hung remote daemon causes docker commands to hang or silently fail — checking first
|
||
# prevents cascading failures and gives clear diagnostic output.
|
||
#
|
||
# Strike system:
|
||
# Each consecutive failed check adds a strike to REMOTE_DOCKER_STRIKES
|
||
# Below REMOTE_DOCKER_STRIKE_LIMIT → warn and return 1 (caller skips operation)
|
||
# At limit → notify critical and return 1 (caller should exit or escalate)
|
||
# Strikes reset when daemon recovers
|
||
#
|
||
# Usage: check_remote_docker_daemon || return 1
|
||
|
||
REMOTE_DOCKER_STRIKES=0
|
||
|
||
check_remote_docker_daemon() {
|
||
local timeout="${1:-10}"
|
||
|
||
if ssh -i "$SSH_KEY" -o ConnectTimeout="$timeout" root@"$REMOTE_SERVER" \
|
||
"timeout $timeout docker info" >/dev/null 2>&1; then
|
||
# Daemon healthy — clear strikes if we had issues
|
||
if [[ "$REMOTE_DOCKER_STRIKES" -gt 0 ]]; then
|
||
info "Remote Docker daemon on $REMOTE_SERVER_NAME recovered — clearing strikes"
|
||
REMOTE_DOCKER_STRIKES=0
|
||
fi
|
||
log "Remote Docker daemon healthy on $REMOTE_SERVER_NAME"
|
||
return 0
|
||
fi
|
||
|
||
REMOTE_DOCKER_STRIKES=$(( REMOTE_DOCKER_STRIKES + 1 ))
|
||
local limit="${REMOTE_DOCKER_STRIKE_LIMIT:-3}"
|
||
warn "Remote Docker daemon not responding on $REMOTE_SERVER_NAME (strike $REMOTE_DOCKER_STRIKES/$limit)"
|
||
|
||
if [[ "$REMOTE_DOCKER_STRIKES" -ge "$limit" ]]; then
|
||
error "Remote Docker daemon unresponsive on $REMOTE_SERVER_NAME after $limit consecutive checks"
|
||
error "Skipping all remote container operations — check Docker on $REMOTE_SERVER_NAME"
|
||
notify "Remote Docker daemon unresponsive on $REMOTE_SERVER_NAME — remote operations skipped on $(hostname)" "Remote Docker Daemon" "warning"
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── STATUS DISPLAY ────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
# Prints current runtime configuration — triggered by --status flag in any script.
|
||
# Useful for verifying detect_hosts() resolved correctly and profile loaded as expected.
|
||
|
||
show_status() {
|
||
local local_ver
|
||
local_ver=$(platform_get_os_version 2>/dev/null || echo "unknown")
|
||
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
||
echo "Local: $LOCAL_SERVER_NAME"
|
||
echo "Remote: $REMOTE_SERVER_NAME"
|
||
echo "IP: ${REMOTE_SERVER:-not resolved}"
|
||
echo "My ID: ${MY_ID:-not set}"
|
||
echo "Remote ID: ${REMOTE_ID:-not set}"
|
||
echo "OS ver: $local_ver"
|
||
echo "Profile: ${PROFILE_NAME:-n/a}"
|
||
|
||
echo "DryRun: ${DRY_RUN:-false}"
|
||
echo "Logging: ${ENABLE_LOGGING:-false}"
|
||
echo "SSH Key: ${SSH_KEY:-not set}"
|
||
echo "Containers: ${CRITICAL_CONTAINER_NAMES[*]:-n/a}"
|
||
echo "Delayed: ${DELAYED_CONTAINERS[*]:-n/a}"
|
||
echo "Excludes: ${EXCLUDE_DIRS[*]:-n/a}"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||
}
|
||
|
||
require_partnership() {
|
||
[[ "${PARTNERSHIP_ENABLED:-false}" == "true" ]] && return 0
|
||
log "PARTNERSHIP_ENABLED=false — skipping cross-server operation"
|
||
exit 0
|
||
} |