Files
Agent-System-Dev/tests/ops/run_fault_drill.sh
T
weichenandCursor de6eb084e5 Measure C4 recovery on fresh conversations
Why: The first pg-stop window counted an in-flight 200 at shutdown, and five writes on the timeline conversation returned 409 while a mock run held it. That measured the probe, not a proven database failure.
Evidence: go test ./... in tests/ops/drill_driver passed locally (SELF).
Impact: The outage clock starts 2s after stop or kill. RTO is five writes on new conversations. Idempotency replays only the window and retries 409 for up to 2 minutes. The Redis phase no longer uses local outside a function.
Refs: feature/u7-ops-drills
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:20:13 +00:00

432 lines
18 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# C4:PG 断连(pg-stop / pg-kill)与 Redis 断连。只打 u7drill,端口 18990–18996。
# 判据失败记在结果里并继续其他轨;产品代码不在这里改。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT/tests/ops/lib.sh"
NAME=u7drill
PG_PORT=18991
PORT_A=18990
PORT_B=18992
REDIS_PORT=18993
PROBE_PORT=18996
FAULT=all
ADMIN_OWNER=drill_admin
ADMIN_PASS=drill-admin-pass-Aa1
USER_OWNER=drill_user
USER_PASS=drill-user-pass-Aa9
usage() { sed -n '2,4p' "$0"; echo " --fault pg-stop|pg-kill|redis-kill|all"; echo " --self-check"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--fault) FAULT="$2"; shift 2 ;;
--self-check) "$ROOT/tests/ops/self_check.sh"; exit $? ;;
-h|--help) usage; exit 0 ;;
*) echo "[fault] unknown arg: $1" >&2; usage >&2; exit 2 ;;
esac
done
case "$FAULT" in
pg-stop|pg-kill|redis-kill|all) ;;
*) echo "[fault] --fault must be pg-stop|pg-kill|redis-kill|all" >&2; exit 2 ;;
esac
ops_require_name "$NAME"
ops_require_port "$PG_PORT"
ops_require_port "$PORT_A"
ops_require_port "$PORT_B"
ops_require_port "$REDIS_PORT"
ops_require_port "$PROBE_PORT"
ops_resource_gate "$ROOT" || exit $?
OWNED=0
APP_A_PIDFILE=""
APP_B_PIDFILE=""
REDIS_DIR=""
cleanup() {
local ec=$?
trap - EXIT INT TERM
if [[ -n "$APP_A_PIDFILE" ]]; then ops_kill_checked "$APP_A_PIDFILE" "127.0.0.1:$PORT_A" TERM || true; fi
if [[ -n "$APP_B_PIDFILE" ]]; then ops_kill_checked "$APP_B_PIDFILE" "127.0.0.1:$PORT_B" TERM || true; fi
if [[ -n "$REDIS_DIR" ]]; then ops_redis_kill "$REDIS_DIR" "$REDIS_PORT" || true; fi
if [[ "$OWNED" -eq 1 ]]; then ops_pg_remove "$ROOT" "$NAME" || true; fi
exit "$ec"
}
trap cleanup EXIT INT TERM
TS="$(date +%Y%m%d-%H%M%S)"
RESULTS="$ROOT/tests/ops/results/fault-$TS"
mkdir -p "$RESULTS"
ops_snapshot "$ROOT" "$RESULTS/host.txt"
echo "[fault] results $RESULTS"
BIN="$ROOT/tests/ops/.build/agent-system"
DRIVER="$ROOT/tests/ops/.build/drill_driver"
SEED="$ROOT/tests/ops/.build/seed"
mkdir -p "$ROOT/tests/ops/.build"
( cd "$ROOT/modules/backend" && CGO_ENABLED=0 go build -o "$BIN" ./cmd/agent-system )
( cd "$ROOT/tests/ops/drill_driver" && go build -o "$DRIVER" . )
( cd "$ROOT/tests/load/collaboration/seed" && go build -o "$SEED" . )
echo "[fault] creating $NAME on 127.0.0.1:$PG_PORT"
ops_pg_create "$ROOT" "$NAME" "$PG_PORT"
OWNED=1
ops_pg_start "$ROOT" "$NAME"
DSN="$(ops_pg_dsn "$ROOT" "$NAME")"
ADMIN_DSN="$(python3 - "$DSN" <<'PY'
import sys
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
u = urlsplit(sys.argv[1])
q = dict(parse_qsl(u.query))
q.setdefault("sslmode", "disable")
print(urlunsplit((u.scheme, u.netloc, "/" + (u.path.lstrip("/") or "postgres"), urlencode(q), "")))
PY
)"
psql "$ADMIN_DSN" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS u7drill_app WITH (FORCE)" -c "CREATE DATABASE u7drill_app" >/dev/null
APP_DSN="$(python3 - "$ADMIN_DSN" <<'PY'
import sys
from urllib.parse import urlsplit, urlunsplit
u = urlsplit(sys.argv[1])
print(urlunsplit((u.scheme, u.netloc, "/u7drill_app", u.query, "")))
PY
)"
"$BIN" user create --dsn "$APP_DSN" --admin --owner "$ADMIN_OWNER" --password "$ADMIN_PASS" >/dev/null
"$BIN" user create --dsn "$APP_DSN" --owner "$USER_OWNER" --password "$USER_PASS" >/dev/null
for i in 0 1 2; do
"$BIN" user create --dsn "$APP_DSN" --owner "seed_u$i" --password "seed-user-pass-${i}-Aa9" >/dev/null
done
"$SEED" --dsn "$APP_DSN" --profile tiny --manifest "$RESULTS/seed-manifest.json"
psql "$APP_DSN" -v ON_ERROR_STOP=1 -c "UPDATE users SET must_change_password=FALSE" >/dev/null
ARTS="$RESULTS/artifacts"
mkdir -p "$ARTS"
start_app() {
local which="$1" port="$2" pidfile="$3"
local cwd="$RESULTS/cwd-$which"
mkdir -p "$cwd"
(
cd "$cwd"
exec "$BIN" serve --dsn "$APP_DSN" --addr "127.0.0.1:$port" --tier team --auth on --mock \
--instance "u7drill-$which" --artifacts "$ARTS" --disable-exec-tools >"$RESULTS/app-$which.log" 2>&1
) &
echo $! >"$pidfile"
local ready=0
for _ in $(seq 1 60); do
local code
code="$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$port/api/health" || true)"
if [[ "$code" == "200" || "$code" == "401" ]]; then ready=1; break; fi
sleep 0.5
done
[[ "$ready" -eq 1 ]] || { echo "[fault] app $which not ready" >&2; return 1; }
}
stop_app() {
local pidfile="$1" port="$2"
ops_kill_checked "$pidfile" "127.0.0.1:$port" TERM || true
for _ in $(seq 1 20); do
ops_port_listening "$port" || return 0
sleep 0.2
done
ops_kill_checked "$pidfile" "127.0.0.1:$port" KILL || true
}
APP_A_PIDFILE="$RESULTS/app-a.pid"
APP_B_PIDFILE="$RESULTS/app-b.pid"
start_app a "$PORT_A" "$APP_A_PIDFILE"
start_app b "$PORT_B" "$APP_B_PIDFILE"
prepare_conv() {
DRILL_BASE="http://127.0.0.1:$PORT_A" DRILL_OWNER="$ADMIN_OWNER" DRILL_PASS="$ADMIN_PASS" \
"$DRIVER" prepare -base "http://127.0.0.1:$PORT_A" -owner "$ADMIN_OWNER" -password "$ADMIN_PASS" -out "$1"
}
verdict_fail=0
note_fail() { echo "[fault] FAIL $*" >&2; verdict_fail=1; }
run_pg_phase() {
local mode="$1"
local phase="$RESULTS/$mode"
mkdir -p "$phase"
echo "[fault] phase $mode"
prepare_conv "$phase/prep.json"
local conv
conv="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["conversationId"])' "$phase/prep.json")"
local project
project="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["projectId"])' "$phase/prep.json")"
"$DRIVER" timeline -base "http://127.0.0.1:$PORT_A" -owner "$ADMIN_OWNER" -password "$ADMIN_PASS" \
-conv "$conv" -every 500ms -dur 100s -out "$phase/timeline.json" >"$phase/timeline.stdout" 2>&1 &
local tl_pid=$!
sleep 8
if [[ "$mode" == "pg-stop" ]]; then
ops_pg_stop "$ROOT" "$NAME"
else
ops_pg_kill "$ROOT" "$NAME"
fi
# 停机返回后留 2 秒,让已经在途的写入结束。断连窗口从这之后才开始计。
sleep 2
local outage_start outage_end
outage_start="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# F2 与 60 秒断连并行:新实例必须拒绝启动,且不落 SQLite。
local probe_cwd="$phase/probe-cwd"
mkdir -p "$probe_cwd"
(
cd "$probe_cwd"
exec "$BIN" serve --dsn "$APP_DSN" --addr "127.0.0.1:$PROBE_PORT" --tier team --auth on --mock \
--instance u7drill-probe --artifacts "$ARTS" --disable-exec-tools >"$phase/probe.log" 2>&1
) &
echo $! >"$phase/probe.pid"
local infra=0
for _ in $(seq 1 60); do
if ops_port_listening "$PG_PORT"; then
echo "[fault] PG came back during the outage window" >&2
infra=1
break
fi
sleep 1
done
outage_end="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
local probe_alive=0
if kill -0 "$(cat "$phase/probe.pid")" 2>/dev/null; then
probe_alive=1
ops_kill_checked "$phase/probe.pid" "127.0.0.1:$PROBE_PORT" TERM || true
fi
wait "$(cat "$phase/probe.pid")" 2>/dev/null || true
local sqlite_n
sqlite_n="$(find "$probe_cwd" "$RESULTS/cwd-a" "$RESULTS/cwd-b" -name '*.db' 2>/dev/null | wc -l | tr -d ' ')"
local apps_alive=1
kill -0 "$(head -1 "$APP_A_PIDFILE")" 2>/dev/null || apps_alive=0
kill -0 "$(head -1 "$APP_B_PIDFILE")" 2>/dev/null || apps_alive=0
if [[ "$infra" -eq 1 ]]; then
echo "INFRA_FAIL pg returned early" >"$phase/verdict.txt"
note_fail "$mode infra"
ops_pg_start "$ROOT" "$NAME" || true
wait "$tl_pid" || true
return 0
fi
local recover_note="" recover_start
ops_pg_start "$ROOT" "$NAME"
recover_start="$(date +%s)"
local recovered=0 tries=0
while (( $(date +%s) - recover_start < 900 )); do
tries=$((tries + 1))
# 时间线会话上可能还有 mock run。五次成功写到五个新会话,避免把 409 当成库没恢复。
if "$DRIVER" writes -base "http://127.0.0.1:$PORT_A" -owner "$ADMIN_OWNER" -password "$ADMIN_PASS" -project "$project" -fresh -n 5 \
>"$phase/writes-a.txt" 2>&1 \
&& "$DRIVER" writes -base "http://127.0.0.1:$PORT_B" -owner "$ADMIN_OWNER" -password "$ADMIN_PASS" -project "$project" -fresh -n 5 \
>"$phase/writes-b.txt" 2>&1; then
recovered=1
break
fi
if [[ "$tries" -eq 2 && -z "$recover_note" ]]; then
recover_note="restarted both serves"
stop_app "$APP_A_PIDFILE" "$PORT_A" || true
stop_app "$APP_B_PIDFILE" "$PORT_B" || true
start_app a "$PORT_A" "$APP_A_PIDFILE"
start_app b "$PORT_B" "$APP_B_PIDFILE"
fi
sleep 3
done
local rto=$(( $(date +%s) - recover_start ))
wait "$tl_pid" || true
"$DRIVER" idem-verify -base "http://127.0.0.1:$PORT_A" -owner "$ADMIN_OWNER" -password "$ADMIN_PASS" \
-conv "$conv" -in "$phase/timeline.json" -since "$outage_start" -until "$outage_end" \
-out "$phase/replays.json" || true
"$DRIVER" sse-check -base "http://127.0.0.1:$PORT_B" -owner "$ADMIN_OWNER" -password "$ADMIN_PASS" \
-conv "$conv" -out "$phase/sse-b.json" || true
sleep 5
local holds
holds="$(DRILL_BASE="http://127.0.0.1:$PORT_A" DRILL_OWNER="$ADMIN_OWNER" DRILL_PASS="$ADMIN_PASS" python3 - <<'PY'
import json, os, urllib.request
base, owner, password = os.environ["DRILL_BASE"], os.environ["DRILL_OWNER"], os.environ["DRILL_PASS"]
body = json.dumps({"owner": owner, "password": password}).encode()
req = urllib.request.Request(base+"/api/auth/login", data=body, headers={"Content-Type":"application/json"})
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
opener.open(req, timeout=10).read()
try:
with opener.open(base+"/api/admin/recovery-holds", timeout=10) as resp:
print(resp.status)
print(resp.read(4000).decode()[:2000])
except urllib.error.HTTPError as e:
print(e.code)
print(e.read(2000).decode()[:2000])
PY
)" || true
printf '%s\n' "$holds" >"$phase/recovery-holds.txt"
{
echo "dup_seq=$(psql "$APP_DSN" -tAc "SELECT COUNT(*) FROM (SELECT conversation_id, seq FROM events GROUP BY 1,2 HAVING COUNT(*)>1) d")"
echo "seq_gap_convs=$(psql "$APP_DSN" -tAc "SELECT COUNT(*) FROM (SELECT conversation_id FROM events GROUP BY conversation_id HAVING COUNT(*) <> MAX(seq)-MIN(seq)+1) g")"
echo "orphan_runs=$(psql "$APP_DSN" -tAc "SELECT COUNT(*) FROM runs WHERE status IN ('pending','running','waiting_hitl')")"
} >"$phase/invariants.txt"
set +e
DRILL_DSN="$APP_DSN" python3 - "$phase" "$outage_start" "$outage_end" "$rto" "$recovered" "$recover_note" "$probe_alive" "$sqlite_n" "$apps_alive" <<'PY'
import json, os, subprocess, sys
from datetime import datetime
phase, outage_start, outage_end, rto, recovered, note, probe_alive, sqlite_n, apps_alive = sys.argv[1:]
dsn = os.environ["DRILL_DSN"]
def parse(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
start, end = parse(outage_start), parse(outage_end)
tl = json.load(open(os.path.join(phase, "timeline.json")))
window = [a for a in tl["attempts"] if start <= parse(a["started"]) <= end]
slow = [a for a in window if a["elapsedMs"] > 10000]
hangs = [a for a in window if a["status"] == 0 and a["elapsedMs"] >= 10000]
ok_in_window = [a for a in window if 200 <= a["status"] < 300]
late = ok_in_window
f1 = len(window) > 0 and not slow and not hangs and not late and apps_alive == "1"
f2 = probe_alive == "0" and sqlite_n == "0" and "拒绝就绪" in open(os.path.join(phase, "probe.log"), errors="replace").read()
f3 = recovered == "1" and int(rto) <= 900
replays = json.load(open(os.path.join(phase, "replays.json")))
replay_bad = [a for a in replays.get("replays", []) if not (200 <= a["status"] < 300)]
ids = [a["requestId"] for a in tl["attempts"] if 200 <= a["status"] < 300]
sql = """
SELECT request_id, COUNT(*) FROM messages
WHERE request_id <> '' GROUP BY 1 HAVING COUNT(*) <> 1
"""
dup = subprocess.check_output(["psql", dsn, "-tAc", sql], text=True)
missing_sql = "SELECT COUNT(*) FROM (VALUES " + ",".join(["('%s')" % i.replace("'", "") for i in ids]) + ") v(id) WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.request_id=v.id)" if ids else "SELECT 0"
missing = subprocess.check_output(["psql", dsn, "-tAc", missing_sql], text=True).strip()
f4 = not replay_bad and dup.strip() == "" and missing == "0"
inv = dict(line.split("=", 1) for line in open(os.path.join(phase, "invariants.txt")) if "=" in line)
sse = json.load(open(os.path.join(phase, "sse-b.json")))
holds = open(os.path.join(phase, "recovery-holds.txt"), errors="replace").read()
orphans = inv.get("orphan_runs", "").strip()
f5 = inv.get("dup_seq", "").strip() == "0" and inv.get("seq_gap_convs", "").strip() == "0" and not sse.get("holes") and (orphans == "0" or "run" in holds)
doc = {
"mode": os.path.basename(phase),
"outage": [outage_start, outage_end],
"window_attempts": len(window),
"slow": len(slow),
"hangs": len(hangs),
"late_success": len(late),
"F1": f1, "F2": f2, "F3": f3, "F4": f4, "F5": f5,
"rto_s": int(rto), "recover_note": note, "probe_alive": probe_alive == "1",
"sqlite_files": int(sqlite_n), "replay_bad": len(replay_bad),
"apps_alive": apps_alive == "1",
"request_dups": dup.strip(), "missing_confirmed": missing,
"sse_holes": sse.get("holes"),
}
open(os.path.join(phase, "verdict.json"), "w").write(json.dumps(doc, indent=2)+"\n")
print(json.dumps(doc, indent=2))
if not all(doc[k] for k in ("F1","F2","F3","F4","F5")):
sys.exit(1)
PY
local judge=$?
set -e
[[ "$judge" -eq 0 ]] || note_fail "$mode criteria"
}
if [[ "$FAULT" == "pg-stop" || "$FAULT" == "all" ]]; then
run_pg_phase pg-stop || true
fi
if [[ "$FAULT" == "pg-kill" || "$FAULT" == "all" ]]; then
run_pg_phase pg-kill || true
fi
if [[ "$FAULT" == "redis-kill" || "$FAULT" == "all" ]]; then
echo "[fault] phase redis-kill"
phase="$RESULTS/redis-kill"
mkdir -p "$phase"
stop_app "$APP_A_PIDFILE" "$PORT_A" || true
stop_app "$APP_B_PIDFILE" "$PORT_B" || true
REDIS_DIR="$phase/redis"
ops_redis_start "$REDIS_PORT" "$REDIS_DIR"
python3 - "$APP_DSN" "$REDIS_PORT" <<'PY'
import json, subprocess, sys
dsn, port = sys.argv[1], sys.argv[2]
cur = subprocess.check_output(["psql", dsn, "-tAc", "SELECT \"values\" FROM settings WHERE owner='local' AND scope='user'"], text=True).strip()
doc = json.loads(cur) if cur else {}
runtime = doc.setdefault("runtime", {})
runtime["redisAddr"] = f"127.0.0.1:{port}"
runtime["rateLimitPerMin"] = 20
runtime["mock"] = True
payload = json.dumps(doc).replace("'", "''")
sql = f"INSERT INTO settings (owner, scope, \"values\") VALUES ('local','user','{payload}') ON CONFLICT (owner, scope) DO UPDATE SET \"values\"=EXCLUDED.\"values\""
subprocess.check_call(["psql", dsn, "-v", "ON_ERROR_STOP=1", "-c", sql])
PY
echo "[fault] finding: default-owner redisAddr was written with SQL; PUT /api/settings/user only updates the caller" >"$phase/finding-settings.txt"
start_app a "$PORT_A" "$APP_A_PIDFILE"
start_app b "$PORT_B" "$APP_B_PIDFILE"
"$DRIVER" burst -bases "http://127.0.0.1:$PORT_A,http://127.0.0.1:$PORT_B" -owner "$USER_OWNER" -password "$USER_PASS" -n 40 -out "$phase/burst-shared.json"
ops_redis_kill "$REDIS_DIR" "$REDIS_PORT"
# G5:Redis 断连时新实例应拒绝启动。
probe_cwd="$phase/probe-cwd"
mkdir -p "$probe_cwd"
set +e
(
cd "$probe_cwd"
timeout 20 "$BIN" serve --dsn "$APP_DSN" --addr "127.0.0.1:$PROBE_PORT" --tier team --auth on --mock \
--instance u7drill-probe --artifacts "$ARTS" --disable-exec-tools >"$phase/probe.log" 2>&1
)
probe_ec=$?
set -e
"$DRIVER" burst -bases "http://127.0.0.1:$PORT_A,http://127.0.0.1:$PORT_B" -owner "$USER_OWNER" -password "$USER_PASS" -n 40 -out "$phase/burst-down.json"
prepare_conv "$phase/prep.json" || true
conv=""
conv="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("conversationId",""))' "$phase/prep.json" 2>/dev/null || true)"
if [[ -n "$conv" ]]; then
"$DRIVER" writes -base "http://127.0.0.1:$PORT_A" -owner "$ADMIN_OWNER" -password "$ADMIN_PASS" -conv "$conv" -n 3 >"$phase/writes-down.txt" 2>&1 || true
fi
ops_redis_start "$REDIS_PORT" "$REDIS_DIR"
"$DRIVER" burst -bases "http://127.0.0.1:$PORT_A,http://127.0.0.1:$PORT_B" -owner "$USER_OWNER" -password "$USER_PASS" -n 40 -out "$phase/burst-back.json"
redis-cli -p "$REDIS_PORT" --scan --pattern 'rl:*' >"$phase/redis-keys.txt" || true
: >"$phase/redis-pttl.txt"
while IFS= read -r key; do
[[ -n "$key" ]] || continue
echo "$key $(redis-cli -p "$REDIS_PORT" pttl "$key")" >>"$phase/redis-pttl.txt"
done <"$phase/redis-keys.txt"
grep -n -i 'degraded\|redis' "$RESULTS/app-a.log" "$RESULTS/app-b.log" >"$phase/log-grep.txt" || true
set +e
python3 - "$phase" "$probe_ec" <<'PY'
import json, os, sys
phase, probe_ec = sys.argv[1], int(sys.argv[2])
def counts(name):
doc = json.load(open(os.path.join(phase, name)))
ok = denied = bad = 0
for key, n in doc["counts"].items():
status = key.rsplit(":", 1)[-1]
if status == "200":
ok += n
elif status == "429":
denied += n
else:
bad += n
return ok, denied, bad
shared = counts("burst-shared.json")
down = counts("burst-down.json")
back = counts("burst-back.json")
pttl = open(os.path.join(phase, "redis-pttl.txt")).read().splitlines()
permanent = [line for line in pttl if line.endswith(" -1")]
log = open(os.path.join(phase, "log-grep.txt"), errors="replace").read()
probe_log = open(os.path.join(phase, "probe.log"), errors="replace").read()
g1 = shared[2] == 0 and 1 <= shared[0] <= 25 and shared[1] > 0
g2 = down[2] == 0
g3 = back[2] == 0 and 1 <= back[0] <= 25 and back[1] > 0 and not permanent
g4 = "degraded" not in log.lower()
g5 = probe_ec != 0 and "redis" in probe_log.lower()
doc = {
"G1_shared_ok_429_bad": shared, "G1": g1,
"G2_down_ok_429_bad": down, "G2": g2,
"G3_back_ok_429_bad": back, "G3": g3,
"G4_degraded_invisible": g4, "G4": True,
"G5_start_refused": g5, "G5_expected_finding": True,
"probe_exit": probe_ec, "permanent_keys": permanent,
"finding_degraded_counter_not_in_logs": g4,
"finding_start_requires_redis": g5,
}
# G4/G5 是预期发现,不把它们算作产品失败;G1-G3 才决定这一轨。
open(os.path.join(phase, "verdict.json"), "w").write(json.dumps(doc, indent=2)+"\n")
print(json.dumps(doc, indent=2))
if not (g1 and g2 and g3):
sys.exit(1)
PY
judge=$?
set -e
[[ "$judge" -eq 0 ]] || note_fail "redis criteria"
fi
echo "[fault] done fail=$verdict_fail results=$RESULTS"
[[ "$verdict_fail" -eq 0 ]]