310 lines
8.3 KiB
Bash
310 lines
8.3 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# resilience-helpers.sh — disruption primitives and FUSE/log assertions for
|
|
# network stability and reconnection E2E tests.
|
|
#
|
|
# Requires lib.sh globals ($FAILURES, $PASSES, $FAILED_TESTS) and config
|
|
# variables from run-resilience.sh ($VM_NAME, $PORT, $VM_IP, $MNT, $LOG_DIR,
|
|
# $DB_NAME, $PGHOST, $CLIENT_PID, $ALPHA, $BETA, $GAMMA, $DELTA).
|
|
#
|
|
# Sourced by run-resilience.sh — do NOT execute directly.
|
|
|
|
# ── timeouts (seconds) ──
|
|
HEALTH_TIMEOUT=45
|
|
READ_TIMEOUT=15
|
|
READ_FAIL_TIMEOUT=10
|
|
RECOVER_TIMEOUT=30
|
|
VM_RESUME_TIMEOUT=30
|
|
|
|
# ── internal pass/fail (reuses lib.sh globals) ──
|
|
_res_pass() {
|
|
printf ' \033[1;32mPASS\033[0m %s\n' "$1"
|
|
((++PASSES))
|
|
}
|
|
|
|
_res_fail() {
|
|
printf ' \033[1;31mFAIL\033[0m %s\n' "$1"
|
|
((++FAILURES))
|
|
FAILED_TESTS+="$1; "
|
|
}
|
|
|
|
# ── client liveness ──
|
|
check_client_alive() {
|
|
if [[ -n "$CLIENT_PID" ]] && ! kill -0 "$CLIENT_PID" 2>/dev/null; then
|
|
printf '\033[1;31m[e2e error]\033[0m client process died — check %s\n' "$LOG_DIR" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ── disruption primitives ──
|
|
server_stop() {
|
|
log "stopping server in VM…"
|
|
incus exec "$VM_NAME" -- systemctl stop musicfs-server.service 2>/dev/null || true
|
|
}
|
|
|
|
server_start() {
|
|
log "starting server in VM…"
|
|
incus exec "$VM_NAME" -- systemctl start musicfs-server.service 2>/dev/null || true
|
|
}
|
|
|
|
vm_pause() {
|
|
log "pausing VM '$VM_NAME'…"
|
|
incus pause "$VM_NAME"
|
|
}
|
|
|
|
vm_resume() {
|
|
log "resuming VM '$VM_NAME'…"
|
|
incus start "$VM_NAME"
|
|
local i
|
|
for ((i = 0; i < VM_RESUME_TIMEOUT; i++)); do
|
|
if incus exec "$VM_NAME" -- true 2>/dev/null; then return 0; fi
|
|
sleep 1
|
|
done
|
|
die "VM did not become reachable after resume"
|
|
}
|
|
|
|
# ── health check helpers ──
|
|
wait_health() {
|
|
local timeout="${1:-$HEALTH_TIMEOUT}"
|
|
local i
|
|
for ((i = 0; i < timeout; i++)); do
|
|
if timeout 5 grpcurl -plaintext "$VM_IP:$PORT" grpc.health.v1.Health/Check 2>/dev/null | grep -q SERVING; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
assert_health_down() {
|
|
local desc="$1"
|
|
if timeout 5 grpcurl -plaintext "$VM_IP:$PORT" grpc.health.v1.Health/Check 2>/dev/null | grep -q SERVING; then
|
|
_res_fail "$desc (server still serving)"
|
|
else
|
|
_res_pass "$desc"
|
|
fi
|
|
}
|
|
|
|
assert_health_up() {
|
|
local desc="$1"
|
|
if wait_health "$HEALTH_TIMEOUT"; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (health check timed out)"
|
|
fi
|
|
}
|
|
|
|
# ── cache control ──
|
|
clear_cache() {
|
|
log "clearing Postgres byte cache…"
|
|
psql -q -d "$DB_NAME" -c "DELETE FROM cached_file_bytes;" 2>/dev/null || true
|
|
}
|
|
|
|
# ── prewarm ──
|
|
prewarm_file() {
|
|
local file="$1"
|
|
timeout "$READ_TIMEOUT" dd if="$file" of=/dev/null bs=4096 2>/dev/null || true
|
|
}
|
|
|
|
prewarm_all() {
|
|
log "prewarming cache (reading all test files)…"
|
|
prewarm_file "$ALPHA"
|
|
prewarm_file "$BETA"
|
|
prewarm_file "$GAMMA"
|
|
prewarm_file "$DELTA"
|
|
}
|
|
|
|
# ── FUSE read assertions ──
|
|
assert_fuse_read_ok() {
|
|
local desc="$1" file="$2"
|
|
check_client_alive || return 1
|
|
if timeout "$READ_TIMEOUT" dd if="$file" of=/dev/null bs=4096 2>/dev/null; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (read failed or timed out after ${READ_TIMEOUT}s)"
|
|
fi
|
|
}
|
|
|
|
# Assert a FUSE read fails with EIO *before* max_seconds.
|
|
# Distinguishes "fast EIO" (system returned an error) from "indefinite hang"
|
|
# (timeout killed dd). Currently FAILS during silent partitions because
|
|
# there's no tonic timeout configured — the RPC hangs until the OS gives up.
|
|
assert_fuse_eio_within() {
|
|
local desc="$1" file="$2" max_seconds="${3:-$READ_FAIL_TIMEOUT}"
|
|
check_client_alive || return 1
|
|
local start; start=$(date +%s)
|
|
local rc=0
|
|
timeout "$max_seconds" dd if="$file" of=/dev/null bs=4096 2>/dev/null || rc=$?
|
|
local end; end=$(date +%s)
|
|
local elapsed=$((end - start))
|
|
if (( rc == 0 )); then
|
|
_res_fail "$desc (read succeeded unexpectedly in ${elapsed}s)"
|
|
elif (( elapsed >= max_seconds )); then
|
|
_res_fail "$desc (hung ${elapsed}s — no EIO returned; missing tonic timeout)"
|
|
else
|
|
_res_pass "$desc (EIO in ${elapsed}s)"
|
|
fi
|
|
}
|
|
|
|
assert_fuse_metaflac_ok() {
|
|
local desc="$1" file="$2"
|
|
check_client_alive || return 1
|
|
if timeout "$READ_TIMEOUT" metaflac --show-tag=TITLE "$file" >/dev/null 2>&1; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (metaflac read failed)"
|
|
fi
|
|
}
|
|
|
|
assert_fuse_id3v2_ok() {
|
|
local desc="$1" file="$2"
|
|
check_client_alive || return 1
|
|
if timeout "$READ_TIMEOUT" id3v2 --list "$file" >/dev/null 2>&1; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (id3v2 read failed)"
|
|
fi
|
|
}
|
|
|
|
assert_fuse_ffprobe_ok() {
|
|
local desc="$1" file="$2"
|
|
check_client_alive || return 1
|
|
if timeout "$READ_TIMEOUT" ffprobe -hide_banner -v quiet -show_format "$file" >/dev/null 2>&1; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (ffprobe failed)"
|
|
fi
|
|
}
|
|
|
|
# ── FUSE directory assertion ──
|
|
assert_dir_listing() {
|
|
local desc="$1" dir="$2" expected="$3"
|
|
check_client_alive || return 1
|
|
local result; result="$(timeout "$READ_TIMEOUT" ls "$dir" 2>/dev/null || true)"
|
|
if [[ "$result" == *"$expected"* ]]; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (directory missing '$expected')"
|
|
fi
|
|
}
|
|
|
|
# ── FUSE write assertions ──
|
|
assert_metaflac_write_readback() {
|
|
local desc="$1" file="$2" tag="$3" value="$4"
|
|
check_client_alive || return 1
|
|
metaflac --remove-tag="$tag" --set-tag="$tag=$value" "$file" 2>/dev/null
|
|
local result; result="$(metaflac --show-tag="$tag" "$file" 2>/dev/null || true)"
|
|
if [[ "$result" == *"$value"* ]]; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (write readback mismatch)"
|
|
fi
|
|
}
|
|
|
|
assert_mp3_write_readback() {
|
|
local desc="$1" file="$2" song="$3"
|
|
check_client_alive || return 1
|
|
id3v2 --song "$song" "$file" 2>/dev/null
|
|
local result; result="$(id3v2 --list "$file" 2>/dev/null || true)"
|
|
if [[ "$result" == *"$song"* ]]; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (MP3 write readback mismatch)"
|
|
fi
|
|
}
|
|
|
|
# ── log assertions ──
|
|
_get_log_file() {
|
|
ls -t "$LOG_DIR"/musicfs.*.log 2>/dev/null | head -1
|
|
}
|
|
|
|
assert_log_contains() {
|
|
local desc="$1" pattern="$2"
|
|
local log_file; log_file="$(_get_log_file)"
|
|
if [[ -n "$log_file" ]] && grep -q "$pattern" "$log_file" 2>/dev/null; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (pattern '$pattern' not in logs)"
|
|
fi
|
|
}
|
|
|
|
log_contains_since() {
|
|
local pattern="$1" from_line="$2"
|
|
local log_file; log_file="$(_get_log_file)"
|
|
[[ -n "$log_file" ]] && tail -n +"$((from_line + 1))" "$log_file" 2>/dev/null | grep -q "$pattern"
|
|
}
|
|
|
|
log_line_count() {
|
|
local log_file; log_file="$(_get_log_file)"
|
|
if [[ -n "$log_file" ]]; then
|
|
wc -l < "$log_file" 2>/dev/null || echo 0
|
|
else
|
|
echo 0
|
|
fi
|
|
}
|
|
|
|
assert_log_contains_after() {
|
|
local desc="$1" pattern="$2" marker="$3"
|
|
local log_file; log_file="$(_get_log_file)"
|
|
if [[ -z "$log_file" ]]; then
|
|
_res_fail "$desc (no log file)"
|
|
return
|
|
fi
|
|
# Find line number of the last occurrence of the marker.
|
|
local marker_line
|
|
marker_line="$(grep -n "$marker" "$log_file" 2>/dev/null | tail -1 | cut -d: -f1)"
|
|
if [[ -z "$marker_line" ]]; then
|
|
_res_fail "$desc (marker '$marker' not in log)"
|
|
return
|
|
fi
|
|
if tail -n +"$((marker_line + 1))" "$log_file" 2>/dev/null | grep -q "$pattern"; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc ('$pattern' not found after marker line $marker_line)"
|
|
fi
|
|
}
|
|
|
|
# ── recovery wait ──
|
|
wait_recover() {
|
|
local file="$1"
|
|
local timeout="${2:-$RECOVER_TIMEOUT}"
|
|
local i
|
|
for ((i = 0; i < timeout; i++)); do
|
|
check_client_alive || return 1
|
|
if timeout 5 dd if="$file" of=/dev/null bs=4096 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
assert_recovered() {
|
|
local desc="$1" file="$2"
|
|
if wait_recover "$file"; then
|
|
_res_pass "$desc"
|
|
else
|
|
_res_fail "$desc (did not recover within ${RECOVER_TIMEOUT}s)"
|
|
fi
|
|
}
|
|
|
|
# ── scenario isolation ──
|
|
ensure_clean_state() {
|
|
log "ensuring clean state between scenarios…"
|
|
# Server must be up
|
|
if ! wait_health 5 2>/dev/null; then
|
|
server_start
|
|
wait_health "$HEALTH_TIMEOUT" || die "server won't come up between scenarios"
|
|
fi
|
|
# VM must be running (not paused)
|
|
local state
|
|
state="$(incus list "$VM_NAME" -f csv -c s 2>/dev/null | head -1)"
|
|
if [[ "$state" == "FROZEN" ]]; then
|
|
vm_resume
|
|
wait_health "$HEALTH_TIMEOUT" || die "server unhealthy after VM resume"
|
|
fi
|
|
check_client_alive || die "client died between scenarios"
|
|
clear_cache
|
|
}
|