Files
nmon/scripts/gate-m4.sh

305 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# Gate M4: stopping a parent fixture sends one notification naming it and its children.
# Procedure: docs/milestones/M4-alerting.md.
# 1. make lint; unit tests of the packages this milestone adds.
# 2. make build + make devtools.
# 3. A webhook sink (nmon-devtools webhook-sink) and one simulated SNMP agent per device of
# testdata/topologies/redundant.yaml (2 cores, 2 distribution, 20 access) on [::1]. nmon serves with
# --icmp off --poll-interval 5s and a config whose only receiver is the sink; its outbound allow-list
# names [::1], which is denied by default (that the sink receives anything proves the allow-list works).
# 4. (a) stop core-1: exactly one device_down, naming core-1, nothing suppressed; restart it.
# (a2) a child that fails alone while its parents are up notifies once.
# (b) stop both cores and all 22 children at the same instant: exactly ONE notification in total, naming
# both cores and the 22 suppressed children, none for any child; its incident ID matches the API's.
# (c) restart nmon on the same data dir: nothing is sent again.
# (d) start core-1: its resolution arrives; the distribution switches (still down, no longer behind two
# dead parents) are announced; the access switches stay suppressed behind them.
# Also: a parent edit that would close a cycle is a 422 naming the path, ack and maintenance validation.
# 5. openapi-check against m3 (additive only), contract drift, mock UI specs.
# 6. Live screenshots of the Alerts page, a suppressed device and the device list into docs/screenshots/M4/.
# Needs: go, make, node 22+, npm, Playwright's chromium (cd web && npx playwright install chromium), curl, ss, awk.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
# Maintainability is a hard rule (docs/MAINTAINABILITY.md): no gate passes with lint findings.
make lint || { echo "FAIL: make lint"; exit 1; }
TMP="$(mktemp -d)"
FAILS=0
NMON_PID="" SINK_PID="" AGENTS_PID=""
SHOTS="$ROOT/${NMON_SHOT_DIR:-docs/screenshots/M4}"
TOPO="$ROOT/testdata/topologies/redundant.yaml"
DEVTOOLS="$ROOT/nmon-devtools"
SINK="$TMP/sink.jsonl"
CTL="$TMP/ctl"
MAP="$TMP/slugs.map" # nmon slug -> topology name (nmon appends a random suffix to every slug)
pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); }
die() { fail "$*"; exit 1; }
cleanup() {
for p in "$NMON_PID" "$AGENTS_PID" "$SINK_PID"; do
[ -n "$p" ] && kill -TERM "$p" 2>/dev/null && wait "$p" 2>/dev/null
done
rm -rf "$TMP"
}
trap cleanup EXIT
trap 'exit 130' INT TERM
pick_port() { # a port free for both TCP and UDP
local p
while :; do
p=$((20000 + RANDOM % 30000))
ss -H -lntu 2>/dev/null | awk '{print $5}' | grep -q "[:.]$p\$" || { echo "$p"; return; }
done
}
pick_udp_range() { # $1 consecutive UDP ports that are all free
local base n="$1" i busy
while :; do
base=$((20000 + RANDOM % 25000)); busy=0
for i in $(seq 0 $((n - 1))); do
ss -H -lnu 2>/dev/null | awk '{print $5}' | grep -q "[:.]$((base + i))\$" && { busy=1; break; }
done
[ "$busy" -eq 0 ] && { echo "$base"; return; }
done
}
# wait_for <seconds> <description> <command...>
wait_for() {
local n="$1" what="$2"; shift 2
for _ in $(seq 1 "$((n * 2))"); do "$@" && return 0; sleep 0.5; done
echo "timed out after ${n}s waiting for $what"; return 1
}
sink() { "$DEVTOOLS" gate-dump sink --file "$SINK" --map "$MAP"; }
alerts() { "$DEVTOOLS" gate-dump alerts --api "$URL" --map "$MAP"; }
devices() { "$DEVTOOLS" gate-dump devices --api "$URL" --map "$MAP"; }
slug_of() { awk -v n="$1" '$2==n {print $1}' "$MAP"; }
# nsink <rule> <state> [device-regex]: notifications received
nsink() { sink | awk -v r="$1" -v s="$2" -v d="${3:-.}" '$1 ~ r && $2 ~ s && $3 ~ d' | wc -l; }
# incident IDs: from the sink (rule state device) and from the API (rule device)
inc_sink() { sink | awk -v r="$1" -v s="$2" -v d="$3" '$1==r && $2==s && $3==d {sub("incident=", "", $6); print $6; exit}'; }
inc_api() { alerts | awk -v d="$1" '$2=="device_down" && $4==d {sub("incident=", "", $6); print $6; exit}'; }
ndown() { devices | awk -v s="$1" '$2==s' | wc -l; }
stop_agent() { touch "$CTL/stop-$1"; }
start_agent() { rm -f "$CTL/stop-$1"; }
CHILDREN="dist-1 dist-2 $(printf 'access-%02d ' $(seq 1 20))"
CHILD_RE='^(dist|access)-'
start_nmon() {
./nmon serve --data-dir "$DATA" --secret-key-file "$TMP/secret.key" --insecure-http --listen "127.0.0.1:$PORT" \
--poll-interval 5s --icmp off --trap-listen '' >>"$TMP/nmon.log" 2>&1 &
NMON_PID=$!
}
api_up() { curl -fsS "$URL/api/v1/devices" >/dev/null 2>&1; }
# 1. Unit tests of what this milestone adds.
echo "== unit tests"
if go test -race -count=1 ./internal/reach/... ./internal/deps/... ./internal/alert/... ./internal/notify/... ./internal/netpolicy/... \
./internal/config/... ./internal/api/... ./internal/app/... ./internal/poller/... ./cmd/... >"$TMP/unit.log" 2>&1; then
pass "go test -race: reach, deps, alert, notify, netpolicy, config, api, app, poller, cmd"
else
tail -n 30 "$TMP/unit.log"; fail "go test -race"
fi
# 2. Binaries.
echo "== make build"
make build >"$TMP/build.log" 2>&1 || { tail -n 30 "$TMP/build.log"; die "make build"; }
make devtools >"$TMP/devtools.log" 2>&1 || { tail -n 30 "$TMP/devtools.log"; die "make devtools"; }
pass "nmon and nmon-devtools built"
# 3. Sink, agents, nmon.
echo "== sink + agents + nmon serve"
mkdir -p "$CTL"; : >"$SINK"
"$DEVTOOLS" webhook-sink --listen '[::1]:0' --out "$SINK" --port-file "$TMP/sink.port" >"$TMP/sink.log" 2>&1 &
SINK_PID=$!
wait_for 20 "the webhook sink" test -s "$TMP/sink.port" || die "webhook sink did not start"
SINK_PORT="$(cat "$TMP/sink.port")"
NDEV="$(grep -c '^ - {slug:' "$TOPO")"
BASE="$(pick_udp_range "$NDEV")"
"$DEVTOOLS" topology-agents --topology "$TOPO" --port-base "$BASE" --control-dir "$CTL" >"$TMP/agents.log" 2>&1 &
AGENTS_PID=$!
wait_for 30 "the simulated agents" grep -q 'agents ready' "$TMP/agents.log" || { cat "$TMP/agents.log"; die "agents did not start"; }
pass "$NDEV simulated agents on [::1]:$BASE-$((BASE + NDEV - 1)), webhook sink on [::1]:$SINK_PORT"
PORT="$(pick_port)"
URL="http://127.0.0.1:$PORT"
DATA="$TMP/data"
mkdir -p "$DATA"
cat >"$DATA/config.yaml" <<YAML
alerting:
outbound_allow: ["::1"]
receivers:
- name: sink
kind: webhook
url: http://[::1]:$SINK_PORT/hook
YAML
start_nmon
wait_for 90 "nmon to serve" api_up || { tail -n 20 "$TMP/nmon.log"; die "nmon serve did not start"; }
"$DEVTOOLS" topology-add --api "$URL" --topology "$TOPO" --port-base "$BASE" --map-out "$MAP" >"$TMP/add.log" 2>&1 || { cat "$TMP/add.log"; die "topology-add"; }
pass "$(cat "$TMP/add.log")"
all_up() { [ "$(ndown up)" -eq "$NDEV" ]; }
wait_for 120 "all $NDEV devices up" all_up || { devices | awk '$2!="up"'; die "devices did not all come up"; }
pass "all $NDEV devices up with --icmp off (SNMP alone decides)"
[ "$(alerts | awk '$2=="device_warning"' | wc -l)" -eq 0 ] && pass "icmp_unsupported is a device warning, not an alert: no warning alert rows exist" ||
{ alerts | head; fail "device_warning alert rows with --icmp off"; }
has_warning() { [ "$(devices | awk '$4 ~ /icmp_unsupported/' | wc -l)" -eq "$NDEV" ]; }
wait_for 40 "icmp_unsupported in every device's warnings" has_warning &&
pass "every device lists icmp_unsupported in its warnings" || { devices | head -n 5; fail "icmp_unsupported device warnings"; }
grep -q 'SNMP alone decides' "$TMP/nmon.log" && pass "startup log explains that SNMP alone decides" || fail "no --icmp off startup log line"
[ "$(sink | wc -l)" -eq 0 ] && pass "nothing was sent while the network was healthy" || { sink; fail "notifications while healthy"; }
# Dependency cycles are refused on the write path, with the path named.
CODE="$(curl -s -o "$TMP/cycle.json" -w '%{http_code}' -X PUT "$URL/api/v1/devices/$(slug_of core-1)" -H 'Content-Type: application/json' \
-d "{\"parents\":[\"$(slug_of access-01)\"]}")"
[ "$CODE" = 422 ] && grep -q 'core-1.*access-01.*dist-1.*core-1' "$TMP/cycle.json" && pass "a parent edit that closes a cycle is a 422 naming the path" ||
{ cat "$TMP/cycle.json"; fail "cycle write was not refused with the path (HTTP $CODE)"; }
# /alerts takes device and iface filters.
curl -fsS "$URL/api/v1/alerts?device=$(slug_of core-1)" | grep -q '"total":0' && pass "GET /alerts?device= filters by device" || fail "alerts device filter"
# (a) one parent down: no suppression, one notification.
echo "== (a) stop core-1"
stop_agent core-1
has_core1() { [ "$(nsink device_down firing '^core-1$')" -ge 1 ]; }
wait_for 90 "device_down for core-1" has_core1 || { tail -n 20 "$TMP/nmon.log"; die "no notification for core-1"; }
sleep 25 # five more evaluation passes: nothing else may arrive
[ "$(nsink device_down firing '^core-1$')" -eq 1 ] && pass "(a) exactly one device_down for core-1" || fail "(a) core-1 notifications: $(nsink device_down firing '^core-1$')"
[ "$(nsink device_down firing .)" -eq 1 ] && pass "(a) no other device sent device_down" || { sink; fail "(a) other notifications"; }
sink | grep -q '^device_down firing core-1 parents=- children=0 ' && pass "(a) no suppression: no parents named, no children counted" || { sink; fail "(a) suppression in the core-1 notice"; }
[ "$(ndown up)" -eq $((NDEV - 1)) ] && pass "(a) the other $((NDEV - 1)) devices stay up (redundant parents)" || { devices | awk '$2!="up"'; fail "(a) other devices left up"; }
alerts | grep -q ' device_down firing core-1 suppressed_by=- ' && pass "(a) API: core-1 firing, not suppressed" || { alerts; fail "(a) API alert for core-1"; }
[ "$(nsink device_warning . .)" -eq 0 ] && pass "no warning was sent (single-signal warnings are held and recorded only)" || fail "device_warning notifications"
start_agent core-1
core1_back() { [ "$(nsink device_down resolved '^core-1$')" -eq 1 ] && all_up; }
wait_for 90 "core-1 to resolve and everything to be up" core1_back || { sink; fail "(a) core-1 did not resolve"; }
# (a2) a child that fails on its own while its parents are up is announced, immediately.
echo "== (a2) stop access-01 alone"
stop_agent access-01
has_a01() { [ "$(nsink device_down firing '^access-01$')" -ge 1 ]; }
wait_for 60 "device_down for access-01" has_a01 || { sink; fail "(a2) no notice for a child failing alone"; }
sleep 15
[ "$(nsink device_down firing '^access-01$')" -eq 1 ] && [ "$(nsink device_down firing .)" -eq 2 ] &&
pass "(a2) a child failing alone while its parents are up notifies exactly once" || { sink; fail "(a2) notifications"; }
start_agent access-01
a01_back() { [ "$(nsink device_down resolved '^access-01$')" -eq 1 ] && all_up; }
wait_for 60 "access-01 to resolve" a01_back || { sink; fail "(a2) access-01 did not resolve"; }
# (b) the real outage: both cores and everything behind them go dark at the same instant. One notice.
echo "== (b) stop both cores and all 22 children at the same moment"
B0="$(sink | wc -l)"
since() { sink | tail -n +"$((B0 + 1))"; }
for d in core-1 core-2 $CHILDREN; do stop_agent "$d"; done
has_core() { [ "$(since | awk '$1=="device_down" && $2=="firing" && $3 ~ /^core-/' | wc -l)" -ge 1 ]; }
wait_for 120 "the grouped notice" has_core || { tail -n 20 "$TMP/nmon.log"; die "no notice for the outage"; }
children_down() { [ "$(ndown down)" -eq "$NDEV" ]; }
wait_for 120 "every device down" children_down || { devices | awk '$2!="down"'; fail "(b) not all devices down"; }
sleep 40 # eight more evaluation passes
[ "$(since | wc -l)" -eq 1 ] && pass "(b) the whole outage produced exactly one notification" || { since; fail "(b) notifications during the outage: $(since | wc -l)"; }
since | grep -q '^device_down firing core-[12] parents=core-1,core-2 children=22 ' && pass "(b) it names both cores and the 22 suppressed children" ||
{ since; fail "(b) the notice does not name both cores and 22 children"; }
[ "$(since | awk '$3 ~ /^(dist|access)-/' | wc -l)" -eq 0 ] && pass "(b) zero per-child notifications" || { since; fail "(b) per-child notifications"; }
SUP="$(alerts | grep -c ' device_down firing \(dist\|access\)-.* suppressed_by=[a-z]')"
[ "$SUP" -eq 22 ] && pass "(b) API: all 22 child alerts are firing with suppressed_by set" || { alerts; fail "(b) suppressed child alerts: $SUP"; }
alerts | grep -q ' device_down firing dist-1 suppressed_by=core-1,core-2 ' && alerts | grep -q ' device_down firing access-01 suppressed_by=dist-1 ' &&
pass "(b) API: dist-1 suppressed by both cores, access-01 by dist-1" || { alerts | grep -E "dist-1|access-01"; fail "(b) suppressed_by values"; }
NOTICED="$(since | awk '{print $3}')"
INC_SINK="$(since | awk '{sub("incident=", "", $6); print $6}')"; INC_API="$(inc_api "$NOTICED")"
[ -n "$INC_SINK" ] && [ "$INC_SINK" = "$INC_API" ] && pass "(b) the incident ID in the sink matches the API ($INC_SINK, $NOTICED)" ||
fail "(b) incident IDs differ: sink $INC_SINK api $INC_API"
ID1="$(alerts | awk -v d="$NOTICED" '$2=="device_down" && $4==d {print $1; exit}')"
curl -fsS "$URL/api/v1/alerts/$ID1/deliveries" >"$TMP/deliveries.json" && grep -q '"status":"sent"' "$TMP/deliveries.json" &&
grep -q '"status":"ok"' "$TMP/deliveries.json" && ! grep -qi 'authorization\|password' "$TMP/deliveries.json" &&
pass "(b) GET /alerts/{id}/deliveries shows the sent attempt, payload redacted" || { cat "$TMP/deliveries.json"; fail "(b) deliveries"; }
INC1_API="$(inc_api core-1)"
# (c) restart: the firing alerts are not announced again.
echo "== (c) restart nmon on the same data dir"
BEFORE="$(sink | wc -l)"
kill -TERM "$NMON_PID"; wait "$NMON_PID" 2>/dev/null; NMON_PID=""
start_nmon
wait_for 90 "nmon to serve again" api_up || { tail -n 20 "$TMP/nmon.log"; die "nmon did not restart"; }
restored() { [ "$(ndown down)" -eq "$NDEV" ]; }
wait_for 60 "the down devices to read down again" restored || { devices | awk '$2!="down"'; fail "(c) devices not down after the restart"; }
sleep 45 # nine more evaluation passes, enough for every device to fail two polls again
[ "$(sink | wc -l)" -eq "$BEFORE" ] && pass "(c) nothing re-sent after the restart ($BEFORE notifications before and after)" ||
{ sink | tail -n +"$((BEFORE + 1))"; grep -E 'poll state|ERROR|WARN' "$TMP/nmon.log" | tail -n 30; fail "(c) notifications after the restart"; }
[ "$(alerts | awk '$2=="device_down" && $3=="firing"' | wc -l)" -eq "$NDEV" ] && alerts | grep -q "incident=$INC_SINK " &&
pass "(c) the same incidents are still firing under the same IDs" || { alerts; fail "(c) alert rows changed"; }
# (d) core-1 returns.
echo "== (d) start core-1 again"
B1="$(sink | wc -l)"
since() { sink | tail -n +"$((B1 + 1))"; }
start_agent core-1
resolved() { [ "$(since | awk '$1=="device_down" && $2=="resolved" && $3=="core-1"' | wc -l)" -ge 1 ]; }
wait_for 90 "the resolution of core-1" resolved || { tail -n 20 "$TMP/nmon.log"; fail "(d) no resolution for core-1"; }
dist_announced() { [ "$(since | awk '$1=="device_down" && $2=="firing" && $3 ~ /^dist-[12]$/' | wc -l)" -ge 2 ]; }
wait_for 90 "dist-1 and dist-2 to be announced" dist_announced || { since; fail "(d) distribution switches were not announced"; }
sleep 25
[ "$(since | awk '$2=="resolved" && $3=="core-1"' | wc -l)" -eq 1 ] && pass "(d) one resolution for core-1" || { since; fail "(d) core-1 resolutions"; }
RES="$(since | awk '$2=="resolved" && $3=="core-1" {sub("incident=", "", $6); print $6}')"
[ -n "$RES" ] && [ "$RES" = "$INC1_API" ] && pass "(d) the resolution carries core-1's incident ID ($RES)" || fail "(d) resolution incident $RES vs $INC1_API"
[ "$(since | awk '$2=="firing" && $3 ~ /^dist-[12]$/' | wc -l)" -eq 2 ] && since | grep -q '^device_down firing dist-1 parents=dist-1 children=10 ' &&
pass "(d) dist-1 and dist-2, no longer behind two dead parents, are announced with 10 suppressed children each" ||
{ since; fail "(d) distribution notices"; }
[ "$(since | awk '$3 ~ /^access-/' | wc -l)" -eq 0 ] && pass "(d) the access switches stay suppressed behind their distribution switch" || { since; fail "(d) access notices"; }
alerts | grep -q ' device_down firing dist-1 suppressed_by=- ' && alerts | grep -q ' device_down firing access-01 suppressed_by=dist-1 ' &&
pass "(d) API: dist-1 no longer suppressed, access-01 still suppressed by dist-1" || { alerts | grep -E "dist-1|access-01"; fail "(d) suppressed_by after recovery"; }
# API validation: ack, maintenance.
echo "== ack and maintenance"
A20="$(slug_of access-20)"
FUTURE="$(date -u -d '+1 day' +%Y-%m-%dT%H:%M:%SZ)"; PAST="$(date -u -d '-1 hour' +%Y-%m-%dT%H:%M:%SZ)"
code() { curl -s -o "$TMP/resp.json" -w '%{http_code}' "$@" -H 'Content-Type: application/json'; }
C2="$(alerts | awk '$2=="device_down" && $3=="firing" && $4=="core-2" {print $1}' | head -n 1)"
C1="$(alerts | awk '$2=="device_down" && $3=="resolved" && $4=="core-1" {print $1}' | head -n 1)"
[ "$(code -X POST "$URL/api/v1/alerts/$C2/ack" -d "{\"reason\":\"waiting for the carrier\",\"until\":\"$PAST\"}")" = 422 ] && pass "ack with until in the past is a 422" || fail "ack in the past"
[ "$(code -X POST "$URL/api/v1/alerts/$C1/ack" -d "{\"reason\":\"x\",\"until\":\"$FUTURE\"}")" = 409 ] && pass "ack of a resolved alert is a 409" || fail "ack of resolved"
[ "$(code -X POST "$URL/api/v1/alerts/$C2/ack" -d "{\"reason\":\"waiting for the carrier\",\"until\":\"$FUTURE\"}")" = 200 ] && alerts | grep -q "^$C2 .* acked=yes" &&
pass "ack of an active alert is a 200 and shows in the API" || { cat "$TMP/resp.json"; fail "ack of active"; }
[ "$(code -X POST "$URL/api/v1/maintenance" -d "{\"device\":\"$A20\",\"ends_at\":\"$PAST\",\"reason\":\"x\"}")" = 422 ] && pass "maintenance ending in the past is a 422" || fail "maintenance past"
[ "$(code -X POST "$URL/api/v1/maintenance" -d "{\"device\":\"nope\",\"ends_at\":\"$FUTURE\",\"reason\":\"x\"}")" = 422 ] && pass "maintenance for an unknown device is a 422" || fail "maintenance unknown device"
[ "$(code -X POST "$URL/api/v1/maintenance" -d "{\"device\":\"$A20\",\"ends_at\":\"$FUTURE\",\"reason\":\"line card swap\"}")" = 201 ] &&
pass "maintenance window created for access-20" || { cat "$TMP/resp.json"; fail "maintenance create"; }
in_maint() { devices | grep -q '^access-20 maintenance '; }
wait_for 20 "access-20 to read maintenance" in_maint && pass "the device list shows access-20 as maintenance" || { devices | grep access-20; fail "maintenance state in the device list"; }
# 5. OpenAPI and the web client.
echo "== openapi"
curl -fsS "$URL/openapi.json" >"$TMP/openapi.live.json" || die "GET /openapi.json"
OPENAPI_BASE_TAG=m3 OPENAPI_NEW="$TMP/openapi.live.json" ./scripts/openapi-check.sh && pass "openapi-check against m3: additive only" || fail "openapi-check"
if ./nmon openapi >"$TMP/openapi.cli.json" 2>/dev/null; then
cmp -s "$TMP/openapi.cli.json" openapi.json && pass "openapi.json matches nmon openapi" || fail "openapi.json is stale: run ./nmon openapi > openapi.json"
fi
echo "== contract drift"
cp web/src/api/schema.d.ts "$TMP/schema.before.d.ts"
if (cd web && npm run gen:api -- "$URL/openapi.json" >"$TMP/genapi.log" 2>&1 && npm run build >"$TMP/webbuild.log" 2>&1); then
pass "web client generated from the live spec type-checks and builds"
else
tail -n 30 "$TMP/genapi.log" "$TMP/webbuild.log" 2>/dev/null; fail "web build against the live spec"
fi
cmp -s web/src/api/schema.d.ts "$TMP/schema.before.d.ts" && pass "web/src/api/schema.d.ts is current" ||
echo "NOTE: web/src/api/schema.d.ts changed after regeneration from the live spec; review and commit it"
(cd web && npm run lint >"$TMP/weblint.log" 2>&1) && pass "web lint after regeneration" || { tail -n 20 "$TMP/weblint.log"; fail "web lint"; }
echo "== playwright (mock)"
if (cd web && npm run e2e) >"$TMP/e2e-mock.log" 2>&1; then pass "mock UI specs (incl. docs/screenshots/M4-mock)"; else tail -n 40 "$TMP/e2e-mock.log"; fail "mock UI specs"; fi
# 6. Live screenshots.
echo "== screenshots (live)"
rm -rf "$SHOTS"; mkdir -p "$SHOTS"
export NMON_URL="$URL" NMON_DEVICE_DOWN="$(slug_of core-2)" NMON_DEVICE_SUPPRESSED="$(slug_of access-01)"
if (cd web && NMON_SHOT_DIR="$SHOTS" npm run e2e:live -- screenshots-m4.spec.ts) >"$TMP/shots.log" 2>&1; then
N="$(ls "$SHOTS"/*.png 2>/dev/null | wc -l)"
[ "$N" -ge 8 ] && pass "$N screenshots in $SHOTS" || fail "only $N screenshots in $SHOTS"
ls "$SHOTS"
else
tail -n 40 "$TMP/shots.log"; fail "screenshots"
fi
if [ "$FAILS" -eq 0 ]; then echo "GATE M4: PASS"; else echo "GATE M4: FAIL ($FAILS)"; exit 1; fi