Files
Varaverk/Fallback/fallback_test.sh
T
Gmer4Lfe 95151c2278 Watchdogs/ folder + host conf rename
Move all watchdog scripts to a dedicated Watchdogs/ folder:
  Docker_Essentials/docker_watchdog.sh   → Watchdogs/
  unRAID_Essentials/system_watchdog.sh   → Watchdogs/
  unRAID_Essentials/resource_watchdog.sh → Watchdogs/
  Orchestrators/watchdog_orchestrator.sh → Watchdogs/
  Tools/watchdog_skip_list_manager.sh    → Watchdogs/

Rename host config files:
  master_host1.conf → host1.conf
  master_host2.conf → host2.conf

Update all references across the ecosystem:
  master.conf: WATCHDOG_ORCHESTRATOR_SCRIPTS paths → Watchdogs/
  load_config.sh: host*.conf glob + all comments
  git_pull_execute.sh: sparse checkout glob + all comments
  Partnership/ssh_setup.sh: HOST_CONF path construction
  user_script_plug-in.sh: all script paths + per-host conf path
  common.sh, README.md, README-User_Script_Plug-in.md: comment refs
  All Partnership, Fallback, Monitors, Transcodes, Tools scripts: comment refs
2026-05-22 17:08:36 -04:00

511 lines
21 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# ==============================================================================================
# ================================= Fallback Test ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Controlled simulation of the fallback lifecycle. Validates the entire sequence
# without waiting for a real outage. Contains no fallback logic — exercises the
# real fallback.sh via an iptables DROP rule on the remote Tailscale IP.
#
# Run during a maintenance window. Users will experience a brief service
# interruption. Use --dry-run to walk through all phases without real changes.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Phase 1 — Pre-flight Both servers reachable, Docker daemons healthy,
# version parity, fallback.sh exists, state NORMAL
# Phase 2 — Block Remote iptables DROP rule added — remote appears unreachable
# Phase 3 — Fallback Detection Wait FALLBACK_TEST_BLOCK_WAIT for fallback.sh to
# detect the outage and enter FALLBACK state
# Phase 4 — Container Start Verify Tier 1 containers started locally
# Phase 5 — Restore iptables rule removed — remote reachable again
# Phase 6 — Handback Wait FALLBACK_TEST_HANDBACK_WAIT for fallback.sh to
# complete full handback and return to NORMAL
# Phase 7 — Container Handback Verify Tier 1 containers stopped locally
# Report — Full pass/fail per phase with timing
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Test Harness Only
# Contains zero fallback logic. All fallback is exercised through fallback.sh.
# Any change to fallback.sh is automatically reflected in the test result.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# iptables Safety Trap
# The DROP rule is removed via trap on ANY exit — normal completion, crash, error,
# ctrl-c. Remote connectivity is always restored regardless of test outcome.
# You cannot accidentally leave the remote permanently blocked.
#
# FALLBACK_ENABLED Gate
# Aborts if FALLBACK_ENABLED=false. Testing a disabled fallback system is
# misleading and potentially destructive.
#
# State Must Be NORMAL
# Pre-flight fails if state is not NORMAL. Running a test during an actual
# fallback event would interfere with the real event.
#
# Version Parity Check
# Pre-flight verifies unRAID version parity before any iptables rules are
# added. A mismatch makes the test result unreliable.
#
# Remote Docker Daemon Check
# Pre-flight confirms remote Docker daemon is responsive before Phase 2.
#
# Lock Acquisition
# acquire_lock() prevents concurrent test runs. Running two tests simultaneously
# would produce conflicting iptables rules and unreliable results.
#
# Host Detection
# detect_hosts() resolves MY_ID / REMOTE_ID from master.conf at startup.
# Exits if the host cannot be identified — prevents testing on an unknown machine.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# FALLBACK_TEST_BLOCK_WAIT
# Seconds to wait in Phase 3 for fallback.sh to detect the outage.
# Must be > FALLBACK_CHECK_INTERVAL + buffer. At 30s interval: use ≥60s.
# (default: 60)
#
# FALLBACK_TEST_HANDBACK_WAIT
# Seconds to wait in Phase 6 for fallback.sh to complete handback.
# Must cover: FALLBACK_HANDBACK_STRIKES × FALLBACK_CHECK_INTERVAL + rsync
# duration + container start time. At 3 strikes × 30s + ~2min rsync +
# ~1min container start: use ≥240s. (default: 300)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# fallback_test.sh --dry-run
# Walk through all 7 phases with output but no iptables changes and no
# container starts/stops. ALWAYS run this before a live test.
#
# fallback_test.sh
# Full live test — real iptables DROP rule, real container lifecycle.
# Users will experience a brief service interruption. Run during a
# maintenance window.
#
# fallback_test.sh --status
# Show current fallback state and test timing configuration. No test run.
#
# fallback_test.sh --log
# Verbose output on every check in every phase.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
FALLBACK_SCRIPT="$SCRIPT_DIR/fallback.sh"
DOCKER_TIMEOUT=15
# ==============================================================================================
# ── SAFETY TRAP — always remove iptables rule on exit ─────────────────────────────────────────
# ==============================================================================================
# Fires on normal exit, error exit, ctrl-c, and script crashes.
# Remote connectivity is ALWAYS restored regardless of test outcome.
IPTABLES_RULE_ACTIVE=false
cleanup() {
if [[ "$IPTABLES_RULE_ACTIVE" == true ]]; then
echo ""
warn "$ICON_SHIELD Cleanup — removing iptables block on $REMOTE_SERVER..."
if [[ "$DRY_RUN" == false ]]; then
iptables -D OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null
IPTABLES_RULE_ACTIVE=false
warn "iptables rule removed — remote connectivity restored"
else
warn "DRY RUN — would remove iptables rule"
fi
fi
}
trap cleanup EXIT
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# FALLBACK_ENABLED gate — no point testing if failover is disabled
if [[ "${FALLBACK_ENABLED:-false}" == false ]]; then
warn "FALLBACK_ENABLED=false — fallback test aborted"
warn "Enable fallback in master.conf before running this test"
exit 0
fi
acquire_lock # strict single instance — modifies iptables and containers
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
resolve_remote_ip
# Validate commands used by this script
validate_unraid_cmd \
"$(which iptables 2>/dev/null || echo /sbin/iptables)" \
"--version" "iptables" \
"iptables" || { error "iptables not found — required for connectivity simulation"; exit 1; }
validate_unraid_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
if [[ ! -f "$FALLBACK_SCRIPT" ]]; then
error "fallback.sh not found at $FALLBACK_SCRIPT"
exit 1
fi
log "fallback.sh found at $FALLBACK_SCRIPT"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no iptables rules or container changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
local_ver=$(grep -oP '(?<=version=")[^"]+' /etc/unraid-version 2>/dev/null || echo "unknown")
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_HOST Remote ID: $REMOTE_ID ($REMOTE_SERVER_NAME$REMOTE_SERVER)"
echo "$ICON_GEAR unRAID ver: $local_ver"
echo "$ICON_FALLBACK Block wait: ${FALLBACK_TEST_BLOCK_WAIT}s"
echo "$ICON_FALLBACK Handback wait: ${FALLBACK_TEST_HANDBACK_WAIT}s"
echo "$ICON_FALLBACK Check interval: ${FALLBACK_CHECK_INTERVAL}s"
echo "$ICON_FALLBACK Handback strikes: ${FALLBACK_HANDBACK_STRIKES}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
if [[ -f "$FALLBACK_STATE_FILE" ]]; then
CURRENT_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2)
echo "$ICON_FALLBACK Current state: ${CURRENT_STATE:-unknown}"
else
echo "$ICON_FALLBACK Current state: no state file"
fi
# Show Tier 1 containers for this host
TIER1_VAR="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER1"
eval "TIER1_CONTAINERS=(\"\${${TIER1_VAR}[@]:-}\")"
echo "$ICON_CONTAINERS Tier 1 to test: ${TIER1_CONTAINERS[*]:-none configured}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── PHASE TRACKING ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
PHASES_PASS=()
PHASES_FAIL=()
TOTAL_START=$(date +%s)
phase_pass() { PHASES_PASS+=("$1"); warn "$ICON_DONE Phase: $1 — PASSED ✅"; }
phase_fail() { PHASES_FAIL+=("$1"); error "Phase: $1 — FAILED ❌"; }
# Get Tier 1 containers for this server's failover responsibility
TIER1_VAR="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER1"
eval "TIER1_CONTAINERS=(\"\${${TIER1_VAR}[@]:-}\")"
# ==============================================================================================
# ━━━ Phase 1 — Pre-flight ━━━
# ==============================================================================================
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " $ICON_SHIELD FALLBACK TEST — $(date '+%Y-%m-%d %H:%M:%S')"
echo " $ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) → $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "━━━ $ICON_SHIELD Phase 1 — Pre-flight ━━━"
# Remote reachable
if ping_remote; then
log "$REMOTE_SERVER_NAME is reachable"
else
error "$REMOTE_SERVER_NAME is not reachable — cannot run test"
phase_fail "Pre-flight"
exit 1
fi
# Internet reachable
if ping_internet; then
log "Internet is reachable"
else
error "No internet connectivity — cannot run test"
phase_fail "Pre-flight"
exit 1
fi
# Version parity — test may produce misleading results on mismatch
if ! check_unraid_version_parity; then
error "unRAID version mismatch — test aborted to prevent misleading results"
phase_fail "Pre-flight"
exit 1
fi
# Remote Docker daemon — must be responsive before test manipulates containers
if ! check_remote_docker_daemon; then
error "Remote Docker daemon not responsive — cannot run test"
phase_fail "Pre-flight"
exit 1
fi
# Fallback state must be NORMAL before test
if [[ -f "$FALLBACK_STATE_FILE" ]]; then
CURRENT_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2)
if [[ "$CURRENT_STATE" != "NORMAL" ]]; then
error "Fallback state is $CURRENT_STATE — must be NORMAL before running test"
phase_fail "Pre-flight"
exit 1
fi
log "Fallback state is NORMAL"
else
warn "No state file found — assuming NORMAL (first run)"
fi
# Tier 1 containers configured
if [[ ${#TIER1_CONTAINERS[@]} -eq 0 ]]; then
error "No Tier 1 containers configured for $MY_ID$REMOTE_ID"
error "Check FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER1 in host*.conf"
phase_fail "Pre-flight"
exit 1
fi
log "Tier 1 containers: ${TIER1_CONTAINERS[*]}"
phase_pass "Pre-flight"
# ==============================================================================================
# ━━━ Phase 2 — Block Remote Connectivity ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_PING Phase 2 — Block Remote Connectivity ━━━"
warn "Adding iptables rule — dropping all traffic to $REMOTE_SERVER ($REMOTE_SERVER_NAME)"
if [[ "$DRY_RUN" == false ]]; then
iptables -I OUTPUT -d "$REMOTE_SERVER" -j DROP
IPTABLES_RULE_ACTIVE=true
# Verify block is working
sleep 2
if ! ping -c1 -W2 "$REMOTE_SERVER" &>/dev/null; then
log "Connectivity block confirmed — ping to remote fails as expected"
phase_pass "Block Remote"
else
error "iptables rule did not block connectivity — ping still succeeds"
phase_fail "Block Remote"
exit 1
fi
else
warn "DRY RUN — would block $REMOTE_SERVER with iptables DROP rule"
phase_pass "Block Remote"
fi
# ==============================================================================================
# ━━━ Phase 3 — Fallback Detection ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_FALLBACK Phase 3 — Fallback Detection ━━━"
warn "Waiting ${FALLBACK_TEST_BLOCK_WAIT}s for fallback.sh to detect outage..."
log "fallback.sh check interval: ${FALLBACK_CHECK_INTERVAL}s"
if [[ "$DRY_RUN" == false ]]; then
sleep "$FALLBACK_TEST_BLOCK_WAIT"
if [[ -f "$FALLBACK_STATE_FILE" ]]; then
NEW_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2)
if [[ "$NEW_STATE" == "FALLBACK" ]]; then
log "State changed to FALLBACK — outage detected correctly ✅"
phase_pass "Fallback Detection"
else
error "State is $NEW_STATE — expected FALLBACK after ${FALLBACK_TEST_BLOCK_WAIT}s"
warn "Is fallback.sh running? Check User Scripts plugin"
phase_fail "Fallback Detection"
fi
else
error "No state file found after wait — fallback.sh may not be running"
phase_fail "Fallback Detection"
fi
else
warn "DRY RUN — would wait ${FALLBACK_TEST_BLOCK_WAIT}s then check for FALLBACK state"
phase_pass "Fallback Detection"
fi
# ==============================================================================================
# ━━━ Phase 4 — Container Start Verification ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CONTAINERS Phase 4 — Tier 1 Containers Started Locally ━━━"
log "Checking Tier 1 containers: ${TIER1_CONTAINERS[*]}"
if [[ "$DRY_RUN" == false ]]; then
CONTAINERS_OK=true
for container in "${TIER1_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$container" 2>/dev/null)
if [[ "$STATUS" == "true" ]]; then
log "$ICON_RUNNING $container is running locally ✅"
else
error "$ICON_NOT_RUNNING $container is NOT running locally"
CONTAINERS_OK=false
fi
done
if [[ "$CONTAINERS_OK" == true ]]; then
phase_pass "Container Start"
else
phase_fail "Container Start"
fi
else
warn "DRY RUN — would verify these Tier 1 containers started: ${TIER1_CONTAINERS[*]}"
phase_pass "Container Start"
fi
# ==============================================================================================
# ━━━ Phase 5 — Restore Remote Connectivity ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_PING Phase 5 — Restore Remote Connectivity ━━━"
warn "Removing iptables block — $REMOTE_SERVER_NAME becomes reachable again"
if [[ "$DRY_RUN" == false ]]; then
iptables -D OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null
IPTABLES_RULE_ACTIVE=false
sleep 3
if ping_remote; then
log "$REMOTE_SERVER_NAME is reachable again ✅"
phase_pass "Restore Connectivity"
else
error "$REMOTE_SERVER_NAME still unreachable after removing iptables rule"
phase_fail "Restore Connectivity"
fi
else
warn "DRY RUN — would remove iptables rule"
phase_pass "Restore Connectivity"
fi
# ==============================================================================================
# ━━━ Phase 6 — Handback ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_FALLBACK Phase 6 — Handback ━━━"
warn "Waiting ${FALLBACK_TEST_HANDBACK_WAIT}s for fallback.sh to complete handback..."
log "Requires $FALLBACK_HANDBACK_STRIKES consecutive checks at ${FALLBACK_CHECK_INTERVAL}s"
log "Minimum handback time: $(( FALLBACK_HANDBACK_STRIKES * FALLBACK_CHECK_INTERVAL ))s"
if [[ "$DRY_RUN" == false ]]; then
sleep "$FALLBACK_TEST_HANDBACK_WAIT"
if [[ -f "$FALLBACK_STATE_FILE" ]]; then
FINAL_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2)
if [[ "$FINAL_STATE" == "NORMAL" ]]; then
log "State returned to NORMAL — handback completed ✅"
phase_pass "Handback"
else
error "State is $FINAL_STATE — expected NORMAL after ${FALLBACK_TEST_HANDBACK_WAIT}s"
warn "Handback may still be in progress — check fallback.sh output"
phase_fail "Handback"
fi
else
error "No state file found"
phase_fail "Handback"
fi
else
warn "DRY RUN — would wait ${FALLBACK_TEST_HANDBACK_WAIT}s then verify NORMAL state"
phase_pass "Handback"
fi
# ==============================================================================================
# ━━━ Phase 7 — Container Handback Verification ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CONTAINERS Phase 7 — Tier 1 Containers Stopped Locally ━━━"
log "Verifying Tier 1 containers returned to $REMOTE_SERVER_NAME"
if [[ "$DRY_RUN" == false ]]; then
HANDBACK_OK=true
for container in "${TIER1_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$container" 2>/dev/null)
if [[ "$STATUS" != "true" ]]; then
log "$ICON_NOT_RUNNING $container stopped locally — handed back ✅"
else
error "$ICON_RUNNING $container still running locally — handback may have failed"
HANDBACK_OK=false
fi
done
if [[ "$HANDBACK_OK" == true ]]; then
phase_pass "Container Handback"
else
phase_fail "Container Handback"
fi
else
warn "DRY RUN — would verify Tier 1 containers stopped locally after handback"
phase_pass "Container Handback"
fi
TOTAL_END=$(date +%s)
# ==============================================================================================
# ━━━ Test Report ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY FALLBACK TEST REPORT ━━━━━"
echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_HOST Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $((TOTAL_END - TOTAL_START)))"
echo ""
echo " Phase Results:"
for phase in "${PHASES_PASS[@]}"; do
echo " $ICON_SUCCESS $phase"
done
for phase in "${PHASES_FAIL[@]}"; do
echo " $ICON_ERROR $phase"
done
echo ""
PASS_COUNT=${#PHASES_PASS[@]}
FAIL_COUNT=${#PHASES_FAIL[@]}
TOTAL_PHASES=$(( PASS_COUNT + FAIL_COUNT ))
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$FAIL_COUNT" -eq 0 ]]; then
warn "$ICON_DONE ALL $TOTAL_PHASES PHASES PASSED"
notify "Fallback test PASSED on $(hostname) — all $TOTAL_PHASES phases completed" \
"Fallback Test" "normal"
else
error "$FAIL_COUNT/$TOTAL_PHASES PHASES FAILED"
notify "Fallback test FAILED on $(hostname)$FAIL_COUNT/$TOTAL_PHASES phases failed: ${PHASES_FAIL[*]}" \
"Fallback Test" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$FAIL_COUNT" -gt 0 ]] && exit 1
exit 0