fix(health-check): read key from on-disk file first, no BWS dependency
Root cause: health-check binary runs inside a Nix venv that doesn't have /usr/local/bin/bws on PATH. When the shell wrapper's BWS_ACCESS_TOKEN export fails (e.g. sudo unavailable, gateway lacks bws group), get_key() returns empty → false 'MODELS FAILING' alert. Fix: read the router API key from the on-disk omniroute_key file first (maintained by sync-key.py on every service start via ExecStartPre — always current, zero subprocess/BWS dependency). Fall back to BWS CLI only if the file is missing/stale. Also: all config values now read from env vars (no hardcoded paths), alert message clarified to indicate both resolution paths failed.
This commit is contained in:
+77
-17
@@ -13,39 +13,84 @@ Design: alert only when EVERY configured model fails (primary AND all
|
|||||||
fallbacks). If any model works, the server's own fallback chain will succeed,
|
fallbacks). If any model works, the server's own fallback chain will succeed,
|
||||||
so the system is healthy even if the primary is down/slow. This prevents
|
so the system is healthy even if the primary is down/slow. This prevents
|
||||||
false alerts from a single slow/failed model.
|
false alerts from a single slow/failed model.
|
||||||
|
|
||||||
|
Key resolution order (no hardcoding):
|
||||||
|
1. On-disk key file maintained by sync-key.py (source of truth for the
|
||||||
|
running server — always current after every service start/restart).
|
||||||
|
2. BWS_ACCESS_TOKEN env var (shell wrapper or gateway-injected).
|
||||||
|
3. /etc/bws-token file → bws secret get (with sudo fallback for
|
||||||
|
non-root processes).
|
||||||
"""
|
"""
|
||||||
import os, sys, json, hashlib, subprocess
|
import os, sys, json, hashlib, subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BWS_SECRET_ID = "2aef2194-971d-4dae-99dd-b49a0041f97c"
|
# Configurable paths — no hardcoding; everything reads from env or known
|
||||||
ROUTER_BASE = "https://9router.asepharyana.my.id/v1"
|
# locations that the Nix build / systemd unit define.
|
||||||
PRIMARY = "openai/claude-opus-5"
|
BWS_SECRET_ID = os.environ.get(
|
||||||
FALLBACKS = ["openai/claude-sonnet-5", "openai/claude-haiku-4-5-20251001", "openai/ATLAS", "openai/gemini", "openai/text", "openai/deepseek-v4-flash-free"]
|
"BWS_ROUTER_KEY_SECRET_ID", "2aef2194-971d-4dae-99dd-b49a0041f97c"
|
||||||
|
)
|
||||||
|
ROUTER_BASE = os.environ.get(
|
||||||
|
"ROUTER_BASE_URL", "https://9router.asepharyana.my.id/v1"
|
||||||
|
)
|
||||||
|
PRIMARY = os.environ.get("HEALTH_CHECK_PRIMARY_MODEL", "openai/claude-opus-5")
|
||||||
|
FALLBACKS = os.environ.get(
|
||||||
|
"HEALTH_CHECK_FALLBACK_MODELS",
|
||||||
|
"openai/claude-sonnet-5,openai/claude-haiku-4-5-20251001,openai/ATLAS,openai/gemini,openai/text,openai/deepseek-v4-flash-free"
|
||||||
|
).split(",")
|
||||||
# Caddy 9router route is now response_header_timeout 120s / read 300s.
|
# Caddy 9router route is now response_header_timeout 120s / read 300s.
|
||||||
# LLM combo TTFT often 30-40s+. Give the check room to complete.
|
# LLM combo TTFT often 30-40s+. Give the check room to complete.
|
||||||
HTTP_TIMEOUT = 150
|
HTTP_TIMEOUT = int(os.environ.get("HEALTH_CHECK_HTTP_TIMEOUT", "150"))
|
||||||
CONSECUTIVE_FAIL_FILE = Path("/tmp/pr-agent-health-fail-count")
|
CONSECUTIVE_FAIL_FILE = Path(
|
||||||
|
os.environ.get("HEALTH_CHECK_FAIL_COUNT_FILE", "/tmp/pr-agent-health-fail-count")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── key resolution ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# On-disk key file — maintained by sync-key.py (runs on every service start
|
||||||
|
# via systemd ExecStartPre). This is the SAME key the server uses, always
|
||||||
|
# current, no BWS dependency.
|
||||||
|
APP_DIR = Path(os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server"))
|
||||||
|
KEYFILE = APP_DIR / "omniroute_key"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_key_from_disk() -> str:
|
||||||
|
"""Read the router key from the on-disk file maintained by sync-key.py.
|
||||||
|
This is the primary source — always current after service start."""
|
||||||
|
try:
|
||||||
|
if KEYFILE.is_file():
|
||||||
|
key = KEYFILE.read_text().strip()
|
||||||
|
if len(key) >= 10:
|
||||||
|
return key
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# ── key from BWS ────────────────────────────────────────────────────────────
|
|
||||||
def _read_token() -> str:
|
def _read_token() -> str:
|
||||||
"""Read BWS token. Direct read fails for non-root (root:bws 640), so fall
|
"""Read BWS access token. Direct read fails for non-root (root:bws 640),
|
||||||
back to `sudo -n cat` (cron user `code` is in sudo group, NOPASSWD)."""
|
so fall back to `sudo -n cat` (cron user `code` is in sudo group, NOPASSWD)."""
|
||||||
|
# Try direct read first (works when gateway has bws group)
|
||||||
for path in (Path("/etc/bws-token"),):
|
for path in (Path("/etc/bws-token"),):
|
||||||
try:
|
try:
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return path.read_text().strip()
|
return path.read_text().strip()
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
pass
|
pass
|
||||||
|
# Fallback: sudo (works when user has NOPASSWD sudo)
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["sudo", "-n", "cat", "/etc/bws-token"],
|
r = subprocess.run(
|
||||||
capture_output=True, text=True, timeout=10)
|
["sudo", "-n", "cat", "/etc/bws-token"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
return r.stdout.strip()
|
return r.stdout.strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def get_key() -> str:
|
|
||||||
|
def _fetch_key_from_bws() -> str:
|
||||||
|
"""Fetch the router key from BWS via the bws CLI."""
|
||||||
token = os.environ.get("BWS_ACCESS_TOKEN", "")
|
token = os.environ.get("BWS_ACCESS_TOKEN", "")
|
||||||
if not token:
|
if not token:
|
||||||
token = _read_token()
|
token = _read_token()
|
||||||
@@ -59,9 +104,7 @@ def get_key() -> str:
|
|||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
return ""
|
return ""
|
||||||
# Value is shell-quoted KEY="value" — take first line only. BWS sometimes
|
# Value is shell-quoted KEY="value" — take first line only.
|
||||||
# appends "# one or more secrets have been commented-out..."; only the
|
|
||||||
# first line is the real key value.
|
|
||||||
line = r.stdout.split("\n")[0]
|
line = r.stdout.split("\n")[0]
|
||||||
if "=" not in line:
|
if "=" not in line:
|
||||||
return ""
|
return ""
|
||||||
@@ -73,7 +116,21 @@ def get_key() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_key() -> str:
|
||||||
|
"""Resolve the router API key. Order: on-disk file → BWS CLI.
|
||||||
|
The on-disk file is always current (sync-key runs on every service start)
|
||||||
|
and has zero external dependencies — preferred path for the health check."""
|
||||||
|
# 1. On-disk file (fast, no subprocess, no BWS dependency)
|
||||||
|
key = _read_key_from_disk()
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
# 2. BWS CLI fallback (for edge cases where the file is missing/stale)
|
||||||
|
key = _fetch_key_from_bws()
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
# ── health check ────────────────────────────────────────────────────────────
|
# ── health check ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def check_model(model: str, key: str) -> tuple:
|
def check_model(model: str, key: str) -> tuple:
|
||||||
"""Returns (ok: bool, detail: str). Uses raw HTTP (no litellm dependency).
|
"""Returns (ok: bool, detail: str). Uses raw HTTP (no litellm dependency).
|
||||||
|
|
||||||
@@ -108,7 +165,10 @@ def check_model(model: str, key: str) -> tuple:
|
|||||||
def main() -> int:
|
def main() -> int:
|
||||||
key = get_key()
|
key = get_key()
|
||||||
if not key:
|
if not key:
|
||||||
print("⚠️ pr-agent health: cannot fetch router key from BWS (bws unavailable)")
|
print(
|
||||||
|
"⚠️ pr-agent health: cannot resolve router key "
|
||||||
|
"(on-disk file missing and BWS unavailable)"
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
@@ -154,4 +214,4 @@ def main() -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
Reference in New Issue
Block a user