870 lines
42 KiB
Bash
870 lines
42 KiB
Bash
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ================================= Failover ===================================================
|
|
# ==============================================================================================
|
|
# Mutual container failover between two unRAID servers.
|
|
# Each server runs this script independently — no direct coordination between servers.
|
|
# All decisions based solely on two pings: remote reachable + internet reachable.
|
|
#
|
|
# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
|
|
# Both servers run this script continuously as a background task via User Scripts.
|
|
# Every FAILOVER_CHECK_INTERVAL seconds each server:
|
|
# 1. Pings the remote server
|
|
# 2. Pings the internet
|
|
# 3. Determines its current state
|
|
# 4. Takes the appropriate action
|
|
#
|
|
# No SSH signaling, no shared state files, no coordination — each server acts autonomously
|
|
# based only on what it can see from its own network perspective.
|
|
#
|
|
# ── STATES ────────────────────────────────────────────────────────────────────────────────────
|
|
# NORMAL — remote up, internet up
|
|
# Own containers only. DDNS ON. Silent operation.
|
|
#
|
|
# FAILOVER — remote down, internet up
|
|
# Start remote containers locally — tiered by outage duration.
|
|
# Remote DDNS started immediately (Tier 1).
|
|
# Own containers keep running — failover is additive.
|
|
#
|
|
# NO_INTERNET — internet down (remote may be up or down)
|
|
# Stop own DDNS immediately — can't update DNS without internet.
|
|
# Do not start remote containers — no internet = no point.
|
|
# Wait for recovery.
|
|
#
|
|
# DARK — remote down AND internet down
|
|
# Same actions as NO_INTERNET.
|
|
# Cannot determine if remote is truly down or just unreachable.
|
|
#
|
|
# ── DDNS RULES — ABSOLUTE ─────────────────────────────────────────────────────────────────────
|
|
# Each server owns its own DDNS — ON when that server has internet.
|
|
# Script controls DDNS exclusively — network state NEVER auto-starts DDNS.
|
|
# DDNS only starts after full handback sequence confirms containers are up.
|
|
# One DDNS per domain active at all times — never two, never zero for long.
|
|
# 1 minute TTL + 1 minute check interval = minimal user impact on failover.
|
|
#
|
|
# ── HANDBACK SEQUENCE ─────────────────────────────────────────────────────────────────────────
|
|
# When remote returns after FAILOVER:
|
|
# 1. Strike confirmation — FAILOVER_HANDBACK_STRIKES consecutive remote-up checks
|
|
# 2. Pre-flight checks — version parity, remote array, remote Docker daemon
|
|
# 3. Stop remote DDNS first — prevents split brain DNS during rsync
|
|
# 4. Stop remote containers — clean state before rsync
|
|
# 5. Tiered rsync writeback — skip if outage under threshold
|
|
# 6. Start remote containers — in dependency order with verification
|
|
# 7. Start remote DDNS last — DNS cuts back ONLY after containers confirmed up
|
|
# 8. Return to NORMAL
|
|
#
|
|
# ── TIERED FAILOVER ───────────────────────────────────────────────────────────────────────────
|
|
# Tier 1 — Immediate — vital services + Live TV — cannot wait
|
|
# Tier 2 — HOST*_TIER2_DELAY — shared productivity services (default 4hr)
|
|
# Tier 3 — HOST*_TIER3_DELAY — secondary services (default 12hr)
|
|
# Tier 4 — HOST*_TIER4_DELAY — arrs + downloaders (default 24hr)
|
|
# Tier delays configurable per host in master_host*.conf
|
|
#
|
|
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
|
|
# FAILOVER_ENABLED gate — exits cleanly if disabled in master.conf
|
|
# Version parity check — refuses handback if unRAID versions mismatch
|
|
# Remote Docker daemon — checks remote daemon before issuing any remote commands
|
|
# Timeout protection — all docker and SSH commands wrapped in timeouts
|
|
# Container verification — verifies containers came up after start (failover critical)
|
|
# MY_ID-based routing — all tier/DDNS/writeback arrays selected via MY_ID not hostname
|
|
# Command validation — validates unRAID notify script before use
|
|
# Silent by default — state transitions warn(), routine cycle checks log()
|
|
#
|
|
# ── CONFIGURATION (master_host*.conf) ─────────────────────────────────────────────────────────
|
|
# HOST*_DDNS_CONTAINERS — DDNS containers this host manages
|
|
# FAILOVER_HOST*_STOP_ON_NO_NET — containers stopped on internet loss
|
|
# FAILOVER_HOST*_RUNS_FOR_HOST*_TIER1-4 — what this host runs for the other
|
|
# HOST*_TIER2_DELAY / TIER3_DELAY / TIER4_DELAY — tier activation delays in minutes
|
|
# HOST*_TIER1_WRITEBACK_DELAY — skip Tier 1 writeback if outage under this
|
|
# FAILOVER_HOST*_WRITEBACK_TIER1-4 — paths synced back on handback per tier
|
|
#
|
|
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
|
|
# FAILOVER_ENABLED — false = exit cleanly (HOST2 being rebuilt etc.)
|
|
# FAILOVER_CHECK_INTERVAL — seconds between checks
|
|
# FAILOVER_HANDBACK_STRIKES — consecutive remote-up checks before handback
|
|
# FAILOVER_STATE_FILE — /boot/config path — survives reboots
|
|
# FAILOVER_RSYNC_ENABLED — gate for writeback rsync jobs
|
|
# EXTERNAL_IP — IP to ping for internet check (default 8.8.8.8)
|
|
#
|
|
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
|
|
# failover.sh — normal start (continuous loop)
|
|
# failover.sh --dry-run — preview state changes without acting on containers
|
|
# failover.sh --status — show current state and exit
|
|
# failover.sh --log — verbose cycle output
|
|
#
|
|
# ── TO STOP THIS SCRIPT ───────────────────────────────────────────────────────────────────────
|
|
# Click Abort in unRAID User Scripts — do NOT kill directly, state file may corrupt.
|
|
# ==============================================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
source "$SCRIPT_DIR/../load_config.sh"
|
|
|
|
parse_args "$@"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Setup ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_GEAR Setup ━━━"
|
|
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
error "Must be run as root"
|
|
exit 1
|
|
fi
|
|
|
|
# FAILOVER_ENABLED gate — exits cleanly when disabled (e.g. HOST2 being rebuilt)
|
|
if [[ "${FAILOVER_ENABLED:-false}" == false ]]; then
|
|
warn "FAILOVER_ENABLED=false — failover monitoring disabled"
|
|
warn "Set FAILOVER_ENABLED=true in master.conf when both servers are ready"
|
|
exit 0
|
|
fi
|
|
|
|
acquire_lock # strict single instance — notifies on collision
|
|
|
|
detect_hosts
|
|
resolve_remote_ip
|
|
|
|
# Validate unRAID notify script — used throughout for state change notifications
|
|
validate_unraid_cmd \
|
|
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
|
|
"" "" \
|
|
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
|
|
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no container or DDNS changes will be made"
|
|
|
|
# Timeout for all docker and SSH docker commands
|
|
DOCKER_TIMEOUT=15
|
|
SSH_TIMEOUT=10
|
|
CONTAINER_VERIFY_WAIT=5 # seconds after start before verifying container is up
|
|
|
|
# ==============================================================================================
|
|
# ── STATE FILE HELPERS ────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
# State file on /boot/config — survives reboots.
|
|
# Format: key=value one per line.
|
|
# Keys: state, failover_start, handback_strikes, tier2_started, tier3_started, tier4_started
|
|
|
|
state_get() {
|
|
grep "^${1}=" "$FAILOVER_STATE_FILE" 2>/dev/null | cut -d= -f2
|
|
}
|
|
|
|
state_set() {
|
|
local key="$1" value="$2"
|
|
if grep -q "^${key}=" "$FAILOVER_STATE_FILE" 2>/dev/null; then
|
|
sed -i "s|^${key}=.*|${key}=${value}|" "$FAILOVER_STATE_FILE"
|
|
else
|
|
echo "${key}=${value}" >> "$FAILOVER_STATE_FILE"
|
|
fi
|
|
}
|
|
|
|
state_init() {
|
|
mkdir -p "$(dirname "$FAILOVER_STATE_FILE")"
|
|
[[ ! -f "$FAILOVER_STATE_FILE" ]] && touch "$FAILOVER_STATE_FILE"
|
|
[[ -z "$(state_get state)" ]] && state_set state "NORMAL"
|
|
[[ -z "$(state_get failover_start)" ]] && state_set failover_start "0"
|
|
[[ -z "$(state_get handback_strikes)" ]] && state_set handback_strikes "0"
|
|
[[ -z "$(state_get tier2_started)" ]] && state_set tier2_started "false"
|
|
[[ -z "$(state_get tier3_started)" ]] && state_set tier3_started "false"
|
|
[[ -z "$(state_get tier4_started)" ]] && state_set tier4_started "false"
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ── CONTAINER HELPERS ─────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
# All docker commands wrapped in DOCKER_TIMEOUT.
|
|
# All SSH docker commands wrapped in SSH_TIMEOUT.
|
|
# Verification after start — failover is critical, confirm containers came up.
|
|
|
|
# Start a container locally and verify it came up
|
|
local_start() {
|
|
local container="$1"
|
|
[[ -z "$container" ]] && return
|
|
local status
|
|
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
|
|
"$container" 2>/dev/null)
|
|
if [[ "$status" == "true" ]]; then
|
|
log "$container already running locally"
|
|
return 0
|
|
fi
|
|
log "$ICON_START Starting $container locally..."
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would start $container locally"
|
|
return 0
|
|
fi
|
|
if timeout "$DOCKER_TIMEOUT" docker start "$container" >/dev/null 2>&1; then
|
|
sleep "$CONTAINER_VERIFY_WAIT"
|
|
local post_status
|
|
post_status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
|
|
"$container" 2>/dev/null)
|
|
if [[ "$post_status" == "true" ]]; then
|
|
log "$ICON_STARTED $container started and running ✅"
|
|
return 0
|
|
else
|
|
error "$container started but crashed immediately"
|
|
notify "$container failed to stay running during failover on $(hostname)" \
|
|
"Failover" "warning"
|
|
return 1
|
|
fi
|
|
else
|
|
error "Failed to start $container locally"
|
|
notify "Failed to start $container locally during failover on $(hostname)" \
|
|
"Failover" "warning"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Stop a container locally
|
|
local_stop() {
|
|
local container="$1"
|
|
[[ -z "$container" ]] && return
|
|
local status
|
|
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
|
|
"$container" 2>/dev/null)
|
|
if [[ "$status" != "true" ]]; then
|
|
log "$container already stopped locally"
|
|
return 0
|
|
fi
|
|
log "$ICON_STOP Stopping $container locally..."
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would stop $container locally"
|
|
return 0
|
|
fi
|
|
timeout "$DOCKER_TIMEOUT" docker stop "$container" >/dev/null 2>&1 && \
|
|
log "$ICON_STOPPED $container stopped" || \
|
|
error "Failed to stop $container locally"
|
|
}
|
|
|
|
# Start a container on remote via SSH and verify it came up
|
|
remote_start() {
|
|
local container="$1"
|
|
[[ -z "$container" ]] && return
|
|
local status
|
|
status=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
|
|
root@"$REMOTE_SERVER" \
|
|
"timeout $DOCKER_TIMEOUT docker inspect -f '{{.State.Running}}' \
|
|
$container 2>/dev/null" 2>/dev/null)
|
|
if [[ "$status" == "true" ]]; then
|
|
log "$container already running on $REMOTE_SERVER_NAME"
|
|
return 0
|
|
fi
|
|
log "$ICON_START Starting $container on $REMOTE_SERVER_NAME..."
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would start $container on $REMOTE_SERVER_NAME"
|
|
return 0
|
|
fi
|
|
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
|
|
root@"$REMOTE_SERVER" \
|
|
"timeout $DOCKER_TIMEOUT docker start $container" >/dev/null 2>&1; then
|
|
sleep "$CONTAINER_VERIFY_WAIT"
|
|
local post_status
|
|
post_status=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
|
-o ConnectTimeout="$SSH_TIMEOUT" root@"$REMOTE_SERVER" \
|
|
"timeout $DOCKER_TIMEOUT docker inspect -f '{{.State.Running}}' \
|
|
$container 2>/dev/null" 2>/dev/null)
|
|
if [[ "$post_status" == "true" ]]; then
|
|
log "$ICON_STARTED $container started on $REMOTE_SERVER_NAME ✅"
|
|
return 0
|
|
else
|
|
error "$container started on $REMOTE_SERVER_NAME but crashed immediately"
|
|
notify "$container failed after start on $REMOTE_SERVER_NAME during handback on $(hostname)" \
|
|
"Failover" "warning"
|
|
return 1
|
|
fi
|
|
else
|
|
error "Failed to start $container on $REMOTE_SERVER_NAME"
|
|
notify "Failed to start $container on $REMOTE_SERVER_NAME during handback on $(hostname)" \
|
|
"Failover" "warning"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Stop a container on remote via SSH
|
|
remote_stop() {
|
|
local container="$1"
|
|
[[ -z "$container" ]] && return
|
|
local status
|
|
status=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
|
|
root@"$REMOTE_SERVER" \
|
|
"timeout $DOCKER_TIMEOUT docker inspect -f '{{.State.Running}}' \
|
|
$container 2>/dev/null" 2>/dev/null)
|
|
if [[ "$status" != "true" ]]; then
|
|
log "$container already stopped on $REMOTE_SERVER_NAME"
|
|
return 0
|
|
fi
|
|
log "$ICON_STOP Stopping $container on $REMOTE_SERVER_NAME..."
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would stop $container on $REMOTE_SERVER_NAME"
|
|
return 0
|
|
fi
|
|
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
|
|
root@"$REMOTE_SERVER" \
|
|
"timeout $DOCKER_TIMEOUT docker stop $container" >/dev/null 2>&1 && \
|
|
log "$ICON_STOPPED $container stopped on $REMOTE_SERVER_NAME" || \
|
|
error "Failed to stop $container on $REMOTE_SERVER_NAME"
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ── DDNS HELPERS ──────────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
# DDNS managed exclusively by this script — never by network state alone.
|
|
# Stopping DDNS on internet loss prevents split-brain DNS updates.
|
|
|
|
local_ddns_stop() {
|
|
warn "$ICON_NET Stopping local DDNS — internet lost"
|
|
for container in "${LOCAL_DDNS_CONTAINERS[@]}"; do
|
|
local_stop "$container"
|
|
done
|
|
}
|
|
|
|
local_ddns_start() {
|
|
warn "$ICON_NET Starting local DDNS — handback complete, containers confirmed up"
|
|
for container in "${LOCAL_DDNS_CONTAINERS[@]}"; do
|
|
local_start "$container"
|
|
done
|
|
}
|
|
|
|
remote_ddns_stop() {
|
|
warn "$ICON_NET Stopping remote DDNS on $REMOTE_SERVER_NAME — prevents split-brain during rsync"
|
|
for container in "${REMOTE_DDNS_CONTAINERS[@]}"; do
|
|
remote_stop "$container"
|
|
done
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ── TIERED FAILOVER HELPERS ───────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
# All routing uses MY_ID — not hostname string comparison.
|
|
# MY_ID set by detect_hosts() — "HOST1" or "HOST2" etc.
|
|
# "This server covers the other" — MY_ID selects which tier arrays to use.
|
|
|
|
get_tier_containers() {
|
|
local tier="$1"
|
|
local remote_id="${REMOTE_ID}"
|
|
local var_name="FAILOVER_${MY_ID}_RUNS_FOR_${remote_id}_TIER${tier}"
|
|
eval "echo \"\${${var_name}[@]:-}\""
|
|
}
|
|
|
|
get_tier2_delay() {
|
|
local var_name="${REMOTE_ID}_TIER2_DELAY"
|
|
echo "${!var_name:-240}"
|
|
}
|
|
|
|
get_tier3_delay() {
|
|
local var_name="${REMOTE_ID}_TIER3_DELAY"
|
|
echo "${!var_name:-720}"
|
|
}
|
|
|
|
get_tier4_delay() {
|
|
local var_name="${REMOTE_ID}_TIER4_DELAY"
|
|
echo "${!var_name:-1440}"
|
|
}
|
|
|
|
get_tier1_writeback_delay() {
|
|
local var_name="${REMOTE_ID}_TIER1_WRITEBACK_DELAY"
|
|
echo "${!var_name:-60}"
|
|
}
|
|
|
|
get_writeback_jobs_for_tier() {
|
|
local tier="$1"
|
|
local remote_id="${REMOTE_ID}"
|
|
if [[ "$tier" -eq 4 ]]; then
|
|
# Tier 4 — daily sync shares of the remote + any extra writeback paths
|
|
local shares_var="${remote_id}_DAILY_SYNC_SHARES"
|
|
local extra_var="FAILOVER_${remote_id}_WRITEBACK_TIER4"
|
|
eval "echo \"\${${shares_var}[@]:-} \${${extra_var}[@]:-}\""
|
|
else
|
|
local var_name="FAILOVER_${remote_id}_WRITEBACK_TIER${tier}"
|
|
eval "echo \"\${${var_name}[@]:-}\""
|
|
fi
|
|
}
|
|
|
|
# Select DDNS arrays based on MY_ID
|
|
set_ddns_arrays() {
|
|
local local_ddns_var="${MY_ID}_DDNS_CONTAINERS"
|
|
local remote_ddns_var="${REMOTE_ID}_DDNS_CONTAINERS"
|
|
eval "LOCAL_DDNS_CONTAINERS=(\"\${${local_ddns_var}[@]:-}\")"
|
|
eval "REMOTE_DDNS_CONTAINERS=(\"\${${remote_ddns_var}[@]:-}\")"
|
|
}
|
|
|
|
# Select internet-loss stop list based on MY_ID
|
|
get_stop_on_no_net() {
|
|
local var_name="FAILOVER_${MY_ID}_STOP_ON_NO_NET"
|
|
eval "echo \"\${${var_name}[@]:-}\""
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Status ━━━
|
|
# ==============================================================================================
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
state_init
|
|
set_ddns_arrays
|
|
CURRENT_STATE=$(state_get state)
|
|
FAILOVER_START_TS=$(state_get failover_start)
|
|
TIER2=$(state_get tier2_started)
|
|
TIER3=$(state_get tier3_started)
|
|
TIER4=$(state_get tier4_started)
|
|
STRIKES=$(state_get handback_strikes)
|
|
|
|
local_ver=$(grep -oP '(?<=version=")[^"]+' /etc/unraid-version 2>/dev/null || echo "unknown")
|
|
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY FAILOVER 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_FAILOVER State: $CURRENT_STATE"
|
|
echo "$ICON_NET Local DDNS: ${LOCAL_DDNS_CONTAINERS[*]:-none}"
|
|
echo "$ICON_NET Remote DDNS: ${REMOTE_DDNS_CONTAINERS[*]:-none}"
|
|
echo "$ICON_TIME Interval: ${FAILOVER_CHECK_INTERVAL}s"
|
|
echo "$ICON_FAILOVER Strikes: $STRIKES / $FAILOVER_HANDBACK_STRIKES"
|
|
|
|
if [[ "$CURRENT_STATE" == "FAILOVER" && "$FAILOVER_START_TS" -gt 0 ]]; then
|
|
ELAPSED=$(( ($(date +%s) - FAILOVER_START_TS) / 60 ))
|
|
echo "$ICON_TIME Outage: ${ELAPSED}min"
|
|
echo "$ICON_FAILOVER Tier 2: $TIER2 (delay: $(get_tier2_delay)min)"
|
|
echo "$ICON_FAILOVER Tier 3: $TIER3 (delay: $(get_tier3_delay)min)"
|
|
echo "$ICON_FAILOVER Tier 4: $TIER4 (delay: $(get_tier4_delay)min)"
|
|
fi
|
|
|
|
echo "$ICON_GEAR Enabled: $FAILOVER_ENABLED"
|
|
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ── HANDBACK SEQUENCE ─────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
# Called when remote returns after FAILOVER state.
|
|
# CRITICAL sequencing — do not reorder without understanding the consequences.
|
|
# Each step must succeed before proceeding — aborts and retries next cycle on failure.
|
|
|
|
run_handback() {
|
|
echo ""
|
|
echo "━━━ $ICON_FAILOVER Handback Sequence — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
warn "$REMOTE_SERVER_NAME has returned — beginning handback"
|
|
|
|
# ── Step 1: Pre-flight checks ────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
|
|
|
|
# Version parity — refuse handback if unRAID versions mismatch
|
|
if ! check_unraid_version_parity; then
|
|
warn "Version parity check failed — aborting handback, will retry next cycle"
|
|
state_set handback_strikes 0
|
|
return 1
|
|
fi
|
|
|
|
# Remote array must be mounted before rsync
|
|
if ! check_remote_array; then
|
|
warn "Remote array not ready — aborting handback, will retry next cycle"
|
|
state_set handback_strikes 0
|
|
return 1
|
|
fi
|
|
|
|
# Remote Docker daemon must be responsive before issuing container commands
|
|
if ! check_remote_docker_daemon; then
|
|
warn "Remote Docker daemon not ready — aborting handback, will retry next cycle"
|
|
state_set handback_strikes 0
|
|
return 1
|
|
fi
|
|
|
|
log "Pre-flight checks passed ✅"
|
|
|
|
# ── Step 2: Stop remote DDNS FIRST ──────────────────────────────────────────────────────
|
|
# CRITICAL — prevents split-brain DNS while data is being synced
|
|
echo ""
|
|
echo "━━━ $ICON_NET DDNS Handoff ━━━"
|
|
remote_ddns_stop
|
|
sleep 5 # brief pause to ensure DDNS stop propagates before rsync
|
|
|
|
# ── Step 3: Stop remote containers ──────────────────────────────────────────────────────
|
|
# Clean state before rsync — no dirty writes during transfer window
|
|
echo ""
|
|
echo "━━━ $ICON_STOP Stop Remote Containers ━━━"
|
|
|
|
local ALL_FAILOVER_CONTAINERS=()
|
|
|
|
# Collect all started tiers in reverse order (highest tier stops first)
|
|
if [[ "$(state_get tier4_started)" == "true" ]]; then
|
|
read -r -a t4 <<< "$(get_tier_containers 4)"
|
|
ALL_FAILOVER_CONTAINERS+=("${t4[@]}")
|
|
fi
|
|
if [[ "$(state_get tier3_started)" == "true" ]]; then
|
|
read -r -a t3 <<< "$(get_tier_containers 3)"
|
|
ALL_FAILOVER_CONTAINERS+=("${t3[@]}")
|
|
fi
|
|
if [[ "$(state_get tier2_started)" == "true" ]]; then
|
|
read -r -a t2 <<< "$(get_tier_containers 2)"
|
|
ALL_FAILOVER_CONTAINERS+=("${t2[@]}")
|
|
fi
|
|
|
|
# Tier 1 always started — stop last (DDNS already handled above)
|
|
read -r -a t1 <<< "$(get_tier_containers 1)"
|
|
for container in "${t1[@]}"; do
|
|
local is_ddns=false
|
|
for ddns in "${REMOTE_DDNS_CONTAINERS[@]}"; do
|
|
[[ "$container" == "$ddns" ]] && is_ddns=true && break
|
|
done
|
|
[[ "$is_ddns" == false ]] && ALL_FAILOVER_CONTAINERS+=("$container")
|
|
done
|
|
|
|
for container in "${ALL_FAILOVER_CONTAINERS[@]}"; do
|
|
[[ -z "$container" ]] && continue
|
|
local_stop "$container"
|
|
done
|
|
|
|
log "All failover containers stopped locally"
|
|
|
|
# ── Step 4: Rsync writeback ──────────────────────────────────────────────────────────────
|
|
# Tiered writeback with skip window — short outages do not benefit from writeback.
|
|
# After a short outage primary's clean nightly state is more reliable than dirty
|
|
# accumulation — skip writeback entirely for outages under the threshold.
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Rsync Writeback ━━━"
|
|
|
|
local outage_minutes
|
|
outage_minutes=$(( ($(date +%s) - $(state_get failover_start)) / 60 ))
|
|
local tier1_wb_delay
|
|
tier1_wb_delay=$(get_tier1_writeback_delay)
|
|
|
|
warn "Outage duration: ${outage_minutes}min"
|
|
|
|
if ! check_rsync_enabled "FAILOVER"; then
|
|
warn "FAILOVER_RSYNC_ENABLED=false — skipping all writeback jobs"
|
|
warn "Handback will complete without syncing state back to primary"
|
|
else
|
|
run_writeback_tier() {
|
|
local tier="$1" threshold="$2" label="$3"
|
|
local jobs
|
|
read -r -a jobs <<< "$(get_writeback_jobs_for_tier "$tier")"
|
|
[[ ${#jobs[@]} -eq 0 ]] && return
|
|
|
|
if [[ "$outage_minutes" -ge "$threshold" ]]; then
|
|
warn "Tier $tier writeback ($label) — outage ${outage_minutes}min >= ${threshold}min"
|
|
for job in "${jobs[@]}"; do
|
|
[[ -z "$job" ]] && continue
|
|
log "Syncing: $job"
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
if [[ "$(basename "$job")" == "Emby" ]]; then
|
|
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" --profile=emby-failover
|
|
else
|
|
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job"
|
|
fi
|
|
else
|
|
warn "DRY RUN — would rsync: $job"
|
|
fi
|
|
done
|
|
else
|
|
log "Tier $tier writeback skipped — outage ${outage_minutes}min < ${threshold}min — primary state is cleaner"
|
|
fi
|
|
}
|
|
|
|
local t2_delay t3_delay t4_delay
|
|
t2_delay=$(get_tier2_delay)
|
|
t3_delay=$(get_tier3_delay)
|
|
t4_delay=$(get_tier4_delay)
|
|
|
|
run_writeback_tier 1 "$tier1_wb_delay" "Emby + auth stack"
|
|
run_writeback_tier 2 "$t2_delay" "productivity services"
|
|
run_writeback_tier 3 "$t3_delay" "secondary services"
|
|
run_writeback_tier 4 "$t4_delay" "media shares + edge cases"
|
|
fi
|
|
|
|
log "Writeback complete"
|
|
|
|
# ── Step 5: Start remote containers ─────────────────────────────────────────────────────
|
|
# Start in tier order with dependency awareness — databases before apps
|
|
echo ""
|
|
echo "━━━ $ICON_START Start Remote Containers ━━━"
|
|
|
|
# Tier 1 — excluding DDNS (handled separately as final step)
|
|
for container in "${t1[@]}"; do
|
|
[[ -z "$container" ]] && continue
|
|
local is_ddns=false
|
|
for ddns in "${REMOTE_DDNS_CONTAINERS[@]}"; do
|
|
[[ "$container" == "$ddns" ]] && is_ddns=true && break
|
|
done
|
|
[[ "$is_ddns" == false ]] && remote_start "$container"
|
|
done
|
|
|
|
sleep 10 # give databases time to initialise before apps
|
|
|
|
[[ "$(state_get tier2_started)" == "true" ]] && \
|
|
for container in "${t2[@]}"; do
|
|
[[ -n "$container" ]] && remote_start "$container"
|
|
done
|
|
|
|
[[ "$(state_get tier3_started)" == "true" ]] && \
|
|
for container in "${t3[@]}"; do
|
|
[[ -n "$container" ]] && remote_start "$container"
|
|
done
|
|
|
|
[[ "$(state_get tier4_started)" == "true" ]] && \
|
|
for container in "${t4[@]}"; do
|
|
[[ -n "$container" ]] && remote_start "$container"
|
|
done
|
|
|
|
log "Remote containers started"
|
|
|
|
# ── Step 6: Start remote DDNS LAST ──────────────────────────────────────────────────────
|
|
# DNS cuts over ONLY after containers confirmed up — this is the final step
|
|
echo ""
|
|
echo "━━━ $ICON_NET DNS Cutover ━━━"
|
|
sleep 5 # brief pause to ensure containers are accepting connections
|
|
for container in "${REMOTE_DDNS_CONTAINERS[@]}"; do
|
|
remote_start "$container"
|
|
done
|
|
warn "$ICON_NET Remote DDNS started — DNS now points at $REMOTE_SERVER_NAME"
|
|
|
|
# ── Step 7: Return to NORMAL ─────────────────────────────────────────────────────────────
|
|
echo ""
|
|
state_set state "NORMAL"
|
|
state_set failover_start "0"
|
|
state_set handback_strikes "0"
|
|
state_set tier2_started "false"
|
|
state_set tier3_started "false"
|
|
state_set tier4_started "false"
|
|
|
|
warn "$ICON_DONE Handback complete — returned to NORMAL"
|
|
notify "Failover handback complete on $(hostname) — $REMOTE_SERVER_NAME is back, all containers returned" \
|
|
"Failover" "normal"
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Main State Machine ━━━
|
|
# ==============================================================================================
|
|
state_init
|
|
set_ddns_arrays
|
|
|
|
echo ""
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo " $ICON_FAILOVER FAILOVER — $(date '+%Y-%m-%d %H:%M:%S')"
|
|
echo " $ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) → monitoring $REMOTE_ID ($REMOTE_SERVER_NAME)"
|
|
echo " $ICON_NET Remote IP: $REMOTE_SERVER"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
FAILOVER_RUNNING=true
|
|
trap 'FAILOVER_RUNNING=false; warn "Failover received shutdown signal — stopping cleanly"; exit 0' \
|
|
SIGTERM SIGINT
|
|
|
|
while [[ "$FAILOVER_RUNNING" == true ]]; do
|
|
NOW=$(date +%s)
|
|
CURRENT_STATE=$(state_get state)
|
|
|
|
# ── Connectivity checks ──────────────────────────────────────────────────────────────────
|
|
REMOTE_UP=false
|
|
INTERNET_UP=false
|
|
|
|
ping_remote && REMOTE_UP=true
|
|
ping_internet && INTERNET_UP=true
|
|
|
|
log "Remote: $REMOTE_UP | Internet: $INTERNET_UP | State: $CURRENT_STATE"
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# NORMAL STATE
|
|
# ════════════════════════════════════════════════════════════════
|
|
if [[ "$CURRENT_STATE" == "NORMAL" ]]; then
|
|
|
|
if [[ "$REMOTE_UP" == true && "$INTERNET_UP" == true ]]; then
|
|
log "$ICON_SUCCESS NORMAL — all systems up"
|
|
|
|
elif [[ "$REMOTE_UP" == false && "$INTERNET_UP" == true ]]; then
|
|
# Remote is down — enter FAILOVER
|
|
echo ""
|
|
echo "━━━ $ICON_FAILOVER Entering FAILOVER — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
warn "$REMOTE_SERVER_NAME ($REMOTE_ID) is unreachable — internet is up — starting failover"
|
|
|
|
state_set state "FAILOVER"
|
|
state_set failover_start "$NOW"
|
|
state_set handback_strikes "0"
|
|
state_set tier2_started "false"
|
|
state_set tier3_started "false"
|
|
state_set tier4_started "false"
|
|
|
|
echo ""
|
|
echo "━━━ $ICON_START Tier 1 — Immediate ━━━"
|
|
read -r -a tier1 <<< "$(get_tier_containers 1)"
|
|
for container in "${tier1[@]}"; do
|
|
[[ -n "$container" ]] && local_start "$container"
|
|
done
|
|
|
|
notify "FAILOVER started on $(hostname) — $REMOTE_SERVER_NAME is down — Tier 1 started" \
|
|
"Failover" "warning"
|
|
|
|
elif [[ "$INTERNET_UP" == false ]]; then
|
|
# Lost internet — enter NO_INTERNET
|
|
echo ""
|
|
echo "━━━ $ICON_NET Entering NO_INTERNET — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
warn "Internet connectivity lost — stopping local DDNS"
|
|
|
|
state_set state "NO_INTERNET"
|
|
local_ddns_stop
|
|
|
|
# Stop containers configured to stop on internet loss
|
|
read -r -a stop_on_no_net <<< "$(get_stop_on_no_net)"
|
|
for container in "${stop_on_no_net[@]}"; do
|
|
[[ -n "$container" ]] && local_stop "$container"
|
|
done
|
|
|
|
notify "NO_INTERNET on $(hostname) — DDNS stopped, waiting for recovery" \
|
|
"Failover" "warning"
|
|
fi
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# FAILOVER STATE
|
|
# ════════════════════════════════════════════════════════════════
|
|
elif [[ "$CURRENT_STATE" == "FAILOVER" ]]; then
|
|
|
|
FAILOVER_START_TS=$(state_get failover_start)
|
|
ELAPSED_MIN=$(( (NOW - FAILOVER_START_TS) / 60 ))
|
|
|
|
if [[ "$REMOTE_UP" == true && "$INTERNET_UP" == true ]]; then
|
|
# Remote returned — increment handback strikes
|
|
STRIKES=$(state_get handback_strikes)
|
|
STRIKES=$(( STRIKES + 1 ))
|
|
state_set handback_strikes "$STRIKES"
|
|
|
|
warn "$ICON_FAILOVER $REMOTE_SERVER_NAME is back — handback strike $STRIKES/$FAILOVER_HANDBACK_STRIKES"
|
|
|
|
if [[ "$STRIKES" -ge "$FAILOVER_HANDBACK_STRIKES" ]]; then
|
|
run_handback
|
|
fi
|
|
|
|
elif [[ "$INTERNET_UP" == false ]]; then
|
|
# Lost internet during failover — enter DARK
|
|
echo ""
|
|
echo "━━━ $ICON_NET Entering DARK — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
warn "Lost internet during failover — entering DARK state"
|
|
state_set state "DARK"
|
|
local_ddns_stop
|
|
notify "DARK state on $(hostname) — lost internet during failover" \
|
|
"Failover" "warning"
|
|
|
|
else
|
|
# Still in failover — check tier escalation
|
|
state_set handback_strikes "0"
|
|
|
|
TIER2_DELAY=$(get_tier2_delay)
|
|
if [[ "$(state_get tier2_started)" == "false" && \
|
|
"$ELAPSED_MIN" -ge "$TIER2_DELAY" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_START Tier 2 — ${ELAPSED_MIN}min outage ━━━"
|
|
read -r -a tier2 <<< "$(get_tier_containers 2)"
|
|
for container in "${tier2[@]}"; do
|
|
[[ -n "$container" ]] && local_start "$container"
|
|
done
|
|
state_set tier2_started "true"
|
|
notify "Failover Tier 2 started on $(hostname) — ${ELAPSED_MIN}min outage" \
|
|
"Failover" "warning"
|
|
fi
|
|
|
|
TIER3_DELAY=$(get_tier3_delay)
|
|
if [[ "$(state_get tier3_started)" == "false" && \
|
|
"$ELAPSED_MIN" -ge "$TIER3_DELAY" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_START Tier 3 — ${ELAPSED_MIN}min outage ━━━"
|
|
read -r -a tier3 <<< "$(get_tier_containers 3)"
|
|
for container in "${tier3[@]}"; do
|
|
[[ -n "$container" ]] && local_start "$container"
|
|
done
|
|
state_set tier3_started "true"
|
|
notify "Failover Tier 3 started on $(hostname) — ${ELAPSED_MIN}min outage" \
|
|
"Failover" "warning"
|
|
fi
|
|
|
|
TIER4_DELAY=$(get_tier4_delay)
|
|
if [[ "$(state_get tier4_started)" == "false" && \
|
|
"$ELAPSED_MIN" -ge "$TIER4_DELAY" ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_START Tier 4 — ${ELAPSED_MIN}min outage — full workflow ━━━"
|
|
read -r -a tier4 <<< "$(get_tier_containers 4)"
|
|
for container in "${tier4[@]}"; do
|
|
[[ -n "$container" ]] && local_start "$container"
|
|
done
|
|
state_set tier4_started "true"
|
|
notify "Failover Tier 4 started on $(hostname) — ${ELAPSED_MIN}min outage — full workflow active" \
|
|
"Failover" "warning"
|
|
fi
|
|
|
|
log "$ICON_FAILOVER FAILOVER — ${ELAPSED_MIN}min — T2:$(state_get tier2_started) T3:$(state_get tier3_started) T4:$(state_get tier4_started)"
|
|
fi
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# NO_INTERNET STATE
|
|
# ════════════════════════════════════════════════════════════════
|
|
elif [[ "$CURRENT_STATE" == "NO_INTERNET" ]]; then
|
|
|
|
if [[ "$INTERNET_UP" == true ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_NET Internet Recovered — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
|
|
if [[ "$REMOTE_UP" == true ]]; then
|
|
warn "Remote is up — returning to NORMAL"
|
|
state_set state "NORMAL"
|
|
local_ddns_start
|
|
notify "Internet recovered on $(hostname) — returning to NORMAL" \
|
|
"Failover" "normal"
|
|
else
|
|
warn "Internet back but $REMOTE_SERVER_NAME still down — entering FAILOVER"
|
|
state_set state "FAILOVER"
|
|
state_set failover_start "$NOW"
|
|
state_set handback_strikes "0"
|
|
state_set tier2_started "false"
|
|
state_set tier3_started "false"
|
|
state_set tier4_started "false"
|
|
|
|
echo ""
|
|
echo "━━━ $ICON_START Tier 1 — Immediate ━━━"
|
|
read -r -a tier1 <<< "$(get_tier_containers 1)"
|
|
for container in "${tier1[@]}"; do
|
|
[[ -n "$container" ]] && local_start "$container"
|
|
done
|
|
notify "Internet recovered on $(hostname) but $REMOTE_SERVER_NAME still down — entering FAILOVER" \
|
|
"Failover" "warning"
|
|
fi
|
|
else
|
|
log "$ICON_NET NO_INTERNET — waiting for connectivity"
|
|
fi
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# DARK STATE
|
|
# ════════════════════════════════════════════════════════════════
|
|
elif [[ "$CURRENT_STATE" == "DARK" ]]; then
|
|
|
|
if [[ "$INTERNET_UP" == true ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_NET Emerging from DARK — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
|
|
if [[ "$REMOTE_UP" == true ]]; then
|
|
warn "Remote up, internet up — transitioning through FAILOVER for handback"
|
|
# Was in failover before DARK — route through FAILOVER state for handback
|
|
state_set state "FAILOVER"
|
|
state_set handback_strikes "0"
|
|
else
|
|
warn "Internet back but $REMOTE_SERVER_NAME still down — entering FAILOVER"
|
|
state_set state "FAILOVER"
|
|
state_set failover_start "$NOW"
|
|
state_set handback_strikes "0"
|
|
state_set tier2_started "false"
|
|
state_set tier3_started "false"
|
|
state_set tier4_started "false"
|
|
|
|
read -r -a tier1 <<< "$(get_tier_containers 1)"
|
|
for container in "${tier1[@]}"; do
|
|
[[ -n "$container" ]] && local_start "$container"
|
|
done
|
|
fi
|
|
notify "Emerging from DARK state on $(hostname)" "Failover" "warning"
|
|
else
|
|
log "$ICON_FAILOVER DARK — no internet, no remote — waiting"
|
|
fi
|
|
fi
|
|
|
|
# ── Sleep until next check ───────────────────────────────────────────────────────────────
|
|
log "Next check in ${FAILOVER_CHECK_INTERVAL}s — $(date '+%H:%M:%S')"
|
|
sleep "$FAILOVER_CHECK_INTERVAL" &
|
|
wait $!
|
|
|
|
done |