#!/bin/bash # ============================================================================================== # ================================= Storage Watchdog =========================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Pool and storage health monitoring — catches runaway data growth before it # fills a pool. Runs as a single-pass script called by system_watchdog.sh each # cycle. Sits between docker_watchdog.sh (container health) and # stability_watchdog.sh (last line of defense). Never reboots — detects, alerts, # and optionally remediates. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Appdata Size Monitoring # Two complementary checks run every cycle: # # Part 1 — Growth rate (zero-config catch-all): # Reads per-container dir totals via du, compares to previous cycle baseline. # Any container growing more than WATCHDOG_APPDATA_GROWTH_GB triggers a focused # *.log scan inside that container's dir. No per-container config required — # new containers are covered automatically. Baseline built on first cycle after # boot; growth detection active from cycle 2. # # Part 2 — Absolute log size: # Finds *.log / *.log.* files over WATCHDOG_APPDATA_LOG_MAX_GB anywhere in # WATCHDOG_APPDATA_PATHS. Catches logs already large but no longer actively # growing. Independent strike counter per file. # # Strike System # Reuses the same strike pattern as CPU/HTTP checks in docker_watchdog.sh: # # Strike 1 — warn + notify: condition first detected this run # Strike 2 — warn + escalated notify: still present next cycle # Strike 3 (WATCHDOG_APPDATA_STRIKE_LIMIT) — action cycle: # WATCHDOG_APPDATA_TRUNCATE_LOGS=true → truncate *.log in-place, clear strikes # WATCHDOG_APPDATA_TRUNCATE_LOGS=false → critical notify, hold strikes until resolved # Condition resolves (growth stops / log drops below threshold) → strikes auto-clear # # Suppress Ceiling (WATCHDOG_APPDATA_SIZES) # Containers in HOST*_WATCHDOG_APPDATA_SIZES suppress growth warnings while # under their configured ceiling MB. Use ONLY when a container legitimately # holds large stable data and would otherwise false-alarm (e.g. Tdarr cache). # Zero-config growth detection covers everything else automatically. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Zero-config for new containers # Growth rate detection requires no per-container configuration. Add a game # server, spin up a new arr, install anything — it is monitored automatically # from the second cycle after it appears. The suppress ceiling in conf is the # exception, not the rule. # # Alert-only for data, truncate-only for logs # Non-log growth (databases, game saves, caches) is detected and alerted but # never touched. Only *.log / *.log.* files are candidates for truncation — # and only when WATCHDOG_APPDATA_TRUNCATE_LOGS=true. Truncation zeroes the # file in-place; the container keeps its open file handle, space is reclaimed # immediately. Never deletes. # # Strike before acting # One cycle of growth could be a legitimate library scan or game save burst. # Three consecutive cycles of growth is a runaway. The strike system separates # transient activity from sustained problems before any action fires. # # Silent when healthy # Produces no output when all checks pass. Loud only when something needs # attention. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Enforcement # Truncating container-owned log files requires root. # # Lock Acquisition # acquire_lock() prevents concurrent runs. Two instances would race on the # strike state file and the growth baseline, double-counting strikes and # potentially truncating a file one cycle early. # # Host Detection # detect_hosts() aliases HOST*_WATCHDOG_APPDATA_SIZES to the correct host's # suppress ceilings. # # WATCHDOG_CHECK_APPDATA Toggle # Exits cleanly before any scanning when the master toggle is off. # # Path Existence Guard # Every entry in WATCHDOG_APPDATA_PATHS is skipped unless it is a non-empty # string naming a real directory. An unconfigured array cannot cause a scan # from an unintended location. # # Truncate-Never-Delete # Action is always truncate -s 0, never rm. The container keeps its open file # handle and space is reclaimed immediately, so a still-running service does # not lose its log destination mid-write. # # Filename Restriction # Only *.log and *.log.* files are ever truncation candidates. Databases, # caches, game saves and every other growing file are alert-only — detected # and reported, never modified. # # Truncation Opt-In # WATCHDOG_APPDATA_TRUNCATE_LOGS defaults to false. Without it explicitly # enabled the action cycle escalates to a critical notification and holds # strikes rather than touching any file. # # Strike Threshold # Nothing acts on first detection. WATCHDOG_APPDATA_STRIKE_LIMIT consecutive # cycles are required, separating a legitimate library scan or save burst # from a genuine runaway. Strikes auto-clear when the condition resolves. # # Suppress Ceiling # Containers listed in WATCHDOG_APPDATA_SIZES are exempt from growth alerts # while under their configured ceiling — prevents known-large stable data # from generating recurring false alarms. # # Dry Run Support # --dry-run reports every truncation that would occur and performs none. # # Atomic Baseline Update # The growth baseline is written to a temp file and moved into place, so an # interrupted run cannot leave a half-written baseline that would read as # false growth on the next cycle. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # master.conf # # WATCHDOG_CHECK_APPDATA # Master toggle for all appdata checks (default: true) # # WATCHDOG_APPDATA_PATHS # Array of paths to scan (e.g. "/mnt/docker-unraid/appdata") # # WATCHDOG_APPDATA_GROWTH_GB # Per-cycle growth threshold in GB — flag containers growing more than this (default: 2) # # WATCHDOG_APPDATA_LOG_MAX_GB # Absolute *.log file size alert threshold in GB (default: 2) # # WATCHDOG_APPDATA_TRUNCATE_LOGS # Auto-truncate oversized *.log files on action cycle (default: false) # # WATCHDOG_APPDATA_STRIKE_LIMIT # Consecutive cycles before action fires (default: 3) # # WATCHDOG_APPDATA_GROWTH_FILE # Per-container size baseline — /tmp resets on reboot (correct: stale baseline # after reboot would give false growth readings on first cycle) # # STORAGE_WATCHDOG_STATE_FILE # Strike counts for this script — /tmp resets on reboot # # host*.conf (aliased by detect_hosts()) # # HOST*_WATCHDOG_APPDATA_SIZES # Per-container growth suppress ceilings in MB. Suppress growth alerts while # a container's dir stays below this ceiling. Only needed when a container # legitimately has large stable data. Growth detection covers everything else. # # ============================================================================================== # STATE FILES # ============================================================================================== # # STORAGE_WATCHDOG_STATE_FILE — strike counts (/tmp — resets on reboot ✅) # WATCHDOG_APPDATA_GROWTH_FILE — per-container size baseline (/tmp — resets on reboot ✅) # # /tmp files reset on reboot — correct. Pre-reboot strikes and growth baselines are # meaningless after a reboot. Both rebuild cleanly from cycle 1. # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # storage_watchdog.sh # Single-pass storage health check. Silent if all healthy. # # storage_watchdog.sh --dry-run # Run all checks without truncating anything. Shows what would be actioned. # # storage_watchdog.sh --status # Show configuration, active strikes, and growth baseline status. Then exit. # # storage_watchdog.sh --log # Verbose output — every container checked, every size comparison, every decision. # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../../load_config.sh" parse_args "$@" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi acquire_lock detect_hosts [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be truncated" log "$ICON_GEAR Config: growth-thresh=${WATCHDOG_APPDATA_GROWTH_GB:-2}GB/cycle log-max=${WATCHDOG_APPDATA_LOG_MAX_GB:-2}GB truncate=${WATCHDOG_APPDATA_TRUNCATE_LOGS:-false} strikes=${WATCHDOG_APPDATA_STRIKE_LIMIT:-3}" log "$ICON_DISK Paths: ${WATCHDOG_APPDATA_PATHS[*]:-none configured}" [[ ${#WATCHDOG_APPDATA_SIZES[@]} -gt 0 ]] && log "$ICON_GEAR Suppress ceilings: $(for k in "${!WATCHDOG_APPDATA_SIZES[@]}"; do printf '%s=%sMB ' "$k" "${WATCHDOG_APPDATA_SIZES[$k]}"; done)" touch "$STORAGE_WATCHDOG_STATE_FILE" 2>/dev/null touch "$WATCHDOG_APPDATA_GROWTH_FILE" 2>/dev/null # ━━━ Strike helpers — wrap common.sh's wd_state_get()/wd_state_set() ━━━ get_strikes() { wd_state_get "$1" "$2"; } set_strikes() { wd_state_set "$1" "$2" "$3"; } # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY STORAGE WATCHDOG STATUS ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_GEAR Appdata check: ${WATCHDOG_CHECK_APPDATA:-true}" echo "$ICON_GEAR Paths: ${WATCHDOG_APPDATA_PATHS[*]:-none}" echo "$ICON_GEAR Growth thresh: ${WATCHDOG_APPDATA_GROWTH_GB:-2}GB/cycle" echo "$ICON_GEAR Log max: ${WATCHDOG_APPDATA_LOG_MAX_GB:-2}GB" echo "$ICON_GEAR Truncate logs: ${WATCHDOG_APPDATA_TRUNCATE_LOGS:-false}" echo "$ICON_GEAR Strike limit: ${WATCHDOG_APPDATA_STRIKE_LIMIT:-3}" echo "" echo "── Active Strikes ──" if [[ -s "$STORAGE_WATCHDOG_STATE_FILE" ]]; then while IFS=':' read -r _sk _sv; do _sv_clean="${_sv//[^0-9]/}" [[ "${_sv_clean:-0}" -gt 0 ]] && echo " $_sk → $_sv_clean strikes" done < "$STORAGE_WATCHDOG_STATE_FILE" else echo " none" fi echo "" echo "── Growth Baseline ──" if [[ -s "$WATCHDOG_APPDATA_GROWTH_FILE" ]]; then _entries=$(wc -l < "$WATCHDOG_APPDATA_GROWTH_FILE") _age=$(( $(date +%s) - $(stat -c %Y "$WATCHDOG_APPDATA_GROWTH_FILE" 2>/dev/null || echo 0) )) echo " Entries: $_entries containers Age: $(( _age / 60 ))m ago" else echo " No baseline yet (builds on first cycle after boot)" fi echo "" echo "── Suppress Ceilings (this host) ──" if [[ ${#WATCHDOG_APPDATA_SIZES[@]} -gt 0 ]]; then for _c in "${!WATCHDOG_APPDATA_SIZES[@]}"; do _ceil_gb=$(awk "BEGIN {printf \"%.0f\", ${WATCHDOG_APPDATA_SIZES[$_c]} / 1024}") echo " $_c → ${_ceil_gb}GB" done else echo " none configured" fi echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Appdata Size Monitoring ━━━ # ============================================================================================== [[ "$WATCHDOG_CHECK_APPDATA" != "true" ]] && exit 0 echo "━━━ $ICON_DISK Storage Watchdog — $(date '+%Y-%m-%d %H:%M:%S') ━━━" WARNINGS=0 _STRIKE_LIMIT=${WATCHDOG_APPDATA_STRIKE_LIMIT:-3} _GROWTH_MB=$(( ${WATCHDOG_APPDATA_GROWTH_GB:-2} * 1024 )) _LOG_KB=$(( ${WATCHDOG_APPDATA_LOG_MAX_GB:-2} * 1024 * 1024 )) # Tracks files handled by Part 1 to prevent duplicate alerts in Part 2 declare -A _HANDLED=() for _appdata_path in "${WATCHDOG_APPDATA_PATHS[@]:-}"; do [[ -z "$_appdata_path" || ! -d "$_appdata_path" ]] && continue # ── Part 1: Growth rate scan ────────────────────────────────────────────────────────────── declare -A _PREV=() if [[ -f "$WATCHDOG_APPDATA_GROWTH_FILE" ]]; then while IFS='|' read -r _cn _cs _; do [[ -n "$_cn" ]] && _PREV["$_cn"]="$_cs" done < "$WATCHDOG_APPDATA_GROWTH_FILE" fi _growth_tmp=$(mktemp 2>/dev/null) || _growth_tmp="" _now=$(date +%s) while IFS= read -r _du_line; do _curr_mb=$(echo "$_du_line" | awk '{print $1}') _cdir=$(echo "$_du_line" | awk '{print $2}') _cname=$(basename "$_cdir") [[ -z "$_cname" || "$_cname" == "*" ]] && continue [[ -n "$_growth_tmp" ]] && echo "${_cname}|${_curr_mb}|${_now}" >> "$_growth_tmp" _prev_mb="${_PREV[$_cname]:-}" [[ -z "$_prev_mb" ]] && continue # First run after boot — building baseline _growth_mb=$(( _curr_mb - _prev_mb )) _safe=$(echo "$_cname" | tr -cd '[:alnum:]_') # Condition resolved — growth stopped, clear strikes if [[ "$_growth_mb" -le 0 ]]; then _existing=$(get_strikes "appdata_growth_${_safe}" "$STORAGE_WATCHDOG_STATE_FILE") _existing="${_existing//[^0-9]/}" [[ "${_existing:-0}" -gt 0 ]] && \ set_strikes "appdata_growth_${_safe}" 0 "$STORAGE_WATCHDOG_STATE_FILE" continue fi # Check suppress ceiling _ceiling="${WATCHDOG_APPDATA_SIZES[$_cname]:-}" if [[ -n "$_ceiling" && "$_curr_mb" -lt "$_ceiling" ]]; then log "$_cname — growth suppressed (${_curr_mb}MB < ${_ceiling}MB ceiling)" continue fi # Growth exceeds threshold — strike logic if [[ "$_growth_mb" -ge "$_GROWTH_MB" ]]; then _growth_gb=$(awk "BEGIN {printf \"%.1f\", $_growth_mb / 1024}") _curr_gb=$(awk "BEGIN {printf \"%.1f\", $_curr_mb / 1024}") _strikes=$(get_strikes "appdata_growth_${_safe}" "$STORAGE_WATCHDOG_STATE_FILE") _strikes="${_strikes//[^0-9]/}"; _strikes="${_strikes:-0}" [[ "$_strikes" -lt "$_STRIKE_LIMIT" ]] && { _strikes=$(( _strikes + 1 )) set_strikes "appdata_growth_${_safe}" "$_strikes" "$STORAGE_WATCHDOG_STATE_FILE" } warn "$_cname — grew ${_growth_gb}GB this cycle (total: ${_curr_gb}GB) [strike ${_strikes}/${_STRIKE_LIMIT}]" (( WARNINGS++ )) # Focused *.log scan inside the growing container's dir _found_logs=() while IFS= read -r _lf; do [[ -n "$_lf" ]] && _found_logs+=("$_lf") done < <(find "$_cdir" -maxdepth 3 -type f \ \( -name "*.log" -o -name "*.log.*" \) \ -size +${_LOG_KB}k -printf "%s %p\n" 2>/dev/null) _log_summary="" [[ ${#_found_logs[@]} -gt 0 ]] && _log_summary=$(printf '%s\n' "${_found_logs[@]}" | \ awk '{printf "%.1fGB %s | ", $1/1073741824, $2}' | head -c 200) if [[ "$_strikes" -lt "$_STRIKE_LIMIT" ]]; then if [[ -n "$_log_summary" ]]; then notify "$_cname grew ${_growth_gb}GB on $(hostname) [strike ${_strikes}/${_STRIKE_LIMIT}] — logs: ${_log_summary}" \ "Storage Watchdog" "warning" else notify "$_cname grew ${_growth_gb}GB on $(hostname) [strike ${_strikes}/${_STRIKE_LIMIT}] — data growth, no log files" \ "Storage Watchdog" "warning" fi else # Action cycle error "$_cname — growth strike limit reached (${_STRIKE_LIMIT} consecutive cycles, ${_growth_gb}GB this cycle)" if [[ "$WATCHDOG_APPDATA_TRUNCATE_LOGS" == "true" && ${#_found_logs[@]} -gt 0 ]]; then for _lf_entry in "${_found_logs[@]}"; do _lf_path=$(echo "$_lf_entry" | cut -d' ' -f2-) _lf_gb=$(echo "$_lf_entry" | awk '{printf "%.1f", $1/1073741824}') _HANDLED["$_lf_path"]=1 if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would truncate $_lf_path (${_lf_gb}GB)" else if truncate -s 0 "$_lf_path" 2>/dev/null; then success "Truncated runaway log: $_lf_path (was ${_lf_gb}GB)" notify "Truncated runaway log on $(hostname): $_lf_path (was ${_lf_gb}GB)" \ "Storage Watchdog" "warning" else warn "Failed to truncate $_lf_path" fi fi done set_strikes "appdata_growth_${_safe}" 0 "$STORAGE_WATCHDOG_STATE_FILE" else if [[ -n "$_log_summary" ]]; then notify "$_cname appdata runaway on $(hostname) — ${_growth_gb}GB growth for ${_STRIKE_LIMIT} cycles — logs: ${_log_summary}" \ "Storage Watchdog" "critical" else notify "$_cname appdata runaway on $(hostname) — ${_growth_gb}GB growth for ${_STRIKE_LIMIT} cycles — data growth, manual investigation needed" \ "Storage Watchdog" "critical" fi fi fi # Mark found logs as handled to suppress Part 2 duplicates this cycle for _lf_entry in "${_found_logs[@]}"; do _HANDLED["$(echo "$_lf_entry" | cut -d' ' -f2-)"]=1 done fi done < <(du -sm "$_appdata_path"/*/ 2>/dev/null) # Atomically update growth baseline [[ -n "$_growth_tmp" ]] && mv "$_growth_tmp" "$WATCHDOG_APPDATA_GROWTH_FILE" 2>/dev/null # ── Part 2: Absolute log size scan ──────────────────────────────────────────────────────── # Catches *.log files already large but no longer actively growing this cycle. # Same strike logic. Skips files already handled by Part 1 above. declare -A _LOG_SEEN=() while IFS= read -r _hit; do [[ -z "$_hit" ]] && continue _fpath=$(echo "$_hit" | cut -d' ' -f2-) [[ -n "${_HANDLED[$_fpath]:-}" ]] && continue _fsize_bytes=$(echo "$_hit" | awk '{print $1}') _fsize_gb=$(awk "BEGIN {printf \"%.1f\", $_fsize_bytes / 1073741824}") _safe_fkey=$(echo "$_fpath" | tr -cd '[:alnum:]_' | cut -c1-120) _LOG_SEEN["appdata_log_${_safe_fkey}"]=1 _strikes=$(get_strikes "appdata_log_${_safe_fkey}" "$STORAGE_WATCHDOG_STATE_FILE") _strikes="${_strikes//[^0-9]/}"; _strikes="${_strikes:-0}" [[ "$_strikes" -lt "$_STRIKE_LIMIT" ]] && { _strikes=$(( _strikes + 1 )) set_strikes "appdata_log_${_safe_fkey}" "$_strikes" "$STORAGE_WATCHDOG_STATE_FILE" } warn "Oversized log: $_fpath (${_fsize_gb}GB) [strike ${_strikes}/${_STRIKE_LIMIT}]" (( WARNINGS++ )) if [[ "$_strikes" -lt "$_STRIKE_LIMIT" ]]; then notify "Oversized log on $(hostname): $_fpath (${_fsize_gb}GB) [strike ${_strikes}/${_STRIKE_LIMIT}]" \ "Storage Watchdog" "warning" else error "Oversized log persists for ${_STRIKE_LIMIT} cycles: $_fpath (${_fsize_gb}GB)" if [[ "$WATCHDOG_APPDATA_TRUNCATE_LOGS" == "true" ]]; then if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would truncate $_fpath (${_fsize_gb}GB)" else if truncate -s 0 "$_fpath" 2>/dev/null; then success "Truncated oversized log: $_fpath (was ${_fsize_gb}GB)" notify "Truncated oversized log on $(hostname): $_fpath (was ${_fsize_gb}GB)" \ "Storage Watchdog" "warning" set_strikes "appdata_log_${_safe_fkey}" 0 "$STORAGE_WATCHDOG_STATE_FILE" unset "_LOG_SEEN[appdata_log_${_safe_fkey}]" else warn "Failed to truncate $_fpath" notify "Oversized log on $(hostname): $_fpath (${_fsize_gb}GB) — ${_STRIKE_LIMIT} cycles, truncate failed" \ "Storage Watchdog" "critical" fi fi else notify "Oversized log on $(hostname): $_fpath (${_fsize_gb}GB) — ${_STRIKE_LIMIT} cycles, manual intervention needed" \ "Storage Watchdog" "critical" fi fi done < <(find "$_appdata_path" -maxdepth 4 -type f \ \( -name "*.log" -o -name "*.log.*" \) \ -size +${_LOG_KB}k -printf "%s %p\n" 2>/dev/null) # Auto-clear strikes for log files no longer oversized this cycle while IFS=':' read -r _sk _sv; do [[ "$_sk" != appdata_log_* ]] && continue _sv_clean="${_sv//[^0-9]/}" [[ "${_sv_clean:-0}" -eq 0 ]] && continue [[ -n "${_LOG_SEEN[$_sk]:-}" ]] && continue set_strikes "$_sk" 0 "$STORAGE_WATCHDOG_STATE_FILE" done < "$STORAGE_WATCHDOG_STATE_FILE" 2>/dev/null unset _LOG_SEEN done unset _PREV _HANDLED # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== if [[ "$WARNINGS" -gt 0 ]]; then echo "" echo "━━━ $ICON_DISK Storage Watchdog — $(date '+%Y-%m-%d %H:%M:%S') ━━━" echo "$ICON_WATCHDOG Warnings: $WARNINGS" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" else echo "Storage healthy ✅ ($(date '+%H:%M:%S'))" fi