508 lines
21 KiB
Bash
Executable File
508 lines
21 KiB
Bash
Executable File
#!/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 fallback 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
|
||
platform_require_cmd \
|
||
"$(which iptables 2>/dev/null || echo /sbin/iptables)" \
|
||
"--version" "iptables" \
|
||
"iptables" || { error "iptables not found — required for connectivity simulation"; exit 1; }
|
||
|
||
|
||
if [[ ! -f "$FALLBACK_SCRIPT" ]]; then
|
||
error "fallback.sh not found at $FALLBACK_SCRIPT"
|
||
exit 1
|
||
fi
|
||
log "fallback.sh found at $FALLBACK_SCRIPT"
|
||
log "$ICON_GEAR Config: remote=${REMOTE_SERVER_NAME} (${REMOTE_SERVER}) fallback-script=${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=$(platform_get_os_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 OS 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 fallback 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_os_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
|
||
echo "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
|
||
echo "$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
|
||
echo "$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
|
||
echo "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
|
||
echo "$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 |